From 3b6128f1f3c8e44192306e5072b8c1e523a5ecf0 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Thu, 8 Feb 2024 11:38:14 +0200 Subject: [PATCH 1/7] feat(3.2.0): refactor parser for plugin system --- .gitpod.yml | 3 +- .npmignore | 7 +- a.md | 109 ++++++++++++++++ parser.js | 249 +++++++++++++++++++++++++++++++++++++ self/tooling/adjust-url.js | 37 ++++++ t.js | 109 ++++++++++++++++ 6 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 a.md create mode 100644 parser.js create mode 100644 self/tooling/adjust-url.js create mode 100644 t.js diff --git a/.gitpod.yml b/.gitpod.yml index 8f26c0bd..372c5432 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -4,6 +4,7 @@ image: # Commands to start on workspace startup tasks: - init: npm ci + - command: node tooling/adjust-url.js ports: - port: 8080 @@ -12,5 +13,5 @@ ports: # TODO: See about publishing to Open VSX for smoother process vscode: extensions: - - https://github.com/freeCodeCamp/courses-vscode-extension/releases/download/v2.1.0/freecodecamp-courses-2.1.0.vsix + - https://github.com/freeCodeCamp/courses-vscode-extension/releases/download/v3.0.0/freecodecamp-courses-3.0.0.vsix - https://github.com/freeCodeCamp/freecodecamp-dark-vscode-theme/releases/download/v1.0.0/freecodecamp-dark-vscode-theme-1.0.0.vsix diff --git a/.npmignore b/.npmignore index 8dc92d0f..c65dba4a 100644 --- a/.npmignore +++ b/.npmignore @@ -1,8 +1,7 @@ -# Included, because it helps with testing when importing straight from GitHub -# .freeCodeCamp/client +.freeCodeCamp/client .freeCodeCamp/tests -# .freeCodeCamp/tsconfig.json -# .freeCodeCamp/webpack.config.cjs +.freeCodeCamp/tsconfig.json +.freeCodeCamp/webpack.config.cjs .devcontainer .github diff --git a/a.md b/a.md new file mode 100644 index 00000000..cd9b285e --- /dev/null +++ b/a.md @@ -0,0 +1,109 @@ +# Heading + +Project description + +## 0 + +### --description-- + +## `Within Description` + +Hi. + +````markdown +# Fake Heading + +Fake project description. + +```js +const a = 1; +``` + +## 0 + +### --description-- + +Fake description. +```` + +Over multiple lines. + +### --tests-- + +Some test text. + +```js +const a = 1; +``` + +## 1 + +### --description-- + +Lesson 1 description. + +### --tests-- + +Lesson 1 tests. + +```js +assert.strictEqual(a, 1); +``` + +### --before-all-- + +```js +const a = 1; +``` + +### --after-all-- + +```js +const a = 2; +``` + +### --before-each-- + +```js +const a = 3; +``` + +### --after-each-- + +```js +const a = 4; +``` + +### --seed-- + +#### --force-- + +#### --cmd-- + +```bash +npm install +``` + +#### --"some/path/to/file.rs"-- + +```rust +fn main() { + println!("Hello, world!"); +} +``` + +### --hints-- + +#### 0 + +Hint 0. Some code: + +```rust +const A: i32 = 1; +``` + +#### 1 + +Hint 1. Inline `code`. + +## --fcc-end-- diff --git a/parser.js b/parser.js new file mode 100644 index 00000000..6d6e7f92 --- /dev/null +++ b/parser.js @@ -0,0 +1,249 @@ +import { lexer } from 'marked'; +import { isForceFlag } from './.freeCodeCamp/tooling/parser.js'; + +/** + * A class that takes a Markdown string, uses the markedjs package to tokenize it, and provides convenience methods to access different tokens in the token tree + */ +export class TokenTwister { + constructor(tokensOrMarkdown) { + if (typeof tokensOrMarkdown == 'string') { + this.tokens = lexer(tokensOrMarkdown); + } else if (Array.isArray(tokensOrMarkdown)) { + this.tokens = tokensOrMarkdown; + } else { + this.tokens = [tokensOrMarkdown]; + } + } + + getHeading(depth, text) { + const tokens = []; + let take = false; + for (const token of this.tokens) { + if ( + token.type === 'heading' && + token.depth <= depth && + TOKENS.some(t => t.marker === token.text) + ) { + take = false; + } + if (take) { + tokens.push(token); + } + if ( + token.type === 'heading' && + token.depth === depth && + token.text === text + ) { + take = true; + } + } + return new TokenTwister(tokens); + } + + getWithinHeading(depth, text) { + const tokens = []; + let take = false; + for (const token of this.tokens) { + if ( + token.type === 'heading' && + token.depth === depth && + token.text === text + ) { + take = true; + } + if ( + token.type === 'heading' && + token?.depth <= depth && + token?.text !== text + ) { + take = false; + } + if (take) { + tokens.push(token); + } + } + return new TokenTwister(tokens); + } + + getLesson(lessonNumber) { + const tokens = []; + let take = false; + for (const token of this.tokens) { + if ( + parseInt(token.text, 10) === lessonNumber + 1 || + token.text === '--fcc-end--' + ) { + take = false; + } + if (take) { + tokens.push(token); + } + if (token.type === 'heading' && token.depth === 2) { + if (parseInt(token.text, 10) === lessonNumber) { + take = true; + } + } + } + return new TokenTwister(tokens); + } + + getDescription() { + return this.getHeading(3, '--description--'); + } + + getTests() { + return this.getHeading(3, '--tests--'); + } + + getSeed() { + return this.getHeading(3, '--seed--'); + } + + getHints() { + return this.getHeading(3, '--hints--'); + } + + getBeforeAll() { + return this.getHeading(3, '--before-all--'); + } + + getAfterAll() { + return this.getHeading(3, '--after-all--'); + } + + getBeforeEach() { + return this.getHeading(3, '--before-each--'); + } + + getAfterEach() { + return this.getHeading(3, '--after-each--'); + } + + /** + * Get first code block text from tokens + * + * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` + */ + get code() { + return this.tokens.find(t => t.type === 'code')?.text; + } + + get seedToIterator() { + return seedToIterator(this.tokens); + } + + get textsAndTests() { + const textTokens = []; + const testTokens = []; + for (const token of this.tokens) { + if (token.type === 'paragraph') { + textTokens.push(token); + } + if (token.type === 'code') { + testTokens.push(token); + } + } + const texts = textTokens.map(t => t.text); + const tests = testTokens.map(t => t.text); + return texts.map((text, i) => [text, tests[i]]); + } + + get hints() { + const hintTokens = [[]]; + let currentHint = 0; + for (const token of this.tokens) { + if (token.type === 'heading' && token.depth === 4) { + if (token.text != currentHint) { + currentHint = token.text; + hintTokens[currentHint] = []; + } + } else { + hintTokens[currentHint].push(token); + } + } + const hints = hintTokens.map(t => t.map(t => t.raw).join('')); + return hints; + } + + get markdown() { + return this.tokens.map(t => t.raw).join(''); + } + + get text() { + return this.tokens.map(t => t.text).join(''); + } +} + +function* seedToIterator(tokens) { + const sectionTokens = []; + let currentSection = 0; + for (const token of tokens) { + if ( + token.type === 'heading' && + token.depth === 4 && + token.text !== '--force--' + ) { + if (token.text !== currentSection) { + currentSection = token.text; + sectionTokens[currentSection] = []; + } + } else { + sectionTokens[currentSection].push(token); + } + } +} + +const TOKENS = [ + { + marker: /\d+/, + depth: 2 + }, + { + marker: '--fcc-end--', + depth: 2 + }, + { + marker: '--description--', + depth: 3 + }, + { + marker: '--tests--', + depth: 3 + }, + { + marker: '--seed--', + depth: 3 + }, + { + marker: '--hints--', + depth: 3 + }, + { + marker: '--before-all--', + depth: 3 + }, + { + marker: '--after-all--', + depth: 3 + }, + { + marker: '--before-each--', + depth: 3 + }, + { + marker: '--after-each--', + depth: 3 + }, + { + marker: '--cmd--', + depth: 4 + }, + { + marker: /(?<=--)[^"]+(?="--)/, + depth: 4 + }, + { + marker: '--force--', + depth: 4 + } +]; diff --git a/self/tooling/adjust-url.js b/self/tooling/adjust-url.js new file mode 100644 index 00000000..3114f5ff --- /dev/null +++ b/self/tooling/adjust-url.js @@ -0,0 +1,37 @@ +//! This script adjusts the preview URL for freeCodeCamp - Courses to open the correct preview. +import { readFile, writeFile } from 'fs/promises'; + +let PREVIEW_URL = 'http://localhost:8080'; +if (process.env.GITPOD_WORKSPACE_URL) { + PREVIEW_URL = `https://8080-${ + process.env.GITPOD_WORKSPACE_URL.split('https://')[1] + }`; +} else if (process.env.CODESPACE_NAME) { + PREVIEW_URL = `https://${process.env.CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`; +} + +const VSCODE_SETTINGS_PATH = '.vscode/settings.json'; + +async function main() { + const settings_file = await readFile(VSCODE_SETTINGS_PATH, 'utf-8'); + const settings = JSON.parse(settings_file); + + let [preview] = settings?.['freecodecamp-courses.workspace.previews']; + if (!preview.url) { + throw new Error('.vscode setting not found'); + } + preview.url = PREVIEW_URL; + + await writeFile( + VSCODE_SETTINGS_PATH, + JSON.stringify(settings, null, 2), + 'utf-8' + ); +} + +try { + main(); +} catch (e) { + console.error('Unable to adjust .vscode/settings.json preview url setting:'); + console.error(e); +} diff --git a/t.js b/t.js new file mode 100644 index 00000000..5f034370 --- /dev/null +++ b/t.js @@ -0,0 +1,109 @@ +import { Renderer, Tokenizer, lexer, marked } from 'marked'; +import { + TokenTwister, + getLessonFromFile, + getProjectDescription +} from './parser.js'; +import { readFile } from 'fs/promises'; + +const options = { + async: false, + breaks: false, + gfm: true, + pedantic: false, + renderer: new Renderer(), + silent: false, + tokenizer: new Tokenizer(), + walkTokens: null +}; + +// const lexr = new marked.Lexer(options); + +// const tokens = lexr.lex(markdown); + +// console.log(tokens); +// const lex = lexer(markdown); +// console.log(JSON.stringify(lex, null, 2)); + +const FILE = './a.md'; + +const markdown = await readFile(FILE, 'utf8'); + +const tokenTwister = new TokenTwister(markdown); + +// const projectTitle = tokenTwister.getHeading(1); +// console.log('-'.repeat(10), 'PROJECT TITLE', '-'.repeat(10)); +// console.log(projectTitle.text); +// console.log('-'.repeat(10), 'PROJECT TITLE', '-'.repeat(10)); +// const projectDescription = 'TODO'; +// console.log('-'.repeat(10), 'PROJECT DESCRIPTION', '-'.repeat(10)); +// console.log(projectDescription.markdown); +// console.log('-'.repeat(10), 'PROJECT DESCRIPTION', '-'.repeat(10)); +const lesson_0 = tokenTwister.getLesson(0); +console.log('-'.repeat(10), 'LESSON 0', '-'.repeat(10)); +console.log(lesson_0.markdown); +console.log('-'.repeat(10), 'LESSON 0', '-'.repeat(10)); +// const description_0 = lesson_0.getDescription(); +// console.log('-'.repeat(10), 'DESCRIPTION 0', '-'.repeat(10)); +// console.log(description_0.markdown); +// console.log('-'.repeat(10), 'DESCRIPTION 0', '-'.repeat(10)); +// const tests_0 = lesson_0.getTests(); +// console.log(tests_0.tokens); +// console.log('-'.repeat(10), 'TESTS 0', '-'.repeat(10)); +// console.log(tests_0.markdown); +// console.log('-'.repeat(10), 'TESTS 0', '-'.repeat(10)); +// const texts_and_tests_0 = tests_0.textsAndTests; +// console.log(texts_and_tests_0); +const seed_0 = lesson_0.getSeed(); +console.log('-'.repeat(10), 'SEED 0', '-'.repeat(10)); +console.log(seed_0.markdown); +console.log('-'.repeat(10), 'SEED 0', '-'.repeat(10)); +const hints_0 = lesson_0.getHints(); +console.log('-'.repeat(10), 'HINTS 0', '-'.repeat(10)); +console.log(hints_0.markdown); +console.log('-'.repeat(10), 'HINTS 0', '-'.repeat(10)); +const lesson_1 = tokenTwister.getLesson(1); +console.log('-'.repeat(10), 'LESSON 1', '-'.repeat(10)); +console.log(lesson_1.markdown); +console.log('-'.repeat(10), 'LESSON 1', '-'.repeat(10)); +// const description_1 = lesson_1.getDescription(); +// console.log('-'.repeat(10), 'DESCRIPTION 1', '-'.repeat(10)); +// console.log(description_1.markdown); +// console.log('-'.repeat(10), 'DESCRIPTION 1', '-'.repeat(10)); +// const tests_1 = lesson_1.getTests(); +// console.log(tests_1.tokens); +// console.log('-'.repeat(10), 'TESTS 1', '-'.repeat(10)); +// console.log(tests_1.markdown); +// console.log('-'.repeat(10), 'TESTS 1', '-'.repeat(10)); +// const texts_and_tests_1 = tests_1.textsAndTests; +// console.log(texts_and_tests_1); +const seed_1 = lesson_1.getSeed(); +console.log('-'.repeat(10), 'SEED 1', '-'.repeat(10)); +console.log(seed_1.markdown); +console.log('-'.repeat(10), 'SEED 1', '-'.repeat(10)); +console.log(seed_1); +console.log(seed_1.seedToIterator); +// const hints_1 = lesson_1.getHints(); +// console.log('-'.repeat(10), 'HINTS 1', '-'.repeat(10)); +// console.log(hints_1.markdown); +// console.log('-'.repeat(10), 'HINTS 1', '-'.repeat(10)); +// const hints_markdown_1 = hints_1.hints; +// console.log(hints_markdown_1); +// const before_all_1 = lesson_1.getBeforeAll(); +// console.log('-'.repeat(10), 'BEFORE ALL 1', '-'.repeat(10)); +// console.log(before_all_1.markdown); +// console.log('-'.repeat(10), 'BEFORE ALL 1', '-'.repeat(10)); +// const after_all_1 = lesson_1.getAfterAll(); +// console.log('-'.repeat(10), 'AFTER ALL 1', '-'.repeat(10)); +// console.log(after_all_1.markdown); +// console.log('-'.repeat(10), 'AFTER ALL 1', '-'.repeat(10)); +// const before_each_1 = lesson_1.getBeforeEach(); +// console.log('-'.repeat(10), 'BEFORE EACH 1', '-'.repeat(10)); +// console.log(before_each_1.markdown); +// console.log('-'.repeat(10), 'BEFORE EACH 1', '-'.repeat(10)); +// const after_each_1 = lesson_1.getAfterEach(); +// console.log('-'.repeat(10), 'AFTER EACH 1', '-'.repeat(10)); +// console.log(after_each_1.markdown); +// console.log('-'.repeat(10), 'AFTER EACH 1', '-'.repeat(10)); +// console.log(before_all_1); +// console.log(before_all_1.code); From 9cd04afeffbd4138f91bccc4580ca4f493cdd24e Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Thu, 8 Feb 2024 12:00:41 +0200 Subject: [PATCH 2/7] throw error on methods called outwith expectation --- parser.js | 58 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/parser.js b/parser.js index 6d6e7f92..759dd9ed 100644 --- a/parser.js +++ b/parser.js @@ -5,7 +5,8 @@ import { isForceFlag } from './.freeCodeCamp/tooling/parser.js'; * A class that takes a Markdown string, uses the markedjs package to tokenize it, and provides convenience methods to access different tokens in the token tree */ export class TokenTwister { - constructor(tokensOrMarkdown) { + constructor(tokensOrMarkdown, caller = null) { + this.caller = caller; if (typeof tokensOrMarkdown == 'string') { this.tokens = lexer(tokensOrMarkdown); } else if (Array.isArray(tokensOrMarkdown)) { @@ -15,7 +16,12 @@ export class TokenTwister { } } - getHeading(depth, text) { + getHeading(depth, text, caller) { + if (this.caller !== 'getLesson') { + throw new Error( + `${caller} must be called on getLesson. Called on ${this.caller}` + ); + } const tokens = []; let take = false; for (const token of this.tokens) { @@ -37,7 +43,7 @@ export class TokenTwister { take = true; } } - return new TokenTwister(tokens); + return new TokenTwister(tokens, caller); } getWithinHeading(depth, text) { @@ -84,39 +90,39 @@ export class TokenTwister { } } } - return new TokenTwister(tokens); + return new TokenTwister(tokens, 'getLesson'); } getDescription() { - return this.getHeading(3, '--description--'); + return this.getHeading(3, '--description--', 'getDescription'); } getTests() { - return this.getHeading(3, '--tests--'); + return this.getHeading(3, '--tests--', 'getTests'); } getSeed() { - return this.getHeading(3, '--seed--'); + return this.getHeading(3, '--seed--', 'getSeed'); } getHints() { - return this.getHeading(3, '--hints--'); + return this.getHeading(3, '--hints--', 'getHints'); } getBeforeAll() { - return this.getHeading(3, '--before-all--'); + return this.getHeading(3, '--before-all--', 'getBeforeAll'); } getAfterAll() { - return this.getHeading(3, '--after-all--'); + return this.getHeading(3, '--after-all--', 'getAfterAll'); } getBeforeEach() { - return this.getHeading(3, '--before-each--'); + return this.getHeading(3, '--before-each--', 'getBeforeEach'); } getAfterEach() { - return this.getHeading(3, '--after-each--'); + return this.getHeading(3, '--after-each--', 'getAfterEach'); } /** @@ -125,14 +131,37 @@ export class TokenTwister { * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` */ get code() { + const callers = [ + 'getBeforeAll', + 'getAfterAll', + 'getBeforeEach', + 'getAfterEach' + ]; + if (!callers.includes(this.caller)) { + throw new Error( + `code must be called on "${callers.join(', ')}". Called on ${ + this.caller + }` + ); + } return this.tokens.find(t => t.type === 'code')?.text; } get seedToIterator() { + if (this.caller !== 'getSeed') { + throw new Error( + `seedToIterator must be called on getSeed. Called on ${this.caller}` + ); + } return seedToIterator(this.tokens); } get textsAndTests() { + if (this.caller !== 'getTests') { + throw new Error( + `textsAndTests must be called on getTests. Called on ${this.caller}` + ); + } const textTokens = []; const testTokens = []; for (const token of this.tokens) { @@ -149,6 +178,11 @@ export class TokenTwister { } get hints() { + if (this.caller !== 'getHints') { + throw new Error( + `hints must be called on getHints. Called on ${this.caller}` + ); + } const hintTokens = [[]]; let currentHint = 0; for (const token of this.tokens) { From 0abc4bdc3cf0e748b87685000462439ed35c5541 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Thu, 8 Feb 2024 19:02:51 +0200 Subject: [PATCH 3/7] broken: markdown rendering --- .freeCodeCamp/client/components/console.tsx | 19 +- .freeCodeCamp/client/components/hints.tsx | 14 +- .freeCodeCamp/client/components/test.tsx | 3 +- .freeCodeCamp/client/index.tsx | 4 +- .freeCodeCamp/client/utils/index.ts | 2 +- .freeCodeCamp/plugin/index.js | 55 +- .../tests/fixtures/expected-format.md | 74 --- .../tests/fixtures/valid-poor-format.md | 31 - .freeCodeCamp/tests/parser.test.js | 204 +++--- .freeCodeCamp/tooling/client-socks.js | 58 +- .freeCodeCamp/tooling/env.js | 11 +- .freeCodeCamp/tooling/lesson.js | 29 +- .freeCodeCamp/tooling/parser.js | 592 +++++++++--------- .freeCodeCamp/tooling/reset.js | 17 +- .freeCodeCamp/tooling/seed.js | 19 +- .freeCodeCamp/tooling/server.js | 19 +- .freeCodeCamp/tooling/tests/main.js | 32 +- .freeCodeCamp/tooling/validate.js | 25 +- docs/src/getting-started.md | 13 - parser.js | 283 --------- .../locales/english/build-x-using-y.md | 37 +- 21 files changed, 592 insertions(+), 949 deletions(-) delete mode 100644 .freeCodeCamp/tests/fixtures/expected-format.md delete mode 100644 .freeCodeCamp/tests/fixtures/valid-poor-format.md delete mode 100644 parser.js diff --git a/.freeCodeCamp/client/components/console.tsx b/.freeCodeCamp/client/components/console.tsx index 9f0b06c4..358a59cb 100644 --- a/.freeCodeCamp/client/components/console.tsx +++ b/.freeCodeCamp/client/components/console.tsx @@ -1,5 +1,4 @@ import { ConsoleError } from '../types'; -import { parseMarkdown } from '../utils'; export const Console = ({ cons }: { cons: ConsoleError[] }) => { return ( @@ -12,16 +11,14 @@ export const Console = ({ cons }: { cons: ConsoleError[] }) => { }; const ConsoleElement = ({ testText, testId, error }: ConsoleError) => { - const consoleMarkdown = `
\n${testId + 1}) ${parseMarkdown( - testText - )}\n\n\`\`\`json\n${JSON.stringify( - error, - null, - 2 - )}\n\`\`\`\n\n
`; return ( -
+
+
+ + {testId + 1} {testText} + + {error} +
+
); }; diff --git a/.freeCodeCamp/client/components/hints.tsx b/.freeCodeCamp/client/components/hints.tsx index e58bb0c4..f9d33526 100644 --- a/.freeCodeCamp/client/components/hints.tsx +++ b/.freeCodeCamp/client/components/hints.tsx @@ -1,5 +1,3 @@ -import { parseMarkdown } from '../utils'; - export const Hints = ({ hints }: { hints: string[] }) => { return (
    @@ -11,12 +9,12 @@ export const Hints = ({ hints }: { hints: string[] }) => { }; const HintElement = ({ hint, i }: { hint: string; i: number }) => { - const consoleMarkdown = `
    \nHint ${ - i + 1 - }\n${hint}\n\n
    `; return ( -
    +
    +
    + Hint ${i + 1} + {hint} +
    +
    ); }; diff --git a/.freeCodeCamp/client/components/test.tsx b/.freeCodeCamp/client/components/test.tsx index 9070739a..b8b3ecf9 100644 --- a/.freeCodeCamp/client/components/test.tsx +++ b/.freeCodeCamp/client/components/test.tsx @@ -1,6 +1,5 @@ import { Loader } from './loader'; import { TestType } from '../types'; -import { parseMarkdown } from '../utils'; export const Test = ({ testText, passed, isLoading, testId }: TestType) => { return ( @@ -10,7 +9,7 @@ export const Test = ({ testText, passed, isLoading, testId }: TestType) => {
    ); diff --git a/.freeCodeCamp/client/index.tsx b/.freeCodeCamp/client/index.tsx index e7cd727d..bcc41c32 100644 --- a/.freeCodeCamp/client/index.tsx +++ b/.freeCodeCamp/client/index.tsx @@ -10,7 +10,7 @@ import { import { Loader } from './components/loader'; import { Landing } from './templates/landing'; import { Project } from './templates/project'; -import { parseMarkdown, parse } from './utils/index'; +import { parse } from './utils/index'; import { Header } from './components/header'; import './styles.css'; import { E44o5 } from './components/error'; @@ -129,7 +129,7 @@ const App = () => { } function updateDescription({ description }: { description: string }) { - setDescription(parseMarkdown(description)); + setDescription(description); } function updateTests({ tests }: { tests: TestType[] }) { diff --git a/.freeCodeCamp/client/utils/index.ts b/.freeCodeCamp/client/utils/index.ts index aade0731..e0032f49 100644 --- a/.freeCodeCamp/client/utils/index.ts +++ b/.freeCodeCamp/client/utils/index.ts @@ -14,7 +14,7 @@ marked.use( }) ); -export function parseMarkdown(markdown: string) { +function parseMarkdown(markdown: string) { return marked.parse(markdown, { gfm: true }); } diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js index 90871670..cf8be76f 100644 --- a/.freeCodeCamp/plugin/index.js +++ b/.freeCodeCamp/plugin/index.js @@ -1,3 +1,8 @@ +import { readFile } from 'fs/promises'; +import { freeCodeCampConfig, getState, ROOT } from '../tooling/env.js'; +import { CoffeeDown } from '../tooling/parser.js'; +import { join } from 'path'; + /** * Project config from `config/projects.json` * @typedef {Object} Project @@ -24,6 +29,19 @@ * @property {boolean} isLoading */ +/** + * @typedef {Object} Lesson + * @property {string} description + * @property {[[string, string]]} tests + * @property {string[]} hints + * @property {[{filePath: string; fileSeed: string} | string]} seed + * @property {boolean?} isForce + * @property {string?} beforeAll + * @property {string?} afterAll + * @property {string?} beforeEach + * @property {string?} afterEach + */ + export const pluginEvents = { /** * @param {Project} project @@ -55,5 +73,40 @@ export const pluginEvents = { /** * @param {Project} project */ - onLessonFailed: async project => {} + onLessonFailed: async project => {}, + + /** + * @param {string} projectDashedName + * @returns {Promise<{title: string; description: string; numberOfLessons: number}>} + */ + getProjectMeta: async projectDashedName => { + const { locale } = await getState(); + const projectFilePath = join( + ROOT, + freeCodeCampConfig.curriculum.locales[locale], + projectDashedName + '.md' + ); + const projectFile = await readFile(projectFilePath, 'utf8'); + const coffeeDown = new CoffeeDown(projectFile); + const projectMeta = coffeeDown.getProjectMeta(); + return projectMeta; + }, + + /** + * @param {string} projectDashedName + * @param {number} lessonNumber + * @returns {Promise} lesson + */ + getLesson: async (projectDashedName, lessonNumber) => { + const { locale } = await getState(); + const projectFilePath = join( + ROOT, + freeCodeCampConfig.curriculum.locales[locale], + projectDashedName + '.md' + ); + const projectFile = await readFile(projectFilePath, 'utf8'); + const coffeeDown = new CoffeeDown(projectFile); + const lesson = coffeeDown.getLesson(lessonNumber); + return lesson; + } }; diff --git a/.freeCodeCamp/tests/fixtures/expected-format.md b/.freeCodeCamp/tests/fixtures/expected-format.md deleted file mode 100644 index 17a3ea89..00000000 --- a/.freeCodeCamp/tests/fixtures/expected-format.md +++ /dev/null @@ -1,74 +0,0 @@ -# Project Title - -Project description. - -## 0 - -### --description-- - -Some description. - -Maybe some code: - -```js -const a = 1; -// A comment at the end? -``` - -### --tests-- - -Test text with many words. - -```js -// First test code -const a = 'test'; -``` - -Second test text with `inline-code`. - -```js -const a = 'test2'; -// Second test code; -``` - -### --seed-- - -#### --"some/file.js"-- - -```js -const file = 'some'; -``` - -#### --cmd-- - -```bash -echo "I am bash seed" -``` - -### --hints-- - -#### 0 - -Hint 1. - -#### 1 - -Hint 2, with multiple lines. - -```js -const a = 0; -``` - -### --before-all-- - -```js -global.__beforeAll = 'before-all'; -``` - -### --before-each-- - -```js -global.__beforeEach = 'before-each'; -``` - -## --fcc-end-- diff --git a/.freeCodeCamp/tests/fixtures/valid-poor-format.md b/.freeCodeCamp/tests/fixtures/valid-poor-format.md deleted file mode 100644 index 46b172a0..00000000 --- a/.freeCodeCamp/tests/fixtures/valid-poor-format.md +++ /dev/null @@ -1,31 +0,0 @@ -# Project Title -Project description. -## 0 -### --description-- -This description has no spaces between the heading. -```rs - -//Same goes for this code. -let mut a = 1; -// comment -``` -### --tests-- -Test text at top. -```js -// First test no space -// No code? - -``` - -Second test text with `inline-code`. - - -```js -// Too many spaces? -const a = 'test2'; -``` -### --before-all-- -```js -global.__beforeAll = 'before-all'; -``` -## --fcc-end-- \ No newline at end of file diff --git a/.freeCodeCamp/tests/parser.test.js b/.freeCodeCamp/tests/parser.test.js index e8c3a540..1643b3f7 100644 --- a/.freeCodeCamp/tests/parser.test.js +++ b/.freeCodeCamp/tests/parser.test.js @@ -1,21 +1,7 @@ /// Tests can be run from `self/` -import { - getProjectTitle, - getLessonFromFile, - getLessonDescription, - getLessonTextsAndTests, - getLessonSeed, - getBeforeAll, - getBeforeEach, - getCommands, - getFilesWithSeed, - isForceFlag, - extractStringFromCode, - getLessonHints, - getProjectDescription -} from '../tooling/parser.js'; import { assert } from 'chai'; import { Logger } from 'logover'; +import { pluginEvents } from '../plugin/index.js'; const logover = new Logger({ debug: '\x1b[33m[parser.test]\x1b[0m', @@ -24,116 +10,110 @@ const logover = new Logger({ timestamp: null }); -const EXPECTED_PATH = '../.freeCodeCamp/tests/fixtures/expected-format.md'; -const POOR_PATH = '../.freeCodeCamp/tests/fixtures/valid-poor-format.md'; - try { - const projectTitle = await getProjectTitle(EXPECTED_PATH); - assert.deepEqual(projectTitle, 'Project Title'); - const projectDescription = await getProjectDescription(EXPECTED_PATH); - assert.deepEqual(projectDescription, 'Project description.'); - const lesson = await getLessonFromFile(EXPECTED_PATH, 0); - - const lessonDescription = getLessonDescription(lesson); - assert.equal( - lessonDescription, - '\n\nSome description.\n\nMaybe some code:\n\n```js\nconst a = 1;\n// A comment at the end?\n```\n\n' + const { + title, + description: projectDescription, + numberOfLessons + } = await pluginEvents.getProjectMeta('build-x-using-y'); + const { + description: lessonDescription, + tests, + hints, + seed, + isForce, + beforeAll, + beforeEach, + afterAll, + afterEach + } = await pluginEvents.getLesson('build-x-using-y', 0); + + assert.deepEqual(title, 'Build X Using Y'); + assert.deepEqual( + projectDescription, + 'In this course, you will build x using y.' ); + assert.deepEqual(numberOfLessons, 1); - const lessonTextsAndTests = getLessonTextsAndTests(lesson); + assert.deepEqual( + lessonDescription, + `Some description here. - assert.equal(lessonTextsAndTests[0][0], 'Test text with many words.'); - assert.equal( - lessonTextsAndTests[0][1], - "// First test code\nconst a = 'test';\n" - ); - assert.equal( - lessonTextsAndTests[1][0], - 'Second test text with `inline-code`.' - ); - assert.equal( - lessonTextsAndTests[1][1], - "const a = 'test2';\n// Second test code;\n" - ); +\`\`\`rust +fn main() { + println!("Hello, world!"); +} +\`\`\` - const lessonSeed = getLessonSeed(lesson); +Here is an image: - const lessonHints = getLessonHints(lesson); + - assert.equal(lessonHints.length, 2); - assert.equal(lessonHints.at(0), 'Hint 1.'); - assert.equal( - lessonHints.at(1), - 'Hint 2, with multiple lines.\n\n```js\nconst a = 0;\n```' +` ); - const beforeAll = getBeforeAll(lesson); - assert.equal(beforeAll, "global.__beforeAll = 'before-all';\n"); - - const beforeEach = getBeforeEach(lesson); - assert.equal(beforeEach, "global.__beforeEach = 'before-each';\n"); - - const commands = getCommands(lessonSeed); - - const filesWithSeed = getFilesWithSeed(lessonSeed); - - const isForce = isForceFlag(lessonSeed); -} catch (e) { - throw logover.error(e); -} - -// ----------------- -// VALID POOR FORMAT -// ----------------- - -try { - const projectTitle = await getProjectTitle(POOR_PATH); - assert.deepEqual(projectTitle, 'Project Title'); - const projectDescription = await getProjectDescription(EXPECTED_PATH); - assert.deepEqual(projectDescription, 'Project description.'); - const lesson = await getLessonFromFile(POOR_PATH, 0); - - const lessonDescription = getLessonDescription(lesson); - assert.equal( - lessonDescription, - '\nThis description has no spaces between the heading.\n```rs\n\n//Same goes for this code.\nlet mut a = 1;\n// comment\n```\n' + const expectedTests = [ + [ + 'First test using Chai.js `assert`.', + '// 0\n// Timeout for 3 seconds\nawait new Promise(resolve => setTimeout(resolve, 3000));\nassert.equal(true, true);' + ], + [ + 'Second test using global variables passed from `before` hook.', + "// 1\nawait new Promise(resolve => setTimeout(resolve, 4000));\nassert.equal(__projectLoc, 'example global variable for tests');" + ], + [ + 'Dynamic helpers should be imported.', + "// 2\nawait new Promise(resolve => setTimeout(resolve, 1000));\nassert.equal(__helpers.testDynamicHelper(), 'Helper success!');" + ] + ]; + + for (const [i, [testText, testCode]] of tests.entries()) { + assert.deepEqual(testText, expectedTests[i][0]); + assert.deepEqual(testCode, expectedTests[i][1]); + } + + const expectedHints = [ + 'Inline hint with `some` code `blocks`.\n\n', + 'Multi-line hint with:\n\n```js\nconst code_block = true;\n```\n\n' + ]; + + for (const [i, hint] of hints.entries()) { + assert.deepEqual(hint, expectedHints[i]); + } + + const expectedSeed = [ + { + filePath: 'build-x-using-y/readme.md', + fileSeed: '# Build X Using Y\n\nIn this course\n\n## 0\n\nHello' + }, + 'npm install' + ]; + + let i = 0; + for (const s of seed) { + assert.deepEqual(s, expectedSeed[i]); + i++; + } + assert.deepEqual(i, 2); + + assert.deepEqual(isForce, true); + + assert.deepEqual( + beforeEach, + "await new Promise(resolve => setTimeout(resolve, 1000));\nconst __projectLoc = 'example global variable for tests';" ); - - const lessonTextsAndTests = getLessonTextsAndTests(lesson); - - assert.equal(lessonTextsAndTests[0][0], 'Test text at top.'); - assert.equal( - lessonTextsAndTests[0][1], - '// First test no space\n// No code?\n\n' + assert.deepEqual( + afterEach, + "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('after each');" ); - assert.equal( - lessonTextsAndTests[1][0], - 'Second test text with `inline-code`.' + assert.deepEqual( + beforeAll, + "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('before all');" ); - assert.equal( - lessonTextsAndTests[1][1], - "// Too many spaces?\nconst a = 'test2';\n" + assert.deepEqual( + afterAll, + "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('after all');" ); - - const lessonSeed = getLessonSeed(lesson); - - const beforeAll = getBeforeAll(lesson); - assert.equal(beforeAll, "global.__beforeAll = 'before-all';\n"); -} catch (e) { - throw logover.error(e); -} - -try { - let stringFromCode = extractStringFromCode(`\`\`\`js -const a = 1; -\`\`\``); - assert.equal(stringFromCode, 'const a = 1;\n'); - stringFromCode = extractStringFromCode(`\`\`\`js -const a = 1; -// comment -\`\`\` -`); - assert.equal(stringFromCode, 'const a = 1;\n// comment\n'); } catch (e) { throw logover.error(e); } diff --git a/.freeCodeCamp/tooling/client-socks.js b/.freeCodeCamp/tooling/client-socks.js index 6899efc8..45d6acd5 100644 --- a/.freeCodeCamp/tooling/client-socks.js +++ b/.freeCodeCamp/tooling/client-socks.js @@ -1,3 +1,5 @@ +import { parseMarkdown } from './parser.js'; + export function toggleLoaderAnimation(ws) { ws.send(parse({ event: 'toggle-loader-animation' })); } @@ -8,7 +10,13 @@ export function toggleLoaderAnimation(ws) { * @param {Test[]} tests Array of Test objects */ export function updateTests(ws, tests) { - ws.send(parse({ event: 'update-tests', data: { tests } })); + const renderedTests = tests?.map((test, i) => { + return { + ...test, + testText: parseMarkdown(test.testText) + }; + }); + ws.send(parse({ event: 'update-tests', data: { tests: renderedTests } })); } /** * Update single test in the tests state @@ -16,7 +24,11 @@ export function updateTests(ws, tests) { * @param {Test} test Test object */ export function updateTest(ws, test) { - ws.send(parse({ event: 'update-test', data: { test } })); + const renderedTest = { + ...test, + testText: parseMarkdown(test.testText) + }; + ws.send(parse({ event: 'update-test', data: { test: renderedTest } })); } /** * Update the lesson description @@ -24,7 +36,13 @@ export function updateTest(ws, test) { * @param {string} description Lesson description */ export function updateDescription(ws, description) { - ws.send(parse({ event: 'update-description', data: { description } })); + const renderedDescription = parseMarkdown(description); + ws.send( + parse({ + event: 'update-description', + data: { description: renderedDescription } + }) + ); } /** * Update the heading of the lesson @@ -32,10 +50,14 @@ export function updateDescription(ws, description) { * @param {{lessonNumber: number; title: string;}} projectHeading Project heading */ export function updateProjectHeading(ws, projectHeading) { + const renderedProjectHeading = { + lessonNumber: projectHeading.lessonNumber, + title: parseMarkdown(projectHeading.title) + }; ws.send( parse({ event: 'update-project-heading', - data: projectHeading + data: renderedProjectHeading }) ); } @@ -45,10 +67,17 @@ export function updateProjectHeading(ws, projectHeading) { * @param {Project} project Project object */ export function updateProject(ws, project) { + const renderedProject = project + ? { + title: parseMarkdown(project.title), + description: parseMarkdown(project.description), + ...project + } + : null; ws.send( parse({ event: 'update-project', - data: project + data: renderedProject }) ); } @@ -58,10 +87,17 @@ export function updateProject(ws, project) { * @param {Project[]} projects Array of Project objects */ export function updateProjects(ws, projects) { + const renderedProjects = projects?.map(project => { + return { + title: parseMarkdown(project.title), + description: parseMarkdown(project.description), + ...project + }; + }); ws.send( parse({ event: 'update-projects', - data: projects + data: renderedProjects }) ); } @@ -84,7 +120,8 @@ export function updateFreeCodeCampConfig(ws, config) { * @param {string[]} hints Markdown strings */ export function updateHints(ws, hints) { - ws.send(parse({ event: 'update-hints', data: { hints } })); + const renderedHints = hints?.map(hint => parseMarkdown(hint)); + ws.send(parse({ event: 'update-hints', data: { hints: renderedHints } })); } /** * @@ -92,6 +129,11 @@ export function updateHints(ws, hints) { * @param {{error: string; testText: string; passed: boolean;isLoading: boolean;testId: number;}} cons */ export function updateConsole(ws, cons) { + cons.testText = parseMarkdown(cons.testText); + if (cons.error) { + const error = `\`\`\`json\n${cons.error}\n\`\`\``; + cons.error = parseMarkdown(error); + } ws.send(parse({ event: 'update-console', data: { cons } })); } @@ -130,7 +172,7 @@ export function parse(obj) { * @param {WebSocket} ws WebSocket connection to the client */ export function resetBottomPanel(ws) { - updateHints(ws, ''); + updateHints(ws, []); updateTests(ws, []); updateConsole(ws, {}); } diff --git a/.freeCodeCamp/tooling/env.js b/.freeCodeCamp/tooling/env.js index 0aac9682..d69a4316 100644 --- a/.freeCodeCamp/tooling/env.js +++ b/.freeCodeCamp/tooling/env.js @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; import { logover } from './logger.js'; -import { getProjectTitle, getProjectDescription } from './parser.js'; +import { pluginEvents } from '../plugin/index.js'; export const ROOT = process.env.INIT_CWD || process.cwd(); @@ -77,14 +77,9 @@ export async function getProjectConfig(projectDashedName) { const project = projects.find(p => p.dashedName === projectDashedName); // Add title and description to project - const { locale } = await getState(); - const projectFilePath = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - project.dashedName + '.md' + const { title, description } = await pluginEvents.getProjectMeta( + projectDashedName ); - const title = await getProjectTitle(projectFilePath); - const description = await getProjectDescription(projectFilePath); project.title = title; project.description = description; diff --git a/.freeCodeCamp/tooling/lesson.js b/.freeCodeCamp/tooling/lesson.js index feead542..66c5933e 100644 --- a/.freeCodeCamp/tooling/lesson.js +++ b/.freeCodeCamp/tooling/lesson.js @@ -1,13 +1,5 @@ // This file parses answer files for lesson content import { join } from 'path'; -import { - getLessonFromFile, - getLessonDescription, - getLessonTextsAndTests, - getProjectTitle, - getLessonSeed, - isForceFlag -} from './parser.js'; import { updateDescription, updateProjectHeading, @@ -29,16 +21,12 @@ import { pluginEvents } from '../plugin/index.js'; export async function runLesson(ws, projectDashedName) { const project = await getProjectConfig(projectDashedName); const { isIntegrated, dashedName, seedEveryLesson, currentLesson } = project; - const { locale, lastSeed } = await getState(); - const projectFile = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - dashedName + '.md' - ); + const { lastSeed } = await getState(); try { - const lesson = await getLessonFromFile(projectFile, currentLesson); - - const description = getLessonDescription(lesson); + const { description, seed, isForce, tests } = await pluginEvents.getLesson( + projectDashedName, + currentLesson + ); if (currentLesson === 0) { await pluginEvents.onProjectStart(project); @@ -47,10 +35,9 @@ export async function runLesson(ws, projectDashedName) { updateProject(ws, project); if (!isIntegrated) { - const textsAndTestsArr = getLessonTextsAndTests(lesson); updateTests( ws, - textsAndTestsArr.reduce((acc, curr, i) => { + tests.reduce((acc, curr, i) => { return [ ...acc, { passed: false, testText: curr[0], testId: i, isLoading: false } @@ -60,7 +47,7 @@ export async function runLesson(ws, projectDashedName) { } resetBottomPanel(ws); - const title = await getProjectTitle(projectFile); + const { title } = await pluginEvents.getProjectMeta(projectDashedName); updateProjectHeading(ws, { title, lessonNumber: currentLesson @@ -74,9 +61,7 @@ export async function runLesson(ws, projectDashedName) { (lastSeed?.projectDashedName === dashedName && lastSeed?.lessonNumber !== currentLesson) ) { - const seed = getLessonSeed(lesson); if (seed) { - const isForce = isForceFlag(seed); // force flag overrides seed flag if ((seedEveryLesson && !isForce) || (!seedEveryLesson && isForce)) { await seedLesson(ws, dashedName); diff --git a/.freeCodeCamp/tooling/parser.js b/.freeCodeCamp/tooling/parser.js index 5f4fb977..d6d7046e 100644 --- a/.freeCodeCamp/tooling/parser.js +++ b/.freeCodeCamp/tooling/parser.js @@ -1,333 +1,335 @@ -// This file contains the parser for the markdown lessons -import { basename, join } from 'path'; -import { readFile } from 'fs/promises'; -import { createReadStream } from 'fs'; -import { createInterface } from 'readline'; -import { freeCodeCampConfig, getState, ROOT } from './env.js'; -import { logover } from './logger.js'; - -const DESCRIPTION_MARKER = '### --description--'; -const TESTS_MARKER = '### --tests--'; -const SEED_MARKER = '### --seed--'; -const HINTS_MARKER = `### --hints--`; -const BEFORE_ALL_MARKER = '### --before-all--'; -const AFTER_ALL_MARKER = '### --after-all--'; -const BEFORE_EACH_MARKER = '### --before-each--'; -const AFTER_EACH_MARKER = '### --after-each--'; -const NEXT_MARKER_REG = `\n###? --`; -const CMD_MARKER = '#### --cmd--'; -const FILE_MARKER_REG = '(?<=#### --")[^"]+(?="--)'; +import { lexer } from 'marked'; /** - * Reads the first line of the file to get the project name - * @param {string} file - The relative path to the locale file - * @returns {Promise<{projectTopic: string; currentProject: string}>} The project name + * A class that takes a Markdown string, uses the markedjs package to tokenize it, and provides convenience methods to access different tokens in the token tree */ -export async function getProjectTitle(file) { - const readable = createReadStream(file); - const reader = createInterface({ input: readable }); - const firstLine = await new Promise((resolve, reject) => { - // Timeout after 1 second - const timeout = setTimeout(() => { - reader.close(); - readable.close(); - reject(new Error('Timeout')); - }, 1000); - reader - .on('line', line => { - reader.close(); - clearTimeout(timeout); - resolve(line); - }) - .on('error', err => { - reader.close(); - clearTimeout(timeout); - reject(err); - }); - }); - readable.close(); - const proj = firstLine.replace('# ', ''); - if (!proj) { - throw new Error('Invalid project title. See example format.'); +export class CoffeeDown { + constructor(tokensOrMarkdown, caller = null) { + this.caller = caller; + if (typeof tokensOrMarkdown == 'string') { + this.tokens = lexer(tokensOrMarkdown); + } else if (Array.isArray(tokensOrMarkdown)) { + this.tokens = tokensOrMarkdown; + } else { + this.tokens = [tokensOrMarkdown]; + } } - return proj; -} -/** - * Gets the project description - * @param {string} file - The relative path to the locale file - * @returns {Promise} The project description - */ -export async function getProjectDescription(file) { - const fileContent = await readFile(file, 'utf8'); - const fileContentSansCR = fileContent.replace(/\r/g, ''); - const projectDescription = fileContentSansCR.match( - new RegExp(`#[^\n]+\n(.*?)\n## 0`, 's') - )?.[1]; - if (!projectDescription) { - throw new Error('Project description not found. See example format.'); - } - return projectDescription.trim(); -} + getProjectMeta() { + // There should only be one H1 in the project which is the title + const title = this.tokens.find( + t => t.type === 'heading' && t.depth === 1 + ).text; + // The first paragraph should be the description + const description = this.tokens.find(t => t.type === 'paragraph').text; -/** - * Gets all content within a lesson - * @param {string} file - The relative path to the english locale file - * @param {number} lessonNumber - The number of the lesson - * @returns {Promise} The content of the lesson - */ -export async function getLessonFromFile(file, lessonNumber = 0) { - const fileContent = await readFile(file, 'utf8'); - const fileContentSansCR = fileContent.replace(/\r/g, ''); - const mat = fileContentSansCR.match( - new RegExp( - `## ${lessonNumber}\n(.*?)\n## (${lessonNumber + 1}|--fcc-end--)`, - 's' - ) - ); - const lesson = mat?.[1]; - if (!lesson) { - logover.debug(`Lesson ${lessonNumber} not found in ${file}`); - throw new Error(`Lesson ${lessonNumber} not found in ${file}`); + // All H2 elements with an integer for text are lesson headings + const numberOfLessons = this.tokens.filter( + t => + t.type === 'heading' && + t.depth === 2 && + Number.isInteger(parseFloat(t.text)) + ).length; + return { title, description, numberOfLessons }; } - // Seed might be in external file, but still needs to be returned - // as part of lesson. - let fileSeedContent; - try { - const { locale } = await getState(); - const project = basename(file); - const seedFile = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - project.replace('.md', '-seed.md') - ); - fileSeedContent = await readFile(seedFile, 'utf8'); - } catch (e) { - if (e?.code !== 'ENOENT') { - logover.debug(`Error reading external seed for lesson ${lessonNumber}`); - logover.debug(e); - throw new Error(`Error reading external seed for lesson ${lessonNumber}`); + getHeading(depth, text, caller) { + if (this.caller !== 'getLesson') { + throw new Error( + `${caller} must be called on getLesson. Called on ${this.caller}` + ); } - } - if (fileSeedContent) { - const fileSeedContentSansCR = fileSeedContent.replace(/\r/g, ''); - const seed = fileSeedContentSansCR.match( - // NOTE: For separate seed file, there is no condition for every lesson to - // have a seed - lesson numbers are not necessarily sequential. - new RegExp(`## ${lessonNumber}\n(.*?)\n## (\d+|--fcc-end--)`, 's') - )?.[1]; - if (seed) { - return lesson + seed; + const tokens = []; + let take = false; + for (const token of this.tokens) { + if ( + token.type === 'heading' && + token.depth <= depth && + TOKENS.some(t => t.marker === token.text) + ) { + take = false; + } + if (take) { + tokens.push(token); + } + if ( + token.type === 'heading' && + token.depth === depth && + token.text === text + ) { + take = true; + } } + return new CoffeeDown(tokens, caller); } - return lesson; -} + getLesson(lessonNumber) { + const lesson = this.#getLesson(lessonNumber); + const description = lesson.getDescription().markdown; + const tests = lesson.getTests().tests; + const seed = lesson.getSeed().seed; + const isForce = lesson + .getSeed() + .tokens.some( + t => t.type === 'heading' && t.depth === 4 && t.text === '--force--' + ); + const hints = lesson.getHints().hints; + const beforeAll = lesson.getBeforeAll().code; + const afterAll = lesson.getAfterAll().code; + const beforeEach = lesson.getBeforeEach().code; + const afterEach = lesson.getAfterEach().code; + return { + description, + tests, + seed, + hints, + beforeAll, + afterAll, + beforeEach, + afterEach, + isForce + }; + } -/** - * Gets the description of the lesson - * @param {string} lesson - The lesson content - * @returns {string | null} The description of the lesson - */ -export function getLessonDescription(lesson) { - const description = parseMarker(DESCRIPTION_MARKER, lesson); - return description ?? null; -} + #getLesson(lessonNumber) { + const tokens = []; + let take = false; + for (const token of this.tokens) { + if ( + token.type === 'heading' && + token.depth === 2 && + (parseInt(token.text, 10) === lessonNumber + 1 || + token.text === '--fcc-end--') + ) { + take = false; + } + if (take) { + tokens.push(token); + } + if (token.type === 'heading' && token.depth === 2) { + if (parseInt(token.text, 10) === lessonNumber) { + take = true; + } + } + } + return new CoffeeDown(tokens, 'getLesson'); + } -/** - * Gets the test text and tests of the lesson - * @param {string} lesson - The lesson content - * @returns {[string, string]} An array of [text, test] - */ -export function getLessonTextsAndTests(lesson) { - const testsString = parseMarker(TESTS_MARKER, lesson); - const textsAndTestsArr = []; - const texts = testsString?.match(/^(.*?)$(?=\n+```js)/gm)?.filter(Boolean); - const tests = testsString?.match(/(?<=```js\n).*?(?=```)/gms); + getDescription() { + return this.getHeading(3, '--description--', 'getDescription'); + } - if (texts?.length) { - for (let i = 0; i < texts.length; i++) { - textsAndTestsArr.push([texts[i], tests[i]]); - } + getTests() { + return this.getHeading(3, '--tests--', 'getTests'); } - return textsAndTestsArr; -} -/** - * Gets the seed of the lesson. If none is found, returns `null`. - * @param {string} lesson - The lesson content - * @returns {string | null} The seed of the lesson - */ -export function getLessonSeed(lesson) { - const seed = parseMarker(SEED_MARKER, lesson); - return seed ?? null; -} + getSeed() { + return this.getHeading(3, '--seed--', 'getSeed'); + } -/** - * Gets the hints of the lesson. If none are found, returns `null`. - * @param {string} lesson - The lesson content - * @returns {string[]} The hints of the lesson - */ -export function getLessonHints(lesson) { - const hints = parseMarker(HINTS_MARKER, lesson); - const hintsArr = hints?.split(/\n#### \d+/); - return hintsArr?.map(h => h.trim()).filter(Boolean) ?? []; -} + getHints() { + return this.getHeading(3, '--hints--', 'getHints'); + } -/** - * Gets the command/script to run before running the lesson tests - * @param {string} lesson - The lesson content - * @returns {string | null} The command to run before running the lesson tests - */ -export function getBeforeAll(lesson) { - const beforeAll = parseMarker(BEFORE_ALL_MARKER, lesson); - if (!beforeAll) return null; - const beforeAllCommand = extractStringFromCode(beforeAll); - return beforeAllCommand ?? null; -} + getBeforeAll() { + return this.getHeading(3, '--before-all--', 'getBeforeAll'); + } -/** - * Gets the command/script to run before running each lesson test - * @param {string} lesson - The lesson content - * @returns {string | null} The command to run before running each lesson test - */ -export function getBeforeEach(lesson) { - const beforeEach = parseMarker(BEFORE_EACH_MARKER, lesson); - if (!beforeEach) return null; - const beforeEachCommand = extractStringFromCode(beforeEach); - return beforeEachCommand ?? null; -} + getAfterAll() { + return this.getHeading(3, '--after-all--', 'getAfterAll'); + } -/** - * Gets the command/script to run after running the lesson tests - * @param {string} lesson - The lesson content - * @returns {string} The command to run after running the lesson tests - */ -export function getAfterAll(lesson) { - const afterAll = parseMarker(AFTER_ALL_MARKER, lesson); - if (!afterAll) return null; - const afterAllCommand = extractStringFromCode(afterAll); - return afterAllCommand ?? null; -} + getBeforeEach() { + return this.getHeading(3, '--before-each--', 'getBeforeEach'); + } -/** - * Gets the command/script to run after running each lesson test - * @param {string} lesson - The lesson content - * @returns {string | null} The command to run after running each lesson test - */ -export function getAfterEach(lesson) { - const beforeEach = parseMarker(AFTER_EACH_MARKER, lesson); - if (!beforeEach) return null; - const beforeEachCommand = extractStringFromCode(beforeEach); - return beforeEachCommand; -} + getAfterEach() { + return this.getHeading(3, '--after-each--', 'getAfterEach'); + } -/** - * Gets any commands of the lesson seed. If none is found, returns an empty array. - * @param {string} seed - The seed content - * @returns {string[]} The commands of the lesson in order - */ -export function getCommands(seed) { - const cmds = seed.match(new RegExp(`${CMD_MARKER}\n(.*?\`\`\`\n)`, 'gs')); - const commands = cmds?.map(cmd => extractStringFromCode(cmd)?.trim()); - return commands ?? []; -} + /** + * Get first code block text from tokens + * + * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` + */ + get code() { + const callers = [ + 'getBeforeAll', + 'getAfterAll', + 'getBeforeEach', + 'getAfterEach' + ]; + if (!callers.includes(this.caller)) { + throw new Error( + `code must be called on "${callers.join(', ')}". Called on ${ + this.caller + }` + ); + } + return this.tokens.find(t => t.type === 'code')?.text; + } -/** - * Gets any seed for specified files of the lesson seed. If none is found, returns an empty array. - * @param {string} seed - The seed content - * @returns {[string, string][]} [[filePath, fileSeed]] - */ -export function getFilesWithSeed(seed) { - const files = seed.match( - new RegExp(`#### --"([^"]+)"--\n(.*?\`\`\`\n)`, 'gs') - ); - const filePaths = seed.match(new RegExp(FILE_MARKER_REG, 'gsm')); - const fileSeeds = files?.map(file => extractStringFromCode(file)?.trim()); + get seed() { + if (this.caller !== 'getSeed') { + throw new Error( + `seedToIterator must be called on getSeed. Called on ${this.caller}` + ); + } + return seedToIterator(this.tokens); + } - const pathAndSeedArr = []; - if (filePaths?.length) { - for (let i = 0; i < filePaths.length; i++) { - pathAndSeedArr.push([filePaths[i], fileSeeds[i]]); + get tests() { + if (this.caller !== 'getTests') { + throw new Error( + `textsAndTests must be called on getTests. Called on ${this.caller}` + ); + } + const textTokens = []; + const testTokens = []; + for (const token of this.tokens) { + if (token.type === 'paragraph') { + textTokens.push(token); + } + if (token.type === 'code') { + testTokens.push(token); + } } + const texts = textTokens.map(t => t.text); + const tests = testTokens.map(t => t.text); + return texts.map((text, i) => [text, tests[i]]); } - return pathAndSeedArr; -} -/** - * Returns `boolean` for if lesson seed contains `force` flag - * @param {string} seed - The seed content - * @returns {boolean} Whether the seed has the `force` flag - */ -export function isForceFlag(seed) { - return seed.includes('#### --force--'); -} + get hints() { + if (this.caller !== 'getHints') { + throw new Error( + `hints must be called on getHints. Called on ${this.caller}` + ); + } + const hintTokens = [[]]; + let currentHint = 0; + for (const token of this.tokens) { + if (token.type === 'heading' && token.depth === 4) { + if (token.text != currentHint) { + currentHint = token.text; + hintTokens[currentHint] = []; + } + } else { + hintTokens[currentHint].push(token); + } + } + const hints = hintTokens.map(t => t.map(t => t.raw).join('')); + return hints; + } -/** - * Returns a string stripped from the input codeblock - * @param {string} code - The codeblock to strip - * @returns {string} The stripped codeblock - */ -export function extractStringFromCode(code) { - return code.replace(/.*?```[a-z]+\n(.*?)```.*/s, '$1'); -} + get markdown() { + return this.tokens.map(t => t.raw).join(''); + } -/** - * Return the total number of lessons for a given project - * @param {string} file - The relative path to the english locale file - * @returns {Promise} The stripped codeblock - */ -export async function getTotalLessons(file) { - const fileContent = await readFile(file, 'utf-8'); - const lessonNumbers = fileContent.match(/\n## \d+/g); - const numberOfLessons = lessonNumbers.length; - return numberOfLessons; + get text() { + return this.tokens.map(t => t.text).join(''); + } } -/** - * Returns the content within the given marker of the lesson - * @param {string} marker - * @param {string} lesson - * @returns {string | undefined} content or `undefined` - * - * **NOTE:** Immutably prepends lesson with `\n` if one does not already exist - */ -function parseMarker(marker, lesson) { - // Lesson must start with a new line, for RegExp to work, - // and `\n` in RegExp cannot be removed, because it prevents - // markers from being matched **within** rendered Markdown - const lessonWithSlashN = lesson[0] === '\n' ? lesson : '\n' + lesson; - const mat = lessonWithSlashN.match( - new RegExp(`\n${marker}(((?!${NEXT_MARKER_REG}).)*\n?)`, 's') - ); - return mat?.[1]; +function* seedToIterator(tokens) { + const sectionTokens = {}; + let currentSection = 0; + for (const token of tokens) { + if ( + token.type === 'heading' && + token.depth === 4 && + token.text !== '--force--' + ) { + if (token.text !== currentSection) { + currentSection = token.text; + sectionTokens[currentSection] = {}; + } + } else if (token.type === 'code') { + sectionTokens[currentSection] = token; + } + } + for (const [filePath, { text }] of Object.entries(sectionTokens)) { + if (filePath === '--cmd--') { + yield text; + } else { + yield { + filePath: filePath.slice(3, filePath.length - 3), + fileSeed: text + }; + } + } } -/** - * Returns a generator on the seed for ordered execution - * @param {string} seed The lesson seed - */ -export function* seedToIterator(seed) { - const sections = seed.match(new RegExp(`#### --(((?!#### --).)*\n?)`, 'sg')); - for (const section of sections) { - if (isForceFlag(section)) { - continue; +import { marked } from 'marked'; +import { markedHighlight } from 'marked-highlight'; +import Prism from 'prismjs'; + +marked.use( + markedHighlight({ + highlight: (code, lang) => { + if (Prism.languages[lang]) { + return Prism.highlight(code, Prism.languages[lang], String(lang)); + } else { + return code; + } } + }) +); - const isFile = section.match( - new RegExp(`#### --"([^"]+)"--\n(.*?\`\`\`\n)`, 's') - ); - const isCMD = section.match(new RegExp(`#### --cmd--\n(.*?\`\`\`\n)`, 's')); - if (isFile) { - const filePath = isFile[1]; - const fileSeed = extractStringFromCode(isFile[2])?.trim(); +export function parseMarkdown(markdown) { + return marked.parse(markdown, { gfm: true }); +} - yield { filePath, fileSeed }; - } else if (isCMD) { - yield extractStringFromCode(isCMD[1])?.trim(); - } else { - throw new Error('Seed is malformed'); - } +const TOKENS = [ + { + marker: /\d+/, + depth: 2 + }, + { + marker: '--fcc-end--', + depth: 2 + }, + { + marker: '--description--', + depth: 3 + }, + { + marker: '--tests--', + depth: 3 + }, + { + marker: '--seed--', + depth: 3 + }, + { + marker: '--hints--', + depth: 3 + }, + { + marker: '--before-all--', + depth: 3 + }, + { + marker: '--after-all--', + depth: 3 + }, + { + marker: '--before-each--', + depth: 3 + }, + { + marker: '--after-each--', + depth: 3 + }, + { + marker: '--cmd--', + depth: 4 + }, + { + marker: /(?<=--)[^"]+(?="--)/, + depth: 4 + }, + { + marker: '--force--', + depth: 4 } -} +]; diff --git a/.freeCodeCamp/tooling/reset.js b/.freeCodeCamp/tooling/reset.js index 4f925ce7..295e8fa2 100644 --- a/.freeCodeCamp/tooling/reset.js +++ b/.freeCodeCamp/tooling/reset.js @@ -1,11 +1,9 @@ // Handles all the resetting of the projects - -import { join } from 'path'; import { resetBottomPanel, updateError } from './client-socks.js'; -import { getConfig, getProjectConfig, getState, ROOT } from './env.js'; +import { getProjectConfig, getState } from './env.js'; import { logover } from './logger.js'; -import { getLessonFromFile, getLessonSeed } from './parser.js'; import { runCommand, runLessonSeed } from './seed.js'; +import { pluginEvents } from '../plugin/index.js'; /** * Resets the current project by running, in order, every seed @@ -15,27 +13,18 @@ export async function resetProject(ws) { resetBottomPanel(ws); // Get commands and handle file setting const { currentProject } = await getState(); - const freeCodeCampConfig = await getConfig(); const project = await getProjectConfig(currentProject); const { currentLesson } = project; - const FILE = join( - ROOT, - freeCodeCampConfig.curriculum.locales['english'], - project.dashedName + '.md' - ); let lessonNumber = 0; try { - let lesson = await getLessonFromFile(FILE, lessonNumber); - await gitResetCurrentProjectDir(); while (lessonNumber <= currentLesson) { - const seed = getLessonSeed(lesson); + const { seed } = pluginEvents.getLesson(currentProject, lessonNumber); if (seed) { await runLessonSeed(seed, currentProject, lessonNumber); } lessonNumber++; - lesson = await getLessonFromFile(FILE, lessonNumber); } } catch (err) { updateError(ws, err); diff --git a/.freeCodeCamp/tooling/seed.js b/.freeCodeCamp/tooling/seed.js index 85e3ebd9..e5caf69c 100644 --- a/.freeCodeCamp/tooling/seed.js +++ b/.freeCodeCamp/tooling/seed.js @@ -1,6 +1,5 @@ // This file handles seeding the lesson contents with the seed in markdown. import { join } from 'path'; -import { getLessonFromFile, getLessonSeed, seedToIterator } from './parser.js'; import { ROOT, getState, @@ -14,6 +13,7 @@ import { exec } from 'child_process'; import { logover } from './logger.js'; import { updateError } from './client-socks.js'; import { watcher } from './hot-reload.js'; +import { pluginEvents } from '../plugin/index.js'; const execute = promisify(exec); /** @@ -24,17 +24,13 @@ const execute = promisify(exec); export async function seedLesson(ws, projectDashedName) { // TODO: Use ws to display loader whilst seeding const project = await getProjectConfig(projectDashedName); - const { currentLesson, dashedName } = project; - const { locale } = await getState(); - const projectFile = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - dashedName + '.md' - ); + const { currentLesson } = project; try { - const lesson = await getLessonFromFile(projectFile, currentLesson); - const seed = getLessonSeed(lesson); + const { seed } = await pluginEvents.getLesson( + projectDashedName, + currentLesson + ); await runLessonSeed(seed, currentLesson); await setState({ @@ -94,9 +90,8 @@ export async function runSeed(fileSeed, filePath) { * @param {number} currentLesson */ export async function runLessonSeed(seed, currentLesson) { - const seedGenerator = seedToIterator(seed); try { - for (const cmdOrFile of seedGenerator) { + for (const cmdOrFile of seed) { if (typeof cmdOrFile === 'string') { const { stdout, stderr } = await runCommand(cmdOrFile); if (stdout || stderr) { diff --git a/.freeCodeCamp/tooling/server.js b/.freeCodeCamp/tooling/server.js index 2f8d2703..7cb37004 100644 --- a/.freeCodeCamp/tooling/server.js +++ b/.freeCodeCamp/tooling/server.js @@ -21,13 +21,9 @@ import { hotReload } from './hot-reload.js'; import { hideAll, showFile, showAll } from './utils.js'; import { join } from 'path'; import { logover } from './logger.js'; -import { - getProjectDescription, - getProjectTitle, - getTotalLessons -} from './parser.js'; import { resetProject } from './reset.js'; import { validateCurriculum } from './validate.js'; +import { pluginEvents } from '../plugin/index.js'; const freeCodeCampConfig = await getConfig(); @@ -112,16 +108,11 @@ async function getProjects() { 'utf-8' ) ); - const { locale } = await getState(); for (const project of projects) { - const projectFilePath = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - project.dashedName + '.md' + const { title, description } = await pluginEvents.getProjectMeta( + project.dashedName ); - const title = await getProjectTitle(projectFilePath); - const description = await getProjectDescription(projectFilePath); project.title = title; project.description = description; } @@ -303,7 +294,9 @@ async function updateProjectConfig() { freeCodeCampConfig.curriculum.locales['english'], project.dashedName + '.md' ); - const numberOfLessons = (await getTotalLessons(projectFilePath)) || 1; + const { numberOfLessons } = await pluginEvents.getProjectMeta( + project.dashedName + ); await setProjectConfig(project.dashedName, { numberOfLessons }); } } diff --git a/.freeCodeCamp/tooling/tests/main.js b/.freeCodeCamp/tooling/tests/main.js index 07c6a8a4..c6e57f4b 100644 --- a/.freeCodeCamp/tooling/tests/main.js +++ b/.freeCodeCamp/tooling/tests/main.js @@ -4,15 +4,6 @@ import { logover } from '../logger.js'; import { getProjectConfig, getState, setProjectConfig } from '../env.js'; import { freeCodeCampConfig, ROOT } from '../env.js'; -import { - getLessonFromFile, - getBeforeAll, - getBeforeEach, - getAfterAll, - getAfterEach, - getLessonHints, - getLessonTextsAndTests -} from '../parser.js'; import { updateTest, updateTests, @@ -51,18 +42,14 @@ export async function runTests(ws, projectDashedName) { const { locale } = await getState(); // toggleLoaderAnimation(ws); const lessonNumber = project.currentLesson; - const projectFile = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - project.dashedName + '.md' - ); + let testsState = []; try { - const lesson = await getLessonFromFile(projectFile, lessonNumber); - const beforeAll = getBeforeAll(lesson); - const beforeEach = getBeforeEach(lesson); - const afterAll = getAfterAll(lesson); - const afterEach = getAfterEach(lesson); + const lesson = await pluginEvents.getLesson( + projectDashedName, + lessonNumber + ); + const { beforeAll, beforeEach, afterAll, afterEach, hints, tests } = lesson; if (beforeAll) { try { @@ -76,10 +63,7 @@ export async function runTests(ws, projectDashedName) { } // toggleLoaderAnimation(ws); - const hints = getLessonHints(lesson); - - const textsAndTestsArr = getLessonTextsAndTests(lesson); - testsState = textsAndTestsArr.map((text, i) => { + testsState = tests.map((text, i) => { return { passed: false, testText: text[0], @@ -120,7 +104,7 @@ export async function runTests(ws, projectDashedName) { }); for (let i = 0; i < textsAndTestsArr.length; i++) { - const [text, testCode] = textsAndTestsArr[i]; + const [_text, testCode] = textsAndTestsArr[i]; testsState[i].isLoading = true; updateTest(ws, testsState[i]); diff --git a/.freeCodeCamp/tooling/validate.js b/.freeCodeCamp/tooling/validate.js index c4cb2adf..2803e4f2 100644 --- a/.freeCodeCamp/tooling/validate.js +++ b/.freeCodeCamp/tooling/validate.js @@ -2,14 +2,7 @@ import { join } from 'path'; import { readdir, access, constants, readFile } from 'fs/promises'; import { freeCodeCampConfig, ROOT } from './env.js'; import { logover } from './logger.js'; -import { - getLessonDescription, - getLessonFromFile, - getLessonTextsAndTests, - getLessonSeed, - getProjectTitle, - getProjectDescription -} from './parser.js'; +import { pluginEvents } from '../plugin/index.js'; const CURRICULUM_PATH = join( ROOT, @@ -241,15 +234,20 @@ export async function validateCurriculum() { `Project "${project}" has a lesson number mismatch. Expected "${expectedLessonNumber}" but got "${lessonNumber}"` ); } - const lesson = await getLessonFromFile(projectPath, Number(lessonNumber)); - const description = getLessonDescription(lesson); + const { + description, + seed: seedContents, + tests: textsAndTests + } = await pluginEvents.getLesson( + project.dashedName, + Number(lessonNumber) + ); if (!description?.trim()) { logover.warn( `Project "${project}" has no description for lesson "${lessonNumber}"` ); } - const seedContents = getLessonSeed(lesson); if (seed) { const seedPath = join(CURRICULUM_PATH, seed); const seedFile = await readFile(seedPath, 'utf8'); @@ -263,7 +261,6 @@ export async function validateCurriculum() { } } - const textsAndTests = getLessonTextsAndTests(lesson); if (!textsAndTests.length) { logover.warn( `Project "${project}" has no tests for lesson "${lessonNumber}"` @@ -285,11 +282,11 @@ export async function validateCurriculum() { expectedLessonNumber++; } - const projectTitle = await getProjectTitle(projectPath); + const { title: projectTitle, description: projectDescription } = + await pluginEvents.getProjectMeta(project.dashedName); if (!projectTitle) { throw new Error(`Project "${project}" has no title: '${projectTitle}'`); } - const projectDescription = await getProjectDescription(projectPath); if (!projectDescription) { throw new Error( `Project "${project}" has no description: '${projectDescription}'` diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5b00375f..4ce006c4 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -40,19 +40,6 @@ Add the following required configuration: } ``` -````admonish attention -Technically, the `develop-course` and `run-course` scripts have to be specific values (see below), but you can customize the command with other conditions. - -```json -{ - "scripts": { - "develop-course": "NODE_ENV=development node ./node_modules/@freecodecamp/freecodecamp-os/.freeCodeCamp/tooling/server.js", - "run-course": "NODE_ENV=production node ./node_modules/@freecodecamp/freecodecamp-os/.freeCodeCamp/tooling/server.js" - } -} -``` -```` - ````admonish example collapsible=true ```json { diff --git a/parser.js b/parser.js deleted file mode 100644 index 759dd9ed..00000000 --- a/parser.js +++ /dev/null @@ -1,283 +0,0 @@ -import { lexer } from 'marked'; -import { isForceFlag } from './.freeCodeCamp/tooling/parser.js'; - -/** - * A class that takes a Markdown string, uses the markedjs package to tokenize it, and provides convenience methods to access different tokens in the token tree - */ -export class TokenTwister { - constructor(tokensOrMarkdown, caller = null) { - this.caller = caller; - if (typeof tokensOrMarkdown == 'string') { - this.tokens = lexer(tokensOrMarkdown); - } else if (Array.isArray(tokensOrMarkdown)) { - this.tokens = tokensOrMarkdown; - } else { - this.tokens = [tokensOrMarkdown]; - } - } - - getHeading(depth, text, caller) { - if (this.caller !== 'getLesson') { - throw new Error( - `${caller} must be called on getLesson. Called on ${this.caller}` - ); - } - const tokens = []; - let take = false; - for (const token of this.tokens) { - if ( - token.type === 'heading' && - token.depth <= depth && - TOKENS.some(t => t.marker === token.text) - ) { - take = false; - } - if (take) { - tokens.push(token); - } - if ( - token.type === 'heading' && - token.depth === depth && - token.text === text - ) { - take = true; - } - } - return new TokenTwister(tokens, caller); - } - - getWithinHeading(depth, text) { - const tokens = []; - let take = false; - for (const token of this.tokens) { - if ( - token.type === 'heading' && - token.depth === depth && - token.text === text - ) { - take = true; - } - if ( - token.type === 'heading' && - token?.depth <= depth && - token?.text !== text - ) { - take = false; - } - if (take) { - tokens.push(token); - } - } - return new TokenTwister(tokens); - } - - getLesson(lessonNumber) { - const tokens = []; - let take = false; - for (const token of this.tokens) { - if ( - parseInt(token.text, 10) === lessonNumber + 1 || - token.text === '--fcc-end--' - ) { - take = false; - } - if (take) { - tokens.push(token); - } - if (token.type === 'heading' && token.depth === 2) { - if (parseInt(token.text, 10) === lessonNumber) { - take = true; - } - } - } - return new TokenTwister(tokens, 'getLesson'); - } - - getDescription() { - return this.getHeading(3, '--description--', 'getDescription'); - } - - getTests() { - return this.getHeading(3, '--tests--', 'getTests'); - } - - getSeed() { - return this.getHeading(3, '--seed--', 'getSeed'); - } - - getHints() { - return this.getHeading(3, '--hints--', 'getHints'); - } - - getBeforeAll() { - return this.getHeading(3, '--before-all--', 'getBeforeAll'); - } - - getAfterAll() { - return this.getHeading(3, '--after-all--', 'getAfterAll'); - } - - getBeforeEach() { - return this.getHeading(3, '--before-each--', 'getBeforeEach'); - } - - getAfterEach() { - return this.getHeading(3, '--after-each--', 'getAfterEach'); - } - - /** - * Get first code block text from tokens - * - * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` - */ - get code() { - const callers = [ - 'getBeforeAll', - 'getAfterAll', - 'getBeforeEach', - 'getAfterEach' - ]; - if (!callers.includes(this.caller)) { - throw new Error( - `code must be called on "${callers.join(', ')}". Called on ${ - this.caller - }` - ); - } - return this.tokens.find(t => t.type === 'code')?.text; - } - - get seedToIterator() { - if (this.caller !== 'getSeed') { - throw new Error( - `seedToIterator must be called on getSeed. Called on ${this.caller}` - ); - } - return seedToIterator(this.tokens); - } - - get textsAndTests() { - if (this.caller !== 'getTests') { - throw new Error( - `textsAndTests must be called on getTests. Called on ${this.caller}` - ); - } - const textTokens = []; - const testTokens = []; - for (const token of this.tokens) { - if (token.type === 'paragraph') { - textTokens.push(token); - } - if (token.type === 'code') { - testTokens.push(token); - } - } - const texts = textTokens.map(t => t.text); - const tests = testTokens.map(t => t.text); - return texts.map((text, i) => [text, tests[i]]); - } - - get hints() { - if (this.caller !== 'getHints') { - throw new Error( - `hints must be called on getHints. Called on ${this.caller}` - ); - } - const hintTokens = [[]]; - let currentHint = 0; - for (const token of this.tokens) { - if (token.type === 'heading' && token.depth === 4) { - if (token.text != currentHint) { - currentHint = token.text; - hintTokens[currentHint] = []; - } - } else { - hintTokens[currentHint].push(token); - } - } - const hints = hintTokens.map(t => t.map(t => t.raw).join('')); - return hints; - } - - get markdown() { - return this.tokens.map(t => t.raw).join(''); - } - - get text() { - return this.tokens.map(t => t.text).join(''); - } -} - -function* seedToIterator(tokens) { - const sectionTokens = []; - let currentSection = 0; - for (const token of tokens) { - if ( - token.type === 'heading' && - token.depth === 4 && - token.text !== '--force--' - ) { - if (token.text !== currentSection) { - currentSection = token.text; - sectionTokens[currentSection] = []; - } - } else { - sectionTokens[currentSection].push(token); - } - } -} - -const TOKENS = [ - { - marker: /\d+/, - depth: 2 - }, - { - marker: '--fcc-end--', - depth: 2 - }, - { - marker: '--description--', - depth: 3 - }, - { - marker: '--tests--', - depth: 3 - }, - { - marker: '--seed--', - depth: 3 - }, - { - marker: '--hints--', - depth: 3 - }, - { - marker: '--before-all--', - depth: 3 - }, - { - marker: '--after-all--', - depth: 3 - }, - { - marker: '--before-each--', - depth: 3 - }, - { - marker: '--after-each--', - depth: 3 - }, - { - marker: '--cmd--', - depth: 4 - }, - { - marker: /(?<=--)[^"]+(?="--)/, - depth: 4 - }, - { - marker: '--force--', - depth: 4 - } -]; diff --git a/self/curriculum/locales/english/build-x-using-y.md b/self/curriculum/locales/english/build-x-using-y.md index 15489fe4..6814c13b 100644 --- a/self/curriculum/locales/english/build-x-using-y.md +++ b/self/curriculum/locales/english/build-x-using-y.md @@ -43,7 +43,6 @@ Dynamic helpers should be imported. // 2 await new Promise(resolve => setTimeout(resolve, 1000)); assert.equal(__helpers.testDynamicHelper(), 'Helper success!'); -// assert.fail('test'); ``` ### --before-each-- @@ -74,4 +73,40 @@ await new Promise(resolve => setTimeout(resolve, 1000)); logover.info('after all'); ``` +### --hints-- + +#### 0 + +Inline hint with `some` code `blocks`. + +#### 1 + +Multi-line hint with: + +```js +const code_block = true; +``` + +### --seed-- + +#### --force-- + +#### --"build-x-using-y/readme.md"-- + +```markdown +# Build X Using Y + +In this course + +## 0 + +Hello +``` + +#### --cmd-- + +```bash +npm install +``` + ## --fcc-end-- From 6c6265c18685662d35dc19f0f915fb8a2ca6772c Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Fri, 9 Feb 2024 15:58:19 +0200 Subject: [PATCH 4/7] render on server, parse markdown in parser --- .freeCodeCamp/client/components/block.tsx | 6 +- .freeCodeCamp/client/components/console.tsx | 16 ++-- .freeCodeCamp/client/components/heading.tsx | 12 ++- .freeCodeCamp/client/components/hints.tsx | 12 ++- .freeCodeCamp/plugin/index.js | 28 +++++- .freeCodeCamp/tooling/client-socks.js | 57 ++++------- .freeCodeCamp/tooling/parser.js | 24 ++++- .freeCodeCamp/tooling/tests/main.js | 12 +-- .freeCodeCamp/tooling/tests/test-worker.js | 2 +- .freeCodeCamp/webpack.config.cjs | 5 +- docs/src/CHANGELOG.md | 15 +++ docs/src/plugin-system.md | 50 ++++++++++ package-lock.json | 13 +-- package.json | 8 +- .../locales/english/learn-freecodecamp-os.md | 96 ++++++------------- self/package-lock.json | 23 ++--- self/package.json | 2 +- self/tooling/plugins.js | 24 ++--- 18 files changed, 228 insertions(+), 177 deletions(-) diff --git a/.freeCodeCamp/client/components/block.tsx b/.freeCodeCamp/client/components/block.tsx index 445eff3d..a5afcc51 100644 --- a/.freeCodeCamp/client/components/block.tsx +++ b/.freeCodeCamp/client/components/block.tsx @@ -60,7 +60,11 @@ export const Block = ({ ) : null}
    -

    {description}

    +

    diff --git a/.freeCodeCamp/client/components/console.tsx b/.freeCodeCamp/client/components/console.tsx index 358a59cb..8c72a318 100644 --- a/.freeCodeCamp/client/components/console.tsx +++ b/.freeCodeCamp/client/components/console.tsx @@ -11,14 +11,14 @@ export const Console = ({ cons }: { cons: ConsoleError[] }) => { }; const ConsoleElement = ({ testText, testId, error }: ConsoleError) => { + const details = `${testId + 1} ${testText} + + ${error}`; return ( -
    -
    - - {testId + 1} {testText} - - {error} -
    -
    +
    ); }; diff --git a/.freeCodeCamp/client/components/heading.tsx b/.freeCodeCamp/client/components/heading.tsx index 14057565..45877a59 100644 --- a/.freeCodeCamp/client/components/heading.tsx +++ b/.freeCodeCamp/client/components/heading.tsx @@ -28,6 +28,7 @@ export const Heading = ({ const canGoForward = lessonNumberExists && numberOfLessons && lessonNumber < numberOfLessons - 1; + const h1 = title + (lessonNumberExists ? ' - Lesson ' + lessonNumber : ''); return (