Skip to content

Commit

Permalink
feat(3.5.0): watch and ignore meta per lesson (#528)
Browse files Browse the repository at this point in the history
  • Loading branch information
ShaunSHamilton authored Mar 18, 2024
2 parents 643cb1f + 5c0678f commit e498250
Show file tree
Hide file tree
Showing 18 changed files with 308 additions and 44 deletions.
17 changes: 11 additions & 6 deletions .freeCodeCamp/plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,8 +97,10 @@ export const pluginEvents = {
const coffeeDown = new CoffeeDown(projectFile);
const projectMeta = coffeeDown.getProjectMeta();
// Remove `<p>` tags if present
const title = parseMarkdown(projectMeta.title).replace(/<p>|<\/p>/g, '');
const description = parseMarkdown(projectMeta.description);
const title = parseMarkdown(projectMeta.title)
.replace(/<p>|<\/p>/g, '')
.trim();
const description = parseMarkdown(projectMeta.description).trim();
const tags = projectMeta.tags;
const numberOfLessons = projectMeta.numberOfLessons;
return { title, description, numberOfLessons, tags };
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 20 additions & 19 deletions .freeCodeCamp/tests/parser.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +18,7 @@ try {
numberOfLessons
} = await pluginEvents.getProjectMeta('build-x-using-y');
const {
meta,
description: lessonDescription,
tests,
hints,
Expand All @@ -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.'
'<p>In this course, you will build x using y.</p>'
);
assert.deepEqual(numberOfLessons, 1);

assert.deepEqual(
lessonDescription,
`Some description here.
\`\`\`rust
fn main() {
println!("Hello, world!");
}
\`\`\`
Here is an image:
<img src="../../images/fcc_primary_large.png" width="300px" />
`
`<p>Some description here.</p>
<pre><code class="language-rust"><span class="token keyword">fn</span> <span class="token function-definition function">main</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token macro property">println!</span><span class="token punctuation">(</span><span class="token string">"Hello, world!"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre><p>Here is an image:</p>
<img src="../../images/fcc_primary_large.png" width="300px" />`
);

const expectedTests = [
[
'First test using Chai.js `assert`.',
'<p>First test using Chai.js <code>assert</code>.</p>',
'// 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.',
'<p>Second test using global variables passed from <code>before</code> hook.</p>',
"// 1\nawait new Promise(resolve => setTimeout(resolve, 4000));\nassert.equal(__projectLoc, 'example global variable for tests');"
],
[
'Dynamic helpers should be imported.',
'<p>Dynamic helpers should be imported.</p>',
"// 2\nawait new Promise(resolve => setTimeout(resolve, 1000));\nassert.equal(__helpers.testDynamicHelper(), 'Helper success!');"
]
];
Expand All @@ -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'
'<p>Inline hint with <code>some</code> code <code>blocks</code>.</p>',
`<p>Multi-line hint with:</p>
<pre><code class="language-js"><span class="token keyword">const</span> code_block <span class="token operator">=</span> <span class="token boolean">true</span><span class="token punctuation">;</span>
</code></pre>`
];

for (const [i, hint] of hints.entries()) {
Expand Down
5 changes: 4 additions & 1 deletion .freeCodeCamp/tooling/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
79 changes: 77 additions & 2 deletions .freeCodeCamp/tooling/hot-reload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
}
}
}
40 changes: 34 additions & 6 deletions .freeCodeCamp/tooling/lesson.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
}
14 changes: 14 additions & 0 deletions .freeCodeCamp/tooling/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*
Expand Down
8 changes: 4 additions & 4 deletions .freeCodeCamp/tooling/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion .freeCodeCamp/tooling/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ export async function validateCurriculum() {
afterAll,
beforeEach,
afterEach,
isForce
isForce,
meta
} = lesson;
if (!description) {
warn(
Expand Down Expand Up @@ -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`'
);
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions docs/src/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading

0 comments on commit e498250

Please sign in to comment.