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, chapterBasedSuperBlocks } from '../../shared/config/curriculum'; import { BlockLayouts, BlockTypes } from '../../shared/config/blocks'; import { getContentConfig, writeBlockStructure } from '../../curriculum/src/file-handler'; import { superBlockToFilename } from '../../curriculum/src/build-curriculum'; import { createQuizFile, createStepFile, validateBlockName, getAllBlocks } from './utils'; import { getBaseMeta } from './helpers/get-base-meta'; import { createIntroMD } from './helpers/create-intro'; import { ChapterModuleSuperblockStructure, updateChapterModuleSuperblockStructure, updateSimpleSuperblockStructure } from './helpers/create-project'; const helpCategories = [ 'HTML-CSS', 'JavaScript', 'Backend Development', 'Python', 'English', 'Odin', 'Euler', 'Rosetta' ] as const; type BlockInfo = { title: string; intro: string[]; }; type SuperBlockInfo = { blocks: Record; }; type IntroJson = Record; interface CreateProjectArgs { superBlock: SuperBlocks; block: string; helpCategory: string; blockType?: 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 )[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, { order: position, 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 ); if (projectArgs.blockType === BlockTypes.quiz) { if (projectArgs.questionCount == null) { throw new Error( 'Property `questionCount` is null when creating new Quiz Challenge' ); } const challengeId = await createQuizChallenge( projectArgs.block, projectArgs.title, projectArgs.questionCount ); void createMetaJson( projectArgs.superBlock, projectArgs.block, projectArgs.title, projectArgs.helpCategory, challengeId ); } else { const challengeId = await createFirstChallenge(projectArgs.block); void createMetaJson( projectArgs.superBlock, projectArgs.block, projectArgs.title, projectArgs.helpCategory, challengeId, projectArgs.order, projectArgs.blockType, projectArgs.blockLayout ); // TODO: remove once we stop relying on markdown in the client. } if ( (chapterBasedSuperBlocks.includes(projectArgs.superBlock) && projectArgs.blockType) == null ) { throw new Error('Missing argument: blockType when updating intro markdown'); } void createIntroMD( projectArgs.superBlock, projectArgs.block, projectArgs.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: [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, blockType?: string, blockLayout?: string ) { let newMeta; if (chapterBasedSuperBlocks.includes(superBlock)) { newMeta = getBaseMeta('FullStack'); newMeta.blockType = blockType; newMeta.blockLayout = blockLayout; if (blockType === BlockTypes.workshop) { newMeta.hasEditableBoundaries = true; } } else { newMeta = getBaseMeta('Step'); newMeta.order = order; } newMeta.name = title; newMeta.dashedName = block; newMeta.helpCategory = helpCategory; // eslint-disable-next-line @typescript-eslint/no-base-to-string newMeta.challengeOrder = [{ id: challengeId.toString(), title: 'Step 1' }]; await writeBlockStructure(block, newMeta); } async function createFirstChallenge(block: string): Promise { const { blockContentDir } = getContentConfig('english') as { blockContentDir: string; }; const newChallengeDir = path.resolve(blockContentDir, block); await fs.mkdir(newChallengeDir, { recursive: true }); // 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 return createStepFile({ projectPath: newChallengeDir + '/', stepNum: 1, challengeType: 0, challengeSeeds, isFirstChallenge: true }); } async function createQuizChallenge( block: string, title: string, questionCount: number ): Promise { const newChallengeDir = path.resolve( __dirname, `../../curriculum/challenges/english/${block}` ); if (!existsSync(newChallengeDir)) { await withTrace(fs.mkdir, newChallengeDir); } return createQuizFile({ 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); }); } 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(existingBlocks => 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 project?', validate: (block: string) => validateBlockName(block, existingBlocks), 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: 'blockType', message: 'Choose a block type', default: BlockTypes.lab, type: 'list', choices: Object.values(BlockTypes), when: (answers: CreateProjectArgs) => chapterBasedSuperBlocks.includes(answers.superBlock) }, { name: 'blockLayout', message: 'Choose a block layout', default: (answers: { blockType: BlockTypes }) => answers.blockType == BlockTypes.quiz ? BlockLayouts.Link : BlockLayouts.ChallengeList, type: 'list', choices: Object.values(BlockLayouts), when: (answers: CreateProjectArgs) => chapterBasedSuperBlocks.includes(answers.superBlock) }, { name: 'questionCount', message: 'Choose a question count', default: 20, type: 'list', choices: [10, 20], when: (answers: CreateProjectArgs) => answers.blockType === BlockTypes.quiz }, { name: 'chapter', message: 'What chapter should this project go in?', default: 'html', type: 'list', choices: async (answers: CreateProjectArgs) => { const chapters = await getChapters(answers.superBlock); return chapters.map(x => x.dashedName); }, when: (answers: CreateProjectArgs) => chapterBasedSuperBlocks.includes(answers.superBlock) }, { name: 'module', message: 'What module should this project go in?', default: 'html', type: 'list', choices: async (answers: CreateProjectArgs) => { const modules = await getModules( answers.superBlock, answers.chapter! ); return modules!.map(x => x.dashedName); }, when: (answers: CreateProjectArgs) => chapterBasedSuperBlocks.includes(answers.superBlock) }, { name: 'position', message: 'At which position does this appear in the module?', default: 1, validate: (position: string) => { return parseInt(position, 10) > 0 ? true : 'Position must be an number greater than zero.'; }, when: (answers: CreateProjectArgs) => chapterBasedSuperBlocks.includes(answers.superBlock), filter: (position: string) => { return parseInt(position, 10); } }, { name: 'order', message: 'Which position does this appear in the certificate?', default: 42, validate: (order: string) => { return parseInt(order, 10) > 0 ? true : 'Order must be an number greater than zero.'; }, when: (answers: CreateProjectArgs) => !chapterBasedSuperBlocks.includes(answers.superBlock), filter: (order: string) => { return parseInt(order, 10); } } ]).then( async ({ superBlock, block, title, helpCategory, blockType, blockLayout, questionCount, chapter, module, position, order }: CreateProjectArgs) => await createProject({ superBlock, block, helpCategory, blockType, blockLayout, questionCount, title, chapter, module, position, order }) ) ) .then(() => console.log( 'All set. Now use pnpm run clean:client in the root and it should be good to go.' ) );