feat(tools): create challenge helper script for quiz files (#59523)

This commit is contained in:
Anna
2025-04-10 10:56:42 -04:00
committed by GitHub
parent 48091110d5
commit b8d4099796
7 changed files with 280 additions and 401 deletions

View File

@@ -40,6 +40,7 @@
"clean:server": "rm -rf ./api-server/lib",
"create:shared": "tsc -p shared",
"create-new-project": "cd ./tools/challenge-helper-scripts/ && pnpm run create-project",
"create-new-quiz": "cd ./tools/challenge-helper-scripts/ && pnpm run create-quiz",
"predevelop": "npm-run-all -p create:shared -s build:curriculum",
"develop": "npm-run-all -p develop:*",
"develop:client": "cd ./client && pnpm run develop",

View File

@@ -0,0 +1,224 @@
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { prompt } from 'inquirer';
import { format } from 'prettier';
import ObjectID from 'bson-objectid';
import { SuperBlocks } from '../../shared/config/curriculum';
import { createQuizFile, validateBlockName } from './utils';
import { getSuperBlockSubPath } from './fs-utils';
import { Meta } from './helpers/project-metadata';
const helpCategories = [
'HTML-CSS',
'JavaScript',
'Backend Development',
'Python'
] as const;
type BlockInfo = {
title: string;
intro: string[];
};
type SuperBlockInfo = {
blocks: Record<string, BlockInfo>;
};
type IntroJson = Record<SuperBlocks, SuperBlockInfo>;
interface CreateQuizArgs {
superBlock: SuperBlocks;
block: string;
helpCategory: string;
title?: string;
questionCount: number;
}
async function createQuiz(
superBlock: SuperBlocks,
block: string,
helpCategory: string,
questionCount: number,
title?: string
) {
if (!title) {
title = block;
}
void updateIntroJson(superBlock, block, title);
const challengeId = await createQuizChallenge(
superBlock,
block,
title,
questionCount
);
void createMetaJson(superBlock, block, title, helpCategory, challengeId);
// TODO: remove once we stop relying on markdown in the client.
void createIntroMD(superBlock, block, title);
}
async function updateIntroJson(
superBlock: SuperBlocks,
block: string,
title: string
) {
const introJsonPath = path.resolve(
__dirname,
'../../client/i18n/locales/english/intro.json'
);
const newIntro = await parseJson<IntroJson>(introJsonPath);
newIntro[superBlock].blocks[block] = {
title,
intro: ['', '']
};
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
) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = await parseJson<Meta>('./quiz-meta.json');
newMeta.name = title;
newMeta.dashedName = block;
newMeta.helpCategory = helpCategory;
newMeta.superBlock = superBlock;
// eslint-disable-next-line @typescript-eslint/no-base-to-string
newMeta.challengeOrder = [{ id: challengeId.toString(), title: title }];
const newMetaDir = path.resolve(metaDir, block);
if (!existsSync(newMetaDir)) {
await withTrace(fs.mkdir, newMetaDir);
}
void withTrace(
fs.writeFile,
path.resolve(metaDir, `${block}/meta.json`),
await format(JSON.stringify(newMeta), { parser: 'json' })
);
}
async function createIntroMD(superBlock: string, block: string, title: string) {
const introMD = `---
title: Introduction to the ${title}
block: ${block}
superBlock: ${superBlock}
---
## Introduction to the ${title}
This page is for the ${title}
`;
const dirPath = path.resolve(
__dirname,
`../../client/src/pages/learn/${superBlock}/${block}/`
);
const filePath = path.resolve(dirPath, 'index.md');
if (!existsSync(dirPath)) {
await withTrace(fs.mkdir, dirPath);
}
void withTrace(fs.writeFile, filePath, introMD, { encoding: 'utf8' });
}
async function createQuizChallenge(
superBlock: SuperBlocks,
block: string,
title: string,
questionCount: number
): Promise<ObjectID> {
const superBlockSubPath = getSuperBlockSubPath(superBlock);
const newChallengeDir = path.resolve(
__dirname,
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
);
if (!existsSync(newChallengeDir)) {
await withTrace(fs.mkdir, newChallengeDir);
}
return createQuizFile({
challengeType: '8',
projectPath: newChallengeDir + '/',
title: title,
dashedName: block,
questionCount: questionCount
});
}
function parseJson<JsonSchema>(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 JsonSchema
);
}
// fs Promise functions return errors, but no stack trace. This adds back in
// the stack trace.
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);
});
}
void prompt([
{
name: 'superBlock',
message: 'Which certification does this belong to?',
default: SuperBlocks.FullStackDeveloper,
type: 'list',
choices: Object.values(SuperBlocks)
},
{
name: 'block',
message: 'What is the dashed name (in kebab-case) for this quiz?',
validate: validateBlockName,
filter: (block: string) => {
return block.toLowerCase().trim();
}
},
{
name: 'title',
default: ({ block }: { block: string }) => block
},
{
name: 'helpCategory',
message: 'Choose a help category',
default: 'HTML-CSS',
type: 'list',
choices: helpCategories
},
{
name: 'questionCount',
message: 'Should this quiz have either ten or twenty questions?',
default: 20,
type: 'list',
choices: [20, 10]
}
])
.then(
async ({
superBlock,
block,
title,
helpCategory,
questionCount
}: CreateQuizArgs) =>
await createQuiz(superBlock, block, helpCategory, questionCount, title)
)
.then(() =>
console.log(
'All set. Now use pnpm run clean:client in the root and it should be good to go.'
)
);

View File

@@ -10,6 +10,7 @@ interface ChallengeOptions {
title: string;
dashedName: string;
challengeType: string;
questionCount?: number;
}
const buildFrontMatter = ({
@@ -76,13 +77,13 @@ const getQuizChallengeTemplate = (
# --description--
To pass the quiz, you must correctly answer at least 18 of the 20 questions below.
To pass the quiz, you must correctly answer at least ${options.questionCount! == 20 ? '18' : '9'} of the ${options.questionCount!.toString()} questions below.
# --quizzes--
## --quiz--
### --question--
${`### --question--
#### --text--
@@ -104,6 +105,7 @@ Placeholder distractor 3
Placeholder answer
`.repeat(options.questionCount! - 1)}
### --question--
#### --text--
@@ -125,403 +127,6 @@ Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
### --question--
#### --text--
Placeholder question
#### --distractors--
Placeholder distractor 1
---
Placeholder distractor 2
---
Placeholder distractor 3
#### --answer--
Placeholder answer
`;
const getVideoChallengeTemplate = (

View File

@@ -5,6 +5,8 @@ import { getProjectName, getProjectPath } from './get-project-info';
export type Meta = {
name: string;
blockLayout: string;
blockType: string;
isUpcomingChange: boolean;
dashedName: string;
helpCategory: string;

View File

@@ -20,7 +20,8 @@
"main": "utils.js",
"scripts": {
"test": "mocha --delay --reporter progress --bail",
"create-project": "tsx create-project"
"create-project": "tsx create-project",
"create-quiz": "tsx create-quiz"
},
"devDependencies": {
"@types/glob": "^8.0.1",

View File

@@ -0,0 +1,14 @@
{
"name": "",
"blockType": "quiz",
"blockLayout": "link",
"isUpcomingChange": true,
"dashedName": "",
"superBlock": "",
"challengeOrder": [
{
"id": "",
"title": ""
}
]
}

View File

@@ -10,6 +10,7 @@ import {
isTaskChallenge,
getTaskNumberFromTitle
} from './helpers/task-helpers';
import { getTemplate } from './helpers/get-challenge-template';
interface Options {
stepNum: number;
@@ -19,6 +20,14 @@ interface Options {
isFirstChallenge?: boolean;
}
interface QuizOptions {
challengeType: string;
projectPath?: string;
title: string;
dashedName: string;
questionCount: number;
}
const createStepFile = ({
stepNum,
challengeType,
@@ -50,6 +59,28 @@ const createChallengeFile = (
fs.writeFileSync(`${path}${filename}.md`, template);
};
const createQuizFile = ({
challengeType,
projectPath = getProjectPath(),
title,
dashedName,
questionCount
}: QuizOptions): ObjectID => {
const challengeId = new ObjectID();
const template = getTemplate(challengeType);
const quizText = template({
challengeId,
challengeType,
title,
dashedName,
questionCount
});
// eslint-disable-next-line @typescript-eslint/no-base-to-string
fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, quizText);
return challengeId;
};
interface InsertOptions {
stepNum: number;
stepId: ObjectID;
@@ -225,5 +256,6 @@ export {
insertStepIntoMeta,
deleteChallengeFromMeta,
deleteStepFromMeta,
validateBlockName
validateBlockName,
createQuizFile
};