diff --git a/package.json b/package.json index 609f5258312..6b44c06e93f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/challenge-helper-scripts/create-quiz.ts b/tools/challenge-helper-scripts/create-quiz.ts new file mode 100644 index 00000000000..4e47e70cda7 --- /dev/null +++ b/tools/challenge-helper-scripts/create-quiz.ts @@ -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; +}; + +type IntroJson = Record; + +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(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('./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 { + 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(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( + fn: (...x: Args) => Promise, + ...args: Args +): Promise { + 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.' + ) + ); diff --git a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts index 356b5bfbf47..7f40f18375e 100644 --- a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts +++ b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts @@ -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 = ( diff --git a/tools/challenge-helper-scripts/helpers/project-metadata.ts b/tools/challenge-helper-scripts/helpers/project-metadata.ts index 2a971ca5e4b..7db46d69c00 100644 --- a/tools/challenge-helper-scripts/helpers/project-metadata.ts +++ b/tools/challenge-helper-scripts/helpers/project-metadata.ts @@ -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; diff --git a/tools/challenge-helper-scripts/package.json b/tools/challenge-helper-scripts/package.json index cb8a9c8457c..c4ee521bffe 100644 --- a/tools/challenge-helper-scripts/package.json +++ b/tools/challenge-helper-scripts/package.json @@ -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", diff --git a/tools/challenge-helper-scripts/quiz-meta.json b/tools/challenge-helper-scripts/quiz-meta.json new file mode 100644 index 00000000000..a37c8fb05a1 --- /dev/null +++ b/tools/challenge-helper-scripts/quiz-meta.json @@ -0,0 +1,14 @@ +{ + "name": "", + "blockType": "quiz", + "blockLayout": "link", + "isUpcomingChange": true, + "dashedName": "", + "superBlock": "", + "challengeOrder": [ + { + "id": "", + "title": "" + } + ] +} diff --git a/tools/challenge-helper-scripts/utils.ts b/tools/challenge-helper-scripts/utils.ts index 8fc843e8ca6..254a60b6dc5 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -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 };