chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,13 @@
|
||||
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
...configTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node // TODO: migrate to ESM and remove globals
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,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';
|
||||
```
|
||||
+9
@@ -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ǐ)`
|
||||
+13
@@ -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 BLANK,BLANK 是王华 (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
Reference in New Issue
Block a user