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
+1
View File
@@ -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,18 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
const baseLanguageOptions = {
globals: {
...globals.browser,
...globals.node // TODO: necessary?
}
};
export default defineConfig({
extends: [configTypeChecked],
languageOptions: {
...baseLanguageOptions
}
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@freecodecamp/challenge-builder",
"version": "0.0.1",
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"description": "Builds challenges for testing and rendering",
"private": false,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"exports": {
"./build": "./dist/build.js",
"./transformers": "./dist/transformers.js",
"./typescript-worker-handler": "./dist/typescript-worker-handler.js",
"./builders": "./dist/builders.js"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"build": "tsc",
"lint": "eslint --max-warnings 0"
},
"type": "module",
"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",
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/lodash-es": "4.17.12",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.28.5",
"@babel/standalone": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/shared": "workspace:*",
"lodash-es": "4.18.1"
}
}
@@ -0,0 +1,61 @@
export type Messenger<Message> = {
postMessage: (message: Message, options: WindowPostMessageOptions) => void;
};
/**
* Sends a message via a messenger (an object containing postMessage) and awaits a response.
*
* @template MessageOut - The type of the message being sent.
* @template MessageIn - The type of the message expected to be returned. Must include a `type` and `value` property.
* @template Value - The type of the value expected in the response message.
*
* @param {Object} params - The parameters for the function.
* @param {Message} params.messenger - An object cabable of sending messages via postMessage.
* @param {MessageOut} params.message - The message to send .
* @param {Function} params.onMessage - A callback function to handle the response.
* @param {MessageIn} params.onMessage.response - The response message.
* @param {Function} params.onMessage.resolve - A function which, when called, resolves the promise with its argument.
* @param {Function} params.onMessage.reject - A function which, when called, rejects the promise with its argument.
*
* @returns {Promise<Value>} A promise that resolves with the response value or rejects with an error message.
*/
export function awaitResponse<
MessageOut,
MessageIn extends { type: string; value: Value; error: string },
Value
>({
messenger,
message,
onMessage
}: {
messenger: Messenger<MessageOut>;
message: MessageOut;
onMessage: (
response: MessageIn,
onSuccess: (res: Value) => void,
onFailure: (err: Error) => void
) => void;
}): Promise<Value> {
return new Promise(
(resolve: (res: Value) => void, reject: (err: Error) => void) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
messenger.postMessage(message, {
targetOrigin: '*',
transfer: [channel.port2]
});
}
);
}
@@ -0,0 +1,94 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it, vi } from 'vitest';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { buildChallenge, getTSConfig } from './build';
vi.mock('./typescript-worker-handler.js', () => ({
compileTypeScriptCode: () => Promise.resolve('const compiled = true;'),
setupTSCompiler: () => Promise.resolve(true)
}));
describe('getTSConfig', () => {
it("should return the tsconfig file's contents if it exists", () => {
const compileOptions = 'any string is valid here';
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'tsconfig', ext: 'json', contents: compileOptions }
] as ChallengeFile[];
expect(getTSConfig(challengeFiles)).toEqual(compileOptions);
});
it('should return null if there is no tsconfig file', () => {
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'app', ext: 'ts' }
] as ChallengeFile[];
expect(getTSConfig(challengeFiles)).toBeNull();
});
it('should throw an error if there are multiple tsconfig.json files', () => {
const challengeFiles = [
{ name: 'index', ext: 'ts' },
{ name: 'tsconfig', ext: 'json' },
{ name: 'tsconfig', ext: 'json' }
] as ChallengeFile[];
expect(() => getTSConfig(challengeFiles)).toThrow(
'TypeScript challenge must include only one tsconfig.json file'
);
});
});
describe('buildChallenge', () => {
it('separates source files when building the test runner source map', async () => {
const challengeFiles = [
{
name: 'index',
ext: 'html',
contents:
'<!doctype html><html><head><link rel="stylesheet" href="styles.css"></head><body><script src="index.ts"></script></body></html>',
fileKey: 'indexhtml',
history: ['index.html']
},
{
name: 'styles',
ext: 'css',
contents: '',
fileKey: 'stylescss',
history: ['styles.css']
},
{
name: 'index',
ext: 'ts',
contents: 'interface FlashCard {}',
fileKey: 'indexts',
history: ['index.ts']
}
] as ChallengeFile[];
const result = await buildChallenge(
{
challengeType: challengeTypes.lab,
challengeFiles,
required: [],
template: '',
url: ''
},
{
preview: false,
disableLoopProtectTests: true,
disableLoopProtectPreview: true
}
);
expect(result.sources?.contents).toContain(
'</html>\n\ninterface FlashCard {}'
);
});
});
+324
View File
@@ -0,0 +1,324 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { concatHtml } from './builders.js';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from './transformers.js';
import { setupTSCompiler } from './typescript-worker-handler.js';
interface Source {
index: string;
contents?: string;
editableContents: string;
}
interface BuildChallengeData {
challengeType: number;
challengeFiles?: ChallengeFile[];
required: { src?: string }[];
template: string;
url: string;
}
interface BuildOptions {
preview: boolean;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
usesTestRunner?: boolean;
}
type ApplyFunctionProps = (
file: ChallengeFile
) => Promise<ChallengeFile> | ChallengeFile;
const applyFunction =
(fn: ApplyFunctionProps) => async (file: ChallengeFile) => {
try {
if (file.error) {
return file;
}
const newFile = await fn.call(this, file);
if (typeof newFile !== 'undefined') {
return newFile;
}
return file;
} catch (error) {
return { ...file, error };
}
};
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
// Test helpers parse sources.contents across all files. Without this boundary,
// an index.ts first-line declaration can be concatenated onto empty/no-newline
// HTML or CSS, so AST checks fail.
const joinWithFileBoundaries = (contents: string[]) => contents.join('\n');
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const index = joinWithFileBoundaries(
challengeFiles.map(challengeFile => challengeFile.source ?? '')
);
const editableContents = joinWithFileBoundaries(
challengeFiles.map(challengeFile => challengeFile.editableContents ?? '')
);
const source: Source | undefined = challengeFiles.length
? {
index,
contents: index,
editableContents
}
: {
index,
editableContents
};
return source;
}
const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
[challengeTypes.modern]: buildDOMChallenge,
[challengeTypes.backend]: buildBackendChallenge,
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge,
[challengeTypes.multifilePythonCertProject]: buildPythonChallenge,
[challengeTypes.lab]: buildDOMChallenge,
[challengeTypes.jsLab]: buildJSChallenge,
[challengeTypes.pyLab]: buildPythonChallenge,
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
[challengeTypes.dailyChallengePy]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
const { challengeType } = challengeData;
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const prefixDoctype = ({
build,
sources
}: {
build: string;
sources: Source;
}) => {
// DOCTYPE should be the first thing written to the frame, so if the user code
// includes a DOCTYPE declaration, we need to find it and write it first.
const doctype = sources.contents?.match(/^<!DOCTYPE html>/i)?.[0] || '';
return doctype + build;
};
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
type BuildResult = {
challengeType: number;
build?: string;
sources: Source | undefined;
loadEnzyme?: boolean;
error?: unknown;
};
function hasTS(challengeFiles: ChallengeFile[]) {
return challengeFiles.some(
challengeFile => challengeFile.ext === 'ts' || challengeFile.ext === 'tsx'
);
}
const isTSConfig = (f: { name: string; ext: string }) =>
f.name === 'tsconfig' && f.ext === 'json';
export function getTSConfig(challengeFiles: ChallengeFile[]) {
const tsConfigFiles = challengeFiles.filter(isTSConfig);
if (tsConfigFiles.length > 1) {
throw new Error(
'TypeScript challenge must include only one tsconfig.json file'
);
}
return tsConfigFiles.length === 1 ? tsConfigFiles[0].contents : null;
}
async function configureTSCompiler(challengeFiles: ChallengeFile[]) {
if (hasTS(challengeFiles)) {
const tsConfig = getTSConfig(challengeFiles);
if (tsConfig) {
await setupTSCompiler(tsConfig);
} else {
await setupTSCompiler();
}
}
}
// TODO: All the buildXChallenge files have a similar structure, so make that
// abstraction (function, class, whatever) and then create the various functions
// out of it.
async function buildDOMChallenge(
{
challengeFiles,
required = [],
template = '',
challengeType
}: BuildChallengeData,
options?: BuildOptions
): Promise<BuildResult> {
// TODO: make this required in the schema.
if (!challengeFiles) throw Error('No challenge files provided');
const hasJsx = challengeFiles.some(
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
);
await configureTSCompiler(challengeFiles);
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
const isMultifile = sourceFiles.length > 1;
// I'm reasonably sure this is fine, but we need to migrate transformers to
// TypeScript to be sure.
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
? getMultifileJSXTransformers(options)
: getTransformers(options)) as unknown as ApplyFunctionProps[];
const pipeLine = composeFunctions(...transformers);
const finalFiles = await Promise.all(sourceFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const contents = (await embedFilesInHtml(finalFiles)) as string;
// if there is an error, we just build the test runner so that it can be
// used to run tests against the code without actually running the code.
const toBuild = error
? {}
: {
required,
template,
contents
};
const requiresReact16 = required.some(({ src }) =>
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
);
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
async function buildJSChallenge(
{
challengeFiles,
challengeType
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
options: BuildOptions
): Promise<BuildResult> {
if (!challengeFiles) throw Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getTransformers(options) as unknown as ApplyFunctionProps[])
);
await configureTSCompiler(challengeFiles);
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
const finalFiles = await Promise.all(sourceFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [...body, challengeFile.contents],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {
if (!challengeFiles) throw new Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getPythonTransformers() as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const sources = buildSourceMap(finalFiles);
return {
challengeType,
sources,
build: sources?.contents,
error
};
}
@@ -0,0 +1,44 @@
import { template as _template } from 'lodash-es';
interface ConcatHTMLOptions {
required?: { src?: string; link?: string }[];
template?: string;
contents?: string;
testRunner?: string;
}
export function concatHtml({
required = [],
template,
contents
}: ConcatHTMLOptions): string {
const embedSource = template
? _template(template)
: ({ source }: { source: ConcatHTMLOptions['contents'] }) => source;
const head = required
.map(({ link, src }) => {
if (link && src) {
throw new Error(`
A required file can not have both a src and a link: src = ${src}, link = ${link}
`);
}
if (src) {
return `<script src='${src}' type='text/javascript'></script>`;
}
if (link) {
return `<link href='${link}' rel='stylesheet' />`;
}
return '';
})
.join('\n');
return `<head>${head}</head>${embedSource({ source: contents }) || ''}`;
}
export function createPythonTerminal(pythonRunnerSrc: string): string {
const head =
'<head><style>#terminal { margin-top: 10px; width: 100%; height: 350px; background-color: #000; color: #00ff00; padding: 5px; overflow: auto; border: 1px solid #ccc; border-radius: 3px; }</style></head>';
const body = `<body><div id='terminal'></div><script src='${pythonRunnerSrc}' type='text/javascript'></script></body>`;
return `<html>${head}${body}</html>`;
}
@@ -0,0 +1,420 @@
import protect from '@freecodecamp/loop-protect';
import {
cond,
flow,
identity,
matchesProperty,
overSome,
partial,
stubTrue
} from 'lodash-es';
import {
transformContents,
createSource
} from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from './worker-executor';
import { compileTypeScriptCode } from './typescript-worker-handler';
const protectTimeout = 100;
const testProtectTimeout = 1500;
const loopsPerTimeoutCheck = 100;
const testLoopsPerTimeoutCheck = 2000;
const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd';
const BABEL_ENV_OPTIONS = {
targets: '> 0.4%, not dead'
};
function loopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may fail if this is not changed.`
);
}
function testLoopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may be failing because of this.`
);
}
// hold Babel and presets so we don't try to import them multiple times
let Babel;
let presetEnv, presetReact;
let presetsJS, presetsJSX;
async function loadBabel() {
if (Babel) return;
Babel = await import(
/* webpackChunkName: "@babel/standalone" */ '@babel/standalone'
);
Babel.registerPlugin(
'loopProtection',
protect(protectTimeout, loopProtectCB, loopsPerTimeoutCheck)
);
Babel.registerPlugin(
'testLoopProtection',
protect(testProtectTimeout, testLoopProtectCB, testLoopsPerTimeoutCheck)
);
}
async function loadPresetEnv() {
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJS = {
presets: [[presetEnv, BABEL_ENV_OPTIONS]]
};
}
async function loadPresetReact() {
if (!presetReact)
presetReact = await import(
/* webpackChunkName: "@babel/preset-react" */ '@babel/preset-react'
);
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJSX = {
presets: [[presetEnv, BABEL_ENV_OPTIONS], presetReact]
};
}
const babelTransformCode = options => code =>
Babel.transform(code, options).code;
const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testTSX = matchesProperty('ext', 'tsx');
const testTypeScript = matchesProperty('ext', 'ts');
const testHTML = matchesProperty('ext', 'html');
const testHTML$JS$JSX$TS$TSX = overSome(
testHTML,
testJS,
testJSX,
testTypeScript,
testTSX
);
const replaceNBSP = cond([
[
testHTML$JS$JSX$TS$TSX,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
]);
const getJSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getTSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const createTranspiler = loopProtectOptions => {
return cond([
[testJS, getJSTranspiler(loopProtectOptions)],
[testJSX, getJSXTranspiler(loopProtectOptions)],
[testTypeScript, getTSTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: false })],
[stubTrue, identity]
]);
};
const createModuleTransformer = loopProtectOptions => {
return cond([
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: true })],
[stubTrue, identity]
]);
};
function getBabelOptions(
presets,
{ preview, disableLoopProtectTests, disableLoopProtectPreview } = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
}
) {
// we protect the preview unless specifically disabled, since it evaluates as
// the user types and they may briefly have infinite looping code accidentally
if (preview && !disableLoopProtectPreview)
return { ...presets, plugins: ['loopProtection'] };
if (!disableLoopProtectTests)
return { ...presets, plugins: ['testLoopProtection'] };
return presets;
}
const sassWorkerExecutor = new WorkerExecutor(
`workers/${version}/sass-compile`
);
async function transformSASS(documentElement) {
// we only teach scss syntax, not sass. Also the compiler does not seem to be
// able to deal with sass.
const styleTags = documentElement.querySelectorAll(
'style[type~="text/scss"]'
);
await Promise.all(
[].map.call(styleTags, async style => {
style.type = 'text/css';
style.innerHTML = await sassWorkerExecutor.execute(style.innerHTML, 5000)
.done;
})
);
}
async function transformScript(documentElement, { useModules }) {
await loadBabel();
await loadPresetEnv();
await loadPresetReact();
const scriptTags = documentElement.querySelectorAll('script');
scriptTags.forEach(script => {
const isBabel = script.type === 'text/babel';
const hasSource = !!script.src;
// TODO: make the use of JSX conditional on more than just the script type.
// It should only be used for React challenges since it would be confusing
// for learners to see the results of a transformation they didn't ask for.
const baseOptions = isBabel ? presetsJSX : presetsJS;
const options = {
...baseOptions,
...(useModules && { plugins: [MODULE_TRANSFORM_PLUGIN] })
};
// The type has to be removed, otherwise the browser will ignore the script.
// However, if we're importing modules, the type will be removed when the
// scripts are embedded in the HTML.
if (isBabel && !useModules) script.removeAttribute('type');
// We could use babel standalone to transform inline code in the preview,
// but that generates a warning that's shown to learner. By removing the
// type attribute and transforming the code we can avoid that warning.
if (isBabel && !hasSource) {
script.removeAttribute('type');
script.setAttribute('data-type', 'text/babel');
}
// Skip unnecessary transformations
script.innerHTML = script.innerHTML
? babelTransformCode(options)(script.innerHTML)
: '';
});
}
const deferScript = scriptCode => {
// Mimic the behavior of a defer script by waiting until the DOM is loaded
// before executing the script.
return `
(() => {
const run = (() => {
if (document.readyState === "interactive") {
${scriptCode}
}
});
document.addEventListener('readystatechange', run, { once: true });
})();
`;
};
export const embedScript = (script, source, contents) => {
const code = contents ?? '';
script.innerHTML = script.hasAttribute('defer') ? deferScript(code) : code;
script.removeAttribute('src');
script.setAttribute('data-src', source);
};
// This does the final transformations of the files needed to embed them into
// HTML.
export const embedFilesInHtml = async function (challengeFiles) {
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs, indexTsx } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = contentDocument => {
const documentElement = contentDocument.documentElement;
const link =
documentElement.querySelector('link[href="styles.css"]') ??
documentElement.querySelector('link[href="./styles.css"]');
const script =
documentElement.querySelector('script[src="script.js"]') ??
documentElement.querySelector('script[src="./script.js"]');
const tsScript =
documentElement.querySelector('script[src="index.ts"]') ??
documentElement.querySelector('script[src="./index.ts"]');
const jsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.jsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]`
);
const tsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.tsx"]`
) ??
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.tsx"]`
);
if (link) {
const style = contentDocument.createElement('style');
style.classList.add('fcc-injected-styles');
style.innerHTML = stylesCss?.contents;
link.parentNode.appendChild(style);
link.removeAttribute('href');
link.dataset.href = 'styles.css';
}
if (script) {
embedScript(script, 'script.js', scriptJs?.contents);
}
if (tsScript) {
embedScript(tsScript, 'index.ts', indexTs?.contents);
}
if (jsxScript) {
embedScript(jsxScript, 'index.jsx', indexJsx?.contents);
jsxScript.removeAttribute('type');
jsxScript.setAttribute('data-type', 'text/babel');
}
if (tsxScript) {
embedScript(tsxScript, 'index.tsx', indexTsx?.contents);
tsxScript.removeAttribute('type');
tsxScript.setAttribute('data-type', 'text/babel');
}
return documentElement.innerHTML;
};
if (indexHtml) {
const contents = await parseAndTransform(
embedStylesAndScript,
indexHtml.contents
);
return contents;
} else if (indexJsx) {
return `<script>${indexJsx.contents}</script>`;
} else if (scriptJs) {
return `<script>${scriptJs.contents}</script>`;
} else if (indexTs) {
return `<script>${indexTs.contents}</script>`;
} else if (indexTsx) {
return `<script>${indexTsx.contents}</script>`;
} else {
throw Error('No html, ts(x) or js(x) file found');
}
};
function challengeFilesToObject(challengeFiles) {
const indexHtml = challengeFiles.find(file => file.fileKey === 'indexhtml');
const indexJsx = challengeFiles.find(file => file.fileKey === 'indexjsx');
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
const tsconfigJson = challengeFiles.find(
file => file.fileKey === 'tsconfigjson'
);
return {
indexHtml,
indexJsx,
stylesCss,
scriptJs,
indexTs,
indexTsx,
tsconfigJson
};
}
const parseAndTransform = async function (transform, contents) {
const parser = new DOMParser();
const newDoc = parser.parseFromString(contents, 'text/html');
return await transform(newDoc);
};
const getHtmlTranspiler = scriptOptions =>
async function (file) {
const transform = async contentDocument => {
const documentElement = contentDocument.documentElement;
await Promise.all([
transformSASS(documentElement),
transformScript(documentElement, scriptOptions)
]);
return documentElement.innerHTML;
};
const contents = await parseAndTransform(transform, file.contents);
return transformContents(() => contents, file);
};
export const getTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createTranspiler(loopProtectOptions)
];
export const getMultifileJSXTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createModuleTransformer(loopProtectOptions)
];
export const getPythonTransformers = () => [createSource, replaceNBSP];
@@ -0,0 +1,140 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPoly } from '@freecodecamp/shared/utils/polyvinyl';
import { embedFilesInHtml, embedScript, getTransformers } from './transformers';
const parseHtml = html => new DOMParser().parseFromString(html, 'text/html');
const defaultLoopProtectOptions = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
};
const applyTransformers = challengeFile => {
const transformers = getTransformers(defaultLoopProtectOptions);
return transformers.reduce(
(fileP, transformer) => fileP.then(file => transformer(file)),
Promise.resolve(challengeFile)
);
};
describe('embedFilesInHtml', () => {
it('does not transpile const declarations to var', async () => {
const file = createPoly({
name: 'script',
ext: 'js',
contents:
'const location = []; const copiedLocation = { ...window.location };',
head: '',
tail: ''
});
const transformed = await applyTransformers(file);
expect(transformed.contents).toMatch(/\bconst\s+location\b/);
expect(transformed.contents).not.toMatch(/\bvar\s+location\b/);
expect(transformed.contents).toMatch(/\.\.\.\s*window\.location/);
});
it('keeps deferred script.js in place', async () => {
const result = await embedFilesInHtml([
{
fileKey: 'indexhtml',
contents:
'<!doctype html><html><head><script defer src="script.js"></script></head><body><main id="app"></main></body></html>'
},
{
fileKey: 'scriptjs',
contents: 'window.app = document.querySelector("#app");'
}
]);
const doc = parseHtml(result);
const script = doc.querySelector('script[data-src="script.js"]');
expect(script).toBeTruthy();
expect(script?.getAttribute('src')).toBeNull();
expect(script?.textContent).toContain(
'window.app = document.querySelector("#app");'
);
expect(script?.parentElement?.tagName).toBe('HEAD');
expect(doc.body.lastElementChild?.id).toBe('app');
});
it('keeps non-deferred script.js in place when embedding', async () => {
const result = await embedFilesInHtml([
{
fileKey: 'indexhtml',
contents:
'<!doctype html><html><head><script src="script.js"></script></head><body><main id="app"></main></body></html>'
},
{
fileKey: 'scriptjs',
contents: 'window.app = document.querySelector("#app");'
}
]);
const doc = parseHtml(result);
const script = doc.querySelector('script[data-src="script.js"]');
expect(script).toBeTruthy();
expect(script?.getAttribute('src')).toBeNull();
expect(script?.parentElement?.tagName).toBe('HEAD');
expect(doc.body.lastElementChild?.id).toBe('app');
});
});
describe('embedScript', () => {
const rawScript = 'console.log("Hello, world!");';
afterEach(() => {
delete document.__hasRun;
document.body.querySelectorAll('script').forEach(s => s.remove());
vi.restoreAllMocks();
});
it('runs deferred scripts when the readystate becomes interactive', async () => {
const script = document.createElement('script');
script.setAttribute('defer', true);
embedScript(script, 'script.js', 'document.__hasRun = true;');
// By default, the jsdom environment is "complete", so we need to mock it to
// test the defer behavior.
vi.spyOn(document, 'readyState', 'get').mockReturnValueOnce('interactive');
// We have to wait for something to happen inside the script. Since we
// dispatch this event, that is something we can wait for.
const scriptRan = new Promise(resolve =>
document.addEventListener('readystatechange', resolve, {
once: true
})
);
document.body.appendChild(script);
document.dispatchEvent(new Event('readystatechange'));
await scriptRan;
expect(document.__hasRun).toBe(true);
});
it('embeds script content into a script tag', () => {
const script = document.createElement('script');
embedScript(script, 'script.js', rawScript);
expect(script.getAttribute('src')).toBeNull();
expect(script.textContent).toEqual(rawScript);
});
it('embeds defered scripts content', () => {
const script = document.createElement('script');
script.setAttribute('defer', true);
embedScript(script, 'script.js', rawScript);
expect(script.getAttribute('defer')).toBe('true');
expect(script.getAttribute('src')).toBeNull();
expect(script.textContent).toContain(rawScript);
});
});
@@ -0,0 +1,45 @@
import browserScripts from '@freecodecamp/browser-scripts/package.json';
import { awaitResponse } from './awaitable-messenger.js';
const typeScriptWorkerSrc = `/js/workers/${browserScripts.version}/typescript-worker.js`;
let worker: Worker | null = null;
function getTypeScriptWorker(): Worker {
if (!worker) {
worker = new Worker(typeScriptWorkerSrc);
}
return worker;
}
export function compileTypeScriptCode(code: string): Promise<string> {
return awaitResponse({
messenger: getTypeScriptWorker(),
message: { type: 'compile', code },
onMessage: (data, onSuccess, onFailure) => {
if (data.type === 'compiled') {
if (!data.error) {
onSuccess(data.value);
} else {
onFailure(Error(data.error));
}
} else {
onFailure(Error('unable to compile code'));
}
}
});
}
export function setupTSCompiler(tsconfig?: string): Promise<boolean> {
return awaitResponse({
messenger: getTypeScriptWorker(),
message: { type: 'setup', ...(tsconfig && { tsconfig }) },
onMessage: (data, onSuccess) => {
if (data.type === 'ready') {
onSuccess(true);
}
// otherwise it times out.
}
});
}
@@ -0,0 +1,149 @@
export class WorkerExecutor {
constructor(
workerName,
{ location = '/js/', maxWorkers = 2, terminateWorker = false } = {}
) {
this._workerPool = [];
this._taskQueue = [];
this._workersInUse = 0;
this._maxWorkers = maxWorkers;
this._terminateWorker = terminateWorker;
this._scriptURL = `${location}${workerName}.js`;
this._getWorker = this._getWorker.bind(this);
}
async _getWorker() {
return this._workerPool.length
? this._workerPool.shift()
: this._createWorker();
}
_createWorker() {
return new Promise((resolve, reject) => {
const newWorker = new Worker(this._scriptURL);
newWorker.onmessage = e => {
if (e.data?.type === 'contentLoaded') {
resolve(newWorker);
}
};
newWorker.onerror = err => reject(err);
});
}
_handleTaskEnd(task) {
return () => {
this._workersInUse--;
const worker = task._worker;
if (worker) {
if (this._terminateWorker) {
worker.terminate();
} else {
worker.onmessage = null;
worker.onerror = null;
this._workerPool.push(worker);
}
}
this._processQueue();
};
}
_processQueue() {
while (this._workersInUse < this._maxWorkers && this._taskQueue.length) {
const task = this._taskQueue.shift();
const handleTaskEnd = this._handleTaskEnd(task);
task._execute(this._getWorker).done.then(handleTaskEnd, handleTaskEnd);
this._workersInUse++;
}
}
execute(data, timeout = 1000) {
const task = eventify({});
task._execute = function (getWorker) {
getWorker().then(
worker => {
task._worker = worker;
const timeoutId = setTimeout(() => {
task._worker.terminate();
task._worker = null;
this.emit('error', { message: 'timeout' });
}, timeout);
worker.onmessage = e => {
clearTimeout(timeoutId);
// data.type is undefined when the message has been processed
// successfully and defined when something else has happened (e.g.
// an error occurred)
if (e.data?.type) {
this.emit(e.data.type, e.data.data);
} else {
this.emit('done', e.data);
}
};
worker.onerror = e => {
clearTimeout(timeoutId);
this.emit('error', { message: e.message });
};
worker.postMessage(data);
},
err => this.emit('error', err)
);
return this;
};
task.done = new Promise((resolve, reject) => {
task
.once('done', data => resolve(data))
.once('error', err => reject(err.message));
});
this._taskQueue.push(task);
this._processQueue();
return task;
}
}
// Error and completion handling
const eventify = self => {
self._events = {};
self.on = (event, listener) => {
if (typeof self._events[event] === 'undefined') {
self._events[event] = [];
}
self._events[event].push(listener);
return self;
};
self.removeListener = (event, listener) => {
if (typeof self._events[event] !== 'undefined') {
const index = self._events[event].indexOf(listener);
if (index !== -1) {
self._events[event].splice(index, 1);
}
}
return self;
};
self.emit = (event, ...args) => {
if (typeof self._events[event] !== 'undefined') {
const listeners = self._events[event].slice();
for (let listener of listeners) {
listener.apply(self, args);
}
}
return self;
};
self.once = (event, listener) => {
self.on(event, function handler(...args) {
self.removeListener(event, handler);
listener.apply(self, args);
});
return self;
};
return self;
};
@@ -0,0 +1,249 @@
/**
* @vitest-environment node
*/
import { it, expect, afterEach, vi } from 'vitest';
import { WorkerExecutor } from './worker-executor';
function mockWorker({ init, postMessage, terminate } = {}) {
global.Worker = vi.fn(function () {
setImmediate(
(init && init(this)) ||
(() =>
this.onmessage && this.onmessage({ data: { type: 'contentLoaded' } }))
);
this.onmessage = null;
this.postMessage =
postMessage ||
function (data) {
setImmediate(
() => this.onmessage && this.onmessage({ data: `${data} processed` })
);
};
this.terminate = terminate || (() => {});
return this;
});
}
afterEach(() => {
delete global.Worker;
});
it('Worker executor should successfully execute one task', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
expect(testWorker).not.toBeUndefined();
const task = testWorker.execute('test');
expect(task).not.toBeUndefined();
expect(task.done).not.toBeUndefined();
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/js/test.js');
});
it('Worker executor should successfully execute two tasks in parallel', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const handler1 = vi.fn();
task1.on('done', handler1);
const errorHandler1 = vi.fn();
task1.on('error', errorHandler1);
const task2 = testWorker.execute('test2');
const handler2 = vi.fn();
task2.on('done', handler2);
const errorHandler2 = vi.fn();
task2.on('error', errorHandler2);
await expect(Promise.all([task1.done, task2.done])).resolves.toEqual([
'test1 processed',
'test2 processed'
]);
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler1).toHaveBeenCalledWith('test1 processed');
expect(errorHandler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledWith('test2 processed');
expect(errorHandler2).not.toHaveBeenCalled();
expect(terminateHandler).not.toHaveBeenCalled();
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks in parallel and use two workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(2);
});
it('Worker executor should successfully execute 3 tasks, use 3 workers and terminate each worker', async () => {
const terminateHandler = vi.fn();
mockWorker({ terminate: terminateHandler });
const testWorker = new WorkerExecutor('test', { terminateWorker: true });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(terminateHandler).toHaveBeenCalledTimes(3);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks in parallel and use 3 workers', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 3 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(3);
});
it('Worker executor should successfully execute 3 tasks and use 1 worker', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', { maxWorkers: 1 });
const task1 = testWorker.execute('test1');
const task2 = testWorker.execute('test2');
const task3 = testWorker.execute('test3');
await expect(
Promise.all([task1.done, task2.done, task3.done])
).resolves.toEqual(['test1 processed', 'test2 processed', 'test3 processed']);
expect(global.Worker).toHaveBeenCalledTimes(1);
});
it('Worker executor should reject task', async () => {
const error = { message: 'Error on init worker' };
mockWorker({
init: () => {
throw error;
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe(error.message);
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler).toHaveBeenCalledWith(error);
});
it('Worker executor should emit LOG events', async () => {
mockWorker({
postMessage: function (data) {
setImmediate(() => {
for (let i = 0; i < 3; i++) {
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: { type: 'LOG', data: i } });
}
// eslint-disable-next-line no-unused-expressions
this.onmessage && this.onmessage({ data: `${data} processed` });
setImmediate(
() =>
this.onmessage && this.onmessage({ data: { type: 'LOG', data: 3 } })
);
});
}
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.on('done', handler);
const errorHandler = vi.fn();
task.on('error', errorHandler);
const logHandler = vi.fn();
task.on('LOG', logHandler);
await expect(task.done).resolves.toBe('test processed');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('test processed');
expect(errorHandler).not.toHaveBeenCalled();
expect(logHandler).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
expect(logHandler.mock.calls[i][0]).toBe(i);
}
});
it('Worker executor should reject task on timeout', async () => {
const terminateHandler = vi.fn();
mockWorker({
postMessage: () => {},
terminate: terminateHandler
});
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test', 0);
const errorHandler = vi.fn();
task.on('error', errorHandler);
await expect(task.done).rejects.toBe('timeout');
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorHandler.mock.calls[0][0]).toEqual({ message: 'timeout' });
expect(terminateHandler).toHaveBeenCalledTimes(1);
});
it('Worker executor should get worker from specified location', async () => {
mockWorker();
const testWorker = new WorkerExecutor('test', {
location: '/other/location/'
});
const task = testWorker.execute('test');
await expect(task.done).resolves.toBe('test processed');
expect(global.Worker).toHaveBeenCalledTimes(1);
expect(global.Worker).toHaveBeenCalledWith('/other/location/test.js');
});
it('Task should only emit handler once', () => {
mockWorker();
const testWorker = new WorkerExecutor('test');
const task = testWorker.execute('test');
const handler = vi.fn();
task.once('testOnce', handler);
task.emit('testOnce', handler);
task.emit('testOnce', handler);
expect(handler).toHaveBeenCalledTimes(1);
});
+12
View File
@@ -0,0 +1,12 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "es2020",
"moduleResolution": "bundler",
"outDir": "dist",
"noEmit": false
}
}
+1
View File
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import './dist/cli.js';
@@ -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
}
}
}
];
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@freecodecamp/challenge-linter",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"main": "index.js",
"type": "module",
"bin": {
"challenge-linter": "./cli.js"
},
"exports": {
".": "./dist/index.js"
},
"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>",
"scripts": {
"build": "tsc",
"lint": "eslint --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/js-yaml": "4.0.9",
"@types/yargs": "17.0.35",
"@vitest/ui": "3.2.4",
"eslint": "9.39.4",
"markdownlint": "0.40.0",
"prismjs": "1.30.0",
"typescript": "5.9.3",
"vitest": "3.2.4",
"yargs": "17.7.2"
},
"dependencies": {
"markdown-it": "^14.1.1"
}
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { configure, processLintErrors } from './index.js';
const argv = yargs(hideBin(process.argv))
.options({ config: { type: 'string' } })
.parseSync();
const configPath = argv.config;
const files = argv._ as string[];
if (!configPath) {
console.error(
'Error: Configuration path is required. Use --config <path-to-config>'
);
process.exit(1);
}
if (files.length === 0) {
console.error('Error: At least one file path is required to lint.');
process.exit(1);
}
const { lint } = configure(configPath);
const runLint = async () => {
const results = await lint(files);
const errors = processLintErrors(results);
if (errors.length > 0) {
errors.forEach(({ file, errors: fileErrors }) => {
console.log('Errors in file', file);
console.log(fileErrors);
});
process.exit(1);
}
};
void runLint();
@@ -0,0 +1,40 @@
---
id: ''
title: ''
challengeType: 0
videoUrl: ''
---
## Description
<section id='description'>
</section>
## Instructions
<section id='instructions'>
</section>
## Tests
<section id='tests'>
text
```yml
tests:
- text: text
testString: testString
```
moretext
</section>
## Challenge Seed
<section id='challengeSeed'>
<div id='html-seed'>
</div>
</section>
## Solution
<section id='solution'>
</section>
@@ -0,0 +1,40 @@
---
id: ''
title: ''
challengeType: 0
videoUrl: ''
---
## Description
<section id='description'>
</section>
## Instructions
<section id='instructions'>
</section>
## Tests
<section id='tests'>
```yml
tests:
- text: text
testString: testString
```
</section>
## Challenge Seed
<section id='challengeSeed'>
<div id='html-seed'>
</div>
</section>
## Solution
<section id='solution'>
</section>
@@ -0,0 +1,40 @@
---
id: ''
title: ''
challengeType: 0
videoUrl: ''
---
## Description
<section id='description'>
</section>
## Instructions
<section id='instructions'>
</section>
## Tests
<section id='tests'>
```yml
tests:
- text: text
testString: testString
```
</section>
## Challenge Seed
<section id='challengeSeed'>
<div id='html-seed'>
</div>
</section>
## Solution
<section id='solution'>
</section>
@@ -0,0 +1,13 @@
# This is a copy of those in the curriculum. They don't need to be in sync.
default: true # include all rules, with exceptions below
MD002: false # first heading should not be a top level heading
MD013: false # lines can be any length
MD022: false # headings don't need surrounding by newlines
MD024: false # no duplicate headers
MD025: false # headings are used as markers by the parser
MD031: true # fenced blocks do need surrounding by newlines
MD033: false # inline html is required
MD040: true # fenced code blocks should have a language specified
MD034: false # allow bare-URLs
MD036: false # TODO: **Example** is the main offender, should that be a heading?
whitespace: false # extra whitespace is ignored, so we don't enforce it.
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import YAML from 'js-yaml';
import { linter } from './linter/index.js';
interface LintResults {
[key: string]: unknown[];
}
const configure = (
configPath: string
): { lint: (files: string[]) => Promise<LintResults> } => {
const lintRules = readFileSync(configPath, 'utf8');
const lint = linter(YAML.load(lintRules));
return { lint };
};
const processLintErrors = (results: LintResults) => {
return Object.entries(results)
.map(([file, errors]) => ({ file, errors }))
.filter(({ errors }) => errors.length > 0);
};
export { configure, processLintErrors };
@@ -0,0 +1,53 @@
import path from 'path';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { configure, processLintErrors } from './index.js';
const badYMLError = expect.objectContaining({
errorContext: '```yml',
errorDetail: expect.stringContaining(
'bad indentation of a mapping entry at line 3, column 17'
),
lineNumber: 19,
ruleDescription: 'YAML code blocks should be valid',
ruleNames: ['yaml-linter']
});
describe('markdown linter', () => {
const good = path.join(__dirname, './fixtures/good.md');
const badYML = path.join(__dirname, './fixtures/badYML.md');
const badFencing = path.join(__dirname, './fixtures/badFencing.md');
const configPath = path.join(__dirname, './fixtures/rules.yaml');
let lint;
beforeAll(() => {
({ lint } = configure(configPath));
});
beforeEach(() => {
console.log = vi.fn();
});
it('should pass `good` markdown', async () => {
const result = await lint([good]);
expect(result[good]).toHaveLength(0);
});
it('should fail invalid YML blocks', async () => {
const result = await lint([badYML]);
expect(result[badYML]).not.toHaveLength(0);
});
it('should fail when code fences are not surrounded by newlines', async () => {
const result = await lint([badFencing]);
expect(result[badFencing]).not.toHaveLength(0);
});
it('should write to the console describing the problem', async () => {
const results = await lint([badYML]);
const errors = processLintErrors(results);
expect(errors[0].file).toContain('badYML.md');
expect(errors[0].errors).toEqual(expect.arrayContaining([badYMLError]));
});
});
@@ -0,0 +1,17 @@
export const names = ['closed-code-blocks'];
export const description = 'Code blocks must have closing triple backticks';
export const tags = ['code'];
export const parser = 'micromark';
function rule(params, onError) {
params.parsers.micromark.tokens
.filter(token => token.type === 'codeFenced')
.forEach(token => {
if (token.text.trim().slice(-3) !== '```') {
onError({
lineNumber: token.endLine,
detail: `Code blocks must have closing triple backticks.`
});
}
});
}
export { rule as function };
@@ -0,0 +1,22 @@
import { lint as markdownlint } from 'markdownlint/promise';
import * as lintPrism from './markdown-prism.js';
import * as lintYAML from './markdown-yaml.js';
import * as fencedCodeBlock from './fenced-code-block.js';
const markdownItFactory = () =>
import('markdown-it').then(module => module.default({ html: true }));
export function linter(rules) {
const lint = async files => {
const options = {
files,
config: rules,
customRules: [lintYAML, lintPrism, fencedCodeBlock],
markdownItFactory
};
return await markdownlint(options);
};
return lint;
}
@@ -0,0 +1,46 @@
import components from 'prismjs/components.js';
export const names = ['prism-langs'];
export const description =
'Code block languages should be supported by PrismJS';
export const tags = ['prism'];
export const parser = 'markdownit';
function rule(params, onError) {
params.parsers.markdownit.tokens
.filter(param => param.type === 'fence')
.forEach(codeBlock => {
// whitespace around the language is ignored by the parser, as is case:
const baseLang = codeBlock.info.trim().toLowerCase();
const lang = getBaseLanguageName(baseLang);
// Rule MD040 checks if the block has a language, so this rule only
// comes into play if a language has been specified.
if (baseLang && !lang) {
onError({
lineNumber: codeBlock.lineNumber,
detail: `'${baseLang}' is not recognised.`
});
}
});
}
export { rule as function };
/*
* This is the method used by https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-prismjs/src/load-prism-language.js
*/
// Get the real name of a language given it or an alias
const getBaseLanguageName = nameOrAlias => {
if (components.languages[nameOrAlias]) {
return nameOrAlias;
}
return Object.keys(components.languages).find(language => {
const { alias } = components.languages[language];
if (!alias) return false;
if (Array.isArray(alias)) {
return alias.includes(nameOrAlias);
} else {
return alias === nameOrAlias;
}
});
};
@@ -0,0 +1,25 @@
import jsYaml from 'js-yaml';
export const names = ['yaml-linter'];
export const description = 'YAML code blocks should be valid';
export const tags = ['yaml'];
export const parser = 'markdownit';
function rule(params, onError) {
params.parsers.markdownit.tokens
.filter(param => param.type === 'fence')
.filter(param => param.info === 'yml' || param.info === 'yaml')
// TODO since the parser only looks for yml, should we reject yaml blocks?
.forEach(codeBlock => {
try {
jsYaml.safeLoad(codeBlock.content);
} catch (e) {
onError({
lineNumber: codeBlock.lineNumber,
detail: e.message,
context: codeBlock.line
});
}
});
}
export { rule as function };
+10
View File
@@ -0,0 +1,10 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"noEmit": false
}
}
+32
View File
@@ -0,0 +1,32 @@
import { ESLint } from 'eslint';
export const createLintStagedConfig = cwd => {
// cwd has to be passed to ESLint or it defaults to process.cwd(), i.e. the root
// of the monorepo.
const cli = new ESLint({ cwd });
return {
'*.(mjs|js|ts|tsx)': async files => {
const ignoredIds = await Promise.all(
files.map(file => cli.isPathIgnored(file))
);
const lintableFiles = files.filter((_, i) => !ignoredIds[i]);
const prettierCommand = [
...files.map(filename => `prettier --write '${filename}'`)
];
// There should be at least one lintable file if we reach here, but if not,
// just run prettier.
return lintableFiles.length === 0
? prettierCommand
: ['eslint --fix ' + lintableFiles.join(' '), ...prettierCommand];
},
'*.!(mjs|js|ts|tsx|css|md)': files =>
files.map(filename => `prettier --write --ignore-unknown '${filename}'`),
'*.css': files => [
...files.map(filename => `stylelint --fix '${filename}'`),
...files.map(filename => `prettier --write '${filename}'`)
]
};
};
+151
View File
@@ -0,0 +1,151 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
import vitest from '@vitest/eslint-plugin';
import { defineConfig, globalIgnores } from 'eslint/config';
import noOnlyTests from 'eslint-plugin-no-only-tests';
import filenamesSimple from 'eslint-plugin-filenames-simple';
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
import reactPlugin from 'eslint-plugin-react';
import jsxAllyPlugin from 'eslint-plugin-jsx-a11y';
import importPlugin from 'eslint-plugin-import';
import testingLibraryPlugin from 'eslint-plugin-testing-library';
import babelParser from '@babel/eslint-parser'; // TODO: can we get away from using babel?
import turbo from 'eslint-plugin-turbo';
import htmlReact from '@html-eslint/eslint-plugin-react';
import { FlatCompat } from '@eslint/eslintrc';
const __dirname = import.meta.dirname;
const compat = new FlatCompat({
baseDirectory: __dirname
});
export const jsFiles = ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'];
export const tsFiles = ['**/*.ts', '**/*.tsx'];
const testFiles = [
'**/*.test.js',
'**/*.test.jsx',
'**/*.test.mjs',
'**/*.test.cjs',
'**/*.test.ts',
'**/*.test.tsx'
];
const base = defineConfig(
globalIgnores(['dist', '.turbo']),
turbo.configs['flat/recommended'],
js.configs.recommended,
eslintConfigPrettier,
{
...vitest.configs.recommended,
files: testFiles
},
{
extends: [importPlugin.flatConfigs.recommended],
settings: { 'import/resolver': { node: true, typescript: true } },
rules: {
// TODO: fix the errors, allow the rules.
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off'
}
},
{
plugins: {
'no-only-tests': noOnlyTests,
'filenames-simple': fixupPluginRules(filenamesSimple)
},
rules: {
'filenames-simple/naming-convention': ['error'],
'no-only-tests/no-only-tests': 'error'
}
},
{
files: jsFiles,
rules: {
'no-unused-expressions': 'error', // This is so the js rules are more in line with the ts rules
'import/no-unresolved': [2, { commonjs: true }] // commonjs is necessary while we still use require()
},
languageOptions: {
parser: babelParser,
parserOptions: {
requireConfigFile: false
}
}
},
{
files: tsFiles,
extends: [importPlugin.flatConfigs['typescript']],
rules: {
'import/no-unresolved': 'off' // handled by TS
}
},
{
files: tsFiles,
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
]
}
}
);
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = defineConfig(...base, {
files: tsFiles,
extends: [tseslint.configs.recommended]
});
/**
* A shared ESLint configuration with type aware linting
*
* @type {import("eslint").Linter.Config[]}
* */
export const configTypeChecked = defineConfig(
...base,
{
files: tsFiles,
extends: [tseslint.configs.recommendedTypeChecked]
},
{
languageOptions: {
parserOptions: {
projectService: true
}
}
}
);
export const configReact = [
reactPlugin.configs['flat'].recommended,
jsxAllyPlugin.flatConfigs.recommended,
...fixupConfigRules(
compat.extends(
'plugin:react-hooks/recommended' // Note: at time of testing, upgrading to v5 creates false positives
)
),
{
plugins: {
'@html-eslint/react': htmlReact
},
rules: {
'@html-eslint/react/no-duplicate-classname': 'error',
'@html-eslint/react/classname-spacing': 'error',
'@html-eslint/react/no-ineffective-attrs': 'error'
}
}
];
export const configTestingLibrary = defineConfig({
files: testFiles,
extends: [testingLibraryPlugin.configs['flat/react']]
});
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@freecodecamp/eslint-config",
"version": "1.0.0",
"type": "module",
"exports": {
"./base": "./base.js",
"./lintstaged": "./.lintstagedrc.js"
},
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"packageManager": "pnpm@10.20.0",
"devDependencies": {
"@babel/eslint-parser": "7.26.5",
"@babel/preset-react": "7.26.3",
"@eslint/compat": "^1.2.6",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@html-eslint/eslint-plugin-react": "^0.60.0",
"@vitest/eslint-plugin": "^1.4.3",
"eslint": "^9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-filenames-simple": "0.9.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsdoc": "48.11.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-no-only-tests": "3.4.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-turbo": "^2.8.3",
"typescript": "5.9.3",
"typescript-eslint": "^8.47.0"
}
}
+1
View File
@@ -0,0 +1 @@
dist
+4
View File
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
+3
View File
@@ -0,0 +1,3 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
export default configTypeChecked;
+101
View File
@@ -0,0 +1,101 @@
{
"name": "@freecodecamp/shared",
"version": "0.0.1",
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"description": "Config and utils used in multiple workspaces",
"private": false,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"scripts": {
"build": "tsdown --log-level warn --no-clean --format cjs --format esm",
"clean": "rm -rf dist",
"develop": "tsdown --log-level warn --no-clean --watch src --format cjs --format esm",
"lint": "eslint --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit"
},
"type": "module",
"exports": {
"./config/blocks": {
"import": "./dist/config/blocks.mjs",
"require": "./dist/config/blocks.cjs"
},
"./config/catalog": {
"import": "./dist/config/catalog.mjs",
"require": "./dist/config/catalog.cjs"
},
"./config/certification-settings": {
"import": "./dist/config/certification-settings.mjs",
"require": "./dist/config/certification-settings.cjs"
},
"./config/challenge-types": {
"import": "./dist/config/challenge-types.mjs",
"require": "./dist/config/challenge-types.cjs"
},
"./config/chapters": {
"import": "./dist/config/chapters.mjs",
"require": "./dist/config/chapters.cjs"
},
"./config/constants": {
"import": "./dist/config/constants.mjs",
"require": "./dist/config/constants.cjs"
},
"./config/curriculum": {
"import": "./dist/config/curriculum.mjs",
"require": "./dist/config/curriculum.cjs"
},
"./config/donation-settings": {
"import": "./dist/config/donation-settings.mjs",
"require": "./dist/config/donation-settings.cjs"
},
"./config/i18n": {
"import": "./dist/config/i18n.mjs",
"require": "./dist/config/i18n.cjs"
},
"./config/modules": {
"import": "./dist/config/modules.mjs",
"require": "./dist/config/modules.cjs"
},
"./utils/get-lines": {
"import": "./dist/utils/get-lines.mjs",
"require": "./dist/utils/get-lines.cjs"
},
"./utils/is-audited": {
"import": "./dist/utils/is-audited.mjs",
"require": "./dist/utils/is-audited.cjs"
},
"./utils/polyvinyl": {
"import": "./dist/utils/polyvinyl.mjs",
"require": "./dist/utils/polyvinyl.cjs"
},
"./utils/shuffle-array": {
"import": "./dist/utils/shuffle-array.mjs",
"require": "./dist/utils/shuffle-array.cjs"
},
"./utils/validate": {
"import": "./dist/utils/validate.mjs",
"require": "./dist/utils/validate.cjs"
},
"./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",
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@vitest/ui": "^4.0.15",
"eslint": "^9.39.1",
"tsdown": "^0.21.0",
"vitest": "^4.0.15"
}
}
+53
View File
@@ -0,0 +1,53 @@
export enum BlockLabel {
lecture = 'lecture',
workshop = 'workshop',
lab = 'lab',
review = 'review',
quiz = 'quiz',
exam = 'exam',
certProject = 'cert-project',
/* The tags below refer to the Language Curricula chapter based certifications*/
warmup = 'warm-up',
learn = 'learn',
practice = 'practice'
}
export enum BlockLayouts {
/**
* These three are for the new Full Stack Developer Certification,
* so we can play with them without affecting the existing block layouts
*/
ChallengeList = 'challenge-list',
Link = 'link',
ChallengeGrid = 'challenge-grid',
DialogueGrid = 'dialogue-grid',
/**
* ChallengeList displays challenges in a list.
* This layout is used in backend blocks, The Odin Project blocks, and blocks in legacy certification.
* Example: https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#basic-javascript
*/
LegacyChallengeList = 'legacy-challenge-list',
/**
* LegacyChallengeGrid displays challenges in a grid.
* This layout is used for step-based blocks.
* Example: https://www.freecodecamp.org/learn/2022/responsive-web-design/#learn-html-by-building-a-cat-photo-app
*/
LegacyChallengeGrid = 'legacy-challenge-grid',
/**
* Link displays the block as a single link.
* This layout is used if the block has a single challenge.
* Example: https://www.freecodecamp.org/learn/2022/responsive-web-design/#build-a-survey-form-project
*/
LegacyLink = 'legacy-link',
/**
* ProjectList displays a list of certification projects.
* This layout is used in legacy certifications.
* Example: https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#javascript-algorithms-and-data-structures-projects
*/
ProjectList = 'project-list'
}
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { catalog } from './catalog';
import { superBlockStages, SuperBlockStage } from './curriculum';
describe('catalog', () => {
it('should have exactly one entry for each superblock in SuperBlockStage.Catalog', () => {
const catalogSuperBlocks = superBlockStages[SuperBlockStage.Catalog];
expect(catalog.map(course => course.superBlock.toString()).sort()).toEqual(
catalogSuperBlocks.map(sb => sb.toString()).sort()
);
});
});
+416
View File
@@ -0,0 +1,416 @@
import { SuperBlocks } from './curriculum';
enum Levels {
Beginner = 'beginner',
Intermediate = 'intermediate',
Advanced = 'advanced'
}
enum Topic {
Html = 'html',
CSS = 'css',
Js = 'js',
React = 'react',
Python = 'python',
DataAnalysis = 'data-analysis',
MachineLearning = 'machine-learning',
D3 = 'd3',
Api = 'api',
InformationSecurity = 'information-security',
ComputerFundamentals = 'computer-fundamentals',
ComputerScience = 'computer-science',
Math = 'math',
Databases = 'databases',
Bash = 'bash',
Git = 'git',
Editors = 'editors',
AI = 'ai'
}
interface Catalog {
superBlock: SuperBlocks;
level: Levels;
hours: number;
topic: Topic;
}
export const catalog: Catalog[] = [
{
superBlock: SuperBlocks.LearnPythonForBeginners,
level: Levels.Beginner,
hours: 5,
topic: Topic.Python
},
{
superBlock: SuperBlocks.IntroductionToAlgorithmsAndDataStructures,
level: Levels.Intermediate,
hours: 6,
topic: Topic.ComputerScience
},
{
superBlock: SuperBlocks.LearnRAGAndMCPFundamentals,
level: Levels.Intermediate,
hours: 2,
topic: Topic.AI
},
{
superBlock: SuperBlocks.SemanticHtml,
level: Levels.Beginner,
hours: 2,
topic: Topic.Html
},
{
superBlock: SuperBlocks.ComputerBasics,
level: Levels.Beginner,
hours: 2,
topic: Topic.ComputerFundamentals
},
{
superBlock: SuperBlocks.BasicCss,
level: Levels.Beginner,
hours: 3,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.BasicHtml,
level: Levels.Beginner,
hours: 3,
topic: Topic.Html
},
{
superBlock: SuperBlocks.DesignForDevelopers,
level: Levels.Beginner,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.HtmlAndAccessibility,
level: Levels.Beginner,
hours: 2,
topic: Topic.Html
},
{
superBlock: SuperBlocks.CssFlexbox,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.IntroductionToPrecalculus,
level: Levels.Intermediate,
hours: 6,
topic: Topic.Math
},
{
superBlock: SuperBlocks.IntroductionToGitAndGithub,
level: Levels.Intermediate,
hours: 20,
topic: Topic.Git
},
{
superBlock: SuperBlocks.IntroductionToPythonBasics,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.AbsoluteAndRelativeUnits,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.PseudoClassesAndElements,
level: Levels.Intermediate,
hours: 1,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.HtmlFormsAndTables,
level: Levels.Beginner,
hours: 2,
topic: Topic.Html
},
{
superBlock: SuperBlocks.CssColors,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.JavascriptFundamentalsReview,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.StylingForms,
level: Levels.Intermediate,
hours: 1,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssBoxModel,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.IntroductionToBash,
level: Levels.Intermediate,
hours: 20,
topic: Topic.Bash
},
{
superBlock: SuperBlocks.IntroductionToLinearDataStructuresInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnAlgorithmsInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.CssTypography,
level: Levels.Intermediate,
hours: 1,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssAndAccessibility,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssPositioning,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.AttributeSelectors,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.ResponsiveDesign,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssVariables,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssGrid,
level: Levels.Intermediate,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.CssAnimations,
level: Levels.Advanced,
hours: 2,
topic: Topic.CSS
},
{
superBlock: SuperBlocks.IntroductionToSQLAndPostgreSQL,
level: Levels.Intermediate,
hours: 30,
topic: Topic.Databases
},
{
superBlock: SuperBlocks.LearnBashScripting,
level: Levels.Intermediate,
hours: 20,
topic: Topic.Bash
},
{
superBlock: SuperBlocks.LearnSQLAndBash,
level: Levels.Intermediate,
hours: 30,
topic: Topic.Databases
},
{
superBlock: SuperBlocks.IntroductionToNano,
level: Levels.Intermediate,
hours: 10,
topic: Topic.Editors
},
{
superBlock: SuperBlocks.IntroductionToVariablesAndStringsInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToBooleansAndNumbersInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToFunctionsInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToArraysInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToObjectsInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToLoopsInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToHigherOrderFunctionsAndCallbacksInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnDomManipulationAndEventsWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToJavascriptAndAccessibility,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnJavascriptDebugging,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnBasicRegexWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToDatesInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnAudioAndVideoEventsWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToMapsAndSetsInJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnLocalstorageAndCrudOperationsWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToJavascriptClasses,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnRecursionWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToFunctionalProgrammingWithJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.IntroductionToAsynchronousJS,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Js
},
{
superBlock: SuperBlocks.LearnDataVisualizationWithD3,
level: Levels.Intermediate,
hours: 20,
topic: Topic.D3
},
{
superBlock: SuperBlocks.LearnOOPWithPython,
level: Levels.Intermediate,
hours: 3,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnPythonLoopsAndSequences,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnPythonDictionariesAndSets,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnErrorHandlingInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnPythonClassesAndObjects,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.IntroductionToOOPInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnGraphsAndTreesInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
},
{
superBlock: SuperBlocks.LearnDynamicProgrammingInPython,
level: Levels.Intermediate,
hours: 40,
topic: Topic.Python
}
];
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import {
Certification,
linkedInCredentialIds,
certToTitleMap,
certToIdMap,
certSlugTypeMap,
isCertified
} from './certification-settings';
describe('linkedInCredentialIds', () => {
it('should contain a value for all certifications', () => {
const allCertifications = Object.values(Certification).sort();
const linkedInCredentialIdsKeys = Object.keys(linkedInCredentialIds).sort();
expect(linkedInCredentialIdsKeys).toEqual(allCertifications);
});
});
describe('certToTitleMap', () => {
it('should not contain duplicate titles', () => {
const titles = Object.values(certToTitleMap);
const uniqueTitles = Array.from(new Set(titles));
expect(titles.length).toBe(uniqueTitles.length);
});
});
describe('certToIdMap', () => {
it('should have no duplicate values', () => {
const ids = Object.values(certToIdMap).sort();
const uniqueIds = Array.from(new Set(ids)).sort();
expect(uniqueIds).toEqual(ids);
});
});
describe('certSlugTypeMap', () => {
it('should contain a value for all certifications', () => {
const allCertifications = Object.values(Certification).sort();
const certSlugTypeMapKeys = Object.keys(certSlugTypeMap).sort();
expect(certSlugTypeMapKeys).toEqual(allCertifications);
});
it('should have no duplicate values', () => {
const types = Object.values(certSlugTypeMap).sort();
const uniqueTypes = Array.from(new Set(types)).sort();
expect(uniqueTypes).toEqual(types);
});
});
describe('isCertified', () => {
it('should return true if a user has the specified certification', () => {
const cert = Certification.RespWebDesignV9;
const user = {
isRespWebDesignCertV9: true
};
expect(isCertified(user, cert)).toBe(true);
});
it('should return false if a user does not have the specified certification', () => {
const cert = Certification.JsAlgoDataStruct;
const user = {
isRespWebDesignCertV9: true
};
expect(isCertified(user, cert)).toBe(false);
});
it('should return false if the certification does not exist', () => {
const cert = 'NonExistentCert' as Certification;
const user = {
isRespWebDesignCertV9: true
};
expect(isCertified(user, cert)).toBe(false);
});
});
@@ -0,0 +1,457 @@
import { SuperBlocks } from '../config/curriculum.js';
/**
* Certifications are not equivalent to superblocks. Each superblock corresponds
* to 0 or 1 certifications, but a certification may not correspond to a
* superblock.
*
* As an example of the former: the CodingInterviewPrep superblock does not
* correspond to a certification. As an example of the latter: the legacy
* front-end certification no longer has a corresponding superblock.
*
* The value of each enum member is the slug of the corresponding certification.
*/
export enum Certification {
RespWebDesign = 'responsive-web-design',
JsAlgoDataStructNew = 'javascript-algorithms-and-data-structures-v8',
FrontEndDevLibs = 'front-end-development-libraries',
DataVis = 'data-visualization',
RelationalDb = 'relational-database-v8',
BackEndDevApis = 'back-end-development-and-apis',
QualityAssurance = 'quality-assurance-v7',
SciCompPy = 'scientific-computing-with-python-v7',
DataAnalysisPy = 'data-analysis-with-python-v7',
InfoSec = 'information-security-v7',
MachineLearningPy = 'machine-learning-with-python-v7',
CollegeAlgebraPy = 'college-algebra-with-python-v8',
FoundationalCSharp = 'foundational-c-sharp-with-microsoft',
PythonV9 = 'python-v9',
RelationalDbV9 = 'relational-databases-v9',
// Upcoming certifications
RespWebDesignV9 = 'responsive-web-design-v9',
JsV9 = 'javascript-v9',
FrontEndDevLibsV9 = 'front-end-development-libraries-v9',
BackEndDevApisV9 = 'back-end-development-and-apis-v9',
A2English = 'a2-english-for-developers',
FullStackDeveloperV9 = 'full-stack-developer-v9',
B1English = 'b1-english-for-developers',
A2Spanish = 'a2-professional-spanish',
A2Chinese = 'a2-professional-chinese',
A1Chinese = 'a1-professional-chinese',
// Legacy certifications
LegacyFrontEnd = 'legacy-front-end',
JsAlgoDataStruct = 'javascript-algorithms-and-data-structures',
LegacyBackEnd = 'legacy-back-end',
LegacyDataVis = 'legacy-data-visualization',
LegacyInfoSecQa = 'information-security-and-quality-assurance',
LegacyFullStack = 'full-stack'
}
export function isCertification(x: string): x is Certification {
return Object.values(Certification).includes(x as Certification);
}
// "Current" certifications are the subset of standard certifications that are
// live and not legacy.
export const currentCertifications = [
Certification.A2English,
Certification.B1English,
Certification.FoundationalCSharp,
Certification.JsV9,
Certification.PythonV9,
Certification.RelationalDbV9,
Certification.RespWebDesignV9
] as const;
// "Legacy" certifications are another class of standard certifications. They're
// still live and claimable, but some parts of the UI handle them differently.
export const legacyCertifications = [
Certification.RespWebDesign,
Certification.JsAlgoDataStruct,
Certification.FrontEndDevLibs,
Certification.DataVis,
Certification.BackEndDevApis,
Certification.LegacyInfoSecQa,
Certification.LegacyFrontEnd,
Certification.JsAlgoDataStructNew,
Certification.LegacyBackEnd,
Certification.LegacyDataVis,
Certification.RelationalDb,
Certification.QualityAssurance,
Certification.SciCompPy,
Certification.DataAnalysisPy,
Certification.InfoSec,
Certification.MachineLearningPy,
Certification.CollegeAlgebraPy
] as const;
// The Legacy Full Stack certification can only be claimed when specific
// "current" and "legacy" certifications have been claimed.
export const legacyFullStackCertification = [
Certification.LegacyFullStack
] as const;
// "Upcoming" certifications are standard certifications that are not live unless
// showUpcomingChanges is true.
export const upcomingCertifications = [
Certification.FrontEndDevLibsV9,
Certification.BackEndDevApisV9,
Certification.FullStackDeveloperV9,
Certification.A2Spanish,
Certification.A2Chinese,
Certification.A1Chinese
] as const;
export const certToIdMap: Record<Certification, string> = {
// Legacy certifications
[Certification.LegacyFrontEnd]: '561add10cb82ac38a17513be',
[Certification.JsAlgoDataStruct]: '561abd10cb81ac38a17513bc',
[Certification.LegacyBackEnd]: '660add10cb82ac38a17513be',
[Certification.LegacyDataVis]: '561add10cb82ac39a17513bc',
[Certification.LegacyInfoSecQa]: '561add10cb82ac38a17213bc',
[Certification.LegacyFullStack]: '561add10cb82ac38a17213bd',
// Current certifications
[Certification.RespWebDesign]: '561add10cb82ac38a17513bc',
[Certification.JsAlgoDataStructNew]: '658180220947283cdc0689ce',
[Certification.FrontEndDevLibs]: '561acd10cb82ac38a17513bc',
[Certification.DataVis]: '5a553ca864b52e1d8bceea14',
[Certification.BackEndDevApis]: '561add10cb82ac38a17523bc',
[Certification.QualityAssurance]: '5e611829481575a52dc59c0e',
[Certification.InfoSec]: '5e6021435ac9d0ecd8b94b00',
[Certification.SciCompPy]: '5e44431b903586ffb414c951',
[Certification.DataAnalysisPy]: '5e46fc95ac417301a38fb934',
[Certification.MachineLearningPy]: '5e46fc95ac417301a38fb935',
[Certification.RelationalDb]: '606243f50267e718b1e755f4',
[Certification.CollegeAlgebraPy]: '61531b20cc9dfa2741a5b800',
[Certification.FoundationalCSharp]: '647f7da207d29547b3bee1ba',
[Certification.A2English]: '651dd7e01d697d0aab7833b7',
// Upcoming certifications
[Certification.RespWebDesignV9]: '68db314d3c11a8bff07c7535',
[Certification.JsV9]: '68c4069c1ef859270e17c495',
[Certification.FrontEndDevLibsV9]: '68e008aa5f80c6099d47b3a2',
[Certification.PythonV9]: '68e6bd5020effa1586e79855',
[Certification.RelationalDbV9]: '68e6bd5120effa1586e79856',
[Certification.BackEndDevApisV9]: '68e6bd5120effa1586e79857',
[Certification.FullStackDeveloperV9]: '64514fda6c245de4d11eb7bb',
[Certification.B1English]: '66607e53317411dd5e8aae21',
[Certification.A2Spanish]: '681a6b22e5a782fe3459984a',
[Certification.A2Chinese]: '682c3153086dd7cabe7f48bc',
[Certification.A1Chinese]: '68f1268149f045a650d4229e'
};
export const completionHours: Record<Certification, number> = {
[Certification.LegacyFrontEnd]: 300,
[Certification.JsAlgoDataStruct]: 300,
[Certification.LegacyBackEnd]: 300,
[Certification.LegacyDataVis]: 300,
[Certification.LegacyInfoSecQa]: 300,
[Certification.LegacyFullStack]: 1800,
[Certification.RespWebDesign]: 300,
[Certification.JsAlgoDataStructNew]: 300,
[Certification.FrontEndDevLibs]: 300,
[Certification.DataVis]: 300,
[Certification.BackEndDevApis]: 300,
[Certification.QualityAssurance]: 300,
[Certification.InfoSec]: 300,
[Certification.SciCompPy]: 300,
[Certification.DataAnalysisPy]: 300,
[Certification.MachineLearningPy]: 300,
[Certification.RelationalDb]: 300,
[Certification.CollegeAlgebraPy]: 300,
[Certification.FoundationalCSharp]: 300,
[Certification.A2English]: 300,
[Certification.RespWebDesignV9]: 300,
[Certification.JsV9]: 300,
[Certification.FrontEndDevLibsV9]: 300,
[Certification.PythonV9]: 300,
[Certification.RelationalDbV9]: 300,
[Certification.BackEndDevApisV9]: 300,
[Certification.FullStackDeveloperV9]: 1800,
[Certification.B1English]: 300,
[Certification.A2Spanish]: 300,
[Certification.A2Chinese]: 300,
[Certification.A1Chinese]: 300
};
type UserCertFlag =
| 'isFrontEndCert'
| 'isJsAlgoDataStructCert'
| 'isBackEndCert'
| 'isDataVisCert'
| 'isInfosecQaCert'
| 'isFullStackCert'
| 'isRespWebDesignCert'
| 'isJsAlgoDataStructCertV8'
| 'isFrontEndLibsCert'
| 'is2018DataVisCert'
| 'isApisMicroservicesCert'
| 'isQaCertV7'
| 'isInfosecCertV7'
| 'isSciCompPyCertV7'
| 'isDataAnalysisPyCertV7'
| 'isMachineLearningPyCertV7'
| 'isRelationalDatabaseCertV8'
| 'isCollegeAlgebraPyCertV8'
| 'isFoundationalCSharpCertV8'
| 'isA2EnglishCert'
| 'isRespWebDesignCertV9'
| 'isJavascriptCertV9'
| 'isFrontEndLibsCertV9'
| 'isPythonCertV9'
| 'isRelationalDatabaseCertV9'
| 'isBackEndDevApisCertV9'
| 'isFullStackDeveloperCertV9'
| 'isB1EnglishCert'
| 'isA2SpanishCert'
| 'isA2ChineseCert'
| 'isA1ChineseCert';
export const certSlugTypeMap: Record<Certification, UserCertFlag> = {
// legacy
[Certification.LegacyFrontEnd]: 'isFrontEndCert',
[Certification.JsAlgoDataStruct]: 'isJsAlgoDataStructCert',
[Certification.LegacyBackEnd]: 'isBackEndCert',
[Certification.LegacyDataVis]: 'isDataVisCert',
[Certification.LegacyInfoSecQa]: 'isInfosecQaCert',
[Certification.LegacyFullStack]: 'isFullStackCert',
// modern
[Certification.RespWebDesign]: 'isRespWebDesignCert',
[Certification.JsAlgoDataStructNew]: 'isJsAlgoDataStructCertV8',
[Certification.FrontEndDevLibs]: 'isFrontEndLibsCert',
[Certification.DataVis]: 'is2018DataVisCert',
[Certification.BackEndDevApis]: 'isApisMicroservicesCert',
[Certification.QualityAssurance]: 'isQaCertV7',
[Certification.InfoSec]: 'isInfosecCertV7',
[Certification.SciCompPy]: 'isSciCompPyCertV7',
[Certification.DataAnalysisPy]: 'isDataAnalysisPyCertV7',
[Certification.MachineLearningPy]: 'isMachineLearningPyCertV7',
[Certification.RelationalDb]: 'isRelationalDatabaseCertV8',
[Certification.CollegeAlgebraPy]: 'isCollegeAlgebraPyCertV8',
[Certification.FoundationalCSharp]: 'isFoundationalCSharpCertV8',
[Certification.A2English]: 'isA2EnglishCert',
[Certification.PythonV9]: 'isPythonCertV9',
[Certification.RelationalDbV9]: 'isRelationalDatabaseCertV9',
[Certification.RespWebDesignV9]: 'isRespWebDesignCertV9',
[Certification.JsV9]: 'isJavascriptCertV9',
// upcoming
[Certification.FrontEndDevLibsV9]: 'isFrontEndLibsCertV9',
[Certification.BackEndDevApisV9]: 'isBackEndDevApisCertV9',
[Certification.FullStackDeveloperV9]: 'isFullStackDeveloperCertV9',
[Certification.B1English]: 'isB1EnglishCert',
[Certification.A2Spanish]: 'isA2SpanishCert',
[Certification.A2Chinese]: 'isA2ChineseCert',
[Certification.A1Chinese]: 'isA1ChineseCert'
};
export type CertificationFlags = {
[key in UserCertFlag]: boolean;
};
export function isCertified(
user: Partial<CertificationFlags>,
cert: Certification
): boolean {
const certFlag = certSlugTypeMap[cert];
return Boolean(user[certFlag]);
}
// TODO: use i18n keys instead of hardcoded titles
export const certToTitleMap: Record<Certification, string> = {
// Legacy certifications
[Certification.LegacyFrontEnd]: 'Legacy Frontend',
[Certification.JsAlgoDataStruct]:
'Legacy JavaScript Algorithms and Data Structures V7',
[Certification.LegacyBackEnd]: 'Legacy Backend',
[Certification.LegacyDataVis]: 'Legacy Data Visualization',
[Certification.LegacyInfoSecQa]:
'Legacy Information Security and Quality Assurance',
[Certification.LegacyFullStack]: 'Legacy Full-Stack',
// Current certifications
[Certification.RespWebDesign]: 'Legacy Responsive Web Design V8',
[Certification.JsAlgoDataStructNew]:
'Legacy JavaScript Algorithms and Data Structures V8',
[Certification.FrontEndDevLibs]: 'Frontend Development Libraries V8',
[Certification.DataVis]: 'Data Visualization V8',
[Certification.BackEndDevApis]: 'Backend Development and APIs V8',
[Certification.QualityAssurance]: 'Quality Assurance',
[Certification.InfoSec]: 'Information Security',
[Certification.SciCompPy]: 'Scientific Computing with Python',
[Certification.DataAnalysisPy]: 'Data Analysis with Python',
[Certification.MachineLearningPy]: 'Machine Learning with Python',
[Certification.RelationalDb]: 'Relational Database V8',
[Certification.CollegeAlgebraPy]: 'College Algebra with Python',
[Certification.FoundationalCSharp]: 'Foundational C# with Microsoft',
[Certification.A2English]: 'A2 English for Developers',
// Upcoming certifications
[Certification.RespWebDesignV9]: 'Responsive Web Design',
[Certification.JsV9]: 'JavaScript',
[Certification.FrontEndDevLibsV9]: 'Frontend Development Libraries',
[Certification.PythonV9]: 'Python',
[Certification.RelationalDbV9]: 'Relational Database',
[Certification.BackEndDevApisV9]: 'Backend Development and APIs',
[Certification.FullStackDeveloperV9]: 'Full-Stack Developer',
[Certification.B1English]: 'B1 English for Developers',
[Certification.A2Spanish]: 'A2 Professional Spanish',
[Certification.A2Chinese]: 'A2 Professional Chinese',
[Certification.A1Chinese]: 'A1 Professional Chinese'
};
export const superBlockToCertMap: {
[key in SuperBlocks]: Certification | null;
} = {
[SuperBlocks.RespWebDesign]: Certification.RespWebDesign,
[SuperBlocks.JsAlgoDataStructNew]: Certification.JsAlgoDataStructNew,
[SuperBlocks.FrontEndDevLibs]: Certification.FrontEndDevLibs,
[SuperBlocks.DataVis]: Certification.DataVis,
[SuperBlocks.RelationalDb]: Certification.RelationalDb,
[SuperBlocks.BackEndDevApis]: Certification.BackEndDevApis,
[SuperBlocks.QualityAssurance]: Certification.QualityAssurance,
[SuperBlocks.SciCompPy]: Certification.SciCompPy,
[SuperBlocks.DataAnalysisPy]: Certification.DataAnalysisPy,
[SuperBlocks.InfoSec]: Certification.InfoSec,
[SuperBlocks.MachineLearningPy]: Certification.MachineLearningPy,
[SuperBlocks.CollegeAlgebraPy]: Certification.CollegeAlgebraPy,
[SuperBlocks.FoundationalCSharp]: Certification.FoundationalCSharp,
[SuperBlocks.RespWebDesignNew]: Certification.RespWebDesign,
[SuperBlocks.JsAlgoDataStruct]: Certification.JsAlgoDataStruct,
[SuperBlocks.RespWebDesignV9]: Certification.RespWebDesignV9,
[SuperBlocks.JsV9]: Certification.JsV9,
[SuperBlocks.FrontEndDevLibsV9]: Certification.FrontEndDevLibsV9,
[SuperBlocks.PythonV9]: Certification.PythonV9,
[SuperBlocks.RelationalDbV9]: Certification.RelationalDbV9,
[SuperBlocks.BackEndDevApisV9]: Certification.BackEndDevApisV9,
[SuperBlocks.FullStackDeveloperV9]: Certification.FullStackDeveloperV9,
[SuperBlocks.A2English]: Certification.A2English,
[SuperBlocks.B1English]: Certification.B1English,
[SuperBlocks.A1Spanish]: null,
[SuperBlocks.A2Spanish]: Certification.A2Spanish,
[SuperBlocks.A2Chinese]: Certification.A2Chinese,
[SuperBlocks.A1Chinese]: Certification.A1Chinese,
[SuperBlocks.PythonForEverybody]: null,
[SuperBlocks.CodingInterviewPrep]: null,
[SuperBlocks.ProjectEuler]: null,
[SuperBlocks.TheOdinProject]: null,
[SuperBlocks.RosettaCode]: null,
[SuperBlocks.BasicHtml]: null,
[SuperBlocks.SemanticHtml]: null,
[SuperBlocks.DevPlayground]: null,
[SuperBlocks.FullStackOpen]: null,
[SuperBlocks.HtmlFormsAndTables]: null,
[SuperBlocks.HtmlAndAccessibility]: null,
[SuperBlocks.ComputerBasics]: null,
[SuperBlocks.BasicCss]: null,
[SuperBlocks.DesignForDevelopers]: null,
[SuperBlocks.AbsoluteAndRelativeUnits]: null,
[SuperBlocks.PseudoClassesAndElements]: null,
[SuperBlocks.CssColors]: null,
[SuperBlocks.StylingForms]: null,
[SuperBlocks.CssBoxModel]: null,
[SuperBlocks.CssFlexbox]: null,
[SuperBlocks.CssTypography]: null,
[SuperBlocks.CssAndAccessibility]: null,
[SuperBlocks.CssPositioning]: null,
[SuperBlocks.AttributeSelectors]: null,
[SuperBlocks.ResponsiveDesign]: null,
[SuperBlocks.CssVariables]: null,
[SuperBlocks.CssGrid]: null,
[SuperBlocks.CssAnimations]: null,
[SuperBlocks.LearnPythonForBeginners]: null,
[SuperBlocks.IntroductionToAlgorithmsAndDataStructures]: null,
[SuperBlocks.LearnOOPWithPython]: null,
[SuperBlocks.LearnRAGAndMCPFundamentals]: null,
[SuperBlocks.IntroductionToPrecalculus]: null,
[SuperBlocks.IntroductionToBash]: null,
[SuperBlocks.IntroductionToSQLAndPostgreSQL]: null,
[SuperBlocks.LearnBashScripting]: null,
[SuperBlocks.LearnSQLAndBash]: null,
[SuperBlocks.IntroductionToNano]: null,
[SuperBlocks.IntroductionToGitAndGithub]: null,
[SuperBlocks.IntroductionToVariablesAndStringsInJS]: null,
[SuperBlocks.IntroductionToBooleansAndNumbersInJS]: null,
[SuperBlocks.IntroductionToFunctionsInJS]: null,
[SuperBlocks.IntroductionToArraysInJS]: null,
[SuperBlocks.IntroductionToObjectsInJS]: null,
[SuperBlocks.IntroductionToLoopsInJS]: null,
[SuperBlocks.JavascriptFundamentalsReview]: null,
[SuperBlocks.IntroductionToHigherOrderFunctionsAndCallbacksInJS]: null,
[SuperBlocks.LearnDomManipulationAndEventsWithJS]: null,
[SuperBlocks.IntroductionToJavascriptAndAccessibility]: null,
[SuperBlocks.LearnJavascriptDebugging]: null,
[SuperBlocks.LearnBasicRegexWithJS]: null,
[SuperBlocks.IntroductionToDatesInJS]: null,
[SuperBlocks.LearnAudioAndVideoEventsWithJS]: null,
[SuperBlocks.IntroductionToMapsAndSetsInJS]: null,
[SuperBlocks.LearnLocalstorageAndCrudOperationsWithJS]: null,
[SuperBlocks.IntroductionToJavascriptClasses]: null,
[SuperBlocks.LearnRecursionWithJS]: null,
[SuperBlocks.IntroductionToFunctionalProgrammingWithJS]: null,
[SuperBlocks.IntroductionToAsynchronousJS]: null,
[SuperBlocks.LearnDataVisualizationWithD3]: null,
[SuperBlocks.IntroductionToPythonBasics]: null,
[SuperBlocks.LearnPythonLoopsAndSequences]: null,
[SuperBlocks.LearnPythonDictionariesAndSets]: null,
[SuperBlocks.LearnErrorHandlingInPython]: null,
[SuperBlocks.LearnPythonClassesAndObjects]: null,
[SuperBlocks.IntroductionToOOPInPython]: null,
[SuperBlocks.IntroductionToLinearDataStructuresInPython]: null,
[SuperBlocks.LearnAlgorithmsInPython]: null,
[SuperBlocks.LearnGraphsAndTreesInPython]: null,
[SuperBlocks.LearnDynamicProgrammingInPython]: null
};
export const certificationRequirements: Partial<
Record<Certification, SuperBlocks[]>
> = {
[Certification.FullStackDeveloperV9]: [
SuperBlocks.RespWebDesignV9,
SuperBlocks.JsV9,
SuperBlocks.FrontEndDevLibsV9,
SuperBlocks.PythonV9,
SuperBlocks.RelationalDbV9,
SuperBlocks.BackEndDevApisV9
]
};
export type CertSlug = (typeof Certification)[keyof typeof Certification];
export const linkedInCredentialIds = {
[Certification.LegacyFrontEnd]: 'lfe',
[Certification.LegacyBackEnd]: 'lbe',
[Certification.LegacyDataVis]: 'ldv',
[Certification.LegacyInfoSecQa]: 'lisaqa',
[Certification.LegacyFullStack]: 'lfs',
[Certification.RespWebDesign]: 'rwd',
[Certification.FrontEndDevLibs]: 'fedl',
[Certification.JsAlgoDataStruct]: 'ljaads',
[Certification.DataVis]: 'dv',
[Certification.BackEndDevApis]: 'bedaa',
[Certification.QualityAssurance]: 'qa',
[Certification.InfoSec]: 'is',
[Certification.SciCompPy]: 'scwp',
[Certification.DataAnalysisPy]: 'dawp',
[Certification.MachineLearningPy]: 'mlwp',
[Certification.RelationalDb]: 'rd',
[Certification.CollegeAlgebraPy]: 'cawp',
[Certification.FoundationalCSharp]: 'fcswm',
[Certification.RespWebDesignV9]: 'rwdv9',
[Certification.JsV9]: 'jsv9',
[Certification.FrontEndDevLibsV9]: 'felv9',
[Certification.PythonV9]: 'pyv9',
[Certification.RelationalDbV9]: 'rdv9',
[Certification.BackEndDevApisV9]: 'bedv9',
[Certification.FullStackDeveloperV9]: 'fsdv9',
[Certification.JsAlgoDataStructNew]: 'jaads',
[Certification.A2English]: 'a2efd',
[Certification.B1English]: 'b1efd',
[Certification.A2Spanish]: 'a2ps',
[Certification.A2Chinese]: 'a2pc',
[Certification.A1Chinese]: 'a1pc'
};
export const oldDataVizId = '561add10cb82ac38a17513b3';
@@ -0,0 +1,197 @@
const html = 0;
const js = 1;
const backend = 2;
const zipline = 3;
const frontEndProject = 3;
const backEndProject = 4;
const jsProject = 5;
const modern = 6;
const step = 7;
const quiz = 8;
const invalid = 9;
const pythonProject = 10;
const video = 11;
const codeAllyPractice = 12;
const codeAllyCert = 13;
const multifileCertProject = 14;
const theOdinProject = 15;
const colab = 16;
const exam = 17;
const msTrophy = 18;
const multipleChoice = 19;
const python = 20;
const dialogue = 21;
const fillInTheBlank = 22;
const multifilePythonCertProject = 23;
const generic = 24;
const lab = 25;
const jsLab = 26;
const pyLab = 27;
const dailyChallengeJs = 28;
const dailyChallengePy = 29;
const examDownload = 30;
const review = 31;
export const challengeTypes = {
html,
js,
backend,
zipline,
frontEndProject,
backEndProject,
pythonProject,
jsProject,
modern,
step,
quiz,
invalid,
video,
codeAllyPractice,
codeAllyCert,
multifileCertProject,
theOdinProject,
colab,
exam,
msTrophy,
multipleChoice,
python,
dialogue,
fillInTheBlank,
multifilePythonCertProject,
generic,
lab,
jsLab,
pyLab,
dailyChallengeJs,
dailyChallengePy,
examDownload,
review
};
export const hasNoSolution = (challengeType: number): boolean => {
const noSolutions = [
backend,
zipline,
frontEndProject,
backEndProject,
step,
quiz,
invalid,
pythonProject,
video,
codeAllyPractice,
codeAllyCert,
theOdinProject,
colab,
exam,
msTrophy,
multipleChoice,
dialogue,
fillInTheBlank,
generic,
examDownload,
review
];
return noSolutions.includes(challengeType);
};
// determine the component view for each challenge
export const viewTypes = {
[html]: 'classic',
[js]: 'classic',
[jsProject]: 'classic',
[frontEndProject]: 'frontend',
[backEndProject]: 'backend',
[pythonProject]: 'frontend',
[modern]: 'modern',
[step]: 'step',
[quiz]: 'quiz',
[backend]: 'backend',
[video]: 'generic',
[codeAllyPractice]: 'codeAlly',
[codeAllyCert]: 'codeAlly',
[multifileCertProject]: 'classic',
[theOdinProject]: 'generic',
[colab]: 'frontend',
[exam]: 'exam',
[msTrophy]: 'msTrophy',
[multipleChoice]: 'generic',
[python]: 'modern',
[dialogue]: 'generic',
[fillInTheBlank]: 'fillInTheBlank',
[multifilePythonCertProject]: 'classic',
[generic]: 'generic',
[lab]: 'classic',
[jsLab]: 'classic',
[pyLab]: 'classic',
[dailyChallengeJs]: 'classic',
[dailyChallengePy]: 'classic',
[examDownload]: 'examDownload',
[review]: 'generic'
};
// determine the type of submit function to use for the challenge on completion
export const submitTypes = {
[html]: 'tests',
[js]: 'tests',
[jsProject]: 'tests',
// requires just a single url
// like codepen.com/my-project
[frontEndProject]: 'project.frontEnd',
// requires two urls
// a hosted URL where the app is running live
// project code url like GitHub
[backEndProject]: 'project.backEnd',
[pythonProject]: 'project.backEnd',
[step]: 'step',
[quiz]: 'tests',
[backend]: 'backend',
[modern]: 'tests',
[video]: 'tests',
[codeAllyCert]: 'project.frontEnd',
[multifileCertProject]: 'tests',
[theOdinProject]: 'tests',
[colab]: 'project.backEnd',
[exam]: 'exam',
[msTrophy]: 'msTrophy',
[multipleChoice]: 'tests',
[python]: 'tests',
[dialogue]: 'tests',
[fillInTheBlank]: 'tests',
[multifilePythonCertProject]: 'tests',
[generic]: 'tests',
[lab]: 'tests',
[jsLab]: 'tests',
[pyLab]: 'tests',
[dailyChallengeJs]: 'tests',
[dailyChallengePy]: 'tests',
[examDownload]: 'examDownload',
[review]: 'tests'
};
const dailyCodingChallengeTypes = [
challengeTypes.dailyChallengeJs,
challengeTypes.dailyChallengePy
];
export const getIsDailyCodingChallenge = (challengeType: number): boolean =>
dailyCodingChallengeTypes.includes(challengeType);
const labChallengeTypes = [
challengeTypes.lab,
challengeTypes.jsLab,
challengeTypes.pyLab
];
export const getIsLabChallenge = (challengeType: number): boolean =>
labChallengeTypes.includes(challengeType);
const dailyCodingChallengeLanguages = {
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python'
};
export const getDailyCodingChallengeLanguage = (
challengeType: number
): string | undefined => dailyCodingChallengeLanguages[challengeType];
+53
View File
@@ -0,0 +1,53 @@
import type { Module } from './modules';
// TODO: Dynamically create these from intro.json or full-stack.json
export enum FsdChapters {
// original FSD
Welcome = 'freecodecamp',
Javascript = 'javascript',
FrontendLibraries = 'frontend-libraries',
BackendJavascript = 'backend-javascript',
Career = 'career',
// new FSD
RwdExam = 'responsive-web-design-certification-exam',
JsExam = 'javascript-certification-exam',
Fed = 'front-end-development-libraries',
FedExam = 'front-end-development-libraries-certification-exam',
PythonExam = 'python-certification-exam',
RdbExam = 'relational-databases-certification-exam',
Bed = 'back-end-development-and-apis',
BedExam = 'back-end-development-and-apis-certification-exam',
FsdExam = 'certified-full-stack-developer-exam',
// used in both
Html = 'html',
Css = 'css',
Python = 'python',
RelationalDatabases = 'relational-databases'
}
export enum A1ChineseChapters {
zhA1Welcome = 'zh-a1-chapter-welcome-to-a1-professional-chinese',
zhA1PinYin = 'zh-a1-chapter-pinyin',
zhA1Greetings = 'zh-a1-chapter-greetings-and-introductions',
zhA1GreetingsLegacy = 'zh-a1-chapter-greeting-and-self-introduction',
zhA1NumbersAndPersonalInformation = 'zh-a1-chapter-numbers-and-personal-information',
zhA1Family = 'zh-a1-chapter-introducing-colleagues-and-family',
zhA1Expressing = 'zh-a1-chapter-expressing-what-you-can-and-cant-do'
}
export enum A1SpanishChapters {
esA1Welcome = 'es-a1-chapter-welcome-to-a1-professional-spanish',
esA1Fundamentals = 'es-a1-chapter-spanish-fundamentals',
esA1Greetings = 'es-a1-chapter-greetings-and-introductions',
esA1Details = 'es-a1-chapter-basic-personal-details',
esA1Describing = 'es-a1-chapter-describing-company-and-people'
}
export interface Chapter {
dashedName: string;
comingSoon?: boolean;
modules: Module[];
chapterType?: string;
}
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { blocklistedUsernames } from './constants';
describe('constants', () => {
describe('blocklistedUsernames', () => {
it('should not contain duplicate values', () => {
const uniqueValues = new Set(blocklistedUsernames);
expect(blocklistedUsernames.length).toEqual(uniqueValues.size);
});
it('should contain all the letters in the latin alphabet', () => {
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
expect(blocklistedUsernames).toEqual(expect.arrayContaining(alphabet));
});
});
});
// Type tests:
type BlocklistedUsernames = (typeof blocklistedUsernames)[number];
type HasString = string extends BlocklistedUsernames ? true : false;
type Expect<T extends true> = T;
// @ts-expect-error - This is intended to fail since we want to ensure that blocklistedUsernames is an array of literals
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Test = Expect<HasString>;
+656
View File
@@ -0,0 +1,656 @@
const alphabet = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z'
] as const;
export const i18nConstants = [
// reserved paths for localizations
'afrikaans',
'arabic',
'bengali',
'catalan',
'chinese',
'czech',
'danish',
'dutch',
'espanol',
'finnish',
'french',
'german',
'greek',
'hebrew',
'hindi',
'hungarian',
'italian',
'japanese',
'korean',
'norwegian',
'polish',
'portuguese',
'romanian',
'russian',
'serbian',
'spanish',
'swahili',
'swedish',
'turkish',
'ukrainian',
'vietnamese'
] as const;
export const blocklistedUsernames = [
...alphabet,
...i18nConstants,
'about',
'academic-honesty',
'account',
'agile',
'all-stories',
'api',
'backend-challenge-completed',
'blocked',
'bonfire',
'catalog',
'cats.json',
'challenge-completed',
'challenge',
'challenges',
'chat',
'code-of-conduct',
'coding-bootcamp-cost-calculator',
'completed-bonfire',
'completed-challenge',
'completed-field-guide',
'completed-jsProject',
'completed-zipline-or-basejump',
'copyright-policy',
'copyright',
'deprecated-signin',
'donate',
'email-signin',
'events',
'exam',
'explorer',
'external',
'field-guide',
'forgot',
'forum',
'freecodecamp',
'get-help',
'get-pai',
'guide',
'how-nonprofit-projects-work',
'internal',
'jobs-form',
'jobs',
'jsProject',
'learn-to-code',
'learn',
'login',
'logout',
'map',
'modern-challenge-completed',
'news',
'nonprofit-project-instructions',
'nonprofits-form',
'nonprofits',
'open-api',
'passwordless-change',
'pmi-acp-agile-project-managers-form',
'pmi-acp-agile-project-managers',
'privacy-policy',
'privacy',
'profile',
'project-completed',
'reset',
'services',
'shop',
'signin',
'signout',
'signup',
'sitemap.xml',
'software-resources-for-nonprofits',
'sponsors',
'stories',
'support',
'terms-of-service',
'terms',
'the-fastest-web-page-on-the-internet',
'twitch',
'unsubscribe',
'unsubscribed',
'update-my-honesty',
'update-my-portfolio',
'update-my-profile-ui',
'update-my-quincy-email',
'update-my-socials',
'update-my-sound',
'update-my-theme',
'update-my-keyboard-shortcuts',
'update-my-username',
'user',
'username',
'wiki',
// some more names from https://github.com/marteinn/The-Big-Username-Blacklist-JS/blob/master/src/list.js
'.htaccess',
'.htpasswd',
'.well-known',
'400',
'401',
'403',
'404',
'405',
'406',
'407',
'408',
'409',
'410',
'411',
'412',
'413',
'414',
'415',
'416',
'417',
'421',
'422',
'423',
'424',
'426',
'428',
'429',
'431',
'500',
'501',
'502',
'503',
'504',
'505',
'506',
'507',
'508',
'509',
'510',
'511',
'about-us',
'abuse',
'access',
'accounts',
'ad',
'add',
'admin',
'administration',
'administrator',
'ads',
'advertise',
'advertising',
'aes128-ctr',
'aes128-gcm',
'aes192-ctr',
'aes256-ctr',
'aes256-gcm',
'affiliate',
'affiliates',
'ajax',
'alert',
'alerts',
'alpha',
'amp',
'analytics',
'app',
'apps',
'asc',
'assets',
'atom',
'auth',
'authentication',
'authorize',
'autoconfig',
'autodiscover',
'avatar',
'backup',
'banner',
'banners',
'beta',
'billing',
'billings',
'blog',
'blogs',
'board',
'bookmark',
'bookmarks',
'broadcasthost',
'business',
'buy',
'cache',
'calendar',
'campaign',
'captcha',
'careers',
'cart',
'cas',
'categories',
'category',
'cdn',
'cgi',
'cgi-bin',
'chacha20-poly1305',
'change',
'channel',
'channels',
'chart',
'checkout',
'clear',
'client',
'close',
'cms',
'com',
'comment',
'comments',
'community',
'compare',
'compose',
'config',
'connect',
'contact',
'contest',
'cookies',
'copy',
'count',
'create',
'crossdomain.xml',
'css',
'curve25519-sha256',
'customer',
'customers',
'customize',
'dashboard',
'db',
'deals',
'debug',
'delete',
'desc',
'destroy',
'dev',
'developer',
'developers',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha1',
'disconnect',
'discuss',
'dns',
'dns0',
'dns1',
'dns2',
'dns3',
'dns4',
'docs',
'documentation',
'domain',
'download',
'downloads',
'downvote',
'draft',
'drop',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'edit',
'editor',
'email',
'enterprise',
'error',
'errors',
'event',
'example',
'exception',
'exit',
'explore',
'export',
'extensions',
'false',
'family',
'faq',
'faqs',
'favicon.ico',
'features',
'feed',
'feedback',
'feeds',
'file',
'files',
'filter',
'follow',
'follower',
'followers',
'following',
'fonts',
'forgot-password',
'forgotpassword',
'form',
'forms',
'forums',
'friend',
'friends',
'ftp',
'get',
'git',
'go',
'group',
'groups',
'guest',
'guidelines',
'guides',
'head',
'header',
'help',
'hide',
'hmac-sha',
'hmac-sha1',
'hmac-sha1-etm',
'hmac-sha2-256',
'hmac-sha2-256-etm',
'hmac-sha2-512',
'hmac-sha2-512-etm',
'home',
'host',
'hosting',
'hostmaster',
'htpasswd',
'http',
'httpd',
'https',
'humans.txt',
'icons',
'images',
'imap',
'img',
'import',
'index',
'info',
'insert',
'investors',
'invitations',
'invite',
'invites',
'invoice',
'is',
'isatap',
'issues',
'it',
'join',
'js',
'json',
'keybase.txt',
'legal',
'license',
'licensing',
'like',
'limit',
'live',
'load',
'local',
'localdomain',
'localhost',
'lock',
'lost-password',
'mail',
'mail0',
'mail1',
'mail2',
'mail3',
'mail4',
'mail5',
'mail6',
'mail7',
'mail8',
'mail9',
'mailer-daemon',
'mailerdaemon',
'marketing',
'marketplace',
'master',
'me',
'media',
'member',
'members',
'message',
'messages',
'metrics',
'mis',
'mobile',
'moderator',
'modify',
'more',
'mx',
'my',
'net',
'network',
'new',
'newsletter',
'newsletters',
'next',
'nil',
'no-reply',
'nobody',
'noc',
'none',
'noreply',
'notification',
'notifications',
'ns',
'ns0',
'ns1',
'ns2',
'ns3',
'ns4',
'ns5',
'ns6',
'ns7',
'ns8',
'ns9',
'null',
'oauth',
'oauth2',
'offer',
'offers',
'online',
'openid',
'order',
'orders',
'overview',
'owner',
'page',
'pages',
'partners',
'passwd',
'password',
'pay',
'payment',
'payments',
'photo',
'photos',
'pixel',
'plans',
'plugins',
'policies',
'policy',
'pop',
'pop3',
'popular',
'portfolio',
'post',
'postfix',
'postmaster',
'poweruser',
'preferences',
'premium',
'press',
'previous',
'pricing',
'print',
'private',
'prod',
'product',
'production',
'profiles',
'project',
'projects',
'public',
'purchase',
'put',
'quota',
'redirect',
'reduce',
'refund',
'refunds',
'register',
'registration',
'remove',
'replies',
'reply',
'report',
'request',
'request-password',
'reset-password',
'response',
'return',
'returns',
'review',
'reviews',
'robots.txt',
'root',
'rootuser',
'rsa-sha2-2',
'rsa-sha2-512',
'rss',
'rules',
'sales',
'save',
'script',
'sdk',
'search',
'secure',
'security',
'select',
'session',
'sessions',
'settings',
'setup',
'share',
'shift',
'site',
'sitemap',
'sites',
'smtp',
'sort',
'source',
'sql',
'ssh',
'ssh-rsa',
'ssl',
'ssladmin',
'ssladministrator',
'sslwebmaster',
'stage',
'staging',
'stat',
'static',
'statistics',
'stats',
'status',
'store',
'style',
'styles',
'stylesheet',
'stylesheets',
'subdomain',
'subscribe',
'sudo',
'super',
'superuser',
'survey',
'sync',
'sysadmin',
'system',
'tablet',
'tag',
'tags',
'team',
'telnet',
'terms-of-use',
'test',
'testimonials',
'theme',
'themes',
'today',
'tools',
'topic',
'topics',
'tour',
'training',
'translate',
'translations',
'trending',
'trial',
'true',
'umac-128',
'umac-128-etm',
'umac-64',
'umac-64-etm',
'undefined',
'unfollow',
'unlike',
'update',
'upgrade',
'usenet',
'users',
'uucp',
'var',
'verify',
'video',
'view',
'void',
'vote',
'webmail',
'webmaster',
'website',
'widget',
'widgets',
'wpad',
'write',
'www',
'www-data',
'www1',
'www2',
'www3',
'www4',
'you',
'yourname',
'yourusername',
'zlib'
] as const;
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { Languages } from './i18n';
import {
SuperBlocks,
SuperBlockStage,
superBlockStages,
notAuditedSuperBlocks,
generateSuperBlockList,
getAuditedSuperBlocks
} from './curriculum';
describe('superBlockOrder', () => {
it('should contain all SuperBlocks', () => {
const allSuperBlocks = Object.values(SuperBlocks);
const superBlockOrderValues = Object.values(superBlockStages).flat();
expect(superBlockOrderValues).toHaveLength(allSuperBlocks.length);
expect(superBlockOrderValues).toEqual(
expect.arrayContaining(allSuperBlocks)
);
});
});
describe('generateSuperBlockList', () => {
it('should return an array of SuperBlocks object with all elements when if all configs are true', () => {
const result = generateSuperBlockList({
showUpcomingChanges: true
});
expect(result).toHaveLength(Object.values(superBlockStages).flat().length);
});
it('should return an array of SuperBlocks without Upcoming when { showUpcomingChanges: false }', () => {
const result = generateSuperBlockList({
showUpcomingChanges: false
});
const tempSuperBlockMap = { ...superBlockStages };
tempSuperBlockMap[SuperBlockStage.Upcoming] = [];
expect(result).toHaveLength(Object.values(tempSuperBlockMap).flat().length);
});
});
describe('Immutability of superBlockOrder, notAuditedSuperBlocks, and flatSuperBlockMap', () => {
it('should not allow modification of superBlockOrder', () => {
expect(() => {
superBlockStages[SuperBlockStage.Core] = [];
}).toThrow(TypeError);
});
it('should not allow modification of notAuditedSuperBlocks', () => {
expect(() => {
notAuditedSuperBlocks[Languages.English] = [];
}).toThrow(TypeError);
});
it('should not allow modification of flatSuperBlockMap', () => {
expect(() => {
notAuditedSuperBlocks[Languages.English] = [];
}).toThrow(TypeError);
});
});
describe('getAuditedSuperBlocks', () => {
Object.keys(notAuditedSuperBlocks).forEach(language => {
it(`should return only audited SuperBlocks for ${language}`, () => {
const auditedSuperBlocks = getAuditedSuperBlocks({
language
});
auditedSuperBlocks.forEach(superblock => {
expect(notAuditedSuperBlocks[language as Languages]).not.toContain(
superblock
);
});
});
});
});
+370
View File
@@ -0,0 +1,370 @@
// TODO: eventually this should all flow from the curriculum service, since it
// defines the top-level structure of the curriculum.
import { Languages } from './i18n.js';
// all superblocks
export enum SuperBlocks {
RespWebDesignNew = '2022/responsive-web-design',
RespWebDesign = 'responsive-web-design',
JsAlgoDataStruct = 'javascript-algorithms-and-data-structures',
JsAlgoDataStructNew = 'javascript-algorithms-and-data-structures-v8',
FrontEndDevLibs = 'front-end-development-libraries',
DataVis = 'data-visualization',
RelationalDb = 'relational-database',
BackEndDevApis = 'back-end-development-and-apis',
QualityAssurance = 'quality-assurance',
SciCompPy = 'scientific-computing-with-python',
DataAnalysisPy = 'data-analysis-with-python',
InfoSec = 'information-security',
MachineLearningPy = 'machine-learning-with-python',
CodingInterviewPrep = 'coding-interview-prep',
TheOdinProject = 'the-odin-project',
ProjectEuler = 'project-euler',
CollegeAlgebraPy = 'college-algebra-with-python',
FoundationalCSharp = 'foundational-c-sharp-with-microsoft',
A2English = 'a2-english-for-developers',
B1English = 'b1-english-for-developers',
A1Spanish = 'a1-professional-spanish',
A2Spanish = 'a2-professional-spanish',
A2Chinese = 'a2-professional-chinese',
A1Chinese = 'a1-professional-chinese',
RosettaCode = 'rosetta-code',
PythonForEverybody = 'python-for-everybody',
BasicHtml = 'basic-html',
SemanticHtml = 'semantic-html',
DevPlayground = 'dev-playground',
FullStackOpen = 'full-stack-open',
RespWebDesignV9 = 'responsive-web-design-v9',
JsV9 = 'javascript-v9',
FrontEndDevLibsV9 = 'front-end-development-libraries-v9',
PythonV9 = 'python-v9',
RelationalDbV9 = 'relational-databases-v9',
BackEndDevApisV9 = 'back-end-development-and-apis-v9',
FullStackDeveloperV9 = 'full-stack-developer-v9',
HtmlFormsAndTables = 'html-forms-and-tables',
HtmlAndAccessibility = 'html-and-accessibility',
ComputerBasics = 'computer-basics',
BasicCss = 'basic-css',
DesignForDevelopers = 'design-for-developers',
AbsoluteAndRelativeUnits = 'absolute-and-relative-units',
PseudoClassesAndElements = 'pseudo-classes-and-elements',
CssColors = 'css-colors',
StylingForms = 'styling-forms',
CssBoxModel = 'css-box-model',
CssFlexbox = 'css-flexbox',
CssTypography = 'css-typography',
CssAndAccessibility = 'css-and-accessibility',
CssPositioning = 'css-positioning',
AttributeSelectors = 'attribute-selectors',
ResponsiveDesign = 'responsive-design',
CssVariables = 'css-variables',
CssGrid = 'css-grid',
CssAnimations = 'css-animations',
LearnPythonForBeginners = 'learn-python-for-beginners',
IntroductionToAlgorithmsAndDataStructures = 'introduction-to-algorithms-and-data-structures',
LearnOOPWithPython = 'learn-oop-with-python',
LearnRAGAndMCPFundamentals = 'learn-rag-mcp-fundamentals',
IntroductionToPrecalculus = 'introduction-to-precalculus',
IntroductionToBash = 'introduction-to-bash',
IntroductionToSQLAndPostgreSQL = 'introduction-to-sql-and-postgresql',
LearnBashScripting = 'learn-bash-scripting',
LearnSQLAndBash = 'learn-sql-and-bash',
IntroductionToNano = 'introduction-to-nano',
IntroductionToGitAndGithub = 'introduction-to-git-and-github',
IntroductionToVariablesAndStringsInJS = 'introduction-to-variables-and-strings-in-javascript',
IntroductionToBooleansAndNumbersInJS = 'introduction-to-booleans-and-numbers-in-javascript',
IntroductionToFunctionsInJS = 'introduction-functions-in-javascript',
IntroductionToArraysInJS = 'introduction-to-arrays-in-javascript',
IntroductionToObjectsInJS = 'introduction-to-objects-in-javascript',
IntroductionToLoopsInJS = 'introduction-to-loops-in-javascript',
JavascriptFundamentalsReview = 'javascript-fundamentals-review',
IntroductionToHigherOrderFunctionsAndCallbacksInJS = 'introduction-to-higher-order-functions-and-callbacks-in-javascript',
LearnDomManipulationAndEventsWithJS = 'learn-dom-manipulation-and-events-with-javascript',
IntroductionToJavascriptAndAccessibility = 'introduction-to-javascript-and-accessibility',
LearnJavascriptDebugging = 'learn-javascript-debugging',
LearnBasicRegexWithJS = 'learn-basic-regex-with-javascript',
IntroductionToDatesInJS = 'introduction-to-dates-in-javascript',
LearnAudioAndVideoEventsWithJS = 'learn-audio-and-video-events-with-javascript',
IntroductionToMapsAndSetsInJS = 'introduction-to-maps-and-sets-in-javascript',
LearnLocalstorageAndCrudOperationsWithJS = 'learn-localstorage-and-crud-operations-with-javascript',
IntroductionToJavascriptClasses = 'introduction-to-javascript-classes',
LearnRecursionWithJS = 'learn-recursion-with-javascript',
IntroductionToFunctionalProgrammingWithJS = 'introduction-to-functional-programming-with-javascript',
IntroductionToAsynchronousJS = 'introduction-to-asynchronous-javascript',
LearnDataVisualizationWithD3 = 'learn-data-visualization-with-d3',
IntroductionToPythonBasics = 'introduction-to-python-basics',
LearnPythonLoopsAndSequences = 'learn-python-loops-and-sequences',
LearnPythonDictionariesAndSets = 'learn-python-dictionaries-and-sets',
LearnErrorHandlingInPython = 'learn-error-handling-in-python',
LearnPythonClassesAndObjects = 'learn-python-classes-and-objects',
IntroductionToOOPInPython = 'introduction-to-oop-in-python',
IntroductionToLinearDataStructuresInPython = 'introduction-to-linear-data-structures-in-python',
LearnAlgorithmsInPython = 'learn-algorithms-in-python',
LearnGraphsAndTreesInPython = 'learn-graphs-and-trees-in-python',
LearnDynamicProgrammingInPython = 'learn-dynamic-programming-in-python'
}
export const languageSuperBlocks = [
SuperBlocks.A2English,
SuperBlocks.B1English,
SuperBlocks.A1Spanish,
SuperBlocks.A2Spanish,
SuperBlocks.A1Chinese,
SuperBlocks.A2Chinese
];
export enum ChallengeLang {
English = 'en-US',
Spanish = 'es',
Chinese = 'zh-CN'
}
// Mapping from superblock to a speech recognition language (BCP-47)
export const superBlockToSpeechLang: Partial<
Record<SuperBlocks, ChallengeLang>
> = {
[SuperBlocks.A1Chinese]: ChallengeLang.Chinese,
[SuperBlocks.A2Chinese]: ChallengeLang.Chinese,
[SuperBlocks.A2English]: ChallengeLang.English,
[SuperBlocks.B1English]: ChallengeLang.English,
[SuperBlocks.A1Spanish]: ChallengeLang.Spanish,
[SuperBlocks.A2Spanish]: ChallengeLang.Spanish
};
/*
* SuperBlockStages.Upcoming = SHOW_UPCOMING_CHANGES === 'true'
* 'Upcoming' is for development -> not shown on stag or prod anywhere
*
* SuperBlockStages.Next = deployed, but only shown if the Growthbook feature
* is enabled.
*
*/
export enum SuperBlockStage {
Core,
English,
Spanish,
Chinese,
Professional,
Extra,
Legacy,
Upcoming,
Next,
Catalog
}
const defaultStageOrder = [
SuperBlockStage.Core,
SuperBlockStage.English,
SuperBlockStage.Spanish,
SuperBlockStage.Chinese,
SuperBlockStage.Extra,
SuperBlockStage.Legacy,
SuperBlockStage.Professional,
SuperBlockStage.Next,
SuperBlockStage.Catalog
];
export function getStageOrder({
showUpcomingChanges
}: Config): SuperBlockStage[] {
const stageOrder = [...defaultStageOrder];
if (showUpcomingChanges) {
stageOrder.push(SuperBlockStage.Upcoming);
}
return stageOrder;
}
export type StageMap = {
[key in SuperBlockStage]: SuperBlocks[];
};
// Groups of superblocks in learn map. This should include all superblocks.
export const superBlockStages: StageMap = {
[SuperBlockStage.Core]: [
SuperBlocks.RespWebDesignV9,
SuperBlocks.JsV9,
SuperBlocks.FrontEndDevLibsV9,
SuperBlocks.PythonV9,
SuperBlocks.RelationalDbV9,
SuperBlocks.BackEndDevApisV9,
SuperBlocks.FullStackDeveloperV9
],
[SuperBlockStage.English]: [SuperBlocks.A2English, SuperBlocks.B1English],
[SuperBlockStage.Spanish]: [SuperBlocks.A1Spanish],
[SuperBlockStage.Chinese]: [SuperBlocks.A1Chinese],
[SuperBlockStage.Professional]: [SuperBlocks.FoundationalCSharp],
[SuperBlockStage.Extra]: [
SuperBlocks.TheOdinProject,
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
SuperBlocks.RosettaCode
],
[SuperBlockStage.Legacy]: [
SuperBlocks.RespWebDesignNew,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.FrontEndDevLibs,
SuperBlocks.DataVis,
SuperBlocks.RelationalDb,
SuperBlocks.BackEndDevApis,
SuperBlocks.QualityAssurance,
SuperBlocks.SciCompPy,
SuperBlocks.DataAnalysisPy,
SuperBlocks.InfoSec,
SuperBlocks.MachineLearningPy,
SuperBlocks.CollegeAlgebraPy,
SuperBlocks.RespWebDesign,
SuperBlocks.JsAlgoDataStruct,
SuperBlocks.PythonForEverybody
],
[SuperBlockStage.Next]: [],
[SuperBlockStage.Upcoming]: [
SuperBlocks.FullStackOpen,
SuperBlocks.A2Spanish,
SuperBlocks.A2Chinese,
SuperBlocks.DevPlayground
],
// Catalog is treated like upcoming for now
// Add catalog superBlocks to catalog.ts when adding new superBlocks
[SuperBlockStage.Catalog]: [
SuperBlocks.HtmlFormsAndTables,
SuperBlocks.BasicHtml,
SuperBlocks.SemanticHtml,
SuperBlocks.HtmlAndAccessibility,
SuperBlocks.ComputerBasics,
SuperBlocks.BasicCss,
SuperBlocks.DesignForDevelopers,
SuperBlocks.AbsoluteAndRelativeUnits,
SuperBlocks.PseudoClassesAndElements,
SuperBlocks.CssColors,
SuperBlocks.StylingForms,
SuperBlocks.CssBoxModel,
SuperBlocks.CssFlexbox,
SuperBlocks.CssTypography,
SuperBlocks.CssAndAccessibility,
SuperBlocks.CssPositioning,
SuperBlocks.AttributeSelectors,
SuperBlocks.ResponsiveDesign,
SuperBlocks.CssVariables,
SuperBlocks.CssGrid,
SuperBlocks.CssAnimations,
SuperBlocks.LearnPythonForBeginners,
SuperBlocks.IntroductionToAlgorithmsAndDataStructures,
SuperBlocks.LearnOOPWithPython,
SuperBlocks.LearnRAGAndMCPFundamentals,
SuperBlocks.IntroductionToPrecalculus,
SuperBlocks.IntroductionToBash,
SuperBlocks.IntroductionToSQLAndPostgreSQL,
SuperBlocks.LearnBashScripting,
SuperBlocks.LearnSQLAndBash,
SuperBlocks.IntroductionToNano,
SuperBlocks.IntroductionToGitAndGithub,
SuperBlocks.IntroductionToVariablesAndStringsInJS,
SuperBlocks.IntroductionToBooleansAndNumbersInJS,
SuperBlocks.IntroductionToFunctionsInJS,
SuperBlocks.IntroductionToArraysInJS,
SuperBlocks.IntroductionToObjectsInJS,
SuperBlocks.IntroductionToLoopsInJS,
SuperBlocks.JavascriptFundamentalsReview,
SuperBlocks.IntroductionToHigherOrderFunctionsAndCallbacksInJS,
SuperBlocks.LearnDomManipulationAndEventsWithJS,
SuperBlocks.IntroductionToJavascriptAndAccessibility,
SuperBlocks.LearnJavascriptDebugging,
SuperBlocks.LearnBasicRegexWithJS,
SuperBlocks.IntroductionToDatesInJS,
SuperBlocks.LearnAudioAndVideoEventsWithJS,
SuperBlocks.IntroductionToMapsAndSetsInJS,
SuperBlocks.LearnLocalstorageAndCrudOperationsWithJS,
SuperBlocks.IntroductionToJavascriptClasses,
SuperBlocks.LearnRecursionWithJS,
SuperBlocks.IntroductionToFunctionalProgrammingWithJS,
SuperBlocks.IntroductionToAsynchronousJS,
SuperBlocks.LearnDataVisualizationWithD3,
SuperBlocks.IntroductionToPythonBasics,
SuperBlocks.LearnPythonLoopsAndSequences,
SuperBlocks.LearnPythonDictionariesAndSets,
SuperBlocks.LearnErrorHandlingInPython,
SuperBlocks.LearnPythonClassesAndObjects,
SuperBlocks.IntroductionToOOPInPython,
SuperBlocks.IntroductionToLinearDataStructuresInPython,
SuperBlocks.LearnAlgorithmsInPython,
SuperBlocks.LearnGraphsAndTreesInPython,
SuperBlocks.LearnDynamicProgrammingInPython
]
};
Object.freeze(superBlockStages);
export const archivedSuperBlocks = superBlockStages[SuperBlockStage.Legacy];
type NotAuditedSuperBlocks = {
[key in Languages]: SuperBlocks[];
};
// when a superBlock is audited, remove it from its language below
// when adding a new language, add all (not audited) superblocks to the object
export const notAuditedSuperBlocks: NotAuditedSuperBlocks = {
[Languages.English]: [],
[Languages.Espanol]: [],
[Languages.Chinese]: [],
[Languages.ChineseTraditional]: [],
[Languages.Italian]: [],
[Languages.Portuguese]: [],
[Languages.Ukrainian]: [],
[Languages.Japanese]: [],
[Languages.German]: [],
[Languages.Swahili]: [],
[Languages.Korean]: []
};
Object.freeze(notAuditedSuperBlocks);
export const chapterBasedSuperBlocks = [
SuperBlocks.FullStackOpen,
SuperBlocks.A1Spanish,
SuperBlocks.RespWebDesignV9,
SuperBlocks.JsV9,
SuperBlocks.FrontEndDevLibsV9,
SuperBlocks.PythonV9,
SuperBlocks.RelationalDbV9,
SuperBlocks.BackEndDevApisV9,
SuperBlocks.FullStackDeveloperV9,
SuperBlocks.LearnDataVisualizationWithD3,
SuperBlocks.A1Chinese
];
Object.freeze(chapterBasedSuperBlocks);
export const certificationCollectionSuperBlocks = [
SuperBlocks.FullStackDeveloperV9
];
Object.freeze(certificationCollectionSuperBlocks);
type Config = {
showUpcomingChanges: boolean;
};
export function generateSuperBlockList(config: Config): SuperBlocks[] {
return getStageOrder(config)
.map(stage => superBlockStages[stage])
.flat();
}
export function getAuditedSuperBlocks({
language = 'english'
}: {
language: string;
}): SuperBlocks[] {
if (!Object.prototype.hasOwnProperty.call(notAuditedSuperBlocks, language)) {
throw Error(`'${language}' key not found in 'notAuditedSuperBlocks'`);
}
// To find the audited superblocks, we need to start with all superblocks.
const flatSuperBlockMap = generateSuperBlockList({
showUpcomingChanges: true
});
const auditedSuperBlocks = flatSuperBlockMap.filter(
superBlock =>
!notAuditedSuperBlocks[language as Languages].includes(superBlock)
);
return auditedSuperBlocks;
}
@@ -0,0 +1,156 @@
// Configuration for client side
export type DonationAmount = 500 | 1000 | 2000 | 2500 | 4000;
export type DonationDuration = 'one-time' | 'month';
export interface DonationConfig {
donationAmount: DonationAmount;
donationDuration: DonationDuration;
}
export const subscriptionAmounts: DonationAmount[] = [500, 1000, 2000, 4000];
export const subscriptionAmountsB: DonationAmount[] = [500, 1000, 2500, 4000];
export const defaultDonation: DonationConfig = {
donationAmount: 500,
donationDuration: 'month'
};
export const defaultTierAmount: DonationAmount = 2000;
export const defaultTierAmountB: DonationAmount = 2500;
export const onetimeSKUConfig = {
production: [
{ amount: '15000', id: 'sku_IElisJHup0nojP' },
{ amount: '10000', id: 'sku_IEliodY88lglPk' },
{ amount: '7500', id: 'sku_IEli9AXW8DwNtT' },
{ amount: '5000', id: 'sku_IElhJxkNh9UgDp' },
{ amount: '2500', id: 'sku_IElhQtqLgKZC8y' }
],
staging: [
{ amount: '15000', id: 'sku_IEPNpHACYJmUwz' },
{ amount: '10000', id: 'sku_IEPMY1OXxnY4WU' },
{ amount: '7500', id: 'sku_IEPLOotEqlMOWC' },
{ amount: '5000', id: 'sku_IEPKAxxAxfMnUI' },
{ amount: '2500', id: 'sku_IEPIgLRzViwq5z' }
]
};
// Configuration for server side
export const durationKeysConfig = ['month', 'one-time'];
export const donationOneTimeConfig = [100000, 25000, 6000];
export const donationSubscriptionConfig = {
duration: {
month: 'monthly'
},
plans: {
month: subscriptionAmounts
}
};
// Shared paypal configuration
// keep the 5 dollars for the modal
export const paypalConfigTypes = {
production: {
month: {
500: { planId: 'P-6B636789V3105190KMTJFH7A' },
1000: { planId: 'P-53P76823N8780520DMVTWF3I' },
2000: { planId: 'P-8HY47434FB9663500MVTWFOA' },
2500: { planId: 'P-1E758922LA293854BNC3SK3A' },
3000: { planId: 'P-1KY930839N8045117L6E4BKY' },
4000: { planId: 'P-0MH28916302828423MVTWEBI' },
5000: { planId: 'P-0WR49877YD949401BL6E4CTA' }
}
},
staging: {
month: {
500: { planId: 'P-37N14480BW163382FLZYPVMA' },
1000: { planId: 'P-28B62039J8092810UL6E3FXA' },
2000: { planId: 'P-7HR706961M9170433L6HI5VI' },
2500: { planId: 'P-2BK29709FB733490FNC3RPGQ' },
3000: { planId: 'P-35V33574BU596924JL6HI6XY' },
4000: { planId: 'P-45M45060289267734L6HJSXA' },
5000: { planId: 'P-0MD70861FY4172444L6HJTUQ' }
}
}
};
interface OneTimeConfig {
amount: DonationAmount;
duration: 'one-time';
planId: null;
}
interface SubscriptionConfig {
amount: DonationAmount;
duration: 'month';
planId: string;
}
export const paypalConfigurator = (
donationAmount: DonationAmount,
donationDuration: 'one-time' | 'month',
paypalConfig: {
month: {
500: { planId: string };
1000: { planId: string };
2000: { planId: string };
2500: { planId: string };
3000: { planId: string };
4000: { planId: string };
5000: { planId: string };
};
}
): OneTimeConfig | SubscriptionConfig => {
if (donationDuration === 'one-time') {
return { amount: donationAmount, duration: donationDuration, planId: null };
}
return {
amount: donationAmount,
duration: donationDuration,
planId: paypalConfig[donationDuration][donationAmount].planId
};
};
export const donationUrls = {
successUrl: 'https://www.freecodecamp.org/news/thank-you-for-donating/',
cancelUrl: 'https://freecodecamp.org/donate'
};
export enum PaymentContext {
Modal = 'modal',
DonatePage = 'donate page',
Certificate = 'certificate'
}
export enum PaymentProvider {
Paypal = 'paypal',
Patreon = 'patreon',
Stripe = 'stripe',
StripeCard = 'stripe card'
}
const stripeProductIds = {
production: {
month: {
500: 'prod_Cc9bIxB2NvjpLy',
1000: 'prod_BuiSxWk7jGSFlJ',
2000: 'prod_IElpZVK7kOn6Fe',
2500: 'prod_JCakZSxh12ZaDF',
4000: 'prod_IElq1foW39g3Cx'
}
},
staging: {
month: {
500: 'prod_GD1GGbJsqQaupl',
1000: 'prod_GD1IzNEXfSCGgy',
2000: 'prod_IEkNp8M03xvsuB',
2500: 'prod_T12UtcRPvzzVN1',
4000: 'prod_IEkPebxS63mVbs'
}
}
};
export const allStripeProductIdsArray = [
...Object.values(stripeProductIds['production']['month']),
...Object.values(stripeProductIds['staging']['month'])
];
+130
View File
@@ -0,0 +1,130 @@
export enum Languages {
English = 'english',
Espanol = 'espanol',
Chinese = 'chinese',
ChineseTraditional = 'chinese-traditional',
Italian = 'italian',
Portuguese = 'portuguese',
Ukrainian = 'ukrainian',
Japanese = 'japanese',
German = 'german',
Swahili = 'swahili',
Korean = 'korean'
}
/*
* List of languages with localizations enabled for builds.
*
* Client is the UI, and Curriculum is the Challenge Content.
*
* An error will be thrown if the CLIENT_LOCALE and CURRICULUM_LOCALE variables
* from the .env file aren't found in their respective arrays below
*/
export const availableLangs = {
client: [
Languages.English,
Languages.Espanol,
Languages.Chinese,
Languages.ChineseTraditional,
Languages.Italian,
Languages.Portuguese,
Languages.Ukrainian,
Languages.Japanese,
Languages.German,
Languages.Swahili,
Languages.Korean
],
curriculum: [
Languages.English,
Languages.Espanol,
Languages.Chinese,
Languages.ChineseTraditional,
Languages.Italian,
Languages.Portuguese,
Languages.Ukrainian,
Languages.Japanese,
Languages.German,
Languages.Swahili,
Languages.Korean
]
};
// ---------------------------------------------------------------------------
// Each client language needs an entry in the rest of the variables below
/* These strings set the i18next language. It needs to be the two character
* string for the language to take advantage of available functionality.
* Use a 639-1 code here https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
*/
export const i18nextCodes = {
[Languages.English]: 'en',
[Languages.Espanol]: 'es',
[Languages.Chinese]: 'zh',
[Languages.ChineseTraditional]: 'zh-Hant',
[Languages.Italian]: 'it',
[Languages.Portuguese]: 'pt-BR',
[Languages.Ukrainian]: 'uk',
[Languages.Japanese]: 'ja',
[Languages.German]: 'de',
[Languages.Swahili]: 'sw',
[Languages.Korean]: 'ko'
};
// These are for the language selector dropdown menu in the footer
export const LangNames: { [key: string]: string } = {
[Languages.English]: 'English',
[Languages.Espanol]: 'Español',
[Languages.Chinese]: '中文(简体字)',
[Languages.ChineseTraditional]: '中文(繁體字)',
[Languages.Italian]: 'Italiano',
[Languages.Portuguese]: 'Português',
[Languages.Ukrainian]: 'Українська',
[Languages.Japanese]: '日本語',
[Languages.German]: 'Deutsch',
[Languages.Swahili]: 'Swahili',
[Languages.Korean]: '한국어'
};
/* These are for formatting dates and numbers. Used with JS .toLocaleString().
* There's an example in profile/components/Camper.js
* List: https://github.com/unicode-cldr/cldr-dates-modern/tree/master/main
*/
export const LangCodes = {
[Languages.English]: 'en-US',
[Languages.Espanol]: 'es-419',
[Languages.Chinese]: 'zh',
[Languages.ChineseTraditional]: 'zh-Hant',
[Languages.Italian]: 'it',
[Languages.Portuguese]: 'pt-BR',
[Languages.Ukrainian]: 'uk',
[Languages.Japanese]: 'ja',
[Languages.German]: 'de',
[Languages.Swahili]: 'sw',
[Languages.Korean]: 'ko'
};
/**
* This array contains languages that should NOT appear in the language selector.
*/
export const hiddenLangs: Languages[] = [];
/**
* This array contains languages that use the RTL layouts.
*/
export const rtlLangs: Languages[] = [];
// locale is sourced from a JSON file, so we use getLangCode to
// find the associated enum values
export function getLangCode(locale: PropertyKey): string {
if (isPropertyOf(LangCodes, locale)) return LangCodes[locale];
throw new Error(`${String(locale)} is not a valid locale`);
}
function isPropertyOf<O>(
obj: Record<string, string>,
key: PropertyKey
): key is keyof O {
return Object.prototype.hasOwnProperty.call(obj, key);
}
+8
View File
@@ -0,0 +1,8 @@
import { BlockLabel } from './blocks';
export interface Module {
dashedName: string;
comingSoon?: boolean;
blocks: string[];
moduleType?: BlockLabel;
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { getLines } from './get-lines';
const content = 'one\ntwo\nthree';
describe('dasherize', () => {
it('returns a string', () => {
expect(getLines('')).toBe('');
});
it("returns '' when the second arg is empty", () => {
expect(getLines(content)).toBe('');
});
it("returns '' when the range is negative", () => {
expect(getLines(content, [1, -1])).toBe('');
});
it("returns '' when the range is [n,n]", () => {
expect(getLines(content, [0, 0])).toBe('');
expect(getLines(content, [1, 1])).toBe('');
expect(getLines(content, [2, 2])).toBe('');
});
it('returns the first line when the range is [0,2]', () => {
expect(getLines(content, [0, 2])).toBe('one');
});
it('returns the second line when the range is [1,3]', () => {
expect(getLines(content, [1, 3])).toBe('two');
});
it('returns the first and second lines when the range is [0,3]', () => {
expect(getLines(content, [0, 3])).toBe('one\ntwo');
});
it('returns the second and third lines when the range is [1,4]', () => {
expect(getLines(content, [1, 4])).toBe('two\nthree');
});
});
+10
View File
@@ -0,0 +1,10 @@
export function getLines(contents: string, range?: number[]): string {
if (!range) {
return '';
}
const lines = contents.split('\n');
const editableLines =
range[1] <= range[0] ? [] : lines.slice(range[0], range[1] - 1);
return editableLines.join('\n');
}
+8
View File
@@ -0,0 +1,8 @@
import { type SuperBlocks, getAuditedSuperBlocks } from '../config/curriculum';
export function isAuditedSuperBlock(language: string, superblock: SuperBlocks) {
const auditedSuperBlocks = getAuditedSuperBlocks({
language
});
return auditedSuperBlocks.includes(superblock);
}
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { createPoly, createSource } from './polyvinyl';
const polyData = {
name: 'test',
ext: 'js',
contents: 'var hello = world;',
history: ['test.js']
};
describe('createSource', () => {
it('should return a vinyl object with a source matching the contents', () => {
const original = createPoly(polyData);
const updated = createSource(original);
expect(original).not.toHaveProperty('source');
expect(updated).toHaveProperty('source', 'var hello = world;');
expect(updated).toMatchObject(original);
});
it('should not update the source if it already exists', () => {
const original = createPoly({
...polyData,
source: 'const hello = world;'
});
const updated = createSource(original);
expect(updated).toStrictEqual(original);
});
});
+135
View File
@@ -0,0 +1,135 @@
const exts = ['js', 'html', 'css', 'jsx', 'ts', 'tsx', 'py', 'json'] as const;
export type Ext = (typeof exts)[number];
export interface IncompleteChallengeFile {
fileKey: string;
ext: Ext;
name: string;
contents: string;
}
export interface ChallengeFile extends IncompleteChallengeFile {
editableRegionBoundaries?: number[];
editableContents?: string;
usesMultifileEditor?: boolean;
error?: unknown;
seed?: string;
source?: string;
path: string;
history: string[];
}
type PolyProps = {
name: string;
ext: string;
contents: string;
history?: string[];
};
// The types are a little awkward, but should suffice until we move the
// curriculum to TypeScript.
type AddedProperties = {
path: string;
fileKey: string;
error: null;
};
export function createPoly<Rest>({
name,
ext,
contents,
history,
...rest
}: PolyProps & Rest): PolyProps & AddedProperties & Rest {
if (typeof name !== 'string') throw new TypeError('name must be a string');
if (typeof ext !== 'string') throw new TypeError('ext must be a string');
if (typeof contents !== 'string')
throw new TypeError('contents must be a string');
return {
...rest,
history: Array.isArray(history) ? history : [name + '.' + ext],
name,
ext,
path: name + '.' + ext,
fileKey: name + ext,
contents,
error: null
} as PolyProps & AddedProperties & Rest;
}
export function isPoly(poly: unknown): poly is ChallengeFile {
function hasProperties(poly: unknown): poly is Record<string, unknown> {
return (
!!poly &&
typeof poly === 'object' &&
'contents' in poly &&
'name' in poly &&
'ext' in poly &&
'fileKey' in poly &&
'history' in poly
);
}
const hasCorrectTypes = (poly: Record<string, unknown>): boolean =>
typeof poly.contents === 'string' &&
typeof poly.name === 'string' &&
exts.includes(poly.ext as Ext) &&
typeof poly.fileKey === 'string' &&
Array.isArray(poly.history);
return hasProperties(poly) && hasCorrectTypes(poly);
}
function checkPoly(poly: ChallengeFile) {
if (!isPoly(poly)) throw Error('Not a PolyVinyl: ' + JSON.stringify(poly));
}
// setContent will lose source if not supplied
export function setContent(
contents: string,
poly: ChallengeFile,
source?: string
): ChallengeFile {
checkPoly(poly);
return {
...poly,
contents,
source
};
}
// This is currently only used to add back properties that are not stored in the
// database.
export function regenerateMissingProperties(file: IncompleteChallengeFile) {
const newPath = file.name + '.' + file.ext;
return {
...file,
path: newPath,
history: [newPath]
};
}
type Wrapper = (x: string) => Promise<string> | string;
// transformContents will keep a copy of the original
// code in the `source` property. If the original polyvinyl
// already contains a source, this version will continue as
// the source property
export async function transformContents(
wrap: Wrapper,
polyP: ChallengeFile | Promise<ChallengeFile>
) {
const poly = await polyP;
const newPoly = setContent(await wrap(poly.contents), poly, poly.source);
return newPoly;
}
// createSource(poly: PolyVinyl) => PolyVinyl
export function createSource<Rest>(
poly: Pick<ChallengeFile, 'contents' | 'source'> & Rest
): Rest & { contents: string; source: string } {
return {
...poly,
source: poly.source ?? poly.contents
};
}
@@ -0,0 +1,14 @@
/** Shuffle array using the FisherYates shuffle algorithm */
export const shuffleArray = <T>(arrToShuffle: Array<T>) => {
const arr = [...arrToShuffle];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
// We know that i and j are within the bounds of the array, TS doesn't
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
return arr;
};
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import {
isValidUsername,
usernameTooShort,
validationSuccess,
usernameIsHttpStatusCode,
invalidCharError,
isMicrosoftTranscriptLink
} from './validate';
function inRange(num: number, range: number[]) {
return num >= range[0] && num <= range[1];
}
describe('isValidUsername', () => {
it('rejects strings with less than 3 characters', () => {
expect(isValidUsername('')).toStrictEqual(usernameTooShort);
expect(isValidUsername('12')).toStrictEqual(usernameTooShort);
expect(isValidUsername('a')).toStrictEqual(usernameTooShort);
expect(isValidUsername('12a')).toStrictEqual(validationSuccess);
});
it('rejects strings which are http response status codes 100-599', () => {
expect(isValidUsername('429')).toStrictEqual(usernameIsHttpStatusCode);
expect(isValidUsername('789')).toStrictEqual(validationSuccess);
});
it('rejects non-ASCII characters', () => {
expect(isValidUsername('👀👂👄')).toStrictEqual(invalidCharError);
});
it('rejects with invalidCharError even if the string is too short', () => {
expect(isValidUsername('.')).toStrictEqual(invalidCharError);
});
it('accepts alphanumeric characters', () => {
expect(
isValidUsername('abcdefghijklmnopqrstuvwxyz0123456789')
).toStrictEqual(validationSuccess);
});
it('accepts hyphens and underscores', () => {
expect(isValidUsername('a-b')).toStrictEqual(validationSuccess);
expect(isValidUsername('a_b')).toStrictEqual(validationSuccess);
});
it('rejects all other ASCII characters', () => {
const allowedCharactersList = ['-', '_', '+'];
const numbers = [48, 57];
const upperCase = [65, 90];
const lowerCase = [97, 122];
const base = 'user';
const finalCode = 127;
for (let code = 0; code <= finalCode; code++) {
const char = String.fromCharCode(code);
let expected: { valid: boolean; error: null | string } = invalidCharError;
if (allowedCharactersList.includes(char)) expected = validationSuccess;
if (inRange(code, numbers)) expected = validationSuccess;
if (inRange(code, upperCase)) expected = validationSuccess;
if (inRange(code, lowerCase)) expected = validationSuccess;
expect(isValidUsername(base + char)).toStrictEqual(expected);
}
});
});
const baseUrl = 'https://learn.microsoft.com/';
describe('isMicrosoftTranscriptLink', () => {
it('should reject links to domains other than learn.microsoft.com', () => {
{
expect(isMicrosoftTranscriptLink('https://lean.microsoft.com')).toBe(
false
);
expect(isMicrosoftTranscriptLink('https://learn.microsft.com')).toBe(
false
);
}
});
it('should reject links without a username', () => {
expect(isMicrosoftTranscriptLink(`${baseUrl}/en-us/users/`)).toBe(false);
});
it('should reject links without a unique id', () => {
expect(
isMicrosoftTranscriptLink(`${baseUrl}/en-us/users/moT01/transcript`)
).toBe(false);
});
it('should reject links with anything after the unique id', () => {
expect(
isMicrosoftTranscriptLink(
`${baseUrl}/en-us/users/moT01/transcript/any-id/more-stuff`
)
).toBe(false);
});
it('should reject the placeholder link', () => {
expect(
isMicrosoftTranscriptLink(
'https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID'
)
).toBe(false);
expect(
isMicrosoftTranscriptLink(
'https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID/'
)
).toBe(false);
});
it.each(['en-us', 'fr-fr', 'lang-country'])(
'should accept links with the %s locale',
locale => {
expect(
isMicrosoftTranscriptLink(
`https://learn.microsoft.com/${locale}/users/moT01/transcript/any-id`
)
).toBe(true);
}
);
});
+58
View File
@@ -0,0 +1,58 @@
type Valid = {
valid: true;
error: null;
};
type Invalid = {
valid: false;
error: string;
};
type Validated = Valid | Invalid;
export const invalidCharError: Invalid = {
valid: false,
error: 'contains invalid characters'
};
export const validationSuccess: Valid = { valid: true, error: null };
export const usernameTooShort: Invalid = {
valid: false,
error: 'is too short'
};
export const usernameIsHttpStatusCode: Invalid = {
valid: false,
error: 'is a reserved error code'
};
const validCharsRE = /^[a-zA-Z0-9\-_+]*$/;
export const isHttpStatusCode = (str: string): boolean => {
const output = parseInt(str, 10);
return !isNaN(output) && output >= 100 && output <= 599;
};
export const isValidUsername = (str: string): Validated => {
if (!validCharsRE.test(str)) return invalidCharError;
if (str.length < 3) return usernameTooShort;
if (isHttpStatusCode(str)) return usernameIsHttpStatusCode;
return validationSuccess;
};
// link template:
// https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID
export const isMicrosoftTranscriptLink = (value: string): boolean => {
let url;
try {
url = new URL(value);
} catch {
return false;
}
const correctDomain = url.hostname === 'learn.microsoft.com';
const correctPath = !!url.pathname.match(
/^\/[^/]+\/users\/[^/]+\/transcript\/[^/]+$/
);
const notPlaceholder = !url.pathname.match(
'/LOCALE/users/USERNAME/transcript/ID'
);
return correctDomain && correctPath && notPlaceholder;
};
+10
View File
@@ -0,0 +1,10 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"noEmit": false
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.test.ts'],
exports: true,
dts: true
});