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
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:
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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 {}'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"module": "es2020",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Executable
+2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
@@ -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}'`)
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -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']]
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,3 @@
|
||||
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
|
||||
|
||||
export default configTypeChecked;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'])
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 Fisher–Yates 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.test.ts'],
|
||||
exports: true,
|
||||
dts: true
|
||||
});
|
||||
Reference in New Issue
Block a user