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

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getSuperblockStructure,
writeSuperblockStructure
} from '@freecodecamp/curriculum/file-handler';
import {
updateChapterModuleSuperblockStructure,
updateSimpleSuperblockStructure
} from './create-project.js';
vi.mock('@freecodecamp/curriculum/file-handler');
const mockGetSuperblockStructure = vi.mocked(getSuperblockStructure);
const mockWriteSuperblockStructure = vi.mocked(writeSuperblockStructure);
const incompleteSimpleChapterModuleSuperblock = {
chapters: [
{
dashedName: 'chapter1',
modules: [
{
dashedName: 'module1c1',
blocks: ['block1', 'block3']
}
]
}
]
};
const simpleChapterModuleSuperblock = {
chapters: [
{
dashedName: 'chapter1',
modules: [
{
dashedName: 'module1c1',
blocks: ['block1', 'block2', 'block3']
}
]
}
]
};
describe('updateSimpleSuperblockStructure', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should insert the block into the blocks array at the expected position', async () => {
const existingBlocks = ['block1', 'block2', 'block4'];
const superblockFilename = 'test-superblock';
const newBlock = 'block3';
const order = 2;
mockGetSuperblockStructure.mockReturnValue({
blocks: existingBlocks
});
await updateSimpleSuperblockStructure(
newBlock,
{ order },
superblockFilename
);
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
superblockFilename,
{
blocks: ['block1', 'block2', 'block3', 'block4']
}
);
});
it('should insert the block into the blocks array at the end when no order is given', async () => {
const existingBlocks = ['block1', 'block2'];
const superblockFilename = 'test-superblock';
const newBlock = 'block3';
mockGetSuperblockStructure.mockReturnValue({
blocks: existingBlocks
});
await updateSimpleSuperblockStructure(newBlock, {}, superblockFilename);
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
superblockFilename,
{
blocks: ['block1', 'block2', 'block3']
}
);
});
});
describe('updateChapterModuleSuperblockStructure', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should insert the block correctly when there is only one chapter and one module', async () => {
const superblockFilename = 'test-superblock';
const newBlock = 'block2';
const position = {
order: 1,
chapter: 'chapter1',
module: 'module1c1'
};
mockGetSuperblockStructure.mockReturnValue(
incompleteSimpleChapterModuleSuperblock
);
await updateChapterModuleSuperblockStructure(
newBlock,
position,
superblockFilename
);
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
superblockFilename,
simpleChapterModuleSuperblock
);
});
it('should create a module if it does not exist', async () => {
const superblockFilename = 'test-superblock';
const newBlock = 'block2';
const position = {
order: 0,
chapter: 'chapter1',
module: 'module2c1'
};
mockGetSuperblockStructure.mockReturnValue(
incompleteSimpleChapterModuleSuperblock
);
await updateChapterModuleSuperblockStructure(
newBlock,
position,
superblockFilename
);
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
superblockFilename,
{
chapters: [
{
dashedName: 'chapter1',
modules: [
{
dashedName: 'module1c1',
blocks: ['block1', 'block3']
},
{
dashedName: 'module2c1',
comingSoon: true,
blocks: ['block2']
}
]
}
]
}
);
});
it('should create a chapter and module if they do not exist', async () => {
const superblockFilename = 'test-superblock';
const newBlock = 'block1m2c2';
const position = {
order: 0,
chapter: 'chapter2',
module: 'module1c2'
};
mockGetSuperblockStructure.mockReturnValue({ chapters: [] });
await updateChapterModuleSuperblockStructure(
newBlock,
position,
superblockFilename
);
expect(mockGetSuperblockStructure).toHaveBeenCalledWith(superblockFilename);
expect(mockWriteSuperblockStructure).toHaveBeenCalledWith(
superblockFilename,
{
chapters: [
{
dashedName: 'chapter2',
comingSoon: true,
modules: [
{
dashedName: 'module1c2',
blocks: ['block1m2c2']
}
]
}
]
}
);
});
});
@@ -0,0 +1,104 @@
// TODO: this belongs in create-project, but we can't test that (since it uses
// prettier) until we migrate to vitest
import {
getSuperblockStructure,
writeSuperblockStructure
} from '@freecodecamp/curriculum/file-handler';
import { insertInto } from './utils.js';
export async function updateSimpleSuperblockStructure(
block: string,
position: { order?: number },
superblockFilename: string
) {
const existing = getSuperblockStructure(superblockFilename) as {
blocks: string[];
};
const order =
typeof position.order === 'number'
? position.order
: existing.blocks.length;
const updated = {
blocks: insertInto(existing.blocks, order, block)
};
await writeSuperblockStructure(superblockFilename, updated);
}
function createNewChapter(chapter: string, module: string, block: string) {
return {
dashedName: chapter,
comingSoon: true,
modules: [
{
dashedName: module,
blocks: [block]
}
]
};
}
function createNewModule(module: string, block: string) {
return {
dashedName: module,
comingSoon: true,
blocks: [block]
};
}
export type ChapterModuleSuperblockStructure = {
chapters: {
dashedName: string;
comingSoon?: boolean;
modules: {
dashedName: string;
comingSoon?: boolean;
blocks: string[];
}[];
}[];
};
export async function updateChapterModuleSuperblockStructure(
block: string,
position: { order: number; chapter: string; module: string },
superblockFilename: string
) {
const existing = getSuperblockStructure(
superblockFilename
) as ChapterModuleSuperblockStructure;
const modifiedChapter = existing.chapters.find(
chapter => chapter.dashedName === position.chapter
);
const modifiedModule = modifiedChapter?.modules.find(
module => module.dashedName === position.module
);
const updatedModule = modifiedModule
? {
...modifiedModule,
blocks: insertInto(modifiedModule.blocks, position.order, block)
}
: createNewModule(position.module, block);
const updatedChapter = modifiedChapter
? {
...modifiedChapter,
modules: modifiedModule
? modifiedChapter.modules.map(module =>
module === modifiedModule ? updatedModule : module
)
: [...modifiedChapter.modules, updatedModule]
}
: createNewChapter(position.chapter, position.module, block);
const updated = {
chapters: modifiedChapter
? existing.chapters.map(chapter =>
chapter === modifiedChapter ? updatedChapter : chapter
)
: [...existing.chapters, updatedChapter]
};
await writeSuperblockStructure(superblockFilename, updated);
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { getArgValue } from './get-arg-value.js';
describe('getArgValue helper', () => {
it('should throw if there no arguments', () => {
const args = ['/Path/to/node', '/Path/to/script'];
expect(() => getArgValue(args)).toThrow('only one argument allowed');
});
it('should throw if the argument is not an integer', () => {
expect.assertions(3);
const args = ['/Path/to/node', '/Path/to/script', 'num=4'];
expect(() => getArgValue(args)).toThrow('argument must be an integer');
const args2 = ['/Path/to/node', '/Path/to/script', '4.1'];
expect(() => getArgValue(args2)).toThrow('argument must be an integer');
const args3 = ['/Path/to/node', '/Path/to/script', '4'];
expect(getArgValue(args3)).toBe(4);
});
});
@@ -0,0 +1,11 @@
const isIntRE = /^\d+$/;
function getArgValue(argv: string[] = []): number {
if (argv.length !== 3) throw Error('only one argument allowed');
const value = argv[2];
const intValue = parseInt(value, 10);
if (!isIntRE.test(value) || !Number.isInteger(intValue))
throw Error('argument must be an integer');
return intValue;
}
export { getArgValue };
@@ -0,0 +1,68 @@
interface Meta {
isUpcomingChange: boolean;
dashedName: string;
helpCategory: string;
challengeOrder: Array<{
id: string;
title: string;
}>;
usesMultifileEditor?: boolean;
hasEditableBoundaries?: boolean;
blockLabel?: string;
blockLayout?: string;
order?: number;
}
const baseMeta: Meta = {
isUpcomingChange: true,
dashedName: '',
helpCategory: '',
blockLayout: 'legacy-challenge-list',
challengeOrder: [
{
id: '',
title: ''
}
]
};
const stepMeta = {
...baseMeta,
usesMultifileEditor: true,
hasEditableBoundaries: true
};
const fullStackStepMeta = {
...baseMeta,
blockLabel: '',
blockLayout: '',
usesMultifileEditor: true
};
const quizMeta = {
...baseMeta,
blockLabel: 'quiz',
blockLayout: 'link'
};
const languageMeta = {
...baseMeta,
blockLayout: 'dialogue-grid'
};
export const getBaseMeta = (
projectType: 'Step' | 'Quiz' | 'Language' | 'FullStack'
): Meta => {
switch (projectType) {
case 'Step':
return stepMeta;
case 'Quiz':
return quizMeta;
case 'FullStack':
return fullStackStepMeta;
case 'Language':
return languageMeta;
default:
return stepMeta;
}
};
@@ -0,0 +1,541 @@
import { ObjectId } from 'bson';
import type { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
const sanitizeTitle = (title: string) => {
return title.includes(':') || title.includes("'") ? `"${title}"` : title;
};
interface ChallengeOptions {
challengeId: ObjectId;
title: string;
dashedName: string;
challengeType: string;
questionCount?: number;
challengeLang?: ChallengeLang;
inputType?: string;
}
const buildFrontMatter = ({
challengeId,
title,
dashedName,
challengeType,
challengeLang,
inputType
}: ChallengeOptions) => {
const langString = challengeLang
? `
lang: ${challengeLang}`
: '';
const inputTypeString = inputType
? `
inputType: ${inputType}`
: '';
return `---
id: ${challengeId.toString()}
title: ${sanitizeTitle(title)}
challengeType: ${challengeType}
dashedName: ${dashedName}${langString}${inputTypeString}
---`;
};
const buildFrontMatterWithVideo = ({
challengeId,
title,
dashedName,
challengeType
}: ChallengeOptions) => `---
id: ${challengeId.toString()}
videoId: ADD YOUR VIDEO ID HERE!!!
title: ${sanitizeTitle(title)}
challengeType: ${challengeType}
dashedName: ${dashedName}
---`;
const getLegacyChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --instructions--
${options.title} instructions.
# --hints--
Test 1
\`\`\`js
\`\`\`
# --seed--
\`\`\`js
\`\`\`
# --solutions--
\`\`\`js
\`\`\`
`;
const getQuizChallengeTemplate = (options: ChallengeOptions): string => {
const count = options.questionCount!;
const regularQuestion = `### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
`;
const questionWithAudio = `### --question--
#### --text--
Placeholder question
#### --audio--
\`\`\`json
{
"audio": {
"filename": "1.1-1.mp3",
"startTimestamp": 5.7,
"finishTimestamp": 6.48
},
"transcript": [
{
"character": "Tom",
"text": "Hi, I'm Tom."
}
]
}
\`\`\`
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
`;
const firstQuestion = options.challengeLang
? questionWithAudio
: regularQuestion;
return `${buildFrontMatter(options)}
# --description--
To pass the quiz, you must correctly answer at least ${count == 20 ? '18' : '9'} of the ${count.toString()} questions below.
# --quizzes--
## --quiz--
${firstQuestion}${regularQuestion.repeat(Math.max(0, count - 2))}
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
`;
};
const getVideoChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatterWithVideo(options)}
# --description--
${options.title} description.
# --question--
## --text--
${options.title} question?
## --answers--
Answer 1
---
Answer 2
---
Answer 3
## --video-solution--
1
`;
const getAssignmentChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --assignment--
${options.title} assignment!
# --question--
## --text--
${options.title} question?
## --answers--
Answer 1
---
Answer 2
---
Answer 3
## --video-solution--
1
`;
const getMultipleChoiceChallengeTemplate = (
options: ChallengeOptions
): string => {
const correctAnswerIndex = Math.floor(Math.random() * 4) + 1;
const feedback = (index: number) =>
index === correctAnswerIndex
? ''
: `
### --feedback--
Include feedback for answer ${index} here.
`;
const answers = [1, 2, 3, 4]
.map(
index => `Answer ${index}
${feedback(index)}`
)
.join('\n---\n\n');
return `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --questions--
## --text--
${options.title} question?
## --answers--
${answers}
## --video-solution--
${correctAnswerIndex}
`;
};
const getFillInTheBlankChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
${options.title} description.
# --fillInTheBlank--
## --sentence--
\`Fill BLANK the BLANK sentence.\`
## --blanks--
\`in\`
### --feedback--
It's \`in\`
---
\`blank\`
`;
const getDialogueChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
Watch the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video.
# --scene--
\`\`\`json
{
"setup": {
"background": "chaos.png",
"characters": [
{
"character": "David",
"position": {"x":50,"y":80,"z":8},
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 5.7,
"finishTimestamp": 6.48
}
},
"commands": [
{
"character": "David",
"opacity": 1,
"startTime": 0
},
{
"character": "David",
"startTime": 1,
"finishTime": 0.78,
"dialogue": {
"text": "I'm Tom.",
"align": "center"
}
},
{
"character": "Tom",
"opacity": 0,
"startTime": 1.28
}
]
}
\`\`\`
`;
const getGenericChallengeTemplate = (
options: ChallengeOptions
): string => `${buildFrontMatter(options)}
# --description--
Generic challenge description.
# --assignment--
Do the assignment.
`;
interface DailyCodingChallengeOptions {
challengeId: ObjectId;
challengeNumber: number;
}
export const getDailyJavascriptChallengeTemplate = ({
challengeId,
challengeNumber
}: DailyCodingChallengeOptions) => `---
id: ${challengeId.toString()}
title: "Challenge ${challengeNumber}: Placeholder"
challengeType: 28
dashedName: challenge-${challengeNumber}
---
# --description--
Placeholder description
# --hints--
Placeholder test
\`\`\`js
assert.isTrue(true);
\`\`\`
# --seed--
## --seed-contents--
\`\`\`js
function placeholder(arg) {
return arg;
}
\`\`\`
# --solutions--
\`\`\`js
function placeholder(arg) {
return arg;
}
\`\`\`
`;
export const getDailyPythonChallengeTemplate = ({
challengeId,
challengeNumber
}: DailyCodingChallengeOptions) => `---
id: ${challengeId.toString()}
title: "Challenge ${challengeNumber}: Placeholder"
challengeType: 29
dashedName: challenge-${challengeNumber}
---
# --description--
Placeholder description
# --hints--
Placeholder test
\`\`\`js
({test: () => { runPython(\`
from unittest import TestCase
TestCase().assertTrue(True)\`)
}})
\`\`\`
# --seed--
## --seed-contents--
\`\`\`py
def placeholder(arg):
return arg
\`\`\`
# --solutions--
\`\`\`py
def placeholder(arg):
return arg
\`\`\`
`;
type Template = (opts: ChallengeOptions) => string;
export const getTemplate = (challengeType: string): Template => {
const template = challengeTypeToTemplate[challengeType];
if (!template) {
throw Error(`Challenge type ${challengeType} has no template.
To create one, please add a new function to this file and include it in the challengeTypeToTemplate map.
`);
}
return template;
};
/**
* This should be kept in parity with the challengeTypes in the
* client.
*
* Keys are explicitly marked null so we know the challenge type itself
* exists, and can expand this to use the correct template later on.
*/
const challengeTypeToTemplate: {
[key: string]: null | Template;
} = {
0: getLegacyChallengeTemplate,
1: getLegacyChallengeTemplate,
2: null,
3: getLegacyChallengeTemplate,
4: getLegacyChallengeTemplate,
5: getLegacyChallengeTemplate,
6: getLegacyChallengeTemplate,
7: null,
8: getQuizChallengeTemplate,
9: null,
10: null,
11: getVideoChallengeTemplate,
12: null,
13: null,
14: null,
15: getAssignmentChallengeTemplate,
16: null,
17: null,
18: null,
19: getMultipleChoiceChallengeTemplate,
20: null,
21: getDialogueChallengeTemplate,
22: getFillInTheBlankChallengeTemplate,
23: null,
24: getGenericChallengeTemplate
};
@@ -0,0 +1,68 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import fs from 'fs';
import { join } from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getFileName } from './get-file-name.js';
const basePath = join(process.cwd(), '__fixtures__');
const commonPath = join(basePath, 'curriculum', 'challenges');
const block = 'project-get-file-name';
const metaPath = join(commonPath, '_meta', block);
const superBlockPath = join(commonPath, 'english', 'superblock-get-file-name');
const projectPath = join(superBlockPath, block);
describe('getFileName helper', () => {
beforeEach(() => {
fs.mkdirSync(superBlockPath, { recursive: true });
fs.mkdirSync(projectPath, { recursive: true });
fs.mkdirSync(metaPath, { recursive: true });
fs.writeFileSync(
join(projectPath, 'this-is-a-challenge.md'),
'---\nid: a\ntitle: This is a Challenge\n---',
'utf-8'
);
fs.writeFileSync(
join(projectPath, 'what-a-cool-thing.md'),
'---\nid: b\ntitle: What a Cool Thing\n---',
'utf-8'
);
fs.writeFileSync(
join(projectPath, 'i-dunno.md'),
'---\nid: c\ntitle: I Dunno\n---',
'utf-8'
);
fs.writeFileSync(
join(metaPath, 'meta.json'),
`{
"id": "mock-id",
"challengeOrder": [{"id": "a", "title": "This title is wrong"}, {"id": "b", "title": "I Dunno"}, {"id": "c", "title": "What a Cool Thing"}}}]}`,
'utf-8'
);
});
it('should return the file name if found', async () => {
expect.assertions(1);
process.env.INIT_CWD = projectPath;
const fileName = await getFileName('a');
expect(fileName).toEqual('this-is-a-challenge.md');
});
it('should return null if not found', async () => {
expect.assertions(1);
process.env.INIT_CWD = projectPath;
const fileName = await getFileName('d');
expect(fileName).toBeNull();
});
afterEach(() => {
delete process.env.INIT_CWD;
try {
fs.rmSync(basePath, { recursive: true });
} catch (err) {
console.log(err);
console.log('Could not remove fixtures folder.');
}
});
});
@@ -0,0 +1,23 @@
import { readdir } from 'fs/promises';
import matter from 'gray-matter';
import { getProjectPath } from './get-project-info.js';
export const getFileName = async (id: string): Promise<string | null> => {
const path = getProjectPath();
const files = await readdir(path);
for (const file of files) {
if (!file.endsWith('.md')) {
continue;
}
let frontMatter = null;
try {
frontMatter = matter.read(`${path}${file}`);
} catch (_err) {
frontMatter = null;
}
if (String(frontMatter?.data.id) === id) {
return file;
}
}
return null;
};
@@ -0,0 +1,26 @@
import { select } from '@inquirer/prompts';
import { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
export const getInputType = async (
challengeType: string,
challengeLang?: ChallengeLang
): Promise<string | undefined> => {
const isRequired =
parseInt(challengeType) === challengeTypes.fillInTheBlank &&
challengeLang === ChallengeLang.Chinese;
if (!isRequired) {
return;
}
const inputType = await select<string>({
message: 'What input type is challenge using?',
choices: [
{ name: 'pinyin-tone', value: 'pinyin-tone' },
{ name: 'pinyin-to-hanzi', value: 'pinyin-to-hanzi' }
]
});
return inputType;
};
@@ -0,0 +1,15 @@
import {
ChallengeLang,
SuperBlocks,
superBlockToSpeechLang
} from '@freecodecamp/shared/config/curriculum';
export const getLangFromSuperBlock = (
superBlock: SuperBlocks
): ChallengeLang => {
const lang = superBlockToSpeechLang[superBlock];
if (!lang) {
throw new Error(`Missing lang mapping for superBlock: ${superBlock}`);
}
return lang;
};
@@ -0,0 +1,13 @@
import { last } from 'lodash';
import { getMetaData } from './project-metadata.js';
function getLastStep(): { stepNum: number } {
const meta = getMetaData();
const challengeOrder = meta.challengeOrder;
const step = last(challengeOrder);
if (!step) throw new Error('No steps found');
return { stepNum: challengeOrder.length };
}
export { getLastStep };
@@ -0,0 +1,39 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { describe, it, expect } from 'vitest';
import { getProjectName, getProjectPath } from './get-project-info.js';
describe('getProjectPath helper', () => {
it('should return the calling dir path', () => {
const mockCallingDir = 'calling/dir';
const expected = `${mockCallingDir}/`;
// Add mock to test condition
process.env.INIT_CWD = mockCallingDir;
expect(getProjectPath()).toEqual(expected);
// Remove mock to not affect other tests
delete process.env.INIT_CWD;
});
it('should return the projects absolute path', () => {
const expected = `${process.cwd()}/`;
expect(getProjectPath()).toEqual(expected);
});
});
describe('getProjectName helper', () => {
it('should return the last path segment of the calling dir', () => {
const mockCallingDir = 'calling/dir';
const expected = 'dir';
// Add mock to test condition
process.env.INIT_CWD = mockCallingDir;
expect(getProjectName()).toEqual(expected);
// Remove mock to not affect other tests
delete process.env.INIT_CWD;
});
});
@@ -0,0 +1,8 @@
export function getProjectPath(): string {
// eslint-disable-next-line turbo/no-undeclared-env-vars
return (process.env.INIT_CWD || process.cwd()) + '/';
}
export function getProjectName(): string {
return getProjectPath().split('/').slice(-2)[0];
}
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { ObjectId } from 'bson';
import { getStepTemplate } from './get-step-template.js';
import { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
const props = {
challengeId: new ObjectId('60d4ebe4801158d1abe1b18f'),
challengeSeeds: [
{
contents: '',
editableRegionBoundaries: [0, 2],
ext: 'html',
id: '',
key: 'indexhtml',
name: 'index'
}
],
stepNum: 5,
challengeType: 0
};
// Note: evaluates at highlevel the process, but seedHeads and seedTails could
// be tested if more specifics are needed.
describe('getStepTemplate util', () => {
it('should be able to create a markdown', () => {
const baseOutput = `---
id: 60d4ebe4801158d1abe1b18f
title: Step 5
challengeType: 0
dashedName: step-5
---
# --description--
step 5 instructions
# --hints--
Test 1
\`\`\`js
\`\`\`
# --seed--
## --seed-contents--
\`\`\`html
--fcc-editable-region--
--fcc-editable-region--
\`\`\`\n`;
expect(getStepTemplate(props)).toEqual(baseOutput);
});
it('should add lang property when challengeLang is passed', () => {
const frontMatter = `---
id: 60d4ebe4801158d1abe1b18f
title: Step 5
challengeType: 0
dashedName: step-5
lang: es
---`;
expect(
getStepTemplate({
...props,
challengeLang: ChallengeLang.Spanish
})
).match(new RegExp(`^${frontMatter}`));
});
});
@@ -0,0 +1,92 @@
import { ObjectId } from 'bson';
import type { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
import { insertErms } from './insert-erms.js';
// Builds a block
function getCodeBlock(label: string, content?: string) {
return `\`\`\`${label}
${typeof content !== 'undefined' ? content : ''}
\`\`\`\n`;
}
// Builds a section
function getSeedSection(content: string, label: string) {
return content
? `
## --${label}--
${content}`
: '';
}
type StepOptions = {
challengeId: ObjectId;
challengeSeeds: ChallengeSeed[];
stepNum: number;
challengeType?: number;
isFirstChallenge?: boolean;
challengeLang?: ChallengeLang;
};
export interface ChallengeSeed {
contents: string;
ext: string;
editableRegionBoundaries: number[];
}
// Build the base markdown for a step
function getStepTemplate({
challengeId,
challengeSeeds,
stepNum,
challengeType,
isFirstChallenge = false,
challengeLang
}: StepOptions): string {
const seedTexts = challengeSeeds
.map(({ contents, ext, editableRegionBoundaries }) => {
let fullContents = contents;
if (editableRegionBoundaries.length >= 2) {
fullContents = insertErms(contents, editableRegionBoundaries);
}
return getCodeBlock(ext, fullContents);
})
.join('\n');
const stepDescription = `step ${stepNum} instructions`;
const seedChallengeSection = getSeedSection(seedTexts, 'seed-contents');
const demoString = isFirstChallenge
? `
# demoType can either be 'onClick' or 'onLoad'. If the project or lab doesn't have a preview, delete the property
demoType: onClick`
: '';
const langString = challengeLang
? `
lang: ${challengeLang}`
: '';
return (
`---
id: ${challengeId.toString()}
title: Step ${stepNum}
challengeType: ${challengeType ?? 'placeholder'}
dashedName: step-${stepNum}${langString}${demoString}
---
# --description--
${stepDescription}
# --hints--
Test 1
${getCodeBlock('js')}
# --seed--` + seedChallengeSection
);
}
export { getStepTemplate };
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { insertErms } from './insert-erms.js';
describe('insertErms helper', () => {
const code = `<h1>Hello World</h1>
<main>
<h2>CatPhotoApp</h2>
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
</main>`;
it('should throw error if erm length is less than 2', () => {
const items = [[], [1]];
items.forEach(item => {
expect(() => {
insertErms(code, item);
}).toThrow();
});
});
it('should update code with markers if provided', () => {
const newCode = `--fcc-editable-region--
<h1>Hello World</h1>
<main>
<h2>CatPhotoApp</h2>
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
--fcc-editable-region--
</main>`;
expect(insertErms(code, [0, 7])).toEqual(newCode);
});
it('should update code with 2 markers if more are provided', () => {
const newCode = `<h1>Hello World</h1>
<main>
--fcc-editable-region--
<h2>CatPhotoApp</h2>
--fcc-editable-region--
<img src="https://www.bit.ly/fcc-relaxing-cat" alt="A cute orange cat lying on its back.">
<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>
<p>Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.</p>
</main>`;
expect(insertErms(code, [2, 4, 6, 7])).toEqual(newCode);
});
});
@@ -0,0 +1,26 @@
// Update given value with markers (labels)
const insertErms = (seedCode: string, erms: number[]): string => {
if (erms.length <= 1) {
throw Error('erms should contain 2 elements');
}
const separator = '\n';
const lines = seedCode.split(separator);
const markerLabel = '--fcc-editable-region--';
// Generate a version of seed code with the erm markers
const newSeedCode = erms
.slice(0, 2)
.reduce((acc, erm) => {
if (Number.isInteger(erm)) {
acc.splice(erm, 0, markerLabel);
}
return acc;
}, lines)
.join(separator);
return newSeedCode;
};
export { insertErms };
@@ -0,0 +1,46 @@
import { input, select } from '@inquirer/prompts';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import { getLastStep } from './get-last-step-file-number.js';
export const newChallengePrompts = async (): Promise<{
title: string;
dashedName: string;
challengeType: string;
}> => {
const challengeType = await select<string>({
message: 'What type of challenge is this?',
choices: Object.entries(challengeTypes).map(([key, value]) => ({
name: key,
value: value.toString()
}))
});
const lastStep = getLastStep().stepNum;
const defaultTitle = `Step ${lastStep + 1}`;
const defaultDashedName = `step-${lastStep + 1}`;
const dashedName = await input({
message: 'What is the dashed name (in kebab-case) for this challenge?',
default: defaultDashedName,
validate: (block: string) => {
if (!block.length) {
return 'please enter a dashed name';
}
if (/[^a-z0-9-]/.test(block)) {
return 'please use alphanumerical characters and kebab case';
}
return true;
},
transformer: (block: string) => block.toLowerCase()
});
const title = await input({
message: 'What is the title of this challenge?',
default: defaultTitle
});
return {
title,
dashedName,
challengeType
};
};
@@ -0,0 +1,26 @@
import { select } from '@inquirer/prompts';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
const taskChallenges = [
challengeTypes.multipleChoice,
challengeTypes.fillInTheBlank,
challengeTypes.generic
];
export const newTaskPrompts = async (): Promise<{
challengeType: string;
}> => {
const challengeType = await select<string>({
message: 'What type of task challenge is this?',
choices: Object.entries(challengeTypes)
.filter(entry => taskChallenges.includes(entry[1]))
.map(([key, value]) => ({
name: key,
value: value.toString()
}))
});
return {
challengeType
};
};
@@ -0,0 +1,33 @@
import fs from 'fs/promises';
import { withTrace } from './utils.js';
export type BlockInfo = {
title: string;
intro: string[];
};
export type SuperBlockInfo = {
blocks: Record<string, BlockInfo>;
chapters?: Record<string, string>;
modules?: Record<string, string>;
'module-intros'?: Record<
string,
{
intro: string[];
note?: string;
title?: string;
}
>;
};
export type IntroJson = Record<string, SuperBlockInfo>;
export function parseIntroJson(filePath: string) {
return withTrace(fs.readFile, filePath, 'utf8').then(
// unfortunately, withTrace does not correctly infer that the third argument
// is a string, so it uses the (path, options?) overload and we have to cast
// result to string.
result => JSON.parse(result as string) as IntroJson
);
}
@@ -0,0 +1,20 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { join } from 'path';
import { describe, it, expect, vi } from 'vitest';
import { getBlockStructure } from '@freecodecamp/curriculum/file-handler';
import { getMetaData } from './project-metadata.js';
vi.mock('@freecodecamp/curriculum/file-handler');
const commonPath = join('curriculum', 'challenges', 'blocks');
const block = 'block-name';
describe('project-metadata helper', () => {
describe('getMetaData helper', () => {
it('should call getBlockStructure with the correct path', () => {
process.env.INIT_CWD = join(commonPath, block);
getMetaData();
expect(getBlockStructure).toHaveBeenCalledWith(block);
});
});
});
@@ -0,0 +1,35 @@
import path from 'path';
import {
getBlockStructure,
writeBlockStructure
} from '@freecodecamp/curriculum/file-handler';
import type { BlockLabel } from '@freecodecamp/shared/config/blocks';
import { getProjectPath } from './get-project-info.js';
export type Meta = {
blockLayout: string;
blockLabel?: BlockLabel;
isUpcomingChange: boolean;
dashedName: string;
helpCategory: string;
time: string;
template: string;
required: string[];
challengeOrder: { id: string; title: string }[];
};
function getMetaData() {
const block = getBlock(getProjectPath());
return getBlockStructure(block) as Meta;
}
function getBlock(filePath: string) {
return path.basename(filePath);
}
async function updateMetaData(newMetaData: Record<string, unknown>) {
const block = getBlock(getProjectPath());
await writeBlockStructure(block, newMetaData);
}
export { getMetaData, updateMetaData, getBlock };
@@ -0,0 +1,9 @@
function isTaskChallenge(title: string): boolean {
return /^\s*task\s\d+$/gi.test(title);
}
function getTaskNumberFromTitle(title: string): number {
return parseInt(title.replace(/\D/g, ''), 10);
}
export { getTaskNumberFromTitle, isTaskChallenge };
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { insertInto } from './utils.js';
describe('insertInto', () => {
it('should not modify the original array', () => {
const arr = [1, 2, 3];
const result = insertInto(arr, 1, 99);
expect(arr).toEqual([1, 2, 3]);
expect(result).not.toBe(arr);
});
it('should insert at the end if the index is larger than the original array', () => {
const arr = [1, 2, 3];
const result = insertInto(arr, 10, 99);
expect(result).toEqual([1, 2, 3, 99]);
});
it('should insert at the beginning if the index is <= 0', () => {
const arr = [1, 2, 3];
const result = insertInto(arr, 0, 99);
expect(result).toEqual([99, 1, 2, 3]);
const resultNeg = insertInto(arr, -5, 99);
expect(resultNeg).toEqual([99, 1, 2, 3]);
});
it('should insert at the correct index', () => {
const arr = [1, 2, 3];
const result = insertInto(arr, 1, 99);
expect(result).toEqual([1, 99, 2, 3]);
});
it('should work with empty arrays', () => {
const arr: number[] = [];
const result = insertInto(arr, 0, 99);
expect(result).toEqual([99]);
});
});
@@ -0,0 +1,19 @@
export function insertInto<T>(arr: T[], index: number, elem: T): T[] {
if (index >= arr.length) return [...arr, elem];
if (index <= 0) return [elem, ...arr];
return arr.flatMap((x, id) => {
return id === index ? [elem, x] : x;
});
}
// fs Promise functions return errors, but no stack trace. This adds back in
// the stack trace.
export function withTrace<Args extends unknown[], Result>(
fn: (...x: Args) => Promise<Result>,
...args: Args
): Promise<Result> {
return fn(...args).catch((reason: Error) => {
throw Error(reason.message);
});
}