diff --git a/package.json b/package.json index 876dc38e96b..294b6aef3d2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "clean:packages": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +", "create:shared": "tsc -p shared", "create-new-project": "cd ./tools/challenge-helper-scripts/ && pnpm run create-project", + "create-new-language-block": "cd ./tools/challenge-helper-scripts/ && pnpm run create-language-block", "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:*", diff --git a/shared/config/curriculum.ts b/shared/config/curriculum.ts index 38f33061f02..bd72fa709e8 100644 --- a/shared/config/curriculum.ts +++ b/shared/config/curriculum.ts @@ -69,6 +69,13 @@ export const folderToSuperBlockMap = Object.fromEntries( Object.entries(superBlockToFolderMap).map(([key, value]) => [value, key]) ); +export const languageSuperBlocks = [ + SuperBlocks.A2English, + SuperBlocks.B1English, + SuperBlocks.A2Spanish, + SuperBlocks.A2Chinese +]; + /* * SuperBlockStages.Upcoming = SHOW_UPCOMING_CHANGES === 'true' * 'Upcoming' is for development -> not shown on stag or prod anywhere diff --git a/tools/challenge-helper-scripts/base-meta.json b/tools/challenge-helper-scripts/base-meta.json deleted file mode 100644 index 72397685989..00000000000 --- a/tools/challenge-helper-scripts/base-meta.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "", - "isUpcomingChange": true, - "usesMultifileEditor": true, - "hasEditableBoundaries": true, - "dashedName": "", - "order": 42, - "superBlock": "", - "challengeOrder": [ - { - "id": "", - "title": "" - } - ] -} diff --git a/tools/challenge-helper-scripts/create-language-block.ts b/tools/challenge-helper-scripts/create-language-block.ts new file mode 100644 index 00000000000..cf2c307d392 --- /dev/null +++ b/tools/challenge-helper-scripts/create-language-block.ts @@ -0,0 +1,198 @@ +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, + languageSuperBlocks, + superBlockToFolderMap +} from '../../shared/config/curriculum'; +import { createDialogueFile, validateBlockName } from './utils'; +import { getBaseMeta } from './helpers/get-base-meta'; + +const helpCategories = ['English'] as const; + +type BlockInfo = { + title: string; + intro: string[]; +}; + +type SuperBlockInfo = { + blocks: Record; +}; + +type IntroJson = Record; + +interface CreateBlockArgs { + superBlock: SuperBlocks; + block: string; + helpCategory: string; + title?: string; +} + +async function createLanguageBlock( + superBlock: SuperBlocks, + block: string, + helpCategory: string, + title?: string +) { + if (!title) { + title = block; + } + void updateIntroJson(superBlock, block, title); + + const challengeId = await createDialogueChallenge(superBlock, block); + 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 = getBaseMeta('Language'); + newMeta.name = title; + newMeta.dashedName = block; + newMeta.helpCategory = helpCategory; + newMeta.superBlock = superBlock; + newMeta.challengeOrder = [ + // eslint-disable-next-line @typescript-eslint/no-base-to-string + { id: challengeId.toString(), title: "Dialogue 1: I'm Tom" } + ]; + 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 createDialogueChallenge( + superBlock: SuperBlocks, + block: string +): Promise { + const superBlockSubPath = superBlockToFolderMap[superBlock]; + const newChallengeDir = path.resolve( + __dirname, + `../../curriculum/challenges/english/${superBlockSubPath}/${block}` + ); + if (!existsSync(newChallengeDir)) { + await withTrace(fs.mkdir, newChallengeDir); + } + return createDialogueFile({ + projectPath: newChallengeDir + '/' + }); +} + +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.A2English, + type: 'list', + choices: Object.values(languageSuperBlocks) + }, + { + name: 'block', + message: 'What is the dashed name (in kebab-case) for this block?', + validate: validateBlockName, + filter: (block: string) => { + return block.toLowerCase().trim(); + } + }, + { + name: 'title', + default: ({ block }: { block: string }) => block + }, + { + name: 'helpCategory', + message: 'Choose a help category', + default: 'English', + type: 'list', + choices: helpCategories + } +]) + .then( + async ({ superBlock, block, helpCategory, title }: CreateBlockArgs) => + await createLanguageBlock(superBlock, block, helpCategory, 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/create-project.ts b/tools/challenge-helper-scripts/create-project.ts index 025581ff89d..51372498ef1 100644 --- a/tools/challenge-helper-scripts/create-project.ts +++ b/tools/challenge-helper-scripts/create-project.ts @@ -10,7 +10,7 @@ import { superBlockToFolderMap } from '../../shared/config/curriculum'; import { createStepFile, validateBlockName } from './utils'; -import { Meta } from './helpers/project-metadata'; +import { getBaseMeta } from './helpers/get-base-meta'; const helpCategories = [ 'HTML-CSS', @@ -97,7 +97,7 @@ async function createMetaJson( challengeId: ObjectID ) { const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta'); - const newMeta = await parseJson('./base-meta.json'); + const newMeta = getBaseMeta('Step'); newMeta.name = title; newMeta.dashedName = block; newMeta.helpCategory = helpCategory; @@ -151,6 +151,7 @@ async function createFirstChallenge( if (!existsSync(newChallengeDir)) { await withTrace(fs.mkdir, newChallengeDir); } + // 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 = { diff --git a/tools/challenge-helper-scripts/create-quiz.ts b/tools/challenge-helper-scripts/create-quiz.ts index bb59585bc3a..7a83dcc0e49 100644 --- a/tools/challenge-helper-scripts/create-quiz.ts +++ b/tools/challenge-helper-scripts/create-quiz.ts @@ -10,7 +10,7 @@ import { superBlockToFolderMap } from '../../shared/config/curriculum'; import { createQuizFile, validateBlockName } from './utils'; -import { Meta } from './helpers/project-metadata'; +import { getBaseMeta } from './helpers/get-base-meta'; const helpCategories = [ 'HTML-CSS', @@ -90,7 +90,7 @@ async function createMetaJson( challengeId: ObjectID ) { const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta'); - const newMeta = await parseJson('./quiz-meta.json'); + const newMeta = getBaseMeta('Quiz'); newMeta.name = title; newMeta.dashedName = block; newMeta.helpCategory = helpCategory; @@ -146,14 +146,12 @@ async function createQuizChallenge( 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 diff --git a/tools/challenge-helper-scripts/helpers/get-base-meta.ts b/tools/challenge-helper-scripts/helpers/get-base-meta.ts new file mode 100644 index 00000000000..f0610297921 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-base-meta.ts @@ -0,0 +1,44 @@ +const baseMeta = { + name: '', + isUpcomingChange: true, + dashedName: '', + superBlock: '', + order: 42, + helpCategory: '', + challengeOrder: [ + { + id: '', + title: '' + } + ] +}; + +const stepMeta = { + ...baseMeta, + usesMultifileEditor: true, + hasEditableBoundaries: true +}; + +const quizMeta = { + ...baseMeta, + blockType: 'quiz', + blockLayout: 'link' +}; + +const languageMeta = { + ...baseMeta, + blockLayout: 'dialogue-grid' +}; + +export const getBaseMeta = (projectType: 'Step' | 'Quiz' | 'Language') => { + switch (projectType) { + case 'Step': + return stepMeta; + case 'Quiz': + return quizMeta; + case 'Language': + return languageMeta; + default: + return stepMeta; + } +}; diff --git a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts index 7f40f18375e..e96a0b9f03e 100644 --- a/tools/challenge-helper-scripts/helpers/get-challenge-template.ts +++ b/tools/challenge-helper-scripts/helpers/get-challenge-template.ts @@ -284,6 +284,50 @@ 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 + } + ] +} +\`\`\` `; type Template = (opts: ChallengeOptions) => string; diff --git a/tools/challenge-helper-scripts/package.json b/tools/challenge-helper-scripts/package.json index 78ad1700780..775f63fca9b 100644 --- a/tools/challenge-helper-scripts/package.json +++ b/tools/challenge-helper-scripts/package.json @@ -21,6 +21,7 @@ "scripts": { "test": "mocha --delay --reporter progress --bail", "create-project": "tsx create-project", + "create-language-block": "tsx create-language-block", "create-quiz": "tsx create-quiz" }, "devDependencies": { diff --git a/tools/challenge-helper-scripts/quiz-meta.json b/tools/challenge-helper-scripts/quiz-meta.json deleted file mode 100644 index a37c8fb05a1..00000000000 --- a/tools/challenge-helper-scripts/quiz-meta.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 254a60b6dc5..86124363393 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import ObjectID from 'bson-objectid'; import matter from 'gray-matter'; +import { challengeTypes } from '../../shared/config/challenge-types'; import { parseMDSync } from '../challenge-parser/parser'; import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getProjectPath } from './helpers/get-project-info'; @@ -21,7 +22,6 @@ interface Options { } interface QuizOptions { - challengeType: string; projectPath?: string; title: string; dashedName: string; @@ -60,13 +60,13 @@ const createChallengeFile = ( }; const createQuizFile = ({ - challengeType, projectPath = getProjectPath(), title, dashedName, questionCount }: QuizOptions): ObjectID => { const challengeId = new ObjectID(); + const challengeType = challengeTypes.quiz.toString(); const template = getTemplate(challengeType); const quizText = template({ @@ -81,6 +81,26 @@ const createQuizFile = ({ return challengeId; }; +const createDialogueFile = ({ + projectPath +}: { + projectPath: string; +}): ObjectID => { + const challengeId = new 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' + }); + // eslint-disable-next-line @typescript-eslint/no-base-to-string + fs.writeFileSync(`${projectPath}${challengeId.toString()}.md`, dialogueText); + return challengeId; +}; + interface InsertOptions { stepNum: number; stepId: ObjectID; @@ -247,6 +267,7 @@ const validateBlockName = (block: string): boolean | string => { export { createStepFile, + createDialogueFile, createChallengeFile, updateStepTitles, updateTaskMeta,