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,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,88 @@
import fs from 'fs';
import { getProjectPath } from './helpers/get-project-info.js';
import { getMetaData } from './helpers/project-metadata.js';
import {
createStepFile,
deleteStepFromMeta,
getChallenge,
insertStepIntoMeta,
updateStepTitles
} from './utils.js';
import { ObjectId } from 'bson';
async function deleteStep(stepNum: number): Promise<void> {
if (stepNum < 1) {
throw Error('Step not deleted. Step num must be a number greater than 0.');
}
const challengeOrder = getMetaData().challengeOrder;
if (stepNum > challengeOrder.length)
throw Error(
`Step # ${stepNum} not deleted. Largest step number is ${challengeOrder.length}.`
);
const stepId = challengeOrder[stepNum - 1].id;
fs.unlinkSync(`${getProjectPath()}${stepId}.md`);
await deleteStepFromMeta({ stepNum });
updateStepTitles();
console.log(`Successfully deleted step #${stepNum}`);
}
async function insertStep(stepNum: number): Promise<void> {
if (stepNum < 1) {
throw Error('Step not inserted. New step number must be greater than 0.');
}
const challengeOrder = getMetaData().challengeOrder;
if (stepNum > challengeOrder.length + 1)
throw Error(
`Step not inserted. New step number must be less than ${
challengeOrder.length + 2
}.`
);
const previousChallenge =
stepNum > 1 ? getChallenge(challengeOrder[stepNum - 2].id) : null;
const nextChallenge =
stepNum <= challengeOrder.length
? getChallenge(challengeOrder[stepNum - 1].id)
: null;
const challengeSeeds = previousChallenge?.challengeFiles ?? [];
const challengeType =
previousChallenge?.challengeType ?? nextChallenge?.challengeType;
const challengeId = new ObjectId();
createStepFile({
challengeId,
stepNum,
challengeType,
challengeSeeds
});
await insertStepIntoMeta({ stepNum, stepId: challengeId });
updateStepTitles();
console.log(`Successfully inserted new step #${stepNum}`);
}
async function createEmptySteps(num: number): Promise<void> {
if (num < 1 || num > 1000) {
throw Error(
`No steps created. arg 'num' must be between 1 and 1000 inclusive`
);
}
const nextStepNum = getMetaData().challengeOrder.length + 1;
for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) {
const challengeId = new ObjectId();
createStepFile({ stepNum, challengeId });
await insertStepIntoMeta({ stepNum, stepId: challengeId });
}
console.log(`Successfully added ${num} steps`);
}
export { deleteStep, insertStep, createEmptySteps };
@@ -0,0 +1,140 @@
/*
Script to create daily coding challenge files in the dev-playground superblock.
Accepts a number arg for how many challenges to create.
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { ObjectId } from 'bson';
import { Meta } from './helpers/project-metadata.js';
import { getArgValue } from './helpers/get-arg-value.js';
import {
getDailyJavascriptChallengeTemplate,
getDailyPythonChallengeTemplate
} from './helpers/get-challenge-template.js';
const numberOfChallengesToCreate = getArgValue(process.argv);
if (numberOfChallengesToCreate > 10) {
throw new Error('Are you sure you want to create that many challenges?');
}
const curriculumPath = join(__dirname, '../../curriculum');
const structureBlocksPath = join(curriculumPath, '/structure/blocks');
const jsStructurePath = join(
structureBlocksPath,
'/daily-coding-challenges-javascript.json'
);
const pyStructurePath = join(
structureBlocksPath,
'/daily-coding-challenges-python.json'
);
const challengeBlocksPath = join(curriculumPath, '/challenges/english/blocks');
const jsChallengesPath = join(
challengeBlocksPath,
'/daily-coding-challenges-javascript'
);
const pyChallengesPath = join(
challengeBlocksPath,
'/daily-coding-challenges-python'
);
for (let i = 0; i < numberOfChallengesToCreate; i++) {
const jsMeta = JSON.parse(readFileSync(jsStructurePath, 'utf-8')) as Meta;
const pyMeta = JSON.parse(readFileSync(pyStructurePath, 'utf-8')) as Meta;
const numberOfJsChallenges = jsMeta.challengeOrder.length;
const numberOfPyChallenges = pyMeta.challengeOrder.length;
if (numberOfJsChallenges !== numberOfPyChallenges) {
throw new Error(
'Inconsistent number of challenges in each daily challenge language, cannot create new challenge.'
);
}
const challengeId = new ObjectId();
const newChallengeNumber = numberOfJsChallenges + 1;
createDailyJsChallenge({
challengeId,
challengeNumber: newChallengeNumber,
meta: jsMeta
});
createDailyPyChallenge({
challengeId,
challengeNumber: newChallengeNumber,
meta: pyMeta
});
}
interface CreateDailyChallengeOptions {
challengeId: ObjectId;
challengeNumber: number;
meta: Meta;
}
function createDailyJsChallenge({
challengeId,
challengeNumber,
meta
}: CreateDailyChallengeOptions) {
const newMeta = {
...meta,
challengeOrder: [
...meta.challengeOrder,
{
id: challengeId.toString(),
title: `Challenge ${challengeNumber}: Placeholder`
}
]
};
writeFileSync(jsStructurePath, JSON.stringify(newMeta, null, 2));
const jsTemplate = getDailyJavascriptChallengeTemplate({
challengeId,
challengeNumber
});
const jsChallengePath = join(
jsChallengesPath,
`${challengeId.toString()}.md`
);
writeFileSync(jsChallengePath, jsTemplate);
}
function createDailyPyChallenge({
challengeId,
challengeNumber,
meta
}: CreateDailyChallengeOptions) {
const newMeta = {
...meta,
challengeOrder: [
...meta.challengeOrder,
{
id: challengeId.toString(),
title: `Challenge ${challengeNumber}: Placeholder`
}
]
};
writeFileSync(pyStructurePath, JSON.stringify(newMeta, null, 2));
const pyTemplate = getDailyPythonChallengeTemplate({
challengeId,
challengeNumber
});
const pyChallengePath = join(
pyChallengesPath,
`${challengeId.toString()}.md`
);
writeFileSync(pyChallengePath, pyTemplate);
}
@@ -0,0 +1,4 @@
import { getArgValue } from './helpers/get-arg-value.js';
import { createEmptySteps } from './commands.js';
void createEmptySteps(getArgValue(process.argv));
@@ -0,0 +1,635 @@
import fs from 'fs/promises';
import path from 'path';
import { select, input, number } from '@inquirer/prompts';
import { format } from 'prettier';
import { ObjectId } from 'bson';
import {
SuperBlocks,
languageSuperBlocks,
chapterBasedSuperBlocks,
ChallengeLang
} from '@freecodecamp/shared/config/curriculum';
import { BlockLayouts, BlockLabel } from '@freecodecamp/shared/config/blocks';
import {
getContentConfig,
writeBlockStructure,
createBlockFolder,
getSuperblockStructure
} from '@freecodecamp/curriculum/file-handler';
import { superBlockToFilename } from '@freecodecamp/curriculum/build-curriculum';
import { getBaseMeta } from './helpers/get-base-meta.js';
import {
createDialogueFile,
createQuizFile,
getAllBlocks,
validateBlockName
} from './utils.js';
import {
updateSimpleSuperblockStructure,
updateChapterModuleSuperblockStructure
} from './helpers/create-project.js';
import { getLangFromSuperBlock } from './helpers/get-lang-from-superblock.js';
import { parseIntroJson } from './helpers/parse-json.js';
import { withTrace } from './helpers/utils.js';
const langToHelpCategory: Record<ChallengeLang, string> = {
[ChallengeLang.English]: 'English',
[ChallengeLang.Chinese]: 'Chinese Curriculum',
[ChallengeLang.Spanish]: 'Spanish Curriculum'
};
interface CreateBlockArgs {
superBlock: SuperBlocks;
block: string;
helpCategory: string;
title?: string;
chapter?: string;
module?: string;
newChapterName?: string;
newChapterTitle?: string;
newModuleName?: string;
newModuleTitle?: string;
position?: number;
blockLabel?: BlockLabel;
blockLayout?: string;
questionCount?: number;
}
async function createLanguageBlock(
superBlock: SuperBlocks,
block: string,
helpCategory: string,
title?: string,
chapter?: string,
module?: string,
chapterTitle?: string,
moduleTitle?: string,
position?: number,
blockLabel?: BlockLabel,
blockLayout?: string,
questionCount?: number
) {
if (!title) {
title = block;
}
await updateIntroJson({
superBlock,
block,
title,
chapter,
module,
chapterTitle,
moduleTitle
});
const challengeLang: ChallengeLang = getLangFromSuperBlock(superBlock);
const challengeId: ObjectId = new ObjectId();
await createMetaJson(
block,
title,
helpCategory,
challengeId,
blockLabel,
blockLayout
);
if (blockLabel === BlockLabel.quiz) {
await createQuizChallenge(
challengeId,
block,
title,
questionCount!,
challengeLang
);
blockLayout = BlockLayouts.Link;
} else {
await createDialogueChallenge(challengeId, block, challengeLang);
}
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
if (chapterBasedSuperBlocks.includes(superBlock)) {
if (!chapter || !module || typeof position === 'undefined') {
throw Error(
'Missing one of the following arguments: chapter, module, position'
);
}
void updateChapterModuleSuperblockStructure(
block,
// Convert human-friendly (1-based) position to 0-based index for insertion.
{ order: position - 1, chapter, module },
superblockFilename
);
} else {
void updateSimpleSuperblockStructure(block, {}, superblockFilename);
}
}
async function updateIntroJson({
superBlock,
block,
title,
chapter,
module,
chapterTitle,
moduleTitle
}: {
superBlock: SuperBlocks;
block: string;
title: string;
chapter?: string;
module?: string;
chapterTitle?: string;
moduleTitle?: string;
}) {
const introJsonPath = path.resolve(
__dirname,
'../../client/i18n/locales/english/intro.json'
);
const newIntro = await parseIntroJson(introJsonPath);
newIntro[superBlock].blocks[block] = {
title,
intro: ['', '']
};
if (chapter && chapterTitle) {
if (!newIntro[superBlock].chapters) {
newIntro[superBlock].chapters = {};
}
if (!newIntro[superBlock].chapters[chapter]) {
newIntro[superBlock].chapters[chapter] = chapterTitle;
}
}
if (module && moduleTitle) {
if (!newIntro[superBlock].modules) {
newIntro[superBlock].modules = {};
}
if (!newIntro[superBlock].modules[module]) {
newIntro[superBlock].modules[module] = moduleTitle;
}
if (!newIntro[superBlock]['module-intros']) {
newIntro[superBlock]['module-intros'] = {};
}
if (!newIntro[superBlock]['module-intros'][module]) {
newIntro[superBlock]['module-intros'][module] = {
note: '',
intro: ['']
};
}
}
void withTrace(
fs.writeFile,
introJsonPath,
await format(JSON.stringify(newIntro), { parser: 'json' })
);
}
async function createMetaJson(
block: string,
title: string,
helpCategory: string,
challengeId: ObjectId,
blockLabel?: BlockLabel,
blockLayout?: string
) {
const newMeta = getBaseMeta('Language');
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
if (blockLabel) {
newMeta.blockLabel = blockLabel;
}
if (blockLayout) {
newMeta.blockLayout = blockLayout;
}
const challengeTitle =
blockLabel === BlockLabel.quiz ? title : "Dialogue 1: I'm Tom";
newMeta.challengeOrder = [
{
id: challengeId.toString(),
title: challengeTitle
}
];
await writeBlockStructure(block, newMeta);
}
async function createDialogueChallenge(
challengeId: ObjectId,
block: string,
challengeLang: ChallengeLang
): Promise<ObjectId> {
const { blockContentDir } = getContentConfig('english') as {
blockContentDir: string;
};
const newChallengeDir = path.resolve(blockContentDir, block);
await fs.mkdir(newChallengeDir, { recursive: true });
return createDialogueFile({
challengeId,
projectPath: newChallengeDir + '/',
challengeLang: challengeLang
});
}
async function createQuizChallenge(
challengeId: ObjectId,
block: string,
title: string,
questionCount: number,
challengeLang: ChallengeLang
): Promise<ObjectId> {
return createQuizFile({
challengeId,
projectPath: await createBlockFolder(block),
title: title,
dashedName: block,
questionCount: questionCount,
challengeLang
});
}
function getBlockPrefix(
superBlock: SuperBlocks,
blockLabel?: BlockLabel
): string | null {
// Only chapter-based super blocks use blockLabel so prefix only applies to them.
if (!chapterBasedSuperBlocks.includes(superBlock)) return null;
let langLevel;
switch (superBlock) {
case SuperBlocks.A2English:
langLevel = 'en-a2';
break;
case SuperBlocks.B1English:
langLevel = 'en-b1';
break;
case SuperBlocks.A1Spanish:
langLevel = 'es-a1';
break;
case SuperBlocks.A2Spanish:
langLevel = 'es-a2';
break;
case SuperBlocks.A1Chinese:
langLevel = 'zh-a1';
break;
case SuperBlocks.A2Chinese:
langLevel = 'zh-a2';
break;
default:
langLevel = superBlock;
}
if (blockLabel === BlockLabel.exam) {
return `${langLevel}-`;
}
return `${langLevel}-${blockLabel}-`;
}
void getAllBlocks()
.then(async existingBlocks => {
const superBlock = await select<SuperBlocks>({
message: 'Which certification does it this belong to?',
default: SuperBlocks.A2English,
choices: Object.values(languageSuperBlocks).map(value => ({
name: value,
value
}))
});
const blockLabel = await select<BlockLabel>({
message: 'Choose a block label',
default: BlockLabel.learn,
choices: Object.values(BlockLabel).map(value => ({
name: value,
value
}))
});
const prefix = getBlockPrefix(superBlock, blockLabel);
const rawBlock = await input({
message: prefix
? `Complete the dashed name after the prefix below.\nPrefix: ${prefix}`
: 'What is the dashed name (in kebab-case) for this block?',
validate: (value: string) => {
if (prefix) {
const uniquePart = value.slice(prefix.length);
const blockLabelValues = Object.values(BlockLabel).filter(
label => label !== BlockLabel.exam
);
const endsWithLabel = blockLabelValues.some(label =>
uniquePart.endsWith(`-${label}`)
);
if (endsWithLabel) {
return `Block name should not end with a block label (e.g., '-${blockLabel}'). The label is already in the prefix.`;
}
}
return validateBlockName(value, existingBlocks);
}
});
const block = prefix
? `${prefix}${rawBlock.toLowerCase().trim()}`
: rawBlock.toLowerCase().trim();
const title = await input({
message: 'Enter a title for this block:',
default: block
});
const helpCategory = langToHelpCategory[getLangFromSuperBlock(superBlock)];
let blockLayout: string | undefined;
if (
chapterBasedSuperBlocks.includes(superBlock) &&
blockLabel !== BlockLabel.quiz
) {
blockLayout = await select<BlockLayouts>({
message: 'Choose a block layout',
default: BlockLayouts.DialogueGrid,
choices: Object.values(BlockLayouts).map(value => ({
name: value,
value
}))
});
}
let questionCount: number | undefined;
if (blockLabel === BlockLabel.quiz) {
questionCount = await select<number>({
message: 'Choose a question count',
default: 20,
choices: [
{ value: 10, name: '10' },
{ value: 20, name: '20' }
]
});
}
let chapter: string | undefined;
if (chapterBasedSuperBlocks.includes(superBlock)) {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
chapter = await select({
message: 'What chapter should this language block go in?',
choices: [
...structure.chapters.map(ch => ({
value: ch.dashedName,
name: ch.dashedName
})),
{
value: '-- Create new chapter --',
name: '-- Create new chapter --'
}
]
});
}
let newChapterName: string | undefined;
if (
chapterBasedSuperBlocks.includes(superBlock) &&
chapter === '-- Create new chapter --'
) {
const rawName = await input({
message: 'Enter the dashed name for the new chapter (in kebab-case):',
validate: (name: string) => {
if (!name || name.trim() === '') {
return 'Chapter name cannot be empty.';
}
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name.trim())) {
return 'Chapter name must be in kebab-case (e.g., "chapter-one").';
}
return true;
}
});
newChapterName = rawName.toLowerCase().trim();
}
let newChapterTitle: string | undefined;
if (
chapterBasedSuperBlocks.includes(superBlock) &&
chapter === '-- Create new chapter --'
) {
newChapterTitle = await input({
message: 'Enter the title for the new chapter:',
default: newChapterName,
validate: (title: string) => {
if (!title || title.trim() === '') {
return 'Chapter title cannot be empty.';
}
return true;
}
});
}
let module: string | undefined;
if (chapterBasedSuperBlocks.includes(superBlock)) {
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
const structure = getSuperblockStructure(superblockFilename) as {
chapters: {
dashedName: string;
modules: { dashedName: string; blocks: string[] }[];
}[];
};
let moduleChoices: { value: string; name: string }[];
if (chapter === '-- Create new chapter --') {
moduleChoices = [
{
value: '-- Create new module --',
name: '-- Create new module --'
}
];
} else {
const existingModules =
structure.chapters
.find(ch => ch.dashedName === chapter)
?.modules.map(m => m.dashedName) ?? [];
moduleChoices = [
...existingModules.map(m => ({
value: m,
name: m
})),
{
value: '-- Create new module --',
name: '-- Create new module --'
}
];
}
module = await select({
message: 'What module should this language block go in?',
choices: moduleChoices
});
}
let newModuleName: string | undefined;
if (
chapterBasedSuperBlocks.includes(superBlock) &&
module === '-- Create new module --'
) {
const rawName = await input({
message: 'Enter the dashed name for the new module (in kebab-case):',
validate: (name: string) => {
if (!name || name.trim() === '') {
return 'Module name cannot be empty.';
}
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name.trim())) {
return 'Module name must be in kebab-case (e.g., "module-one").';
}
return true;
}
});
newModuleName = rawName.toLowerCase().trim();
}
let newModuleTitle: string | undefined;
if (
chapterBasedSuperBlocks.includes(superBlock) &&
module === '-- Create new module --'
) {
newModuleTitle = await input({
message: 'Enter the title for the new module:',
default: newModuleName,
validate: (title: string) => {
if (!title || title.trim() === '') {
return 'Module title cannot be empty.';
}
return true;
}
});
}
let position: number | undefined;
if (chapterBasedSuperBlocks.includes(superBlock)) {
position = await number({
message: 'At which position does this new block appear in the module?',
default: 1,
validate: (value: number | undefined) => {
if (!value || value <= 0) {
return 'Position must be a number greater than zero.';
}
return true;
}
});
}
return {
superBlock,
block,
helpCategory,
title,
chapter,
module,
newChapterName,
newChapterTitle,
newModuleName,
newModuleTitle,
position,
blockLabel,
blockLayout,
questionCount
};
})
.then(async (answers: CreateBlockArgs) => {
const {
superBlock,
block,
helpCategory,
title,
chapter,
module,
newChapterName,
newModuleTitle,
newChapterTitle,
newModuleName,
position,
blockLabel,
blockLayout,
questionCount
} = answers;
const resolvedChapter =
chapter === '-- Create new chapter --' ? newChapterName : chapter;
const resolvedModule =
module === '-- Create new module --' ? newModuleName : module;
// Only pass chapter title if we're creating a new chapter
const chapterTitle =
chapter === '-- Create new chapter --' ? newChapterTitle : undefined;
// Only pass module title if we're creating a new module
const moduleTitle =
module === '-- Create new module --' ? newModuleTitle : undefined;
await createLanguageBlock(
superBlock,
block,
helpCategory,
title,
resolvedChapter,
resolvedModule,
chapterTitle,
moduleTitle,
position,
blockLabel,
blockLayout,
questionCount
);
})
.then(() => console.log('All set. Refresh the page to see the changes.'))
.catch((err: unknown) =>
console.error(
'Error creating language block:',
err instanceof Error ? err.message : String(err)
)
);
@@ -0,0 +1,27 @@
import { ObjectId } from 'bson';
import { getTemplate } from './helpers/get-challenge-template.js';
import { newChallengePrompts } from './helpers/new-challenge-prompts.js';
import { getProjectPath } from './helpers/get-project-info.js';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
import { createChallengeFile } from './utils.js';
const createNextChallenge = async () => {
const path = getProjectPath();
const options = await newChallengePrompts();
const template = getTemplate(options.challengeType);
const challengeId = new ObjectId();
const challengeText = template({ ...options, challengeId });
createChallengeFile(challengeId.toString(), challengeText, path);
const meta = getMetaData();
meta.challengeOrder.push({
id: challengeId.toString(),
title: options.title
});
await updateMetaData(meta);
};
void createNextChallenge();
@@ -0,0 +1,4 @@
import { getLastStep } from './helpers/get-last-step-file-number.js';
import { insertStep } from './commands.js';
void insertStep(getLastStep().stepNum + 1);
@@ -0,0 +1,60 @@
import { ObjectId } from 'bson';
import { getTemplate } from './helpers/get-challenge-template.js';
import { newTaskPrompts } from './helpers/new-task-prompts.js';
import { getProjectPath } from './helpers/get-project-info.js';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
import {
createChallengeFile,
getChallenge,
updateTaskMeta,
updateTaskMarkdownFiles
} from './utils.js';
import { getInputType } from './helpers/get-input-type.js';
const createNextTask = async () => {
const { challengeType } = await newTaskPrompts();
const meta = getMetaData();
const prevChallengeId =
meta.challengeOrder[meta.challengeOrder.length - 1]?.id;
const challengeLang = prevChallengeId
? getChallenge(prevChallengeId)?.lang
: undefined;
const inputType = await getInputType(challengeType, challengeLang);
// Placeholder title, to be replaced by updateTaskMarkdownFiles
const options = {
title: `Task 0`,
dashedName: 'task-0',
challengeType,
...{ ...(challengeLang && { challengeLang }) },
...{ ...(inputType && { inputType }) }
};
const path = getProjectPath();
const template = getTemplate(options.challengeType);
const challengeId = new ObjectId();
const challengeText = template({ ...options, challengeId });
const challengeIdString = challengeId.toString();
createChallengeFile(challengeIdString, challengeText, path);
console.log('Finished creating new task markdown file.');
meta.challengeOrder.push({
id: challengeIdString,
title: options.title
});
await updateMetaData(meta);
console.log(`Finished inserting task into 'meta.json' file.`);
await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles();
console.log('Finished updating task markdown files.');
};
void createNextTask();
@@ -0,0 +1,402 @@
import fs from 'fs/promises';
import path from 'path';
import { select, input, number } from '@inquirer/prompts';
import { format } from 'prettier';
import { ObjectId } from 'bson';
import {
SuperBlocks,
chapterBasedSuperBlocks
} from '@freecodecamp/shared/config/curriculum';
import { BlockLayouts, BlockLabel } from '@freecodecamp/shared/config/blocks';
import {
createBlockFolder,
writeBlockStructure
} from '@freecodecamp/curriculum/file-handler';
import { superBlockToFilename } from '@freecodecamp/curriculum/build-curriculum';
import {
createQuizFile,
createStepFile,
validateBlockName,
getAllBlocks
} from './utils.js';
import { getBaseMeta } from './helpers/get-base-meta.js';
import { parseIntroJson } from './helpers/parse-json.js';
import {
ChapterModuleSuperblockStructure,
updateChapterModuleSuperblockStructure,
updateSimpleSuperblockStructure
} from './helpers/create-project.js';
import { withTrace } from './helpers/utils.js';
const helpCategories = [
'HTML-CSS',
'JavaScript',
'Backend Development',
'Python',
'English',
'Odin',
'Euler',
'Rosetta',
'General'
] as const;
interface CreateProjectArgs {
superBlock: SuperBlocks;
block: string;
helpCategory: string;
blockLabel?: string;
blockLayout?: string;
questionCount?: number;
order?: number;
chapter?: string;
position?: number;
module?: string;
title?: string;
}
async function createProject(projectArgs: CreateProjectArgs) {
if (!projectArgs.title) {
projectArgs.title = projectArgs.block;
}
const order = projectArgs.order;
const chapter = projectArgs.chapter;
const module = projectArgs.module;
const position = projectArgs.position;
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[projectArgs.superBlock];
if (chapterBasedSuperBlocks.includes(projectArgs.superBlock)) {
if (!chapter || !module || typeof position == 'undefined') {
throw Error(
'Missing one of the following arguments: chapter, module, position'
);
}
void updateChapterModuleSuperblockStructure(
projectArgs.block,
// Convert human-friendly (1-based) position to 0-based index for insertion.
{ order: position - 1, chapter, module },
superblockFilename
);
} else {
if (typeof order == 'undefined') {
throw Error('Missing argument: order');
}
void updateSimpleSuperblockStructure(
projectArgs.block,
{ order },
superblockFilename
);
}
void updateIntroJson(
projectArgs.superBlock,
projectArgs.block,
projectArgs.title
);
const challengeId = new ObjectId();
if (projectArgs.blockLabel === BlockLabel.quiz) {
if (projectArgs.questionCount == null) {
throw new Error(
'Property `questionCount` is null when creating new Quiz Challenge'
);
}
await createMetaJson(
projectArgs.superBlock,
projectArgs.block,
projectArgs.title,
projectArgs.helpCategory,
challengeId
);
await createQuizChallenge({
challengeId,
block: projectArgs.block,
title: projectArgs.title,
questionCount: projectArgs.questionCount
});
} else {
await createMetaJson(
projectArgs.superBlock,
projectArgs.block,
projectArgs.title,
projectArgs.helpCategory,
challengeId,
projectArgs.order,
projectArgs.blockLabel,
projectArgs.blockLayout
);
await createFirstChallenge({ block: projectArgs.block, challengeId });
}
if (
(chapterBasedSuperBlocks.includes(projectArgs.superBlock) &&
projectArgs.blockLabel) == null
) {
throw new Error(
'Missing argument: blockLabel when updating intro markdown'
);
}
}
async function updateIntroJson(
superBlock: SuperBlocks,
block: string,
title: string
) {
const introJsonPath = path.resolve(
__dirname,
'../../client/i18n/locales/english/intro.json'
);
const newIntro = await parseIntroJson(introJsonPath);
newIntro[superBlock].blocks[block] = {
title,
intro: [title, '']
};
void withTrace(
fs.writeFile,
introJsonPath,
await format(JSON.stringify(newIntro), { parser: 'json' })
);
}
async function createMetaJson(
superBlock: SuperBlocks,
block: string,
title: string,
helpCategory: string,
challengeId: ObjectId,
order?: number,
blockLabel?: string,
blockLayout?: string
) {
let newMeta;
if (chapterBasedSuperBlocks.includes(superBlock)) {
newMeta = getBaseMeta('FullStack');
newMeta.blockLabel = blockLabel;
newMeta.blockLayout = blockLayout;
if (blockLabel === BlockLabel.workshop) {
newMeta.hasEditableBoundaries = true;
}
} else {
newMeta = getBaseMeta('Step');
newMeta.order = order;
}
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
newMeta.challengeOrder = [{ id: challengeId.toString(), title: 'Step 1' }];
await writeBlockStructure(block, newMeta);
}
async function createFirstChallenge({
block,
challengeId
}: {
block: string;
challengeId: ObjectId;
}) {
// TODO: would be nice if the extension made sense for the challenge, but, at
// least until react I think they're all going to be html anyway.
const challengeSeeds = [
{
contents: '',
ext: 'html',
editableRegionBoundaries: [0, 2]
}
];
// including trailing slash for compatibility with createStepFile
createStepFile({
challengeId,
projectPath: await createBlockFolder(block),
stepNum: 1,
challengeType: 0,
challengeSeeds,
isFirstChallenge: true
});
}
async function createQuizChallenge({
challengeId,
block,
title,
questionCount
}: {
challengeId: ObjectId;
block: string;
title: string;
questionCount: number;
}): Promise<ObjectId> {
return createQuizFile({
challengeId,
projectPath: await createBlockFolder(block),
title: title,
dashedName: block,
questionCount: questionCount
});
}
async function getChapters(superBlock: string) {
const blockMetaFile = await fs.readFile(
'../../curriculum/structure/superblocks/' + superBlock + '.json',
{ encoding: 'utf8' }
);
const blockMetaData = JSON.parse(
blockMetaFile
) as ChapterModuleSuperblockStructure;
return blockMetaData.chapters;
}
async function getModules(superBlock: string, chapterName: string) {
const blockMetaFile = await fs.readFile(
'../../curriculum/structure/superblocks/' + superBlock + '.json',
{ encoding: 'utf8' }
);
const blockMetaData = JSON.parse(
blockMetaFile
) as ChapterModuleSuperblockStructure;
const modifiedChapter = blockMetaData.chapters.find(
x => x.dashedName === chapterName
);
return modifiedChapter?.modules;
}
void getAllBlocks()
.then(async existingBlocks => {
const superBlock = await select<SuperBlocks>({
message: 'Which certification does this belong to?',
default: SuperBlocks.RespWebDesignV9,
choices: Object.values(SuperBlocks).map(value => ({
name: value,
value
}))
});
const rawBlock = await input({
message: 'What is the dashed name (in kebab-case) for this project?',
validate: (value: string) => validateBlockName(value, existingBlocks)
});
const block = rawBlock.toLowerCase().trim();
const title = await input({
message: 'Enter a title for this project:',
default: block
});
const helpCategory = await select<string>({
message: 'Choose a help category',
default: 'HTML-CSS',
choices: helpCategories.map(value => ({
name: value,
value
}))
});
let blockLabel: BlockLabel | undefined;
let blockLayout: BlockLayouts | undefined;
let questionCount: number | undefined;
let chapter: string | undefined;
let module: string | undefined;
let position: number | undefined;
let order: number | undefined;
if (chapterBasedSuperBlocks.includes(superBlock)) {
blockLabel = await select<BlockLabel>({
message: 'Choose a block label',
default: BlockLabel.lab,
choices: Object.values(BlockLabel).map(value => ({
name: value,
value
}))
});
blockLayout = await select<BlockLayouts>({
message: 'Choose a block layout',
default:
blockLabel === BlockLabel.quiz
? BlockLayouts.Link
: BlockLayouts.ChallengeList,
choices: Object.values(BlockLayouts).map(value => ({
name: value,
value
}))
});
if (blockLabel === BlockLabel.quiz) {
questionCount = await select<number>({
message: 'Choose a question count',
default: 20,
choices: [
{ name: '10', value: 10 },
{ name: '20', value: 20 }
]
});
}
const chapters = await getChapters(superBlock);
chapter = await select({
message: 'What chapter should this project go in?',
choices: chapters.map(x => ({
name: x.dashedName,
value: x.dashedName
}))
});
const modules = await getModules(superBlock, chapter);
module = await select({
message: 'What module should this project go in?',
choices: modules!.map(x => ({
name: x.dashedName,
value: x.dashedName
}))
});
position = await number({
message: 'At which position does this appear in the module?',
default: 1,
validate: (value: number | undefined) =>
value && value > 0
? true
: 'Position must be a number greater than zero.'
});
} else {
order = await number({
message: 'Which position does this appear in the certificate?',
default: 42,
validate: (value: number | undefined) =>
value && value > 0
? true
: 'Order must be a number greater than zero.'
});
}
return {
superBlock,
block,
title,
helpCategory,
blockLabel,
blockLayout,
questionCount,
chapter,
module,
position,
order
};
})
.then(async (answers: CreateProjectArgs) => {
await createProject(answers);
})
.then(() => console.log('All set. Refresh the page to see the changes.'))
.catch((err: unknown) =>
console.error(
'Error creating project:',
err instanceof Error ? err.message : String(err)
)
);
@@ -0,0 +1,151 @@
import fs from 'fs/promises';
import path from 'path';
import { select, input } from '@inquirer/prompts';
import { format } from 'prettier';
import { ObjectId } from 'bson';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import {
createBlockFolder,
writeBlockStructure
} from '@freecodecamp/curriculum/file-handler';
import { superBlockToFilename } from '@freecodecamp/curriculum/build-curriculum';
import { createQuizFile, getAllBlocks, validateBlockName } from './utils.js';
import { getBaseMeta } from './helpers/get-base-meta.js';
import { updateSimpleSuperblockStructure } from './helpers/create-project.js';
import { parseIntroJson } from './helpers/parse-json.js';
import { withTrace } from './helpers/utils.js';
const helpCategories = [
'HTML-CSS',
'JavaScript',
'Backend Development',
'Python'
] as const;
async function createQuiz(
superBlock: SuperBlocks,
block: string,
helpCategory: string,
questionCount: number,
title?: string
) {
if (!title) {
title = block;
}
await updateIntroJson(superBlock, block, title);
const challengeId = new ObjectId();
await createMetaJson(block, title, helpCategory, challengeId);
await createQuizChallenge({ challengeId, block, title, questionCount });
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
void updateSimpleSuperblockStructure(block, { order: 0 }, superblockFilename);
}
async function updateIntroJson(
superBlock: SuperBlocks,
block: string,
title: string
) {
const introJsonPath = path.resolve(
__dirname,
'../../client/i18n/locales/english/intro.json'
);
const newIntro = await parseIntroJson(introJsonPath);
newIntro[superBlock].blocks[block] = {
title,
intro: ['', '']
};
void withTrace(
fs.writeFile,
introJsonPath,
await format(JSON.stringify(newIntro), { parser: 'json' })
);
}
async function createMetaJson(
block: string,
title: string,
helpCategory: string,
challengeId: ObjectId
) {
const newMeta = getBaseMeta('Quiz');
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
newMeta.challengeOrder = [{ id: challengeId.toString(), title: title }];
await writeBlockStructure(block, newMeta);
}
async function createQuizChallenge({
block,
challengeId,
title,
questionCount
}: {
block: string;
challengeId: ObjectId;
title: string;
questionCount: number;
}) {
createQuizFile({
challengeId,
projectPath: await createBlockFolder(block),
title: title,
dashedName: block,
questionCount: questionCount
});
}
void getAllBlocks().then(async existingBlocks => {
const superBlock = await select<SuperBlocks>({
message: 'Which certification does this belong to?',
default: SuperBlocks.RespWebDesignV9,
choices: Object.values(SuperBlocks).map(value => ({
name: value,
value
}))
});
const block = await input({
message: 'What is the dashed name (in kebab-case) for this quiz?',
validate: (block: string) => validateBlockName(block, existingBlocks)
});
const transformedBlock = block.toLowerCase().trim();
const title = await input({
message: 'What is the new name?',
default: transformedBlock
});
const helpCategory = await select<string>({
message: 'Choose a help category',
default: 'HTML-CSS',
choices: helpCategories.map(value => ({
name: value,
value
}))
});
const questionCount = await select<number>({
message: 'Should this quiz have either ten or twenty questions?',
default: 20,
choices: [
{ name: '20 questions', value: 20 },
{ name: '10 questions', value: 10 }
]
});
await createQuiz(
superBlock,
transformedBlock,
helpCategory,
questionCount,
title
);
console.log('All set. Refresh the page to see the changes.');
});
@@ -0,0 +1,173 @@
/*
* This is a one-off script to create challenges with specific markdown. Fill in
* the content below where the comments suggest. When you are done, go to the
* English block folder where you want to create the challenge in the terminal,
* and run `pnpm create-this-challenge`. It will use the Object ID as the
* filename. Change the `challengeId` at the bottom to use the dashed name if
* you want that.
*/
import { ObjectId } from 'bson';
import {
getBlockStructure,
writeBlockStructure
} from '@freecodecamp/curriculum/file-handler';
import { createChallengeFile } from './utils.js';
import { getProjectPath } from './helpers/get-project-info.js';
import { getBlock, type Meta } from './helpers/project-metadata.js';
const challengeId = new ObjectId().toString();
/***** Only change code below this line *****/
/*
* Fill in the variables below with the challenge info. Run the script and paste
* the body of the challenge into the md file created by the script.
*
* NOTE: if the body of the challenge is not correctly formatted, see below for
* examples of the correct format.
*
*/
const num = 1;
const title = 'Task ' + num;
const dashedName = 'task-' + num;
const challengeType = 22;
const template = `---
id: ${challengeId}
title: ${title}
challengeType: ${challengeType}
dashedName: ${dashedName}
---
`;
// template for fill in the blank lessons
/*
<!--
AUDIO REFERENCE:
Sarah: "I see. Let's open an _, then. What happened when you _ the changes?"
-->
# --description--
The word `I'm` is a contraction of `I am`. Contractions are a way to shorten common combinations of words, especially with verbs.
# --fillInTheBlank--
## --sentence--
`Hi, that's right! _ Tom McKenzie.`
## --blanks--
`I'm`
### --feedback--
Some `hints` for the learner.
*/
// template for multiple choice lessons
/*
<!--
AUDIO REFERENCE:
Sarah: "I see. Let's open an issue, then."
-->
# --description--
Sarah's response to Bob includes a specific phrase that shows she understands the problem he's facing. Recognizing such phrases is important in understanding communication cues in conversations.
# --question--
## --text--
Which part of Sarah's sentence shows that she understands the problem?
## --answers
I see
---
Let's open an issue
### --feedback--
While this part suggests a solution, this is not the part that directly indicates understanding.
---
`then`
### --feedback--
The word `then` is part of suggesting a solution, but it doesn't directly show understanding.
---
`an issue`
### --feedback--
The term `an issue` relates to the solution, not to the expression of understanding the problem.
## --video-solution--
1
*/
// template for dialogs
/*
---
id: 651dd3e06ffb500e3f2ce478
title: "Dialogue 1: Maria Introduces Herself to Tom"
challengeType: 21
dashedName: dialogue-1-maria-introduces-herself-to-tom
---
# --description--
Watch the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video
*/
/***** Only change code above this line *****/
const path = getProjectPath();
if (
!/freeCodeCamp\/curriculum\/challenges\/english\/blocks\/[^/]+\/$/.test(path)
) {
throw Error(`
You cannot run this script from anywhere other than a block folder of the English curriculum.
In the terminal, go to the block folder where you want to create this challenge first.
For example: 'freeCodeCamp/curriculum/challenges/english/blocks/learn-greetings-in-your-first-day-at-the-office/'
`);
}
const block = getBlock(path);
const meta = getBlockStructure(block) as Meta;
if (meta.challengeOrder.some(c => c.title === title)) {
throw Error(`
A challenge with the title ${title} already exists in this block.
`);
}
meta.challengeOrder.push({
id: challengeId,
title
});
void writeBlockStructure(block, meta);
createChallengeFile(challengeId, template, path);
@@ -0,0 +1,37 @@
import { unlink } from 'fs/promises';
import { select } from '@inquirer/prompts';
import { getProjectPath } from './helpers/get-project-info.js';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
import { getFileName } from './helpers/get-file-name.js';
const deleteChallenge = async () => {
const path = getProjectPath();
const challenges = getMetaData().challengeOrder;
const challengeToDeleteId = await select<string>({
message: 'Which challenge should be deleted?',
choices: challenges.map(({ id, title }) => ({
name: title,
value: id
}))
});
const indexToDelete = challenges.findIndex(
({ id }) => id === challengeToDeleteId
);
const fileToDelete = await getFileName(challengeToDeleteId);
if (!fileToDelete) {
throw new Error(`File not found for challenge ${challengeToDeleteId}`);
}
await unlink(`${path}${fileToDelete}`);
const meta = getMetaData();
meta.challengeOrder.splice(indexToDelete, 1);
await updateMetaData(meta);
};
void deleteChallenge();
@@ -0,0 +1,4 @@
import { deleteStep } from './commands.js';
import { getArgValue } from './helpers/get-arg-value.js';
void deleteStep(getArgValue(process.argv));
@@ -0,0 +1,49 @@
import { unlink } from 'fs/promises';
import { select } from '@inquirer/prompts';
import { getProjectPath } from './helpers/get-project-info.js';
import { getFileName } from './helpers/get-file-name.js';
import {
deleteChallengeFromMeta,
updateTaskMarkdownFiles,
updateTaskMeta
} from './utils.js';
import { isTaskChallenge } from './helpers/task-helpers.js';
import { getMetaData } from './helpers/project-metadata.js';
const deleteTask = async () => {
const path = getProjectPath();
const challenges = getMetaData().challengeOrder;
const challengeToDeleteId = await select<string>({
message: 'Which challenge should be deleted?',
choices: challenges.map(({ id, title }) => ({
name: title,
value: id
}))
});
const indexToDelete = challenges.findIndex(
({ id }) => id === challengeToDeleteId
);
const fileToDelete = await getFileName(challengeToDeleteId);
if (!fileToDelete) {
throw new Error(`File not found for challenge ${challengeToDeleteId}`);
}
await unlink(`${path}${fileToDelete}`);
console.log(`Finished deleting file: '${fileToDelete}'.`);
await deleteChallengeFromMeta(indexToDelete);
console.log(`Finished removing challenge from 'meta.json'.`);
if (isTaskChallenge(challenges[indexToDelete].title)) {
await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles();
console.log(`Finished updating task markdown files.`);
}
};
void deleteTask();
@@ -0,0 +1,3 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
export default configTypeChecked;
@@ -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);
});
}
@@ -0,0 +1,40 @@
import { ObjectId } from 'bson';
import { select } from '@inquirer/prompts';
import { getTemplate } from './helpers/get-challenge-template.js';
import { newChallengePrompts } from './helpers/new-challenge-prompts.js';
import { getProjectPath } from './helpers/get-project-info.js';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
import { createChallengeFile } from './utils.js';
const insertChallenge = async () => {
const path = getProjectPath();
const options = await newChallengePrompts();
const challenges = getMetaData().challengeOrder;
const challengeAfterId = await select<string>({
message: 'Which challenge should come AFTER this new one?',
choices: challenges.map(({ id, title }) => ({
name: title,
value: id
}))
});
const indexToInsert = challenges.findIndex(
({ id }) => id === challengeAfterId
);
const template = getTemplate(options.challengeType);
const challengeId = new ObjectId();
const challengeText = template({ ...options, challengeId });
createChallengeFile(challengeId.toString(), challengeText, path);
const meta = getMetaData();
meta.challengeOrder.splice(indexToInsert, 0, {
id: challengeId.toString(),
title: options.title
});
await updateMetaData(meta);
};
void insertChallenge();
@@ -0,0 +1,4 @@
import { getArgValue } from './helpers/get-arg-value.js';
import { insertStep } from './commands.js';
void insertStep(getArgValue(process.argv));
@@ -0,0 +1,68 @@
import { ObjectId } from 'bson';
import { select } from '@inquirer/prompts';
import { getTemplate } from './helpers/get-challenge-template.js';
import { newTaskPrompts } from './helpers/new-task-prompts.js';
import { getProjectPath } from './helpers/get-project-info.js';
import {
createChallengeFile,
getChallenge,
insertChallengeIntoMeta,
updateTaskMeta,
updateTaskMarkdownFiles
} from './utils.js';
import { getMetaData } from './helpers/project-metadata.js';
import { getInputType } from './helpers/get-input-type.js';
const insertChallenge = async () => {
const challenges = getMetaData().challengeOrder;
const challengeAfterId = await select<string>({
message: 'Which challenge should come AFTER this new one?',
choices: challenges.map(({ id, title }) => ({
name: title,
value: id
}))
});
const challengeLang = getChallenge(challengeAfterId)?.lang;
const indexToInsert = challenges.findIndex(
({ id }) => id === challengeAfterId
);
const newTaskTitle = 'Task 0';
const { challengeType } = await newTaskPrompts();
const inputType = await getInputType(challengeType, challengeLang);
const options = {
title: newTaskTitle,
dashedName: 'task-0',
challengeType,
...{ ...(challengeLang && { challengeLang }) },
...{ ...(inputType && { inputType }) }
};
const path = getProjectPath();
const template = getTemplate(challengeType);
const challengeId = new ObjectId();
const challengeText = template({ ...options, challengeId });
const challengeIdString = challengeId.toString();
createChallengeFile(challengeIdString, challengeText, path);
console.log('Finished creating new task markdown file.');
await insertChallengeIntoMeta({
index: indexToInsert,
id: challengeId,
title: newTaskTitle
});
console.log(`Finished inserting task into 'meta.json' file.`);
await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles();
console.log('Finished updating task markdown files.');
};
void insertChallenge();
@@ -0,0 +1,47 @@
{
"name": "@freecodecamp/curriculum-helper-scripts",
"version": "0.0.0-next.1",
"description": "freeCodeCamp's project-based curriculum scripts",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"repository": {
"type": "git",
"url": "https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "utils.js",
"scripts": {
"create-daily-challenges": "tsx create-daily-challenges",
"create-project": "tsx create-project",
"create-language-block": "tsx create-language-block",
"create-quiz": "tsx create-quiz",
"rename-block": "tsx rename-block",
"lint": "eslint --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@freecodecamp/curriculum": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*",
"@inquirer/prompts": "^7.8.3",
"@total-typescript/ts-reset": "^0.6.1",
"@vitest/ui": "^4.0.15",
"bson": "^7.0.0",
"eslint": "^9.39.1",
"gray-matter": "4.0.3",
"prettier": "3.8.2",
"typescript": "5.9.3",
"vitest": "^4.0.15"
}
}
@@ -0,0 +1,177 @@
import fs from 'fs/promises';
import path, { join } from 'path';
import { input } from '@inquirer/prompts';
import { format } from 'prettier';
import { IntroJson, parseIntroJson } from './helpers/parse-json';
import { withTrace } from './helpers/utils';
import { getAllBlocks, validateBlockName } from './utils';
import {
getBlockStructure,
getBlockStructurePath,
getSuperblockStructure,
writeBlockStructure,
writeSuperblockStructure,
getContentConfig,
getCurriculumStructure
} from '@freecodecamp/curriculum/file-handler';
interface RenameBlockArgs {
newBlock: string;
oldBlock: string;
newName: string;
}
const introJsonPath = path.resolve(
__dirname,
'../../client/i18n/locales/english/intro.json'
);
function getBlockTitleFromIntro(intro: IntroJson, block: string) {
for (const superBlockInfo of Object.values(intro)) {
const blockInfo = superBlockInfo.blocks[block];
if (blockInfo?.title) return blockInfo.title;
}
}
function renameBlockInSimpleStructure(
blocks: string[] | undefined,
oldBlock: string,
newBlock: string
) {
if (!blocks) return false;
const blockIndex = blocks.findIndex(block => block === oldBlock);
if (blockIndex === -1) return false;
blocks[blockIndex] = newBlock;
return true;
}
function renameBlockInChapterStructure(
chapters:
| {
modules: {
blocks: string[];
}[];
}[]
| undefined,
oldBlock: string,
newBlock: string
) {
if (!chapters) return false;
let updated = false;
for (const chapter of chapters) {
for (const module of chapter.modules) {
const blockIndex = module.blocks.findIndex(block => block === oldBlock);
if (blockIndex !== -1) {
module.blocks[blockIndex] = newBlock;
updated = true;
}
}
}
return updated;
}
function renameBlockInIntro(
intro: IntroJson,
superblock: string,
oldBlock: string,
newBlock: string,
newName: string
) {
const superBlockIntro = intro[superblock];
if (!superBlockIntro) return false;
const introBlocks = Object.entries(superBlockIntro.blocks);
const blockIntroIndex = introBlocks.findIndex(
([block]) => block === oldBlock
);
if (blockIntroIndex === -1) return false;
const currentBlockInfo = introBlocks[blockIntroIndex]?.[1];
if (!currentBlockInfo) return false;
introBlocks[blockIntroIndex] = [
newBlock,
{ ...currentBlockInfo, title: newName }
];
superBlockIntro.blocks = Object.fromEntries(introBlocks);
return true;
}
async function renameBlock({ newBlock, newName, oldBlock }: RenameBlockArgs) {
const blockStructure = getBlockStructure(oldBlock);
const blockStructurePath = getBlockStructurePath(oldBlock);
blockStructure.dashedName = newBlock;
await writeBlockStructure(newBlock, blockStructure);
await fs.rm(blockStructurePath);
console.log('New block structure .json written.');
const { blockContentDir } = getContentConfig('english');
const oldBlockContentDir = join(blockContentDir, oldBlock);
const newBlockContentDir = join(blockContentDir, newBlock);
await fs.rename(oldBlockContentDir, newBlockContentDir);
console.log('Block challenges moved to new directory.');
const newIntro = await parseIntroJson(introJsonPath);
let didUpdateIntro = false;
const { superblocks } = getCurriculumStructure();
console.log('Updating superblocks containing renamed block.');
for (const superblock of superblocks) {
const superblockStructure = getSuperblockStructure(superblock);
const didUpdateSuperblock =
renameBlockInSimpleStructure(
superblockStructure.blocks,
oldBlock,
newBlock
) ||
renameBlockInChapterStructure(
superblockStructure.chapters,
oldBlock,
newBlock
);
if (didUpdateSuperblock) {
await writeSuperblockStructure(superblock, superblockStructure);
console.log(`Updated superblock .json file written for ${superblock}.`);
didUpdateIntro =
renameBlockInIntro(newIntro, superblock, oldBlock, newBlock, newName) ||
didUpdateIntro;
}
}
if (didUpdateIntro) {
await withTrace(
fs.writeFile,
introJsonPath,
await format(JSON.stringify(newIntro), { parser: 'json' })
);
console.log('Updated locale intro.json file written.');
}
}
void getAllBlocks().then(async existingBlocks => {
const intro = await parseIntroJson(introJsonPath);
const oldBlock = await input({
message: 'What is the dashed name of block to rename?',
validate: (block: string) =>
existingBlocks.includes(block) || 'Block not found in existing blocks.'
});
const newName = await input({
message: 'What is the new name?',
default: getBlockTitleFromIntro(intro, oldBlock) ?? oldBlock
});
const newBlock = await input({
message: 'What is the new dashed name (in kebab-case)?',
validate: (block: string) => validateBlockName(block, existingBlocks)
});
await renameBlock({ newBlock, newName, oldBlock });
console.log('All set. Refresh the page to see the changes.');
});
@@ -0,0 +1,77 @@
import { exec } from 'child_process';
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import gray from 'gray-matter';
import { select } from '@inquirer/prompts';
const asyncExec = promisify(exec);
void (async () => {
const superblocks = await readdir(
join(process.cwd(), 'curriculum', 'challenges', 'english')
);
const superblock = await select<string>({
message: 'Select target superblock:',
choices: superblocks.map(value => ({ name: value, value }))
});
const blocks = await readdir(
join(process.cwd(), 'curriculum', 'challenges', 'english', superblock)
);
const block = await select<string>({
message: 'Select target block:',
choices: blocks.map(value => ({ name: value, value }))
});
const files = await readdir(
join(
process.cwd(),
'curriculum',
'challenges',
'english',
superblock,
block
)
);
console.log(`Processing ${files.length} files.`);
for (const file of files) {
const fileData = await readFile(
join(
process.cwd(),
'curriculum',
'challenges',
'english',
superblock,
block,
file
)
);
const challengeId = (await gray(fileData).data.id) as string;
if (`${challengeId}.md` === file) {
console.warn(`${file} already has the correct name. Skipping.`);
continue;
}
await asyncExec(
`git mv ${join(
'curriculum',
'challenges',
'english',
superblock,
block,
file
)} ${join(
'curriculum',
'challenges',
'english',
superblock,
block,
`${challengeId}.md`
)}`
);
}
})();
@@ -0,0 +1,11 @@
import { updateTaskMeta, updateTaskMarkdownFiles } from './utils.js';
const reorderTasks = async () => {
await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles();
console.log('Finished updating task markdown files.');
};
void reorderTasks();
+1
View File
@@ -0,0 +1 @@
import '@total-typescript/ts-reset';
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig-base.json",
"include": ["**/*.ts"],
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
}
@@ -0,0 +1,9 @@
{
"$schema": "https://v2-10-0.turborepo.dev/schema.json",
"extends": ["//"],
"tasks": {
"test": {
"env": ["CURRICULUM_LOCALE", "SHOW_UPCOMING_CHANGES"]
}
}
}
@@ -0,0 +1,50 @@
import { select, confirm } from '@inquirer/prompts';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
const updateChallengeOrder = async () => {
const oldChallengeOrder = getMetaData().challengeOrder;
console.log('Current challenge order is: ');
console.table(oldChallengeOrder.map(({ title }) => ({ title })));
const newChallengeOrder: { id: string; title: string }[] = [];
while (oldChallengeOrder.length) {
const nextChallengeId = await select<string>({
message: newChallengeOrder.length
? `What challenge comes after ${
newChallengeOrder[newChallengeOrder.length - 1].title
}?`
: 'What is the first challenge?',
choices: oldChallengeOrder.map(({ id, title }) => ({
name: title,
value: id
}))
});
const nextChallengeIndex = oldChallengeOrder.findIndex(
({ id }) => id === nextChallengeId
);
const targetChallenge = oldChallengeOrder[nextChallengeIndex];
oldChallengeOrder.splice(nextChallengeIndex, 1);
newChallengeOrder.push(targetChallenge);
}
console.log('New challenge order is: ');
console.table(newChallengeOrder.map(({ title }) => ({ title })));
const isCorrect = await confirm({
message: 'Is this correct?',
default: false
});
if (!isCorrect) {
console.error('Aborting.');
return;
}
const meta = getMetaData();
meta.challengeOrder = newChallengeOrder;
await updateMetaData(meta);
};
void (async () => await updateChallengeOrder())();
@@ -0,0 +1,3 @@
import { updateStepTitles } from './utils.js';
updateStepTitles();
@@ -0,0 +1,192 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import fs from 'fs';
import path, { join } from 'path';
import matter from 'gray-matter';
import { ObjectId } from 'bson';
import { vi, describe, it, expect, afterEach } from 'vitest';
vi.mock('fs', () => {
return {
default: {
writeFileSync: vi.fn(),
readdirSync: vi.fn()
}
};
});
vi.mock('gray-matter', () => {
return {
default: {
read: vi.fn(),
stringify: vi.fn()
}
};
});
vi.mock('./helpers/get-step-template', () => {
return {
getStepTemplate: vi.fn()
};
});
const mockMeta = {
challengeOrder: [{ id: 'abc', title: 'mock title' }]
};
vi.mock('./helpers/project-metadata', () => {
return {
getMetaData: vi.fn(() => mockMeta),
updateMetaData: vi.fn()
};
});
import { getStepTemplate } from './helpers/get-step-template.js';
import {
createChallengeFile,
createStepFile,
insertStepIntoMeta,
updateStepTitles,
validateBlockName
} from './utils.js';
import { updateMetaData } from './helpers/project-metadata.js';
const block = 'utils-project';
const projectPath = join(
'curriculum',
'challenges',
'english',
'blocks',
block
);
describe('Challenge utils helper scripts', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('createStepFile util', () => {
it('should create next step', () => {
process.env.INIT_CWD = projectPath;
const mockTemplate = 'Mock template...';
(getStepTemplate as ReturnType<typeof vi.fn>).mockReturnValue(
mockTemplate
);
const challengeId = new ObjectId();
createStepFile({
challengeId,
stepNum: 3,
challengeType: 0
});
// Internal tasks
// - Should generate a template for the step that is being created
expect(getStepTemplate).toHaveBeenCalledTimes(1);
expect(fs.writeFileSync).toHaveBeenCalledWith(
`${projectPath}/${challengeId.toString()}.md`,
mockTemplate
);
});
});
describe('createProject util', () => {
it('should allow alphanumerical names with trailing whitespace', () => {
expect(
validateBlockName('learn-callbacks-by-creating-a-bookshelf ', [])
).toBe(true);
});
it('should allow alphanumerical names with no trailing whitespace', () => {
expect(
validateBlockName('learn-callbacks-by-creating-a-bookshelf', [])
).toBe(true);
});
it('should not allow non-kebab case names', () => {
expect(validateBlockName('learnCallbacksBetter', [])).toBe(
'please use alphanumerical characters and kebab case'
);
});
it('should not allow white space names', () => {
expect(validateBlockName(' ', [])).toBe('please enter a dashed name');
});
it('should not allow empty names', () => {
expect(validateBlockName('', [])).toBe('please enter a dashed name');
});
it('should not allow names that already exist', () => {
expect(validateBlockName('name', ['name'])).toBe(
'a block with this name already exists'
);
});
});
describe('createChallengeFile util', () => {
it('should create the challenge using an ObjectId string as the filename', () => {
process.env.INIT_CWD = projectPath;
const template = 'pretend this is a template';
const challengeId = new ObjectId();
createChallengeFile(challengeId.toString(), template);
// - Should write a file named after the ObjectId with the given template
expect(fs.writeFileSync).toHaveBeenCalledWith(
`${projectPath}/${challengeId.toString()}.md`,
template
);
});
});
describe('insertStepIntoMeta util', () => {
it('should call updateMetaData with a new file id and name', async () => {
process.env.INIT_CWD = projectPath;
const stepId = new ObjectId();
await insertStepIntoMeta({
stepNum: 3,
stepId
});
expect(updateMetaData).toHaveBeenCalledWith({
challengeOrder: [
{ id: 'abc', title: 'Step 1' }, // title gets overwritten
{ id: stepId.toString(), title: 'Step 2' }
]
});
});
});
describe('updateStepTitles util', () => {
it('should apply meta.challengeOrder to step files', () => {
process.env.INIT_CWD = projectPath;
(getStepTemplate as ReturnType<typeof vi.fn>).mockReturnValue(
'Mock template...'
);
(fs.readdirSync as ReturnType<typeof vi.fn>).mockReturnValue([
'name.md',
'another-name.md'
]);
(matter.read as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: 'abc' },
content: 'goes here'
});
updateStepTitles();
expect(fs.readdirSync).toHaveBeenCalledWith(projectPath + '/');
expect(fs.writeFileSync).toHaveBeenCalledWith(
path.join(projectPath, 'name.md'),
undefined
);
expect(fs.writeFileSync).toHaveBeenCalledWith(
path.join(projectPath, 'another-name.md'),
undefined
);
expect(matter.stringify).toHaveBeenCalledWith('goes here', {
dashedName: 'step-1',
id: 'abc',
title: 'Step 1'
});
});
});
afterEach(() => {
delete process.env.INIT_CWD;
});
});
+317
View File
@@ -0,0 +1,317 @@
import fs from 'fs';
import path from 'path';
import { ObjectId } from 'bson';
import matter from 'gray-matter';
import { uniq } from 'lodash';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
import { parseCurriculumStructure } from '@freecodecamp/curriculum/build-curriculum';
import { parseMDSync } from '../challenge-parser/parser/index.js';
import { getMetaData, updateMetaData } from './helpers/project-metadata.js';
import { getProjectPath } from './helpers/get-project-info.js';
import { ChallengeSeed, getStepTemplate } from './helpers/get-step-template.js';
import {
isTaskChallenge,
getTaskNumberFromTitle
} from './helpers/task-helpers.js';
import { getTemplate } from './helpers/get-challenge-template.js';
interface Options {
challengeId: ObjectId;
stepNum: number;
challengeType?: number;
projectPath?: string;
challengeSeeds?: ChallengeSeed[];
isFirstChallenge?: boolean;
challengeLang?: ChallengeLang;
}
interface QuizOptions {
challengeId: ObjectId;
projectPath?: string;
title: string;
dashedName: string;
questionCount: number;
challengeLang?: ChallengeLang;
}
export async function getAllBlocks() {
const { fullSuperblockList } = (await parseCurriculumStructure()) as {
fullSuperblockList: {
blocks: { dashedName: string }[];
}[];
};
const existingBlocks = fullSuperblockList.flatMap(({ blocks }) =>
blocks.map(({ dashedName }) => dashedName)
);
return uniq(existingBlocks);
}
const createStepFile = ({
stepNum,
challengeType,
challengeId,
projectPath = getProjectPath(),
challengeSeeds = [],
isFirstChallenge = false,
challengeLang
}: Options) => {
const template = getStepTemplate({
challengeId,
challengeSeeds,
stepNum,
challengeType,
isFirstChallenge,
challengeLang
});
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, template);
};
const createChallengeFile = (
filename: string,
template: string,
path = getProjectPath()
): void => {
fs.writeFileSync(`${path}${filename}.md`, template);
};
const createQuizFile = ({
challengeId,
projectPath = getProjectPath(),
title,
dashedName,
questionCount,
challengeLang
}: QuizOptions): ObjectId => {
const challengeType = challengeTypes.quiz.toString();
const template = getTemplate(challengeType);
const quizText = template({
challengeId,
challengeType,
title,
dashedName,
questionCount,
challengeLang
});
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, quizText);
return challengeId;
};
const createDialogueFile = ({
challengeId,
projectPath,
challengeLang
}: {
challengeId: ObjectId;
projectPath: string;
challengeLang: ChallengeLang;
}): ObjectId => {
const challengeType = challengeTypes.dialogue.toString();
const template = getTemplate(challengeType);
const dialogueText = template({
challengeId,
challengeType,
title: "Dialogue 1: I'm Tom",
dashedName: 'dialogue-1-im-tom',
challengeLang
});
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, dialogueText);
return challengeId;
};
interface InsertOptions {
stepNum: number;
stepId: ObjectId;
}
interface InsertChallengeOptions {
index: number;
id: ObjectId;
title: string;
}
async function insertChallengeIntoMeta({
index,
id,
title
}: InsertChallengeOptions) {
const existingMeta = getMetaData();
const challengeOrder = [...existingMeta.challengeOrder];
challengeOrder.splice(index, 0, { id: id.toString(), title });
await updateMetaData({ ...existingMeta, challengeOrder });
}
async function insertStepIntoMeta({ stepNum, stepId }: InsertOptions) {
const existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder];
oldOrder.splice(stepNum - 1, 0, { id: stepId.toString(), title: '' });
// rename all the files in challengeOrder
const challengeOrder = oldOrder.map(({ id }, index) => ({
id,
title: `Step ${index + 1}`
}));
await updateMetaData({ ...existingMeta, challengeOrder });
}
async function deleteStepFromMeta({ stepNum }: { stepNum: number }) {
const existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder];
oldOrder.splice(stepNum - 1, 1);
// rename all the files in challengeOrder
const challengeOrder = oldOrder.map(({ id }, index) => ({
id,
title: `Step ${index + 1}`
}));
await updateMetaData({ ...existingMeta, challengeOrder });
}
async function deleteChallengeFromMeta(challengeIndex: number) {
const existingMeta = getMetaData();
const challengeOrder = [...existingMeta.challengeOrder];
challengeOrder.splice(challengeIndex, 1);
await updateMetaData({ ...existingMeta, challengeOrder });
}
async function updateTaskMeta() {
const existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder];
let currentTaskNumber = 1;
const challengeOrder = oldOrder.map(challenge => {
if (isTaskChallenge(challenge.title)) {
return {
id: challenge.id,
title: `Task ${currentTaskNumber++}`
};
} else {
return challenge;
}
});
await updateMetaData({ ...existingMeta, challengeOrder });
}
const updateStepTitles = (): void => {
const meta = getMetaData();
const fileNames: string[] = [];
fs.readdirSync(getProjectPath()).forEach(fileName => {
if (path.extname(fileName).toLowerCase() === '.md') {
fileNames.push(fileName);
}
});
fileNames.forEach(fileName => {
const filePath = `${getProjectPath()}${fileName}`;
const frontMatter = matter.read(filePath);
const newStepNum =
meta.challengeOrder.findIndex(({ id }) => id === frontMatter.data.id) + 1;
const title = `Step ${newStepNum}`;
const dashedName = `step-${newStepNum}`;
const newData = {
...frontMatter.data,
title,
dashedName
};
fs.writeFileSync(filePath, matter.stringify(frontMatter.content, newData));
});
};
const updateTaskMarkdownFiles = (): void => {
const meta = getMetaData();
const fileNames: string[] = [];
fs.readdirSync(getProjectPath()).forEach(fileName => {
if (path.extname(fileName).toLowerCase() === '.md') {
fileNames.push(fileName);
}
});
fileNames.forEach(fileName => {
const filePath = `${getProjectPath()}${fileName}`;
const frontMatter = matter.read(filePath);
const challenge = meta.challengeOrder.find(
({ id }) => id === frontMatter.data.id
);
if (!challenge || !challenge.title) {
throw new Error(
`Challenge id from ${fileName} not found in meta.json file.`
);
}
// only update task challenges, dialogue challenges shouldn't change
if (isTaskChallenge(challenge.title)) {
const newTaskNumber = getTaskNumberFromTitle(challenge.title);
const title = `Task ${newTaskNumber}`;
const dashedName = `task-${newTaskNumber}`;
const newData = {
...frontMatter.data,
title,
dashedName
};
fs.writeFileSync(
filePath,
matter.stringify(frontMatter.content, newData)
);
}
});
};
type Challenge = {
challengeType: number;
challengeFiles: ChallengeSeed[];
lang?: ChallengeLang;
};
const getChallenge = (challengeId: string): Challenge => {
const challengePath = path.join(getProjectPath(), `${challengeId}.md`);
const challenge = parseMDSync(challengePath) as Challenge;
return challenge;
};
const validateBlockName = (
block: string,
existingBlocks: string[]
): true | string => {
if (existingBlocks.includes(block.trim())) {
return 'a block with this name already exists';
}
if (!block.trim().length) {
return 'please enter a dashed name';
}
if (/[^a-z0-9-]/.test(block.trim())) {
return 'please use alphanumerical characters and kebab case';
}
return true;
};
export {
createStepFile,
createDialogueFile,
createChallengeFile,
updateStepTitles,
updateTaskMeta,
updateTaskMarkdownFiles,
getChallenge,
insertChallengeIntoMeta,
insertStepIntoMeta,
deleteChallengeFromMeta,
deleteStepFromMeta,
validateBlockName,
createQuizFile
};
+4
View File
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
+13
View File
@@ -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
}
}
}
];
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@freecodecamp/challenge-parser",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "index.js",
"scripts": {
"lint": "eslint --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit"
},
"dependencies": {
"hast-util-to-html": "7.1.3",
"js-yaml": "3.14.2",
"lodash": "4.18.1",
"mdast-builder": "1.1.1",
"mdast-util-directive": "1.0.1",
"mdast-util-gfm-strikethrough": "0.2.3",
"mdast-util-gfm-table": "0.1.6",
"mdast-util-to-hast": "9.1.2",
"mdast-util-to-markdown": "0.6.5",
"micromark-extension-gfm-strikethrough": "0.6.5",
"micromark-extension-gfm-table": "0.4.3",
"remark": "13.0.0",
"remark-directive": "1.0.1",
"remark-frontmatter": "3.0.0",
"remark-html": "13.0.2",
"remark-parse": "9.0.0",
"remark-stringify": "9.0.1",
"to-vfile": "5.0.3",
"unified": "7.1.0",
"unist-util-find": "1.0.4",
"unist-util-find-after": "3.0.0",
"unist-util-find-all-after": "3.0.2",
"unist-util-find-all-before": "3.0.1",
"unist-util-find-all-between": "2.1.0",
"unist-util-is": "4.1.0",
"unist-util-modify-children": "2.0.0",
"unist-util-position": "3.1.0",
"unist-util-remove": "2.1.0",
"unist-util-visit": "2.0.3",
"unist-util-visit-children": "1.1.4"
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"eslint": "^9.39.1",
"typescript": "5.9.3",
"unist-util-select": "3.0.4",
"vitest": "^4.0.15"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
<code> code in </code> code tags _emphasis_ followed by <div><span>some nested html </span></div>
@@ -0,0 +1,3 @@
Paragraph 1
Third _hint_ with <code>code</code> and `inline code`
@@ -0,0 +1,13 @@
const { join } = require('path');
const remark = require('remark');
const directive = require('remark-directive');
const frontmatter = require('remark-frontmatter');
const { read } = require('to-vfile');
const parseFixture = async (filename) => remark()
.use(directive)
.use(frontmatter, ['yaml'])
.parse(await read(join(__dirname, filename)));
module.exports = parseFixture;
@@ -0,0 +1,176 @@
# --description--
When you add a lower rank heading element to the page, it's implied that you're starting a new subsection.
After the last <code>h2</code> element of the second `section` element, add an `h3` element with the text `Things cats love:`.
<blockquote>
<p>Some text in a blockquote</p>
<p>
Some text in a blockquote, with <code>code</code>
</p>
</blockquote>
# --instructions--
Do something with the `code`.
To test that adjacent tags are handled correctly:
a bit of <code>code</code> <tag>with more after a space</tag> and another pair of <strong>elements</strong> <em>with a space</em>
# --hints--
The second `section` element appears to be missing or does not have both an opening and closing tag.
```js
assert(
document.querySelectorAll('main > section')[1] &&
code.match(/\<\/section>/g).length == 2
);
```
There should be an `h3` element right above the second `section` element's closing tag.
```js
assert(
document.querySelectorAll('main > section')[1].lastElementChild.nodeName ===
'H3'
);
```
The `h3` element right above the second `section` element's closing tag should have the text `Things cats love:`. Make sure to include the colon at the end of the text.
```js
assert(
document
.querySelectorAll('main > section')[1]
.lastElementChild.innerText.toLowerCase()
.replace(/\s+/g, ' ') === 'things cats love:'
);
```
There should be an `h2` element with the text `Cat Lists` above the last `h3` element that is nested in the last `section` element'. You may have accidentally deleted the `h2` element.
```js
const secondSectionLastElemNode = document.querySelectorAll('main > section')[1]
.lastElementChild;
assert(
secondSectionLastElemNode.nodeName === 'H3' &&
secondSectionLastElemNode.previousElementSibling.innerText
.toLowerCase()
.replace(/\s+/g, ' ') === 'cat lists'
);
```
# --seed--
## --seed-contents--
::id{#html-key}
```html
<html>
<body>
<h1>CatPhotoApp</h1>
<main>
<section>
<h2>Cat Photos</h2>
<!-- TODO: Add link to cat photos -->
<p>
Click here to view more
<a target="_blank" href="https://www.freecodecamp.org/cat-photos"
>cat photos</a
>.
</p>
<a href="https://www.freecodecamp.org/cat-photos"
><img
src="https://bit.ly/fcc-relaxing-cat"
alt="A cute orange cat lying on its back."
/></a>
</section>
--fcc-editable-region--
<section>
<h2>Cat Lists</h2>
</section>
--fcc-editable-region--
</main>
</body>
</html>
```
```css
body {
background: white;
}
h1 {
font-size: 20px;
}
--fcc-editable-region--
--fcc-editable-region--
a {
color: green;
}
```
::id{#final-key}
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
<h1>CatPhotoApp</h1>
<main>
<section>
<h2>Cat Photos</h2>
<!-- TODO: Add link to cat photos -->
<p>
Click here to view more
<a target="_blank" href="https://www.freecodecamp.org/cat-photos"
>cat photos</a
>.
</p>
<a href="https://www.freecodecamp.org/cat-photos"
><img
src="https://bit.ly/fcc-relaxing-cat"
alt="A cute orange cat lying on its back."
/></a>
</section>
<section>
<h2>Cat Lists</h2>
</section>
</main>
</body>
</html>
```
```css
body {
background: white;
}
h1 {
font-size: 20px;
}
a {
color: green;
}
```
::id{#final-key}
```js
var x = 'y';
```
@@ -0,0 +1,47 @@
# --description--
This challenge has a scene.
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 2.6,
"finishTimestamp": 4
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.7,
"finishTime": 2.4,
"dialogue": {
"text": "I'm Maria, the team lead.",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 3.4
}
]
}
```
@@ -0,0 +1,5 @@
```css
div {
background: red
}
```
@@ -0,0 +1,6 @@
```js
for (let index = 0; index < array.length; index++) {
const element = array[index];
// imported from script.md
}
```
@@ -0,0 +1,89 @@
# --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: green;
}
```
```js
var x = 'y';
```
```json
{
"compilerOptions": {
"target": "ES2020"
}
}
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --after-all--
```js
// after all code
function teardown() {
return 'cleaned up';
}
teardown();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
```js
// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --after-all--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --before-all--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
```js
// before each code
function setup() {
return 'initialized';
}
setup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --before-all--
```js
// before all code
function foo() {
return 'bar';
}
foo();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
@@ -0,0 +1,73 @@
# --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
```js
// test code
```
First hint
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
# --seed--
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: green;
}
```
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,42 @@
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: green;
}
```
```c
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,9 @@
# --fillInTheBlank--
## --sentence--
`BLANK BLANK`
## --blanks--
`你 (nǐ)`
@@ -0,0 +1,17 @@
# --fillInTheBlank--
## --sentence--
`我 (wǒ) BLANK UI 设计师 (shè jì shī) 。`
## --blanks--
`是 (shì)`
### --feedback--
Feedback text.
# --explanation--
Explanation text.
@@ -0,0 +1,9 @@
# --fillInTheBlank--
## --sentence--
`你好 (nǐ hǎo)`
## --blanks--
`你`
@@ -0,0 +1,9 @@
# --fillInTheBlank--
## --sentence--
`BLANK hǎo`
## --blanks--
`nǐ`
@@ -0,0 +1,9 @@
# --fillInTheBlank--
## --sentence--
`BLANK好`
## --blanks--
`你 (nǐ)`
@@ -0,0 +1,13 @@
# --fillInTheBlank--
## --sentence--
`BLANK 好 (hǎo) BLANK`
## --blanks--
`你`
---
`nǐ`
@@ -0,0 +1,46 @@
---
lang: zh-CN
inputType: pinyin-to-hanzi
---
# --fillInTheBlank--
## --sentence--
`BLANK BLANKBLANK 是王华 (shì Wang Hua),请问你 (qǐng wèn nǐ) BLANK 什么名字 (shén me míng zi)`
## --blanks--
`你 (nǐ)`
### --feedback--
Feedback text containing `汉字 (hàn zì)`.
---
`好 (hǎo)`
### --feedback--
This means "good" or "well".
---
`我 (wǒ)`
### --feedback--
This means "I".
---
`叫 (jiào)`
### --feedback--
This means "to be called".
# --explanation--
Explanation text containing `汉字 (hàn zì)`.
@@ -0,0 +1,54 @@
---
id: test-with-chinese-mcq
title: Ruby Test
challengeType: 19
lang: zh-CN
---
# --instructions--
Instructions containing `汉字 (hàn zì)`.
# --questions--
## --text--
Question text containing `汉字 (hàn zì)`.
## --answers--
`你好 (nǐ hǎo)`
### --feedback--
Feedback text.
---
`请 (qǐng)`
### --feedback--
`请 (qǐng)` is not correct.
---
`请问 (qǐng wèn)`
---
`问 (wèn)`
### --feedback--
Feedback text.
## --video-solution--
3
# --explanation--
`我是 (wǒ shì) Web 开发者 (kāi fā zhě)。` I am a web developer.
`你好 (nǐ hǎo),我是王华 (wǒ shì Wang Hua),请问你叫什么名字 (qǐng wèn nǐ jiào shén me míng zi)` Hello, I am Wang Hua, may I ask what your name is?
@@ -0,0 +1,87 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Quiz 1, question 1 with `中文 (zhōng wén)`
#### --audio--
```json
{
"audio": {
"filename": "7.3-1.mp3",
"startTimestamp": 24.7,
"finishTimestamp": 26.2
},
"transcript": [
{
"character": "Wang Hua",
"text": "你好 (nǐ hǎo)。"
}
]
}
```
#### --distractors--
Quiz 1, question 1, distractor 1 with `中文 (zhōng wén)`
---
Quiz 1, question 1, distractor 2 with `中文 (zhōng wén)`
---
Quiz 1, question 1, distractor 3 with `中文 (zhōng wén)`
#### --answer--
Quiz 1, question 1, answer with `中文 (zhōng wén)`
### --question--
#### --text--
Quiz 1, question 2 with `中文`
#### --distractors--
Quiz 1, question 2, distractor 1 with `中文`
---
Quiz 1, question 2, distractor 2 with `中文`
---
Quiz 1, question 2, distractor 3 with `中文`
#### --answer--
Quiz 1, question 2, answer with `中文`
### --question--
#### --text--
Quiz 1, question 3 with `zhōng wén`
#### --distractors--
Quiz 1, question 3, distractor 1 with `zhōng wén`
---
Quiz 1, question 3, distractor 2 with `zhōng wén`
---
Quiz 1, question 3, distractor 3 with `zhōng wén`
#### --answer--
Quiz 1, question 3, answer with `zhōng wén`
@@ -0,0 +1,35 @@
# --description--
This challenge has a Chinese scene with plain hanzi (no pinyin).
# --scene--
```json
{
"setup": {
"background": "company1-reception.png",
"characters": [
{
"character": "Wang Hua",
"position": { "x": 50, "y": 15, "z": 1.4 },
"opacity": 0
}
],
"audio": {
"filename": "test.mp3",
"startTime": 1
}
},
"commands": [
{
"character": "Wang Hua",
"startTime": 1,
"finishTime": 2,
"dialogue": {
"text": "你好,世界。",
"align": "center"
}
}
]
}
```
@@ -0,0 +1,37 @@
# --description--
This challenge has a Chinese scene with hanzi-pinyin pairs.
# --scene--
```json
{
"setup": {
"background": "company1-reception.png",
"characters": [
{
"character": "Wang Hua",
"position": { "x": 50, "y": 15, "z": 1.4 },
"opacity": 0
}
],
"audio": {
"filename": "ZH_A1_welcome_hello_world.mp3",
"startTime": 1,
"startTimestamp": 5.18,
"finishTimestamp": 6.71
}
},
"commands": [
{
"character": "Wang Hua",
"startTime": 1,
"finishTime": 2.53,
"dialogue": {
"text": "你好 (nǐ hǎo),世界 (shì jiè)。",
"align": "center"
}
}
]
}
```
@@ -0,0 +1,17 @@
# --description--
:root appears, :import appears
the next paragraph should appear
::import
even though it's an import directive, but if we use the full syntax `::directive-name{attr="name" attr2="a/path"}`
::import{component="name" from="script.md"}
it goes.
::: name [inline-content] {key=val}
a container directive
:::
@@ -0,0 +1,84 @@
# --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
--fcc-editable-region-- --fcc-editable-region--
background: green;
}
```
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,84 @@
# --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
--fcc-editable-region--
background: green;
--fcc-editable-region--
}
```
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,64 @@
# --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
# --seed--
This section intentionally has no `## --seed-contents--`.
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,7 @@
# --description--
This is a test file for empty interactive elements.
# --interactive--
no subsections
@@ -0,0 +1,36 @@
---
id: with-en-us-mcq
title: en-US MCQ
challengeType: 19
lang: en-US
---
# --instructions--
Instructions containing `some code`.
# --questions--
## --text--
Question text containing `highlighted text`.
## --answers--
`correct answer`
---
`wrong answer`
### --feedback--
Feedback text containing `highlighted text`.
## --video-solution--
1
# --explanation--
Explanation text containing `highlighted text`.
@@ -0,0 +1,44 @@
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: green;
}
```
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
--fcc-editable-region--
background: white;
--fcc-editable-region--
}
```
```js
var x = 'y';
```
@@ -0,0 +1,36 @@
---
id: with-es-mcq
title: Spanish MCQ
challengeType: 19
lang: es
---
# --instructions--
Instructions containing `texto resaltado`.
# --questions--
## --text--
Question text containing `texto resaltado`.
## --answers--
`correct answer`
---
`wrong answer`
### --feedback--
Feedback text containing `texto resaltado`.
## --video-solution--
1
# --explanation--
Explanation text containing `texto resaltado`.
@@ -0,0 +1,83 @@
# --description--
# this should still be inside --description--
Paragraph 1
```html
code example
```
# --instructions--
Paragraph 0
```html
code example 0
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```
# --seed--
## --seed-contents--
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: green;
}
```
```js
var x = 'y';
```
# --solutions--
::id{#html-key}
```html
<html>
<body>
</body>
</html>
```
```css
body {
background: white;
}
```
```js
var x = 'y';
```
@@ -0,0 +1,28 @@
# --description--
In English, to check or confirm something people sometimes use tag questions. For example, `You are a programmer, right?` Here, `right?` is used as a tag to check or confirm the previous statement.
# --fillInTheBlank--
## --sentence--
`A sentence BLANK paragraph 1`
`not enough newlines, so no paragraph 2`
Sentence in BLANK 2
## --blanks--
`are`
### --feedback--
Pay attention to the verb in the sentence.
---
`right`
### --feedback--
Pay attention to the verb in the sentence.
@@ -0,0 +1,27 @@
# --description--
In English, to check or confirm something people sometimes use tag questions. For example, `You are a programmer, right?` Here, `right?` is used as a tag to check or confirm the previous statement.
# --fillInTheBlank--
## --sentence--
`A sentence BLANK paragraph 1`
Sentence in BLANK 2
## --blanks--
`are`
### --feedback--
Pay attention to the verb in the sentence.
---
`right`
### --feedback--
Pay attention to the verb in the sentence.
@@ -0,0 +1,29 @@
# --fillInTheBlank--
## --sentence--
`Sure thing! BLANK _ _.`
## --blanks--
`I'd`
### --feedback--
Feedback 1
## --blanks--
`love`
### --feedback--
Feedback 2
## --blanks--
`to`
### --feedback--
Feedback 3
@@ -0,0 +1,19 @@
# --description--
In English, when making introductions or identifying someone, you use the verb `to be`. In this case, `You are` is used to address the person Maria is talking to and affirmatively identify their occupation.
Maria is introducing herself and confirming Tom's job role. `Are` is used in the present affirmative to make a statement.
# --fillInTheBlank--
## --sentence--
`Hello, You BLANK the new graphic designer, right?`
## --blanks--
`are`
### --feedback--
The verb `to be` is an irregular verb. When conjugated with the pronoun `you`, `be` becomes `are`. For example: `You are an English learner.`

Some files were not shown because too many files have changed in this diff Show More