chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
@@ -0,0 +1 @@
dist
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,27 @@
import { cpSync, mkdirSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { version as workerVersion } from '@freecodecamp/browser-scripts/package.json';
import { version as helperVersion } from '@freecodecamp/curriculum-helpers/package.json';
const __dirname = import.meta.dirname;
const distDir = resolve(__dirname, 'dist');
const destJsDir = resolve(distDir, './js');
rmSync(distDir, { recursive: true, force: true });
mkdirSync(destJsDir, { recursive: true });
cpSync(
resolve(__dirname, './node_modules/sass.js/dist/sass.sync.js'),
resolve(destJsDir, 'workers', workerVersion, 'sass.sync.js')
);
cpSync(
resolve(
__dirname,
'./node_modules/@freecodecamp/curriculum-helpers/dist/test-runner'
),
resolve(destJsDir, `test-runner/${helperVersion}/`),
{ recursive: true }
);
@@ -0,0 +1,13 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
export default [
...configTypeChecked,
{
languageOptions: {
globals: {
...globals.node // TODO: migrate to ESM and remove globals
}
}
}
];
+28
View File
@@ -0,0 +1,28 @@
import type { PyodideInterface } from 'pyodide';
export interface FrameDocument extends Document {
__initTestFrame: (e: InitTestFrameArg) => Promise<void>;
__runTest: (
testString: string
) => Promise<
{ pass: boolean } | { err: { message: string; stack?: string } }
>;
}
export interface PythonDocument extends FrameDocument {
__initPythonFrame: () => Promise<void>;
__runPython: (code: string) => Promise<PyodideInterface>;
}
export interface InitTestFrameArg {
code: {
contents?: string;
editableContents?: string;
};
loadEnzyme?: () => void;
}
export type FrameWindow = Window &
typeof globalThis & {
$: typeof $;
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,111 @@
import type { VirtualTypeScriptEnvironment } from '@typescript/vfs';
import type { CompilerHost, CompilerOptions } from 'typescript';
import reactTypes from './react-types.json';
type TS = typeof import('typescript');
type TSVFS = typeof import('@typescript/vfs');
export class Compiler {
ts: TS;
tsvfs: TSVFS;
tsEnv?: VirtualTypeScriptEnvironment;
compilerHost?: CompilerHost;
constructor(ts: TS, tsvfs: TSVFS) {
this.ts = ts;
this.tsvfs = tsvfs;
}
async setup(opts?: { useNodeModules?: boolean; tsconfig?: string }) {
const ts = this.ts;
const tsvfs = this.tsvfs;
// This just parses the JSON, it doesn't do any validation.
const parsedOptions = opts?.tsconfig
? (ts.parseConfigFileTextToJson('', opts.tsconfig).config as {
compilerOptions?: unknown;
})
: undefined;
// For now we're only interested in the compilerOptions, so that's all we're
// extracting and validating. For everything else, we could
// parseJsonConfigFileContent and create a host using createSystem and
// fsMap, but that needs compilerOptions... This is a bit of a chicken and
// egg problem, which we don't need to solve yet.
const validatedOptions = ts.convertCompilerOptionsFromJson(
parsedOptions?.compilerOptions ?? {},
'./'
);
const compilerOptions: CompilerOptions = {
target: ts.ScriptTarget.ES2024,
module: ts.ModuleKind.Preserve, // Babel is handling module transformation, so TS should leave them alone.
skipLibCheck: true, // TODO: look into why this is needed. Are we doing something wrong? Could it be that it's not "synced" with this TS version?
// from the docs: "Note: it's possible for this list to get out of
// sync with TypeScript over time. It was last synced with TypeScript
// 3.8.0-rc."
jsx: ts.JsxEmit.Preserve, // Babel will handle JSX,
allowUmdGlobalAccess: true, // Necessary because React is loaded via a UMD script.
...validatedOptions.options
};
const fsMap = opts?.useNodeModules
? tsvfs.createDefaultMapFromNodeModules(compilerOptions, ts)
: await tsvfs.createDefaultMapFromCDN(
compilerOptions,
ts.version,
false, // TODO: cache this. It needs a store that's available to workers and implements https://github.com/microsoft/TypeScript-Website/blob/ac68b8b8e4a621113c4ee45c4051002fd55ede24/packages/typescript-vfs/src/index.ts#L11
ts
);
// This can be any path, but doing this means import React from 'react' works, if we ever need it.
const reactTypesPath = `/node_modules/@types/react/index.d.ts`;
// It may be necessary to get all the types (global.d.ts etc)
fsMap.set(reactTypesPath, reactTypes['react-18'] || '');
const system = tsvfs.createSystem(fsMap);
// TODO: if passed an invalid compiler options object (e.g. { module:
// ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext
// }), this will throw. When we allow users to set compiler options, we should
// show them the diagnostics from this function.
this.tsEnv = tsvfs.createVirtualTypeScriptEnvironment(
system,
[reactTypesPath],
ts,
compilerOptions
);
this.compilerHost = tsvfs.createVirtualCompilerHost(
system,
compilerOptions,
ts
).compilerHost;
}
compile(rawCode: string, fileName: string) {
if (!this.tsEnv || !this.compilerHost) {
throw Error('TypeScript environment not set up');
}
// If we try to update or create an empty file, the environment will become
// permanently unable to interact with that file. The workaround is to create
// a file with a single newline character.
const code = rawCode || '\n';
// TODO: If creating the file fresh each time is too slow, we can try checking
// if the file exists and updating it if it does.
this.tsEnv.createFile(fileName, code);
const program = this.tsEnv.languageService.getProgram()!;
const emitOutput = this.tsEnv.languageService.getEmitOutput(fileName);
const result = emitOutput.outputFiles[0].text;
const error = this.ts.formatDiagnostics(
this.ts.getPreEmitDiagnostics(program),
this.compilerHost
);
return { result, error };
}
}
@@ -0,0 +1,59 @@
{
"name": "@freecodecamp/browser-scripts",
"version": "1.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"files": [
"dist"
],
"exports": {
".": "./index.d.ts",
"./ts-compiler": "./modules/typescript-compiler.ts",
"./test-runner": "./test-runner.ts",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "index.js",
"scripts": {
"lint": "eslint --max-warnings 0",
"build": "pnpm copy-scripts && pnpm bundle",
"bundle": "NODE_OPTIONS=\"--max-old-space-size=7168\" webpack -c webpack.config.cjs --env production",
"copy-scripts": "tsx ./copy-scripts.ts"
},
"type": "module",
"keywords": [],
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/plugin-transform-runtime": "7.23.7",
"@babel/preset-env": "7.23.7",
"@babel/preset-typescript": "7.23.3",
"@freecodecamp/eslint-config": "workspace:*",
"@typescript/vfs": "1.6.4",
"babel-loader": "8.4.1",
"eslint": "^9.39.1",
"process": "0.11.10",
"tsx": "^4.21.0",
"typescript": "5.9.3",
"util": "0.12.5",
"webpack": "5.106.2",
"webpack-cli": "4.10.0"
},
"dependencies": {
"@freecodecamp/curriculum-helpers": "^9.0.1",
"pyodide": "^0.23.3",
"sass.js": "0.11.1"
}
}
@@ -0,0 +1,242 @@
// We have to specify pyodide.js because we need to import that file (not .mjs)
// and 'import' defaults to .mjs.
// This is to do with how webpack handles node fallbacks - it uses the node
// resolution algorithm to find the file, but that requires the full file name.
// We can't add the extension, because it's in a bundle we're importing. However
// we can import the .js file and then the strictness does not apply.
import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js';
import pkg from 'pyodide/package.json';
import type { PyProxy, PythonError } from 'pyodide/ffi';
import { formatException } from '@freecodecamp/curriculum-helpers';
const ctx: Worker & typeof globalThis = self as unknown as Worker &
typeof globalThis;
let pyodide: PyodideInterface | null = null;
interface PythonRunEvent extends MessageEvent {
data: {
type: 'run';
code: {
contents: string;
editableContents: string;
};
};
}
interface ListenRequestEvent extends MessageEvent {
data: {
type: 'listen';
};
}
interface CancelEvent extends MessageEvent {
data: {
type: 'cancel';
value: number;
};
}
// Since messages are buffered, it needs to be possible to discard 'run'
// messages. Otherwise messages could build up while the worker is busy (for
// example, while loading pyodide) and the work would try to process them in
// sequence. Instead, it will ignore messages until it receives a 'listen'
// message and will inform the client every time it starts ignoring messages.
let ignoreRunMessages = true;
async function setupPyodide() {
if (pyodide) return pyodide;
pyodide = await loadPyodide({
// TODO: host this ourselves
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/`
});
// We freeze this to prevent learners from getting the worker into a
// weird state. NOTE: this has to come after pyodide is loaded, because
// pyodide modifies self while loading.
Object.freeze(self);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
pyodide.FS.writeFile('/home/pyodide/format_exception.py', formatException, {
encoding: 'utf8'
});
ignoreRunMessages = true;
postMessage({ type: 'stopped' });
}
function resetPyodide() {
if (pyodide) pyodide = null;
void setupPyodide();
}
void setupPyodide();
function initRunPython() {
if (!pyodide) throw new Error('pyodide not loaded');
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const str = pyodide.globals.get('str') as (x: unknown) => string;
function print(...args: unknown[]) {
const text = args.map(x => str(x)).join(' ');
postMessage({ type: 'print', text });
}
function input(text: string) {
// TODO: send unique ids to the main thread and the service worker, so we
// can have multiple concurrent input requests.
postMessage({ type: 'input', text });
const request = new XMLHttpRequest();
request.open('POST', '/python/intercept-input/', false);
request.send(null);
// We want to raise a KeyboardInterrupt if the user cancels. To do that,
// this function returns a JS object with the 'type' property set to
// 'cancel'. Then the python code can actually raise the exception.
return JSON.parse(request.responseText) as {
type: 'msg' | 'cancel';
value?: string;
};
}
function __interruptExecution() {
postMessage({ type: 'reset' });
}
// I tried setting jsglobals here, to provide 'input' and 'print' to python,
// without having to modify the global window object. However, it didn't work
// because pyodide needs access to that object. Instead, I used
// registerJsModule when setting up runPython.
// Make print available to python
pyodide.registerJsModule('jscustom', {
print,
input,
__interruptExecution
});
// Create fresh globals each time user code is run.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const globals = pyodide.globals.get('dict')() as PyProxy;
// Some tests rely on __name__ being set to __main__ and we new dicts do not
// have this set by default.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
globals.set('__name__', '__main__');
// The runPython helper is a shortcut for running python code with our
// custom globals.
const runPython = (pyCode: string) =>
pyodide!.runPython(pyCode, { globals }) as unknown;
runPython(`
from pyodide.ffi import JsException
import jscustom
from jscustom import print
from jscustom import input
def __wrap(func):
def fn(*args):
try:
data = func(*args)
if data.type == 'cancel':
raise KeyboardInterrupt(data.value)
return data.value
except JsException:
jscustom.__interruptExecution()
raise
return fn
input = __wrap(input)
`);
// Exposing sys.last_value can create memory leaks, so this just returns a
// string instead of the actual exception. args[0] is what was passed to the
// exception constructor. In our case, that's the id we want.
// TODO: I'm using 'join' to make sure we're not leaking a reference to the
// exception. This might be excessive, but I don't know enough about pyodide
// to be sure.
runPython(`
import sys
def __get_reset_id():
if sys.last_value and sys.last_value.args:
return "".join(str(sys.last_value.args[0]))
else:
return ""
`);
runPython(`
def print_exception():
from format_exception import format_exception
formatted = format_exception(exception=sys.last_value, traceback=sys.last_traceback, filename="<exec>", new_filename="main.py")
print(formatted)
`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const printException = globals.get('print_exception') as PyProxy &
(() => string);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const getResetId = globals.get('__get_reset_id') as PyProxy & (() => string);
return { runPython, getResetId, globals, printException };
}
ctx.onmessage = (e: PythonRunEvent | ListenRequestEvent | CancelEvent) => {
const { data } = e;
if (data.type === 'listen') {
handleListenRequest();
} else if (data.type === 'cancel') {
handleCancelRequest(data);
} else {
handleRunRequest(data);
}
};
// This lets the client know that there is nothing to cancel.
function handleCancelRequest({ value }: { value: number }) {
postMessage({ type: 'is-alive', text: value });
}
function handleListenRequest() {
ignoreRunMessages = false;
}
function handleRunRequest(data: PythonRunEvent['data']) {
try {
if (ignoreRunMessages) return;
const code = (data.code.contents || '').slice();
// TODO: use reset-terminal for clarity?
postMessage({ type: 'reset' });
const { runPython, getResetId, globals, printException } = initRunPython();
// use pyodide.runPythonAsync if we want top-level await
try {
runPython(code);
} catch (e) {
const err = e as PythonError;
// the formatted exception is printed to the terminal
printException();
// but the full error is logged to the console for debugging
console.error(err);
const resetId = getResetId();
// TODO: if a user raises a KeyboardInterrupt with a custom message this
// will be treated as a reset, the client will resend their code and this
// will loop. Can we fix that? Perhaps by using a custom exception?
if (err.type === 'KeyboardInterrupt' && resetId) {
// If the client sends a lot of run messages, it's easy for them to build
// up while the worker is busy. As such, we both ignore any queued run
// messages...
ignoreRunMessages = true;
// ...and tell the client that we're ignoring them.
postMessage({ type: 'stopped', text: getResetId() });
}
} finally {
getResetId.destroy();
printException.destroy();
globals.destroy();
}
} catch (e) {
// This should only be reach if pyodide crashes, but it's helpful to log
// the error in case it's something else.
console.error(e);
void resetPyodide();
}
}
@@ -0,0 +1,40 @@
import { version } from '@freecodecamp/browser-scripts/package.json';
// work around for SASS error in Edge
// https://github.com/medialize/sass.js/issues/96#issuecomment-424386171
interface WorkerWithSass extends Worker {
Sass: {
compile(data: unknown, callback: unknown): void;
};
}
const ctx: WorkerWithSass & typeof globalThis =
self as unknown as WorkerWithSass & typeof globalThis;
if (!ctx.crypto) {
(ctx.crypto as unknown) = {
getRandomValues: function (array: number[]) {
for (let i = 0, l = array.length; i < l; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
};
}
ctx.importScripts(`/js/workers/${version}/sass.sync.js`);
ctx.onmessage = e => {
const data: unknown = e.data;
ctx.Sass.compile(data, (result: Record<string, unknown>) => {
if (result.status === 0) {
ctx.postMessage(result.text);
} else {
ctx.postMessage({
type: 'error',
data: { message: result.formatted }
});
}
});
};
ctx.postMessage({ type: 'contentLoaded' });
@@ -0,0 +1,3 @@
export type { FCCTestRunner } from '@freecodecamp/curriculum-helpers/test-runner.js';
export { version } from '@freecodecamp/curriculum-helpers/package.json';
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["WebWorker", "DOM"],
"allowJs": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"paths": {
"pyodide/ffi": ["./node_modules/pyodide/ffi.d.ts"] // Remove after updating pyodide (later versions correctly export ffi)
}
}
}
@@ -0,0 +1,103 @@
import { Compiler } from './modules/typescript-compiler';
// Most of the ts types are only a guideline. This is because we're not bundling
// TS in this worker. The specific TS version is going to be determined by the
// challenge (in general - it will be hardcoded in the MVP). So, the vfs types
// should be correct, but ts may not be.
declare const tsvfs: typeof import('@typescript/vfs');
declare const ts: typeof import('typescript');
const ctx: Worker & typeof globalThis = self as unknown as Worker &
typeof globalThis;
interface TSCompileEvent extends MessageEvent {
data: {
type: 'compile';
code: string;
};
}
interface TSCompiledMessage {
type: 'compiled';
value: string;
error: string;
}
interface SetupEvent extends MessageEvent {
data: {
type: 'setup';
tsconfig?: string;
};
}
interface CancelEvent extends MessageEvent {
data: {
type: 'cancel';
value: number;
};
}
// Pin at the latest TS version available as cdnjs doesn't support version range.
const TS_VERSION = '5.9.2';
let cachedVersion: string | null = null;
// NOTE: vfs.globals must only be imported once, otherwise it will throw.
importScripts(
'https://cdnjs.cloudflare.com/ajax/libs/typescript-vfs/1.6.1/vfs.globals.js'
);
function importTS(version: string) {
if (cachedVersion == version) return;
importScripts(
/* typescript.min.js fails with
typescript.min.js:320 Uncaught TypeError: Class constructors cannot be invoked without 'new'
so we're using the non-minified version for now. */
`https://cdnjs.cloudflare.com/ajax/libs/typescript/${version}/typescript.js`
);
cachedVersion = version;
}
ctx.onmessage = (e: TSCompileEvent | SetupEvent | CancelEvent) => {
const { data, ports } = e;
if (data.type === 'setup') {
void handleSetupRequest(data, ports[0]);
} else if (data.type === 'cancel') {
handleCancelRequest(data);
} else {
handleCompileRequest(data, ports[0]);
}
};
importTS(TS_VERSION);
const compiler = new Compiler(ts, tsvfs);
// This lets the client know that there is nothing to cancel.
function handleCancelRequest({ value }: { value: number }) {
postMessage({ type: 'is-alive', text: value });
}
async function handleSetupRequest(data: SetupEvent['data'], port: MessagePort) {
await compiler.setup({
tsconfig: data.tsconfig
});
// We freeze this to prevent learners from getting the worker into a weird
// state.
Object.freeze(self);
port.postMessage({ type: 'ready' });
}
function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) {
const { result, error } = compiler.compile(data.code, 'index.tsx');
const message: TSCompiledMessage = {
type: 'compiled',
value: result,
error: error
};
port.postMessage(message);
}
@@ -0,0 +1,22 @@
// TODO: this is a straight up copy of the format function from the client.
// Figure out a way to share it.
import { inspect } from 'util/util.js';
export function format(x) {
// we're trying to mimic console.log, so we avoid wrapping strings in quotes:
if (typeof x === 'string') return x;
else if (x instanceof Set) {
return `Set(${x.size}) {${Array.from(x).join(', ')}}`;
} else if (x instanceof Map) {
return `Map(${x.size}) {${Array.from(
x.entries(),
([k, v]) => `${k} => ${v}`
).join(', ')}})`;
} else if (typeof x === 'bigint') {
return x.toString() + 'n';
} else if (typeof x === 'symbol') {
return x.toString();
}
return inspect(x);
}
@@ -0,0 +1,70 @@
const path = require('path');
const webpack = require('webpack');
const { version } = require('./package.json');
module.exports = (env = {}) => {
const __DEV__ = env.production !== true;
return {
cache: __DEV__ ? { type: 'filesystem' } : false,
mode: __DEV__ ? 'development' : 'production',
entry: {
'sass-compile': './sass-compile.ts',
'python-worker': './python-worker.ts',
'typescript-worker': './typescript-worker.ts'
},
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
output: {
chunkFilename: '[name]-[contenthash].js',
path: path.resolve(__dirname, `dist/js/workers/${version}`),
clean: false // We handle cleaning in copy-scripts.ts
},
stats: {
// Display bailout reasons
optimizationBailout: true
},
module: {
rules: [
{
test: /\.(js|ts)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
babelrc: false,
presets: [
[
'@babel/preset-env',
{ modules: false, targets: '> 0.25%, not dead' }
],
'@babel/preset-typescript'
],
plugins: [
'@babel/plugin-transform-runtime',
'@babel/plugin-syntax-dynamic-import'
]
}
}
}
]
},
plugins: [
new webpack.ProvidePlugin({
process: 'process/browser'
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer']
})
],
resolve: {
fallback: {
buffer: require.resolve('buffer'),
util: require.resolve('util'),
stream: false,
process: require.resolve('process/browser.js')
},
extensions: ['.js', '.ts']
}
};
};
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,61 @@
const crypto = require('crypto');
const createdIds = new Set();
function createChallengeNode(
challenge,
reporter,
{ isReloading } = { isReloading: false }
) {
const contentDigest = crypto
.createHash('md5')
.update(JSON.stringify(challenge))
.digest('hex');
const internal = {
contentDigest,
type: challenge.challengeType === 7 ? 'CertificateNode' : 'ChallengeNode'
};
if (internal.type === 'ChallengeNode') {
const { block, dashedName, superBlock } = challenge;
const slug = `/learn/${superBlock}/${block}/${dashedName}`;
const blockHashSlug = `/learn/${superBlock}/#${block}`;
challenge.fields = {
slug,
blockHashSlug
};
}
// Challenge id should be unique for CertificateNodes, but not for
// ChallengeNodes
const id =
internal.type === 'ChallengeNode' ? challenge.fields.slug : challenge.id;
if (createdIds.has(id) && !isReloading) {
throw Error(`
Challenge slugs must be unique, but ${id} already exists.
`);
}
createdIds.add(id);
return JSON.parse(
JSON.stringify(
Object.assign(
{},
{
children: [],
parent: null,
internal,
sourceInstanceName: 'challenge'
},
{ challenge },
{
id
}
)
)
);
}
exports.createChallengeNode = createChallengeNode;
@@ -0,0 +1,13 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
export default [
...configTypeChecked,
{
languageOptions: {
globals: {
...globals.node // TODO: migrate to ESM and remove globals
}
}
}
];
@@ -0,0 +1,369 @@
const chokidar = require('chokidar');
const { sortBy } = require('lodash');
const {
getSuperblockStructure
} = require('@freecodecamp/curriculum/file-handler');
const {
superBlockToFilename
} = require('@freecodecamp/curriculum/build-curriculum');
const { createChallengeNode } = require('./create-challenge-nodes');
const {
createChallengePages,
getTemplateComponent
} = require('../../../client/utils/gatsby');
// createPagesStatefully only runs once, but we need the following when
// updating challenges, so they have to be stored in memory.
let allChallengeNodes;
const filepathToStatefullyCreatedNodes = new Map();
const filePathToCreatedNodes = new Map();
// reverse lookup, to detect if an updated file has "overwritten" another file
// (i.e. the updated file now has the same node id as another file).
const idToFilepath = new Map();
// recently overwritten files
const idToOverwrittenFile = new Map();
exports.sourceNodes = function sourceChallengesSourceNodes(
{ actions, reporter, createNodeId, createContentDigest },
pluginOptions
) {
const { source, onSourceChange, curriculumPath } = pluginOptions;
if (typeof source !== 'function') {
reporter.panic(`
"source" is a required option for fcc-source-challenges. It must be a
function that delivers challenge objects to the plugin
`);
}
if (typeof onSourceChange !== 'function') {
reporter.panic(`
"onSourceChange" is a required option for fcc-source-challenges. It must be
a function that delivers a new challenge object to the plugin
`);
}
if (typeof curriculumPath !== 'string') {
reporter.panic(`
"curriculumPath" is a required option for fcc-source-challenges. It must be
a path to a curriculum directory
`);
}
const { createNode, deleteNode, deletePage } = actions;
const watcher = chokidar.watch(curriculumPath, {
ignored: /(^|[/\\])\../,
ignoreInitial: true,
persistent: true,
cwd: curriculumPath
});
function deletePages(filePath) {
const statefulNodes = filepathToStatefullyCreatedNodes.get(filePath) || [];
statefulNodes.forEach(node => {
deleteNode(node);
deletePage({
path: node.challenge.fields.slug,
component: getTemplateComponent(node.challenge.challengeType)
});
idToFilepath.delete(node.id);
});
const createdNodes = filePathToCreatedNodes.get(filePath) || [];
createdNodes.forEach(node => {
deleteNode(node);
idToFilepath.delete(node.id);
});
filepathToStatefullyCreatedNodes.delete(filePath);
filePathToCreatedNodes.delete(filePath);
}
function tryToDeletePages(filePath) {
const oldCreatedNodeIds = (filePathToCreatedNodes.get(filePath) ?? []).map(
node => node.id
);
const oldStatefullyCreatedNodeIds = (
filepathToStatefullyCreatedNodes.get(filePath) ?? []
).map(node => node.id);
const oldNodeIds = [...oldCreatedNodeIds, ...oldStatefullyCreatedNodeIds];
const overwrittenFiles = new Set(
oldNodeIds.map(id => idToOverwrittenFile.get(id))
);
if (overwrittenFiles.has(filePath)) {
// since this has already been overwritten, it doesn't need
// deleting, but there's no longer any need to track that it was
// overwritten.
oldNodeIds.forEach(id => {
idToOverwrittenFile.delete(id);
});
} else {
deletePages(filePath);
}
}
function handleChallengeUpdate(filePath, action = 'changed') {
// This has to be a blunt instrument, since we're not watching the structure
// files. If a .md file changes, we have to assume the structure may have
// changed too and update the structure nodes accordingly.
createSuperBlockStructureNodes();
if (action === 'deleted') {
// We have to return before calling onSourceChange, since the file is
// gone.
return tryToDeletePages(filePath);
}
return onSourceChange(filePath)
.then(challenges => {
const actionText = action === 'added' ? 'creating' : 'replacing';
reporter.info(
`Challenge file ${action}: ${filePath}, ${actionText} challengeNodes with ids ${challenges.map(({ id }) => id).join(', ')}`
);
if (action === 'changed') {
tryToDeletePages(filePath);
}
const challengeNodes = challenges.map(challenge =>
reportNodeCreationToGatsby(challenge, {
isReloading: true
})
);
// Track if file has been overwritten.
challengeNodes.forEach(({ id }) => {
const maybeFilepath = idToFilepath.get(id);
if (maybeFilepath) {
idToOverwrittenFile.set(id, maybeFilepath);
}
});
challengeNodes.forEach(node => {
idToFilepath.set(node.id, filePath);
});
// we always need to track the created nodes to ensure the pages get
// recreated.
filePathToCreatedNodes.set(filePath, challengeNodes);
})
.catch(e =>
reporter.error(
`fcc-replace-challenge\nattempting to replace ${filePath}\n\n${e.message}\n${e.stack ? ` ${e.stack}` : ''}`
)
);
}
// On file change, replace only the changed challenge. The key is ensuring
// onSourceChange returns a challenge with complete metadata.
watcher.on('change', filePath =>
/\.md?$/.test(filePath) ? handleChallengeUpdate(filePath, 'changed') : null
);
// On file add, replace just the new challenge.
watcher.on('add', filePath => {
if (!/\.md?$/.test(filePath)) return;
handleChallengeUpdate(filePath, 'added');
});
watcher.on('unlink', filePath => {
if (!/\.md?$/.test(filePath)) return;
handleChallengeUpdate(filePath, 'deleted');
});
function sourceAndCreateNodes() {
return source()
.then(challenges => Promise.all(challenges))
.then(challenges => {
// create challenge nodes
challenges.forEach(challenge => {
const newNode = reportNodeCreationToGatsby(challenge);
const existingNodes =
filepathToStatefullyCreatedNodes.get(challenge.sourceLocation) ||
[];
filepathToStatefullyCreatedNodes.set(challenge.sourceLocation, [
...existingNodes,
newNode
]);
idToFilepath.set(newNode.id, challenge.sourceLocation);
});
// create superblock structure nodes
createSuperBlockStructureNodes();
return Promise.resolve();
})
.catch(e => {
console.log(e);
reporter.panic(`fcc-source-challenges
${e.message}
`);
});
}
function reportNodeCreationToGatsby(challenge, options) {
const challengeNode = createChallengeNode(challenge, reporter, options);
createNode(challengeNode);
return challengeNode;
}
function createSuperBlockStructureNodes() {
Object.keys(superBlockToFilename).forEach(superBlock => {
const filename = superBlockToFilename[superBlock] || superBlock;
try {
const structure = getSuperblockStructure(filename);
const nodeId = createNodeId(`SuperBlockStructure-${superBlock}`);
const nodeContent = JSON.stringify(structure);
createNode({
...structure,
superBlock,
id: nodeId,
parent: null,
children: [],
internal: {
type: 'SuperBlockStructure',
content: nodeContent,
contentDigest: createContentDigest(structure)
}
});
} catch (err) {
reporter.warn(
`Could not load structure for ${superBlock} (${filename}): ${err.message}`
);
}
});
}
return new Promise((resolve, reject) => {
watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject));
});
};
const createIdToNextPathMap = nodes =>
nodes.reduce((map, node, index) => {
const nextNode = nodes[index + 1];
const nextPath = nextNode ? nextNode.challenge.fields.slug : null;
if (nextPath) map[node.id] = nextPath;
return map;
}, {});
const createIdToPrevPathMap = nodes =>
nodes.reduce((map, node, index) => {
const prevNode = nodes[index - 1];
const prevPath = prevNode ? prevNode.challenge.fields.slug : null;
if (prevPath) map[node.id] = prevPath;
return map;
}, {});
exports.createPagesStatefully = async function ({ graphql, actions }) {
const result = await graphql(`
{
allChallengeNode(
sort: [
{ challenge: { superOrder: ASC } }
{ challenge: { order: ASC } }
{ challenge: { challengeOrder: ASC } }
]
) {
edges {
node {
id
challenge {
block
blockLabel
blockLayout
certification
challengeType
dashedName
demoType
disableLoopProtectTests
disableLoopProtectPreview
fields {
slug
blockHashSlug
}
id
isLastChallengeInBlock
order
required {
link
src
}
challengeOrder
challengeFiles {
name
ext
contents
history
fileKey
}
saveSubmissionToDB
solutions {
contents
ext
history
fileKey
}
superBlock
superOrder
template
usesMultifileEditor
chapter
module
}
}
}
}
}
`);
allChallengeNodes = result.data.allChallengeNode.edges.map(
({ node }) => node
);
const idToNextPathCurrentCurriculum =
createIdToNextPathMap(allChallengeNodes);
const idToPrevPathCurrentCurriculum =
createIdToPrevPathMap(allChallengeNodes);
const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum
});
// Create challenge pages.
allChallengeNodes.forEach(nodeToPage);
};
exports.createPages = function ({ actions }) {
if (!allChallengeNodes) return;
// actions.createPage has to be called in the createPages hook
const newNodes = [...filePathToCreatedNodes.values()].flat();
// Nodes need sorting so createChallengePages can find the first and last
// challenges in a block.
const sortedNodes = sortBy(
[...allChallengeNodes, ...newNodes],
['challenge.superOrder', 'challenge.order', 'challenge.challengeOrder']
);
const idToNextPathCurrentCurriculum = createIdToNextPathMap(sortedNodes);
const idToPrevPathCurrentCurriculum = createIdToPrevPathMap(sortedNodes);
for (const node of newNodes) {
const nodeToPage = createChallengePages(actions.createPage, {
idToNextPathCurrentCurriculum,
idToPrevPathCurrentCurriculum
});
nodeToPage(node, 0, sortedNodes);
}
// It's important NOT to clear the createdNodes, since Gatsby deletes any
// pages that are not recreated each time createPages is called.
};
@@ -0,0 +1,33 @@
{
"name": "@freecodecamp/gatsby-source-challenges",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"scripts": {
"lint": "eslint --max-warnings 0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "gatsby-node.js",
"peerDependencies": {
"gatsby": "^5.0.0"
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",
"chokidar": "3.6.0",
"eslint": "^9.39.1"
}
}