diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js index 88062d8f..4f08ad30 100644 --- a/.freeCodeCamp/plugin/index.js +++ b/.freeCodeCamp/plugin/index.js @@ -32,6 +32,7 @@ import { logover } from '../tooling/logger.js'; /** * @typedef {Object} Lesson + * @property {{watch?: string[]; ignore?: string[]} | undefined} meta * @property {string} description * @property {[[string, string]]} tests * @property {string[]} hints @@ -96,8 +97,10 @@ export const pluginEvents = { const coffeeDown = new CoffeeDown(projectFile); const projectMeta = coffeeDown.getProjectMeta(); // Remove `
` tags if present - const title = parseMarkdown(projectMeta.title).replace(/
|<\/p>/g, ''); - const description = parseMarkdown(projectMeta.description); + const title = parseMarkdown(projectMeta.title) + .replace(/
|<\/p>/g, '') + .trim(); + const description = parseMarkdown(projectMeta.description).trim(); const tags = projectMeta.tags; const numberOfLessons = projectMeta.numberOfLessons; return { title, description, numberOfLessons, tags }; @@ -135,14 +138,16 @@ export const pluginEvents = { } } } - const { afterAll, afterEach, beforeAll, beforeEach, isForce } = lesson; - const description = parseMarkdown(lesson.description); + const { afterAll, afterEach, beforeAll, beforeEach, isForce, meta } = + lesson; + const description = parseMarkdown(lesson.description).trim(); const tests = lesson.tests.map(([testText, test]) => [ - parseMarkdown(testText), + parseMarkdown(testText).trim(), test ]); - const hints = lesson.hints.map(parseMarkdown); + const hints = lesson.hints.map(h => parseMarkdown(h).trim()); return { + meta, description, tests, hints, diff --git a/.freeCodeCamp/tests/parser.test.js b/.freeCodeCamp/tests/parser.test.js index 1643b3f7..e674465b 100644 --- a/.freeCodeCamp/tests/parser.test.js +++ b/.freeCodeCamp/tests/parser.test.js @@ -1,4 +1,5 @@ /// Tests can be run from `self/` +/// node ../.freeCodeCamp/tests/parser.test.js import { assert } from 'chai'; import { Logger } from 'logover'; import { pluginEvents } from '../plugin/index.js'; @@ -17,6 +18,7 @@ try { numberOfLessons } = await pluginEvents.getProjectMeta('build-x-using-y'); const { + meta, description: lessonDescription, tests, hints, @@ -29,40 +31,37 @@ try { } = await pluginEvents.getLesson('build-x-using-y', 0); assert.deepEqual(title, 'Build X Using Y'); + assert.deepEqual(meta, { + watch: ['some/file.js'], + ignore: ['another/file.js'] + }); assert.deepEqual( projectDescription, - 'In this course, you will build x using y.' + '
In this course, you will build x using y.
' ); assert.deepEqual(numberOfLessons, 1); assert.deepEqual( lessonDescription, - `Some description here. - -\`\`\`rust -fn main() { - println!("Hello, world!"); -} -\`\`\` - -Here is an image: - - - -` + `Some description here.
+fn main() {
+ println!("Hello, world!");
+}
+
Here is an image:
+` ); const expectedTests = [ [ - 'First test using Chai.js `assert`.', + 'First test using Chai.js assert
.
Second test using global variables passed from before
hook.
Dynamic helpers should be imported.
', "// 2\nawait new Promise(resolve => setTimeout(resolve, 1000));\nassert.equal(__helpers.testDynamicHelper(), 'Helper success!');" ] ]; @@ -73,8 +72,10 @@ Here is an image: } const expectedHints = [ - 'Inline hint with `some` code `blocks`.\n\n', - 'Multi-line hint with:\n\n```js\nconst code_block = true;\n```\n\n' + 'Inline hint with some
code blocks
.
Multi-line hint with:
+const code_block = true;
+
`
];
for (const [i, hint] of hints.entries()) {
diff --git a/.freeCodeCamp/tooling/env.js b/.freeCodeCamp/tooling/env.js
index d69a4316..c71aaf89 100644
--- a/.freeCodeCamp/tooling/env.js
+++ b/.freeCodeCamp/tooling/env.js
@@ -34,7 +34,10 @@ export async function getState() {
// or not is based on the current lesson matching the last seeded lesson
// So, to ensure the first lesson is seeded, this is -1
lessonNumber: -1
- }
+ },
+ // All lessons start at 0, but the logic for whether to run certain effects
+ // is based on the current lesson matching the last lesson
+ lastWatchChange: -1
};
try {
const state = JSON.parse(
diff --git a/.freeCodeCamp/tooling/hot-reload.js b/.freeCodeCamp/tooling/hot-reload.js
index 43016d4f..b0f2a40e 100644
--- a/.freeCodeCamp/tooling/hot-reload.js
+++ b/.freeCodeCamp/tooling/hot-reload.js
@@ -5,17 +5,19 @@ import { runLesson } from './lesson.js';
import { runTests } from './tests/main.js';
import { watch } from 'chokidar';
import { logover } from './logger.js';
+import path from 'path';
+import { readdir } from 'fs/promises';
const defaultPathsToIgnore = [
'.logs/.temp.log',
'config/',
'/node_modules/',
- '.git',
+ '.git/',
'/target/',
'/test-ledger/'
];
-const pathsToIgnore =
+export const pathsToIgnore =
freeCodeCampConfig.hotReload?.ignore || defaultPathsToIgnore;
export const watcher = watch(ROOT, {
@@ -63,3 +65,76 @@ export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) {
}
});
}
+
+/**
+ * Stops the global `watcher` from watching the entire workspace.
+ */
+export function unwatchAll() {
+ const watched = watcher.getWatched();
+ for (const [dir, files] of Object.entries(watched)) {
+ for (const file of files) {
+ watcher.unwatch(path.join(dir, file));
+ }
+ }
+}
+
+// Need to handle
+// From ROOT, must add all directories before file/s
+// path.dirname... all the way to ROOT
+// path.isAbsolute to find out if what was passed into `meta` is absolute or relative
+// path.parse to get the dir and base
+// path.relative(ROOT, path) to get the relative path from ROOT
+// path.resolve directly on `meta`?
+/**
+ * **Example:**
+ * - Assuming ROOT is `/home/freeCodeCampOS/self`
+ * - Takes `lesson-watcher/src/watched.js`
+ * - Calls `watcher.add` on each of these in order:
+ * - `/home/freeCodeCampOS/self`
+ * - `/home/freeCodeCampOS/self/lesson-watcher`
+ * - `/home/freeCodeCampOS/self/lesson-watcher/src`
+ * - `/home/freeCodeCampOS/self/lesson-watcher/src/watched.js`
+ * @param {string} pathRelativeToRoot
+ */
+export function watchPathRelativeToRoot(pathRelativeToRoot) {
+ const paths = getAllPathsWithRoot(pathRelativeToRoot);
+ for (const path of paths) {
+ watcher.add(path);
+ }
+}
+
+function getAllPathsWithRoot(pathRelativeToRoot) {
+ const paths = [];
+ let currentPath = pathRelativeToRoot;
+ while (currentPath !== ROOT) {
+ paths.push(currentPath);
+ currentPath = path.dirname(currentPath);
+ }
+ paths.push(ROOT);
+ // The order does not _seem_ to matter, but the theory says it should
+ return paths.reverse();
+}
+
+/**
+ * Adds all folders and files to the `watcher` instance.
+ *
+ * Does nothing with the `pathsToIgnore`, because they are already ignored by the `watcher`.
+ */
+export async function watchAll() {
+ await watchPath(ROOT);
+}
+
+async function watchPath(rootPath) {
+ const paths = await readdir(rootPath, { withFileTypes: true });
+ for (const p of paths) {
+ const fullPath = path.join(rootPath, p.name);
+ // if (pathsToIgnore.find(i => fullPath.includes(i))) {
+ // console.log('Ignoring: ', fullPath);
+ // continue;
+ // }
+ watcher.add(fullPath);
+ if (p.isDirectory()) {
+ await watchPath(fullPath);
+ }
+ }
+}
diff --git a/.freeCodeCamp/tooling/lesson.js b/.freeCodeCamp/tooling/lesson.js
index eb7763eb..29ee50d8 100644
--- a/.freeCodeCamp/tooling/lesson.js
+++ b/.freeCodeCamp/tooling/lesson.js
@@ -8,10 +8,16 @@ import {
updateError,
resetBottomPanel
} from './client-socks.js';
-import { ROOT, getState, getProjectConfig, freeCodeCampConfig } from './env.js';
+import { ROOT, getState, getProjectConfig, setState } from './env.js';
import { logover } from './logger.js';
import { seedLesson } from './seed.js';
import { pluginEvents } from '../plugin/index.js';
+import {
+ unwatchAll,
+ watchAll,
+ watchPathRelativeToRoot,
+ watcher
+} from './hot-reload.js';
/**
* Runs the lesson from the `projectDashedName` config.
@@ -21,12 +27,14 @@ import { pluginEvents } from '../plugin/index.js';
export async function runLesson(ws, projectDashedName) {
const project = await getProjectConfig(projectDashedName);
const { isIntegrated, dashedName, seedEveryLesson, currentLesson } = project;
- const { lastSeed } = await getState();
+ const { lastSeed, lastWatchChange } = await getState();
try {
- const { description, seed, isForce, tests } = await pluginEvents.getLesson(
- projectDashedName,
- currentLesson
- );
+ const { description, seed, isForce, tests, meta } =
+ await pluginEvents.getLesson(projectDashedName, currentLesson);
+
+ // TODO: Consider performance optimizations
+ // - Do not run at all if whole project does not contain any `meta`.
+ await handleWatcher(meta, { lastWatchChange, currentLesson });
if (currentLesson === 0) {
await pluginEvents.onProjectStart(project);
@@ -74,3 +82,23 @@ export async function runLesson(ws, projectDashedName) {
logover.error(err);
}
}
+
+async function handleWatcher(meta, { lastWatchChange, currentLesson }) {
+ // Calling `watcher` methods takes a performance hit. So, check is behind a check that the lesson has changed.
+ if (lastWatchChange !== currentLesson) {
+ if (meta?.watch) {
+ unwatchAll();
+ for (const path of meta.watch) {
+ const toWatch = join(ROOT, path);
+ watchPathRelativeToRoot(toWatch);
+ }
+ } else if (meta?.ignore) {
+ await watchAll();
+ watcher.unwatch(meta.ignore);
+ } else {
+ // Reset watcher back to default/freecodecamp.conf.json
+ await watchAll();
+ }
+ }
+ await setState({ lastWatchChange: currentLesson });
+}
diff --git a/.freeCodeCamp/tooling/parser.js b/.freeCodeCamp/tooling/parser.js
index 644516bc..3501599e 100644
--- a/.freeCodeCamp/tooling/parser.js
+++ b/.freeCodeCamp/tooling/parser.js
@@ -96,7 +96,10 @@ export class CoffeeDown {
const afterAll = lesson.getAfterAll().code;
const beforeEach = lesson.getBeforeEach().code;
const afterEach = lesson.getAfterEach().code;
+
+ const meta = lesson.getMeta();
return {
+ meta,
description,
tests,
seed,
@@ -165,6 +168,17 @@ export class CoffeeDown {
return this.getHeading(3, '--after-each--', 'getAfterEach');
}
+ getMeta() {
+ const firstHeadingMarker = this.tokens.findIndex(t => {
+ return t.type === 'heading' && t.depth === 3;
+ });
+ const tokensBeforeFirstHeading = this.tokens.slice(0, firstHeadingMarker);
+ const jsonMeta =
+ tokensBeforeFirstHeading.find(t => t.type === 'code' && t.lang === 'json')
+ ?.text ?? '{}';
+ return JSON.parse(jsonMeta);
+ }
+
/**
* Get first code block text from tokens
*
diff --git a/.freeCodeCamp/tooling/test-utils.js b/.freeCodeCamp/tooling/test-utils.js
index 61fd773f..146602de 100644
--- a/.freeCodeCamp/tooling/test-utils.js
+++ b/.freeCodeCamp/tooling/test-utils.js
@@ -8,10 +8,10 @@ import { logover } from './logger.js';
// ---------------
// GENERIC HELPERS
// ---------------
-const PATH_TERMINAL_OUT = join(ROOT, '.logs/.terminal_out.log');
-const PATH_BASH_HISTORY = join(ROOT, '.logs/.bash_history.log');
-const PATH_CWD = join(ROOT, '.logs/.cwd.log');
-const PATH_TEMP = join(ROOT, '.logs/.temp.log');
+export const PATH_TERMINAL_OUT = join(ROOT, '.logs/.terminal_out.log');
+export const PATH_BASH_HISTORY = join(ROOT, '.logs/.bash_history.log');
+export const PATH_CWD = join(ROOT, '.logs/.cwd.log');
+export const PATH_TEMP = join(ROOT, '.logs/.temp.log');
/**
* @typedef ControlWrapperOptions
diff --git a/.freeCodeCamp/tooling/validate.js b/.freeCodeCamp/tooling/validate.js
index 06e15494..daa41b90 100644
--- a/.freeCodeCamp/tooling/validate.js
+++ b/.freeCodeCamp/tooling/validate.js
@@ -371,7 +371,8 @@ export async function validateCurriculum() {
afterAll,
beforeEach,
afterEach,
- isForce
+ isForce,
+ meta
} = lesson;
if (!description) {
warn(
@@ -490,6 +491,13 @@ export async function validateCurriculum() {
'afterEach should be a string'
);
}
+ if (meta?.watch && meta?.ignore) {
+ panic(
+ `Invalid meta in lesson ${i} of ${dashedName}`,
+ meta,
+ 'Lesson should not have both `watch` and `ignore`'
+ );
+ }
}
}
diff --git a/docs/src/CHANGELOG.md b/docs/src/CHANGELOG.md
index 4d18a508..d01b79f4 100644
--- a/docs/src/CHANGELOG.md
+++ b/docs/src/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+## [3.6.0] -
+
+### Add
+
+- Use bash's `script` command to record terminal input and output
+ - Gated behind a feature flag
+ - Existing `.logs/` files will be deprecated in favour of `script` command in `4.0`
+
+## [3.5.1] -
+
+### Fix
+
+- Use `worker.postMessage` to cause side-effects in main thread
+
+## [3.5.0] - 2024-03-18
+
+### Add
+
+- `meta` to `getLesson`
+ - `meta.watch` and `meta.ignore` to alter watch behaviour when lesson loads
+
+### Fix
+
+- Add `/` to end of `.git` in `defaultPathsToIgnore` to prevent files starting with `.git` from being ignored
+- Trim `description` and `title` fields from parser
+
## [3.4.1] - 2024-03-11
### Fix
diff --git a/docs/src/plugin-system.md b/docs/src/plugin-system.md
index 0d7626d5..28c52c3c 100644
--- a/docs/src/plugin-system.md
+++ b/docs/src/plugin-system.md
@@ -65,6 +65,7 @@ This function can be called multiple times per lesson. Therefore, it is expected
```typescript
(projectDashedName: string, lessonNumber: number) =>
Promise<{
+ meta?: { watch?: string[]; ignore?: string[] };
description: string;
tests: [[string, string]];
hints: string[];
@@ -77,6 +78,8 @@ This function can be called multiple times per lesson. Therefore, it is expected
}>;
```
+The `meta` field is expected to be an object with either a `watch` or `ignore` field. The `watch` field is expected to be an array of strings, and the `ignore` field is expected to be an array of strings.
+
The `description` field is expected to be either a plain string, or an HTML string which is then rendered in the client.
The `tests[][0]` field is the test text, and the `tests[][1]` field is the test code. The test text is expected to be either a plain string, or an HTML string.
diff --git a/docs/src/project-syntax.md b/docs/src/project-syntax.md
index f5e395bd..4e49ea36 100644
--- a/docs/src/project-syntax.md
+++ b/docs/src/project-syntax.md
@@ -42,6 +42,25 @@ Zero-based numbering, because of course
```
````
+### `meta`
+
+````markdown
+##