diff --git a/client/utils/build-challenges.js b/client/utils/build-challenges.js index 193a0c4a116..3bf1f6ad9c9 100644 --- a/client/utils/build-challenges.js +++ b/client/utils/build-challenges.js @@ -9,6 +9,7 @@ const { getContentDir, getBlockCreator } = require('../../curriculum/build-curriculum'); +const { getBlockStructure } = require('../../curriculum/file-handler'); const { curriculumLocale } = envData; @@ -23,7 +24,7 @@ exports.replaceChallengeNode = () => { const filename = path.basename(filePath); console.log(`Replacing challenge node for ${filePath}`); - const meta = blockCreator.getMetaForBlock(block); + const meta = getBlockStructure(block); return await blockCreator.createChallenge({ filename, diff --git a/curriculum/build-curriculum.js b/curriculum/build-curriculum.js index 47b81b1d6e7..a4d4c006ef5 100644 --- a/curriculum/build-curriculum.js +++ b/curriculum/build-curriculum.js @@ -1,6 +1,5 @@ const fs = require('fs'); const path = require('path'); -const assert = require('assert'); const { isEmpty } = require('lodash'); const debug = require('debug')('fcc:build-curriculum'); @@ -13,14 +12,13 @@ const { const { buildCertification } = require('./build-certification'); const { applyFilters } = require('./utils'); - -const CURRICULUM_DIR = __dirname; -const I18N_CURRICULUM_DIR = path.resolve( - CURRICULUM_DIR, - 'i18n-curriculum', - 'curriculum' -); -const STRUCTURE_DIR = path.resolve(CURRICULUM_DIR, 'structure'); +const { + getContentDir, + getLanguageConfig, + getCurriculumStructure, + getBlockStructure, + getSuperblockStructure +} = require('./file-handler'); /** * Creates a BlockCreator instance for a specific language with appropriate configuration @@ -35,7 +33,6 @@ const STRUCTURE_DIR = path.resolve(CURRICULUM_DIR, 'structure'); const getBlockCreator = (lang, skipValidation, opts) => { const { blockContentDir, - blockStructureDir, i18nBlockContentDir, dictionariesDir, i18nDictionariesDir @@ -47,7 +44,6 @@ const getBlockCreator = (lang, skipValidation, opts) => { return new BlockCreator({ lang, blockContentDir, - blockStructureDir, i18nBlockContentDir, commentTranslations: createCommentMap( dictionariesDir, @@ -194,84 +190,12 @@ const superBlockNames = { 'dev-playground': 'dev-playground' }; -/** - * Gets language-specific configuration paths for curriculum content - * @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) - * @param {Object} [options] - Optional configuration object with directory overrides - * @param {string} [options.baseDir] - Base directory for curriculum content (defaults to CURRICULUM_DIR) - * @param {string} [options.i18nBaseDir] - Base directory for i18n content (defaults to I18N_CURRICULUM_DIR) - * @param {string} [options.structureDir] - Directory for curriculum structure (defaults to STRUCTURE_DIR) - * @returns {Object} Object containing all relevant directory paths for the language - * @throws {AssertionError} When required i18n directories don't exist for non-English languages - */ -function getLanguageConfig( - lang, - { baseDir, i18nBaseDir, structureDir } = { - baseDir: CURRICULUM_DIR, - i18nBaseDir: I18N_CURRICULUM_DIR, - structureDir: STRUCTURE_DIR - } -) { - const contentDir = path.resolve(baseDir, 'challenges', 'english'); - const i18nContentDir = path.resolve(i18nBaseDir, 'challenges', lang); - const blockContentDir = path.resolve(contentDir, 'blocks'); - const i18nBlockContentDir = path.resolve(i18nContentDir, 'blocks'); - const blockStructureDir = path.resolve(structureDir, 'blocks'); - const dictionariesDir = path.resolve(baseDir, 'dictionaries'); - const i18nDictionariesDir = path.resolve(i18nBaseDir, 'dictionaries'); - - if (lang !== 'english') { - assert( - fs.existsSync(i18nContentDir), - `i18n content directory does not exist: ${i18nContentDir}` - ); - assert( - fs.existsSync(i18nBlockContentDir), - `i18n block content directory does not exist: ${i18nBlockContentDir}` - ); - assert( - fs.existsSync(i18nDictionariesDir), - `i18n dictionaries directory does not exist: ${i18nDictionariesDir}` - ); - } - - debug(`Using content directory: ${contentDir}`); - debug(`Using i18n content directory: ${i18nContentDir}`); - debug(`Using block content directory: ${blockContentDir}`); - debug(`Using i18n block content directory: ${i18nBlockContentDir}`); - debug(`Using dictionaries directory: ${dictionariesDir}`); - debug(`Using i18n dictionaries directory: ${i18nDictionariesDir}`); - - return { - contentDir, - i18nContentDir, - blockContentDir, - i18nBlockContentDir, - blockStructureDir, - dictionariesDir, - i18nDictionariesDir - }; -} - -/** - * Gets the appropriate content directory path for a given language - * @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) - * @returns {string} Path to the content directory for the specified language - */ -function getContentDir(lang) { - const { contentDir, i18nContentDir } = getLanguageConfig(lang); - - return lang === 'english' ? contentDir : i18nContentDir; -} - -const getCurriculumStructure = () => { - const curriculumPath = path.resolve(STRUCTURE_DIR, 'curriculum.json'); - if (!fs.existsSync(curriculumPath)) { - throw new Error(`Curriculum file not found: ${curriculumPath}`); - } - - return JSON.parse(fs.readFileSync(curriculumPath, 'utf8')); -}; +const superBlockToFilename = Object.entries(superBlockNames).reduce( + (map, entry) => { + return { ...map, [entry[1]]: entry[0] }; + }, + {} +); /** * Builds an array of superblock structures from a curriculum object @@ -304,26 +228,6 @@ function addSuperblockStructure(superblocks) { return superblockStructures; } -function getSuperblockStructure(superblock) { - const superblockPath = path.resolve( - STRUCTURE_DIR, - 'superblocks', - `${superblock}.json` - ); - - return JSON.parse(fs.readFileSync(superblockPath, 'utf8')); -} - -function getBlockStructure(block) { - const blockPath = path.resolve(STRUCTURE_DIR, 'blocks', `${block}.json`); - - try { - return JSON.parse(fs.readFileSync(blockPath, 'utf8')); - } catch { - console.warn('block missing', block); - } -} - function addBlockStructure( superblocks, _getBlockStructure = getBlockStructure @@ -383,5 +287,6 @@ module.exports = { getBlockCreator, getBlockStructure, getSuperblockStructure, - createCommentMap + createCommentMap, + superBlockToFilename }; diff --git a/curriculum/build-superblock.js b/curriculum/build-superblock.js index fe2087c48a2..7649a2ddd09 100644 --- a/curriculum/build-superblock.js +++ b/curriculum/build-superblock.js @@ -226,8 +226,6 @@ class BlockCreator { /** * @param {object} options - Options object * @param {string} options.blockContentDir - Directory containing block content files - * @param {string} options.blockStructureDir - Directory containing block structure files (meta - * .json) * @param {string} options.i18nBlockContentDir - Directory containing i18n block content files * @param {string} options.lang - Language code for the block content * @param {object} options.commentTranslations - Translations for comments in challenges @@ -238,14 +236,12 @@ class BlockCreator { */ constructor({ blockContentDir, - blockStructureDir, i18nBlockContentDir, lang, commentTranslations, skipValidation }) { this.blockContentDir = blockContentDir; - this.blockStructureDir = blockStructureDir; this.i18nBlockContentDir = i18nBlockContentDir; this.lang = lang; this.commentTranslations = commentTranslations; @@ -309,29 +305,6 @@ class BlockCreator { ); } - /** - * Gets meta information for a block from its JSON file - * @param {string} blockName - Name of the block - * @returns {object} The meta information object for the block - * @throws {Error} If meta file is not found - */ - getMetaForBlock(blockName) { - // Read meta.json for this block - const metaPath = path.resolve(this.blockStructureDir, `${blockName}.json`); - if (!fs.existsSync(metaPath)) { - throw new Error( - `Meta file not found for block ${blockName}: ${metaPath}` - ); - } - - // Not all "meta information" can be found in the meta.json. - const rawMeta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - debug( - `Meta file indicates ${rawMeta.challengeOrder.length} challenges should exist` - ); - return rawMeta; - } - async processBlock(block, { superBlock, order }) { const blockName = block.dashedName; debug(`Processing block ${blockName} in superblock ${superBlock}`); diff --git a/curriculum/file-handler.js b/curriculum/file-handler.js new file mode 100644 index 00000000000..7f2f3d7c9fa --- /dev/null +++ b/curriculum/file-handler.js @@ -0,0 +1,199 @@ +const path = require('node:path'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const fsP = require('node:fs/promises'); + +// const prettier = require('prettier'); + +const debug = require('debug')('fcc:file-handler'); + +const CURRICULUM_DIR = __dirname; +const I18N_CURRICULUM_DIR = path.resolve( + CURRICULUM_DIR, + 'i18n-curriculum', + 'curriculum' +); +const STRUCTURE_DIR = path.resolve(CURRICULUM_DIR, 'structure'); +const BLOCK_STRUCTURE_DIR = path.resolve(STRUCTURE_DIR, 'blocks'); + +/** + * Gets language-specific configuration paths for curriculum content + * @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) + * @param {Object} [options] - Optional configuration object with directory overrides + * @param {string} [options.baseDir] - Base directory for curriculum content (defaults to CURRICULUM_DIR) + * @param {string} [options.i18nBaseDir] - Base directory for i18n content (defaults to I18N_CURRICULUM_DIR) + * @param {string} [options.structureDir] - Directory for curriculum structure (defaults to STRUCTURE_DIR) + * @returns {Object} Object containing all relevant directory paths for the language + * @throws {AssertionError} When required i18n directories don't exist for non-English languages + */ +function getContentConfig( + lang, + { baseDir, i18nBaseDir } = { + baseDir: CURRICULUM_DIR, + i18nBaseDir: I18N_CURRICULUM_DIR + } +) { + const contentDir = path.resolve(baseDir, 'challenges', 'english'); + const i18nContentDir = path.resolve(i18nBaseDir, 'challenges', lang); + const blockContentDir = path.resolve(contentDir, 'blocks'); + const i18nBlockContentDir = path.resolve(i18nContentDir, 'blocks'); + const dictionariesDir = path.resolve(baseDir, 'dictionaries'); + const i18nDictionariesDir = path.resolve(i18nBaseDir, 'dictionaries'); + + if (lang !== 'english') { + assert( + fs.existsSync(i18nContentDir), + `i18n content directory does not exist: ${i18nContentDir}` + ); + assert( + fs.existsSync(i18nBlockContentDir), + `i18n block content directory does not exist: ${i18nBlockContentDir}` + ); + assert( + fs.existsSync(i18nDictionariesDir), + `i18n dictionaries directory does not exist: ${i18nDictionariesDir}` + ); + } + + debug(`Using content directory: ${contentDir}`); + debug(`Using i18n content directory: ${i18nContentDir}`); + debug(`Using block content directory: ${blockContentDir}`); + debug(`Using i18n block content directory: ${i18nBlockContentDir}`); + debug(`Using dictionaries directory: ${dictionariesDir}`); + debug(`Using i18n dictionaries directory: ${i18nDictionariesDir}`); + + return { + contentDir, + i18nContentDir, + blockContentDir, + i18nBlockContentDir, + dictionariesDir, + i18nDictionariesDir + }; +} + +/** + * Gets the appropriate content directory path for a given language + * @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) + * @returns {string} Path to the content directory for the specified language + */ +function getContentDir(lang) { + const { contentDir, i18nContentDir } = getContentConfig(lang); + + return lang === 'english' ? contentDir : i18nContentDir; +} + +function getCurriculumStructure() { + const curriculumPath = path.resolve(STRUCTURE_DIR, 'curriculum.json'); + if (!fs.existsSync(curriculumPath)) { + throw new Error(`Curriculum file not found: ${curriculumPath}`); + } + + return JSON.parse(fs.readFileSync(curriculumPath, 'utf8')); +} + +function getBlockStructurePath(block) { + return path.resolve(BLOCK_STRUCTURE_DIR, `${block}.json`); +} + +function getBlockStructure(block) { + return JSON.parse(fs.readFileSync(getBlockStructurePath(block), 'utf8')); +} + +async function writeBlockStructure(block, structure) { + // TODO: format with prettier (jest, at least this version, is not compatible + // with prettier) + const content = JSON.stringify(structure); + await fsP.writeFile(getBlockStructurePath(block), content, 'utf8'); +} + +async function writeSuperblockStructure(superblock, structure) { + const content = JSON.stringify(structure); + await fsP.writeFile(getSuperblockStructurePath(superblock), content); +} + +function getSuperblockStructure(superblockFilename) { + const superblockPath = getSuperblockStructurePath(superblockFilename); + + if (!fs.existsSync(superblockPath)) { + throw Error(`Superblock file not found: ${superblockPath}`); + } + + return JSON.parse(fs.readFileSync(superblockPath, 'utf8')); +} + +function getSuperblockStructurePath(superblockFilename) { + return path.resolve( + STRUCTURE_DIR, + 'superblocks', + `${superblockFilename}.json` + ); +} + +/** + * Gets language-specific configuration paths for curriculum content + * @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) + * @param {Object} [options] - Optional configuration object with directory overrides + * @param {string} [options.baseDir] - Base directory for curriculum content (defaults to CURRICULUM_DIR) + * @param {string} [options.i18nBaseDir] - Base directory for i18n content (defaults to I18N_CURRICULUM_DIR) + * @param {string} [options.structureDir] - Directory for curriculum structure (defaults to STRUCTURE_DIR) + * @returns {Object} Object containing all relevant directory paths for the language + * @throws {AssertionError} When required i18n directories don't exist for non-English languages + */ +function getLanguageConfig( + lang, + { baseDir, i18nBaseDir, structureDir } = { + baseDir: CURRICULUM_DIR, + i18nBaseDir: I18N_CURRICULUM_DIR, + structureDir: STRUCTURE_DIR + } +) { + const contentDir = path.resolve(baseDir, 'challenges', 'english'); + const i18nContentDir = path.resolve(i18nBaseDir, 'challenges', lang); + const blockContentDir = path.resolve(contentDir, 'blocks'); + const i18nBlockContentDir = path.resolve(i18nContentDir, 'blocks'); + const blockStructureDir = path.resolve(structureDir, 'blocks'); + const dictionariesDir = path.resolve(baseDir, 'dictionaries'); + const i18nDictionariesDir = path.resolve(i18nBaseDir, 'dictionaries'); + + if (lang !== 'english') { + assert( + fs.existsSync(i18nContentDir), + `i18n content directory does not exist: ${i18nContentDir}` + ); + assert( + fs.existsSync(i18nBlockContentDir), + `i18n block content directory does not exist: ${i18nBlockContentDir}` + ); + assert( + fs.existsSync(i18nDictionariesDir), + `i18n dictionaries directory does not exist: ${i18nDictionariesDir}` + ); + } + + debug(`Using content directory: ${contentDir}`); + debug(`Using i18n content directory: ${i18nContentDir}`); + debug(`Using block content directory: ${blockContentDir}`); + debug(`Using i18n block content directory: ${i18nBlockContentDir}`); + debug(`Using dictionaries directory: ${dictionariesDir}`); + debug(`Using i18n dictionaries directory: ${i18nDictionariesDir}`); + + return { + contentDir, + i18nContentDir, + blockContentDir, + i18nBlockContentDir, + blockStructureDir, + dictionariesDir, + i18nDictionariesDir + }; +} + +exports.getContentConfig = getContentConfig; +exports.getContentDir = getContentDir; +exports.getBlockStructure = getBlockStructure; +exports.getSuperblockStructure = getSuperblockStructure; +exports.getCurriculumStructure = getCurriculumStructure; +exports.writeBlockStructure = writeBlockStructure; +exports.writeSuperblockStructure = writeSuperblockStructure; +exports.getLanguageConfig = getLanguageConfig; diff --git a/curriculum/package.json b/curriculum/package.json index dbfad46e3b4..51f3b2de4ba 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -32,7 +32,6 @@ "delete-challenge": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge", "delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task", "lint": "tsx --tsconfig ../tsconfig.json lint-localized", - "repair-meta": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/repair-meta", "reorder-tasks": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks", "update-challenge-order": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order", "update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles", diff --git a/curriculum/test/test-challenges.js b/curriculum/test/test-challenges.js index 59235529479..76353cc22c8 100644 --- a/curriculum/test/test-challenges.js +++ b/curriculum/test/test-challenges.js @@ -37,9 +37,10 @@ const { prefixDoctype, helperVersion } = require('../../client/src/templates/Challenges/utils/frame'); -const { STRUCTURE_DIR, getBlockCreator } = require('../build-curriculum'); + const { curriculumSchemaValidator } = require('../schema/curriculum-schema'); const { validateMetaSchema } = require('../schema/meta-schema'); +const { getBlockStructure } = require('../file-handler'); const ChallengeTitles = require('./utils/challenge-titles'); const MongoIds = require('./utils/mongo-ids'); const createPseudoWorker = require('./utils/pseudo-worker'); @@ -168,10 +169,7 @@ async function setup() { // we can skip them. // TODO: omit certifications from the list of challenges if (dashedBlockName && !meta[dashedBlockName]) { - meta[dashedBlockName] = await getBlockCreator(lang).getMetaForBlock( - dashedBlockName, - STRUCTURE_DIR - ); + meta[dashedBlockName] = getBlockStructure(dashedBlockName); const result = validateMetaSchema(meta[dashedBlockName]); if (result.error) { diff --git a/shared/config/curriculum.ts b/shared/config/curriculum.ts index 0a4f42da272..de0332a8614 100644 --- a/shared/config/curriculum.ts +++ b/shared/config/curriculum.ts @@ -35,41 +35,6 @@ export enum SuperBlocks { DevPlayground = 'dev-playground' } -// Note that this object is used to create folderToSuperBlockMap object -export const superBlockToFolderMap = { - [SuperBlocks.RespWebDesign]: '01-responsive-web-design', - [SuperBlocks.JsAlgoDataStruct]: - '02-javascript-algorithms-and-data-structures', - [SuperBlocks.FrontEndDevLibs]: '03-front-end-development-libraries', - [SuperBlocks.DataVis]: '04-data-visualization', - [SuperBlocks.BackEndDevApis]: '05-back-end-development-and-apis', - [SuperBlocks.QualityAssurance]: '06-quality-assurance', - [SuperBlocks.SciCompPy]: '07-scientific-computing-with-python', - [SuperBlocks.DataAnalysisPy]: '08-data-analysis-with-python', - [SuperBlocks.InfoSec]: '09-information-security', - [SuperBlocks.CodingInterviewPrep]: '10-coding-interview-prep', - [SuperBlocks.MachineLearningPy]: '11-machine-learning-with-python', - [SuperBlocks.RelationalDb]: '13-relational-databases', - [SuperBlocks.RespWebDesignNew]: '14-responsive-web-design-22', - [SuperBlocks.JsAlgoDataStructNew]: - '15-javascript-algorithms-and-data-structures-22', - [SuperBlocks.TheOdinProject]: '16-the-odin-project', - [SuperBlocks.CollegeAlgebraPy]: '17-college-algebra-with-python', - [SuperBlocks.ProjectEuler]: '18-project-euler', - [SuperBlocks.FoundationalCSharp]: '19-foundational-c-sharp-with-microsoft', - [SuperBlocks.A2English]: '21-a2-english-for-developers', - [SuperBlocks.RosettaCode]: '22-rosetta-code', - [SuperBlocks.PythonForEverybody]: '23-python-for-everybody', - [SuperBlocks.B1English]: '24-b1-english-for-developers', - [SuperBlocks.FullStackDeveloper]: '25-front-end-development', - [SuperBlocks.A2Spanish]: '26-a2-professional-spanish', - [SuperBlocks.A2Chinese]: '27-a2-professional-chinese', - [SuperBlocks.BasicHtml]: '28-basic-html', - [SuperBlocks.SemanticHtml]: '29-semantic-html', - [SuperBlocks.A1Chinese]: '30-a1-professional-chinese', - [SuperBlocks.DevPlayground]: '99-dev-playground' -}; - export const languageSuperBlocks = [ SuperBlocks.A2English, SuperBlocks.B1English, diff --git a/tools/challenge-auditor/index.ts b/tools/challenge-auditor/index.ts index d8320279e34..8641570bf96 100644 --- a/tools/challenge-auditor/index.ts +++ b/tools/challenge-auditor/index.ts @@ -11,8 +11,7 @@ import { availableLangs } from '../../shared/config/i18n'; import { getChallengesForLang } from '../../curriculum/get-challenges'; import { SuperBlocks, - getAuditedSuperBlocks, - superBlockToFolderMap + getAuditedSuperBlocks } from '../../shared/config/curriculum'; // TODO: re-organise the types to a common 'types' folder that can be shared @@ -91,15 +90,17 @@ void (async () => { 'challenges', language ); - const auditedFiles = englishFilePaths.filter(file => - certs.some( - cert => - // we're not ready to audit the new curriculum yet - (cert !== SuperBlocks.JsAlgoDataStructNew || - process.env.SHOW_UPCOMING_CHANGES === 'true') && - file.startsWith(superBlockToFolderMap[cert]) - ) - ); + // TODO: decide if we need to audit files at all. + const auditedFiles = englishFilePaths; + // const auditedFiles = englishFilePaths.filter(file => + // certs.some( + // cert => + // // we're not ready to audit the new curriculum yet + // (cert !== SuperBlocks.JsAlgoDataStructNew || + // process.env.SHOW_UPCOMING_CHANGES === 'true') && + // file.startsWith(superBlockToFolderMap[cert]) + // ) + // ); const noMissingFiles = await auditChallengeFiles(auditedFiles, { langCurriculumDirectory }); diff --git a/tools/challenge-helper-scripts/commands.ts b/tools/challenge-helper-scripts/commands.ts index e851da5b49a..8bd416deed0 100644 --- a/tools/challenge-helper-scripts/commands.ts +++ b/tools/challenge-helper-scripts/commands.ts @@ -1,9 +1,6 @@ import fs from 'fs'; -import { SuperBlocks } from '../../shared/config/curriculum'; -import { challengeTypes } from '../../shared/config/challenge-types'; import { getProjectPath } from './helpers/get-project-info'; -import { getMetaData, updateMetaData } from './helpers/project-metadata'; -import { getChallengeOrderFromFileTree } from './helpers/get-challenge-order'; +import { getMetaData } from './helpers/project-metadata'; import { createStepFile, deleteStepFromMeta, @@ -12,7 +9,7 @@ import { updateStepTitles } from './utils'; -function deleteStep(stepNum: number): void { +async function deleteStep(stepNum: number): Promise { if (stepNum < 1) { throw Error('Step not deleted. Step num must be a number greater than 0.'); } @@ -27,13 +24,13 @@ function deleteStep(stepNum: number): void { const stepId = challengeOrder[stepNum - 1].id; fs.unlinkSync(`${getProjectPath()}${stepId}.md`); - deleteStepFromMeta({ stepNum }); + await deleteStepFromMeta({ stepNum }); updateStepTitles(); console.log(`Successfully deleted step #${stepNum}`); } -function insertStep(stepNum: number): void { +async function insertStep(stepNum: number): Promise { if (stepNum < 1) { throw Error('Step not inserted. New step number must be greater than 0.'); } @@ -45,11 +42,6 @@ function insertStep(stepNum: number): void { challengeOrder.length + 2 }.` ); - const challengeType = [SuperBlocks.SciCompPy].includes( - getMetaData().superBlock - ) - ? challengeTypes.python - : challengeTypes.html; const challengeSeeds = stepNum > 1 @@ -60,16 +52,15 @@ function insertStep(stepNum: number): void { const stepId = createStepFile({ stepNum, - challengeType, challengeSeeds }); - insertStepIntoMeta({ stepNum, stepId }); + await insertStepIntoMeta({ stepNum, stepId }); updateStepTitles(); console.log(`Successfully inserted new step #${stepNum}`); } -function createEmptySteps(num: number): void { +async function createEmptySteps(num: number): Promise { if (num < 1 || num > 1000) { throw Error( `No steps created. arg 'num' must be between 1 and 1000 inclusive` @@ -77,35 +68,11 @@ function createEmptySteps(num: number): void { } const nextStepNum = getMetaData().challengeOrder.length + 1; - const challengeType = [SuperBlocks.SciCompPy].includes( - getMetaData().superBlock - ) - ? challengeTypes.python - : challengeTypes.html; - for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) { - const stepId = createStepFile({ stepNum, challengeType }); - insertStepIntoMeta({ stepNum, stepId }); + const stepId = createStepFile({ stepNum }); + await insertStepIntoMeta({ stepNum, stepId }); } console.log(`Successfully added ${num} steps`); } -const repairMeta = async () => { - const sortByStepNum = (a: string, b: string) => - parseInt(a.split(' ')[1]) - parseInt(b.split(' ')[1]); - - const challengeOrder = await getChallengeOrderFromFileTree(); - if (!challengeOrder.every(({ title }) => /Step \d+/.test(title))) { - throw new Error( - 'You can only run this command on project-based blocks with step files.' - ); - } - const sortedChallengeOrder = challengeOrder.sort((a, b) => - sortByStepNum(a.title, b.title) - ); - const meta = getMetaData(); - meta.challengeOrder = sortedChallengeOrder; - updateMetaData(meta); -}; - -export { deleteStep, insertStep, createEmptySteps, repairMeta }; +export { deleteStep, insertStep, createEmptySteps }; diff --git a/tools/challenge-helper-scripts/create-empty-steps.ts b/tools/challenge-helper-scripts/create-empty-steps.ts index 801049657ad..32db1d3f4ee 100644 --- a/tools/challenge-helper-scripts/create-empty-steps.ts +++ b/tools/challenge-helper-scripts/create-empty-steps.ts @@ -1,6 +1,4 @@ import { getArgValue } from './helpers/get-arg-value'; import { createEmptySteps } from './commands'; -import { validateMetaData } from './helpers/project-metadata'; -validateMetaData(); -createEmptySteps(getArgValue(process.argv)); +void createEmptySteps(getArgValue(process.argv)); diff --git a/tools/challenge-helper-scripts/create-language-block.ts b/tools/challenge-helper-scripts/create-language-block.ts index 13cea2d6f27..2777cadd15b 100644 --- a/tools/challenge-helper-scripts/create-language-block.ts +++ b/tools/challenge-helper-scripts/create-language-block.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { prompt } from 'inquirer'; @@ -7,12 +6,17 @@ import ObjectID from 'bson-objectid'; import { SuperBlocks, - languageSuperBlocks, - superBlockToFolderMap + languageSuperBlocks } from '../../shared/config/curriculum'; -import { createDialogueFile, validateBlockName } from './utils'; +import { + getContentConfig, + writeBlockStructure +} from '../../curriculum/file-handler'; +import { superBlockToFilename } from '../../curriculum/build-curriculum'; import { getBaseMeta } from './helpers/get-base-meta'; import { createIntroMD } from './helpers/create-intro'; +import { createDialogueFile, validateBlockName } from './utils'; +import { updateSimpleSuperblockStructure } from './helpers/create-project'; const helpCategories = ['English'] as const; @@ -46,7 +50,11 @@ async function createLanguageBlock( await updateIntroJson(superBlock, block, title); const challengeId = await createDialogueChallenge(superBlock, block); - await createMetaJson(superBlock, block, title, helpCategory, challengeId); + await createMetaJson(block, title, helpCategory, challengeId); + const superblockFilename = ( + superBlockToFilename as Record + )[superBlock]; + void updateSimpleSuperblockStructure(block, { order: 0 }, superblockFilename); // TODO: remove once we stop relying on markdown in the client. await createIntroMD(superBlock, block, title); } @@ -73,46 +81,34 @@ async function updateIntroJson( } 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' }) - ); + await writeBlockStructure(block, newMeta); } 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); - } + const { blockContentDir } = getContentConfig('english') as { + blockContentDir: string; + }; + + const newChallengeDir = path.resolve(blockContentDir, block); + await fs.mkdir(newChallengeDir, { recursive: true }); + return createDialogueFile({ projectPath: newChallengeDir + '/' }); diff --git a/tools/challenge-helper-scripts/create-next-challenge.ts b/tools/challenge-helper-scripts/create-next-challenge.ts index 36fdbb6b32c..cc79f0d7517 100644 --- a/tools/challenge-helper-scripts/create-next-challenge.ts +++ b/tools/challenge-helper-scripts/create-next-challenge.ts @@ -22,7 +22,7 @@ const createNextChallenge = async () => { id: challengeId.toString(), title: options.title }); - updateMetaData(meta); + await updateMetaData(meta); }; void createNextChallenge(); diff --git a/tools/challenge-helper-scripts/create-next-step.ts b/tools/challenge-helper-scripts/create-next-step.ts index cc9f26e29ae..7364edab878 100644 --- a/tools/challenge-helper-scripts/create-next-step.ts +++ b/tools/challenge-helper-scripts/create-next-step.ts @@ -1,6 +1,4 @@ import { getLastStep } from './helpers/get-last-step-file-number'; import { insertStep } from './commands'; -import { validateMetaData } from './helpers/project-metadata'; -validateMetaData(); -insertStep(getLastStep().stepNum + 1); +void insertStep(getLastStep().stepNum + 1); diff --git a/tools/challenge-helper-scripts/create-next-task.ts b/tools/challenge-helper-scripts/create-next-task.ts index 422034c6740..a7361094fe5 100644 --- a/tools/challenge-helper-scripts/create-next-task.ts +++ b/tools/challenge-helper-scripts/create-next-task.ts @@ -2,11 +2,7 @@ import ObjectID from 'bson-objectid'; import { getTemplate } from './helpers/get-challenge-template'; import { newTaskPrompts } from './helpers/new-task-prompts'; import { getProjectPath } from './helpers/get-project-info'; -import { - getMetaData, - updateMetaData, - validateMetaData -} from './helpers/project-metadata'; +import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { createChallengeFile, updateTaskMeta, @@ -14,8 +10,6 @@ import { } from './utils'; const createNextTask = async () => { - validateMetaData(); - const { challengeType } = await newTaskPrompts(); // Placeholder title, to be replaced by updateTaskMarkdownFiles @@ -40,10 +34,10 @@ const createNextTask = async () => { id: challengeIdString, title: options.title }); - updateMetaData(meta); + await updateMetaData(meta); console.log(`Finished inserting task into 'meta.json' file.`); - updateTaskMeta(); + await updateTaskMeta(); console.log("Finished updating tasks in 'meta.json'."); updateTaskMarkdownFiles(); diff --git a/tools/challenge-helper-scripts/create-project.ts b/tools/challenge-helper-scripts/create-project.ts index 79ce960de03..66f73f57993 100644 --- a/tools/challenge-helper-scripts/create-project.ts +++ b/tools/challenge-helper-scripts/create-project.ts @@ -1,17 +1,19 @@ -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 { - SuperBlocks, - superBlockToFolderMap -} from '../../shared/config/curriculum'; + getContentConfig, + writeBlockStructure +} from '../../curriculum/file-handler'; +import { superBlockToFilename } from '../../curriculum/build-curriculum'; import { createStepFile, validateBlockName } from './utils'; import { getBaseMeta } from './helpers/get-base-meta'; import { createIntroMD } from './helpers/create-intro'; +import { updateSimpleSuperblockStructure } from './helpers/create-project'; const helpCategories = [ 'HTML-CSS', @@ -56,14 +58,15 @@ async function createProject( void updateIntroJson(superBlock, block, title); const challengeId = await createFirstChallenge(superBlock, block); - void createMetaJson( - superBlock, - block, - title, - helpCategory, - order, - challengeId - ); + void createMetaJson(block, title, helpCategory, challengeId); + const superblockFilename = ( + superBlockToFilename as Record + )[superBlock]; + // TODO: handle full-stack-developer (createProjects needs calling with a + // chapter and module name as well) + if (superBlock !== SuperBlocks.FullStackDeveloper) { + void updateSimpleSuperblockStructure(block, { order }, superblockFilename); + } // TODO: remove once we stop relying on markdown in the client. void createIntroMD(superBlock, block, title); } @@ -90,46 +93,31 @@ async function updateIntroJson( } async function createMetaJson( - superBlock: SuperBlocks, block: string, title: string, helpCategory: string, - order: number, challengeId: ObjectID ) { - const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta'); const newMeta = getBaseMeta('Step'); newMeta.name = title; newMeta.dashedName = block; newMeta.helpCategory = helpCategory; - newMeta.order = order; - newMeta.superBlock = superBlock; // eslint-disable-next-line @typescript-eslint/no-base-to-string newMeta.challengeOrder = [{ id: challengeId.toString(), title: 'Step 1' }]; - 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' }) - ); + await writeBlockStructure(block, newMeta); } async function createFirstChallenge( 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); - } + 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. diff --git a/tools/challenge-helper-scripts/create-quiz.ts b/tools/challenge-helper-scripts/create-quiz.ts index ea66fe1ebd4..b834bc6f20a 100644 --- a/tools/challenge-helper-scripts/create-quiz.ts +++ b/tools/challenge-helper-scripts/create-quiz.ts @@ -1,17 +1,19 @@ -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 { - SuperBlocks, - superBlockToFolderMap -} from '../../shared/config/curriculum'; + getContentConfig, + writeBlockStructure +} from '../../curriculum/file-handler'; +import { superBlockToFilename } from '../../curriculum/build-curriculum'; import { createQuizFile, validateBlockName } from './utils'; import { getBaseMeta } from './helpers/get-base-meta'; import { createIntroMD } from './helpers/create-intro'; +import { updateSimpleSuperblockStructure } from './helpers/create-project'; const helpCategories = [ 'HTML-CSS', @@ -57,7 +59,11 @@ async function createQuiz( title, questionCount ); - await createMetaJson(superBlock, block, title, helpCategory, challengeId); + await createMetaJson(block, title, helpCategory, challengeId); + const superblockFilename = ( + superBlockToFilename as Record + )[superBlock]; + void updateSimpleSuperblockStructure(block, { order: 0 }, superblockFilename); // TODO: remove once we stop relying on markdown in the client. await createIntroMD(superBlock, block, title); } @@ -84,30 +90,19 @@ async function updateIntroJson( } async function createMetaJson( - superBlock: SuperBlocks, block: string, title: string, helpCategory: string, challengeId: ObjectID ) { - const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta'); const newMeta = getBaseMeta('Quiz'); 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' }) - ); + await writeBlockStructure(block, newMeta); } async function createQuizChallenge( @@ -116,14 +111,13 @@ async function createQuizChallenge( title: string, questionCount: number ): Promise { - const superBlockSubPath = superBlockToFolderMap[superBlock]; - const newChallengeDir = path.resolve( - __dirname, - `../../curriculum/challenges/english/${superBlockSubPath}/${block}` - ); - if (!existsSync(newChallengeDir)) { - await withTrace(fs.mkdir, newChallengeDir); - } + const { blockContentDir } = getContentConfig('english') as { + blockContentDir: string; + }; + + const newChallengeDir = path.resolve(blockContentDir, block); + await fs.mkdir(newChallengeDir, { recursive: true }); + return createQuizFile({ projectPath: newChallengeDir + '/', title: title, diff --git a/tools/challenge-helper-scripts/create-this-challenge.ts b/tools/challenge-helper-scripts/create-this-challenge.ts index 2d851a6407c..d90cdca71fe 100644 --- a/tools/challenge-helper-scripts/create-this-challenge.ts +++ b/tools/challenge-helper-scripts/create-this-challenge.ts @@ -7,9 +7,14 @@ * you want that. */ import ObjectID from 'bson-objectid'; + +import { + getBlockStructure, + writeBlockStructure +} from '../../curriculum/file-handler'; import { createChallengeFile } from './utils'; import { getProjectPath } from './helpers/get-project-info'; -import { getMetaData, updateMetaData } from './helpers/project-metadata'; +import { getBlock, type Meta } from './helpers/project-metadata'; // eslint-disable-next-line @typescript-eslint/no-base-to-string const challengeId = new ObjectID().toString(); @@ -141,16 +146,18 @@ Watch the video const path = getProjectPath(); if ( - !/freeCodeCamp\/curriculum\/challenges\/english\/[^/]+\/[^/]+\/$/.test(path) + !/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/21-a2-english-for-developers/learn-greetings-in-your-first-day-at-the-office/' +For example: 'freeCodeCamp/curriculum/challenges/english/blocks/learn-greetings-in-your-first-day-at-the-office/' `); } -const meta = getMetaData(); +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. @@ -162,8 +169,6 @@ meta.challengeOrder.push({ title }); -// write the meta.json file -updateMetaData(meta); +void writeBlockStructure(block, meta); -// write the challenge file, the first argument is the filename createChallengeFile(challengeId, template, path); diff --git a/tools/challenge-helper-scripts/delete-challenge.ts b/tools/challenge-helper-scripts/delete-challenge.ts index a46d8cf3588..feb70cb157b 100644 --- a/tools/challenge-helper-scripts/delete-challenge.ts +++ b/tools/challenge-helper-scripts/delete-challenge.ts @@ -2,13 +2,12 @@ import { unlink } from 'fs/promises'; import { prompt } from 'inquirer'; import { getProjectPath } from './helpers/get-project-info'; import { getMetaData, updateMetaData } from './helpers/project-metadata'; -import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; import { getFileName } from './helpers/get-file-name'; const deleteChallenge = async () => { const path = getProjectPath(); - const challenges = getChallengeOrderFromMeta(); + const challenges = getMetaData().challengeOrder; const challengeToDelete = (await prompt({ name: 'id', @@ -32,7 +31,7 @@ const deleteChallenge = async () => { const meta = getMetaData(); meta.challengeOrder.splice(indexToDelete, 1); - updateMetaData(meta); + await updateMetaData(meta); }; void deleteChallenge(); diff --git a/tools/challenge-helper-scripts/delete-step.ts b/tools/challenge-helper-scripts/delete-step.ts index 2b4d26d9639..3790bf97338 100644 --- a/tools/challenge-helper-scripts/delete-step.ts +++ b/tools/challenge-helper-scripts/delete-step.ts @@ -1,6 +1,4 @@ import { deleteStep } from './commands'; import { getArgValue } from './helpers/get-arg-value'; -import { validateMetaData } from './helpers/project-metadata'; -validateMetaData(); -deleteStep(getArgValue(process.argv)); +void deleteStep(getArgValue(process.argv)); diff --git a/tools/challenge-helper-scripts/delete-task.ts b/tools/challenge-helper-scripts/delete-task.ts index b761882263b..2f8ac28fa78 100644 --- a/tools/challenge-helper-scripts/delete-task.ts +++ b/tools/challenge-helper-scripts/delete-task.ts @@ -1,21 +1,18 @@ import { unlink } from 'fs/promises'; import { prompt } from 'inquirer'; import { getProjectPath } from './helpers/get-project-info'; -import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; import { getFileName } from './helpers/get-file-name'; -import { validateMetaData } from './helpers/project-metadata'; import { deleteChallengeFromMeta, updateTaskMarkdownFiles, updateTaskMeta } from './utils'; import { isTaskChallenge } from './helpers/task-helpers'; +import { getMetaData } from './helpers/project-metadata'; const deleteTask = async () => { - validateMetaData(); - const path = getProjectPath(); - const challenges = getChallengeOrderFromMeta(); + const challenges = getMetaData().challengeOrder; const challengeToDelete = (await prompt({ name: 'id', @@ -39,11 +36,11 @@ const deleteTask = async () => { await unlink(`${path}${fileToDelete}`); console.log(`Finished deleting file: '${fileToDelete}'.`); - deleteChallengeFromMeta(indexToDelete); + await deleteChallengeFromMeta(indexToDelete); console.log(`Finished removing challenge from 'meta.json'.`); if (isTaskChallenge(challenges[indexToDelete].title)) { - updateTaskMeta(); + await updateTaskMeta(); console.log("Finished updating tasks in 'meta.json'."); updateTaskMarkdownFiles(); diff --git a/tools/challenge-helper-scripts/helpers/create-project.test.ts b/tools/challenge-helper-scripts/helpers/create-project.test.ts new file mode 100644 index 00000000000..b73cf92278e --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/create-project.test.ts @@ -0,0 +1,186 @@ +import { + getSuperblockStructure, + writeSuperblockStructure +} from '../../../curriculum/file-handler'; +import { + updateChapterModuleSuperblockStructure, + updateSimpleSuperblockStructure +} from './create-project'; + +jest.mock('../../../curriculum/file-handler'); + +const mockGetSuperblockStructure = + getSuperblockStructure as jest.MockedFunction; +const mockWriteSuperblockStructure = + writeSuperblockStructure as jest.MockedFunction< + typeof 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(() => { + jest.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'] + } + ); + }); +}); + +describe('updateChapterModuleSuperblockStructure', () => { + beforeEach(() => { + jest.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', + 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', + modules: [ + { + dashedName: 'module1c2', + blocks: ['block1m2c2'] + } + ] + } + ] + } + ); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/create-project.ts b/tools/challenge-helper-scripts/helpers/create-project.ts new file mode 100644 index 00000000000..de608cdc754 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/create-project.ts @@ -0,0 +1,94 @@ +// 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 '../../../curriculum/file-handler'; +import { insertInto } from './utils'; + +export async function updateSimpleSuperblockStructure( + block: string, + position: { order: number }, + superblockFilename: string +) { + const existing = getSuperblockStructure(superblockFilename) as { + blocks: string[]; + }; + const updated = { + blocks: insertInto(existing.blocks, position.order, block) + }; + await writeSuperblockStructure(superblockFilename, updated); +} + +function createNewChapter(chapter: string, module: string, block: string) { + return { + dashedName: chapter, + modules: [ + { + dashedName: module, + blocks: [block] + } + ] + }; +} + +function createNewModule(module: string, block: string) { + return { + dashedName: module, + blocks: [block] + }; +} + +type ChapterModuleSuperblockStructure = { + chapters: { + dashedName: string; + modules: { + dashedName: string; + 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); +} diff --git a/tools/challenge-helper-scripts/helpers/get-base-meta.ts b/tools/challenge-helper-scripts/helpers/get-base-meta.ts index f0610297921..0354b7f2741 100644 --- a/tools/challenge-helper-scripts/helpers/get-base-meta.ts +++ b/tools/challenge-helper-scripts/helpers/get-base-meta.ts @@ -2,9 +2,8 @@ const baseMeta = { name: '', isUpcomingChange: true, dashedName: '', - superBlock: '', - order: 42, helpCategory: '', + blockLayout: 'legacy-challenge-list', challengeOrder: [ { id: '', diff --git a/tools/challenge-helper-scripts/helpers/get-challenge-order.test.ts b/tools/challenge-helper-scripts/helpers/get-challenge-order.test.ts deleted file mode 100644 index 7787988e8f7..00000000000 --- a/tools/challenge-helper-scripts/helpers/get-challenge-order.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import fs from 'fs'; -import { join } from 'path'; - -import { - getChallengeOrderFromFileTree, - getChallengeOrderFromMeta -} from './get-challenge-order'; - -const basePath = join( - process.cwd(), - '__fixtures__' + process.env.JEST_WORKER_ID -); -const commonPath = join(basePath, 'curriculum', 'challenges'); - -const block = 'project-get-challenge-order'; -const metaPath = join(commonPath, '_meta', block); -const superBlockPath = join( - commonPath, - 'english', - 'superblock-get-challenge-order' -); -const projectPath = join(superBlockPath, block); - -describe('get-challenge-order helper', () => { - beforeEach(() => { - fs.mkdirSync(superBlockPath, { recursive: true }); - fs.mkdirSync(projectPath, { recursive: true }); - fs.mkdirSync(metaPath, { recursive: true }); - }); - describe('getChallengeOrderFromMeta helper', () => { - beforeEach(() => { - fs.writeFileSync( - join(projectPath, 'this-is-a-challenge.md'), - '---\nid: 1\ntitle: This is a Challenge\n---', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'what-a-cool-thing.md'), - '---\nid: 100\ntitle: What a Cool Thing\n---', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'i-dunno.md'), - '---\nid: 2\ntitle: I Dunno\n---' - ); - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{ - "id": "mock-id", - "challengeOrder": [{"id": "1", "title": "This title is wrong"}, {"id": "2", "title": "I Dunno"}, {"id": "100", "title": "What a Cool Thing"}]}`, - 'utf-8' - ); - }); - - it('should load the file order', () => { - process.env.CALLING_DIR = projectPath; - const challengeOrder = getChallengeOrderFromMeta(); - expect(challengeOrder).toEqual([ - { id: '1', title: 'This title is wrong' }, - { id: '2', title: 'I Dunno' }, - { id: '100', title: 'What a Cool Thing' } - ]); - }); - }); - - describe('getChallengeOrderFromFileTree helper', () => { - beforeEach(() => { - fs.writeFileSync( - join(projectPath, 'step-001.md'), - '---\nid: a8d97bd4c764e91f9d2bda01\ntitle: Step 1\n---', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-002.md'), - '---\nid: a6b0bb188d873cb2c8729495\ntitle: Step 2\n---', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-003.md'), - '---\nid: a5de63ebea8dbee56860f4f2\ntitle: Step 3\n---' - ); - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{ - "id": "mock-id", - "challengeOrder": [{"id": "a8d97bd4c764e91f9d2bda01", "title": "Step 1"}, {"id": "a6b0bb188d873cb2c8729495", "title": "Step 3"}, {"id": "a5de63ebea8dbee56860f4f2", "title": "Step 2"}]}`, - 'utf-8' - ); - }); - - it('should load the file order', async () => { - expect.assertions(1); - process.env.CALLING_DIR = projectPath; - const challengeOrder = await getChallengeOrderFromFileTree(); - expect(challengeOrder).toEqual([ - { id: 'a8d97bd4c764e91f9d2bda01', title: 'Step 1' }, - { id: 'a6b0bb188d873cb2c8729495', title: 'Step 2' }, - { id: 'a5de63ebea8dbee56860f4f2', title: 'Step 3' } - ]); - }); - }); - afterEach(() => { - delete process.env.CALLING_DIR; - try { - fs.rmSync(basePath, { recursive: true }); - } catch (err) { - console.log(err); - console.log('Could not remove fixtures folder.'); - } - }); -}); diff --git a/tools/challenge-helper-scripts/helpers/get-challenge-order.ts b/tools/challenge-helper-scripts/helpers/get-challenge-order.ts deleted file mode 100644 index 1b3aaffe114..00000000000 --- a/tools/challenge-helper-scripts/helpers/get-challenge-order.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readdir } from 'fs/promises'; -import { join } from 'path'; - -import matter from 'gray-matter'; - -import { getProjectPath } from './get-project-info'; -import { getMetaData } from './project-metadata'; - -export const getChallengeOrderFromFileTree = async (): Promise< - { id: string; title: string }[] -> => { - const path = getProjectPath(); - const fileList = await readdir(path); - const challengeOrder = fileList - .map(file => { - return matter.read(join(path, file)); - }) - .map(({ data }) => ({ - id: data.id as string, - title: data.title as string - })); - return challengeOrder; -}; - -export const getChallengeOrderFromMeta = (): { - id: string; - title: string; -}[] => { - const meta = getMetaData(); - return meta.challengeOrder.map(({ id, title }) => ({ - id, - title - })); -}; diff --git a/tools/challenge-helper-scripts/helpers/get-step-template.ts b/tools/challenge-helper-scripts/helpers/get-step-template.ts index 1767c8469ee..6b15b0c9d47 100644 --- a/tools/challenge-helper-scripts/helpers/get-step-template.ts +++ b/tools/challenge-helper-scripts/helpers/get-step-template.ts @@ -24,7 +24,7 @@ type StepOptions = { challengeId: ObjectID; challengeSeeds: Record; stepNum: number; - challengeType: number; + challengeType?: number; isFirstChallenge?: boolean; }; @@ -79,7 +79,7 @@ demoType: onClick` `--- id: ${challengeId.toString()} title: Step ${stepNum} -challengeType: ${challengeType} +challengeType: ${challengeType ?? 'placeholder'} dashedName: step-${stepNum}${demoString} --- diff --git a/tools/challenge-helper-scripts/helpers/project-metadata.test.ts b/tools/challenge-helper-scripts/helpers/project-metadata.test.ts index cf6bd25e2d5..dd6e1b972f8 100644 --- a/tools/challenge-helper-scripts/helpers/project-metadata.test.ts +++ b/tools/challenge-helper-scripts/helpers/project-metadata.test.ts @@ -1,202 +1,18 @@ -import fs from 'fs'; import { join } from 'path'; -import { - getMetaData, - getProjectMetaPath, - validateMetaData -} from './project-metadata'; +import { getBlockStructure } from '../../../curriculum/file-handler'; +import { getMetaData } from './project-metadata'; -const basePath = join( - process.cwd(), - '__fixtures__' + process.env.JEST_WORKER_ID -); -const commonPath = join(basePath, 'curriculum', 'challenges'); +jest.mock('../../../curriculum/file-handler'); -const block = 'project-project-metadata'; -const metaPath = join(commonPath, '_meta', block); -const superBlockPath = join( - commonPath, - 'english', - 'superblock-project-metadata' -); -const projectPath = join(superBlockPath, block); +const commonPath = join('curriculum', 'challenges', 'blocks'); +const block = 'block-name'; describe('project-metadata helper', () => { - beforeEach(() => { - fs.mkdirSync(superBlockPath, { recursive: true }); - fs.mkdirSync(projectPath, { recursive: true }); - fs.mkdirSync(metaPath, { recursive: true }); - }); - describe('getProjectMetaPath helper', () => { - it('should return the meta path', () => { - const expected = join(metaPath, 'meta.json'); - - process.env.CALLING_DIR = projectPath; - - expect(getProjectMetaPath()).toEqual(expected); - }); - }); - describe('getMetaData helper', () => { - beforeEach(() => { - fs.writeFileSync( - join(projectPath, 'step-001.md'), - 'Lorem ipsum...', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-002.md'), - 'Lorem ipsum...', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-003.md'), - 'Lorem ipsum...', - 'utf-8' - ); - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{ - "id": "mock-id", - "challengeOrder": [{"id": "1", "title": "Step 1"}, {"id": "2", "title": "Step 2"}, {"id": "1", "title": "Step 3"}]}`, - 'utf-8' - ); + it('should call getBlockStructure with the correct path', () => { + process.env.CALLING_DIR = join(commonPath, block); + getMetaData(); + expect(getBlockStructure).toHaveBeenCalledWith(block); }); - - it('should process requested file', () => { - const expected = { - id: 'mock-id', - challengeOrder: [ - { id: '1', title: 'Step 1' }, - { id: '2', title: 'Step 2' }, - { id: '1', title: 'Step 3' } - ] - }; - process.env.CALLING_DIR = projectPath; - expect(getMetaData()).toEqual(expected); - }); - - it('should throw if file is not found', () => { - process.env.CALLING_DIR = - 'curriculum/challenges/english/superblock/mick-priject'; - - const errorPath = join( - 'curriculum', - 'challenges', - '_meta', - 'mick-priject', - 'meta.json' - ); - expect(() => { - getMetaData(); - }).toThrowError( - new Error(`ENOENT: no such file or directory, open '${errorPath}'`) - ); - }); - }); - - describe('validateMetaData helper', () => { - it('should throw if a stepfile is missing', () => { - fs.writeFileSync( - join(projectPath, 'step-001.md'), - `--- -id: id-1 -title: Step 2 -challengeType: a -dashedName: step-2 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-003.md'), - `--- -id: id-3 -title: Step 3 -challengeType: c -dashedName: step-3 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{ - "id": "mock-id", - "challengeOrder": [{"id": "1", "title": "Step 1"}, {"id": "2", "title": "Step 2"}, {"id": "1", "title": "Step 3"}]}`, - 'utf-8' - ); - - process.env.CALLING_DIR = projectPath; - - expect(() => validateMetaData()).toThrow( - `The file -${projectPath}/1.md -does not exist, but is required by the challengeOrder of -${metaPath}/meta.json - -To fix this, you can rename the file containing id: 1 to 1.md -If there is no file for this id, then either the challengeOrder needs to be updated, or the file needs to be created. -` - ); - }); - - it('should throw if a step is present in the project, but not the meta', () => { - fs.writeFileSync( - join(projectPath, '1.md'), - `--- -id: id-1 -title: Step 2 -challengeType: a -dashedName: step-2 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, '2.md'), - `--- -id: id-2 -title: Step 1 -challengeType: b -dashedName: step-1 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, '3.md'), - `--- -id: id-3 -title: Step 3 -challengeType: c -dashedName: step-3 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{ - "id": "mock-id", - "challengeOrder": [{"id": "1", "title": "Step 1"}, {"id": "2", "title": "Step 2"}, {"id": "1", "title": "Step 3"}]}`, - 'utf-8' - ); - - process.env.CALLING_DIR = projectPath; - - expect(() => validateMetaData()).toThrow( - `File ${projectPath}/3.md should be in the meta.json's challengeOrder` - ); - }); - }); - afterEach(() => { - delete process.env.CALLING_DIR; - try { - fs.rmSync(basePath, { recursive: true }); - } catch (err) { - console.log(err); - console.log('Could not remove fixtures folder.'); - } }); }); diff --git a/tools/challenge-helper-scripts/helpers/project-metadata.ts b/tools/challenge-helper-scripts/helpers/project-metadata.ts index 7db46d69c00..92cd067b45e 100644 --- a/tools/challenge-helper-scripts/helpers/project-metadata.ts +++ b/tools/challenge-helper-scripts/helpers/project-metadata.ts @@ -1,7 +1,10 @@ -import fs from 'fs'; import path from 'path'; -import glob from 'glob'; -import { getProjectName, getProjectPath } from './get-project-info'; + +import { + getBlockStructure, + writeBlockStructure +} from '../../../curriculum/file-handler'; +import { getProjectPath } from './get-project-info'; export type Meta = { name: string; @@ -10,65 +13,24 @@ export type Meta = { isUpcomingChange: boolean; dashedName: string; helpCategory: string; - order: number; time: string; template: string; required: string[]; - superBlock: string; challengeOrder: { id: string; title: string }[]; }; -function getMetaData(): Meta { - const metaData = fs.readFileSync(getProjectMetaPath(), 'utf-8'); - return JSON.parse(metaData) as Meta; +function getMetaData() { + const block = getBlock(getProjectPath()); + return getBlockStructure(block) as Meta; } -function updateMetaData(newMetaData: Record): void { - fs.writeFileSync(getProjectMetaPath(), JSON.stringify(newMetaData, null, 2)); +function getBlock(filePath: string) { + return path.basename(filePath); } -function getProjectMetaPath(): string { - return path.join( - getProjectPath(), - '../../..', - '_meta', - getProjectName(), - 'meta.json' - ); +async function updateMetaData(newMetaData: Record) { + const block = getBlock(getProjectPath()); + await writeBlockStructure(block, newMetaData); } -// This (and everything else) should be async, but it's fast enough -// for the moment. -function validateMetaData(): void { - const { challengeOrder } = getMetaData(); - - // each step in the challengeOrder should correspond to a file - challengeOrder.forEach(({ id }) => { - const filePath = `${getProjectPath()}${id}.md`; - try { - fs.accessSync(filePath); - } catch (_e) { - throw new Error( - `The file -${filePath} -does not exist, but is required by the challengeOrder of -${getProjectMetaPath()} - -To fix this, you can rename the file containing id: ${id} to ${id}.md -If there is no file for this id, then either the challengeOrder needs to be updated, or the file needs to be created. -` - ); - } - }); - - // each file should have a corresponding step in the challengeOrder - glob.sync(`${getProjectPath()}/*.md`).forEach(file => { - const id = path.basename(file, '.md'); - if (!challengeOrder.find(({ id: stepId }) => stepId === id)) - throw new Error( - `File ${file} should be in the meta.json's challengeOrder` - ); - }); -} - -export { getMetaData, updateMetaData, getProjectMetaPath, validateMetaData }; +export { getMetaData, updateMetaData, getBlock }; diff --git a/tools/challenge-helper-scripts/helpers/utils.test.ts b/tools/challenge-helper-scripts/helpers/utils.test.ts new file mode 100644 index 00000000000..acf9223ec3e --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/utils.test.ts @@ -0,0 +1,36 @@ +import { insertInto } from './utils'; + +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]); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/utils.ts b/tools/challenge-helper-scripts/helpers/utils.ts new file mode 100644 index 00000000000..96e222c6719 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/utils.ts @@ -0,0 +1,8 @@ +export function insertInto(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; + }); +} diff --git a/tools/challenge-helper-scripts/insert-challenge.ts b/tools/challenge-helper-scripts/insert-challenge.ts index f03573a4bcf..7da25d9251f 100644 --- a/tools/challenge-helper-scripts/insert-challenge.ts +++ b/tools/challenge-helper-scripts/insert-challenge.ts @@ -5,14 +5,13 @@ import { newChallengePrompts } from './helpers/new-challenge-prompts'; import { getProjectPath } from './helpers/get-project-info'; import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { createChallengeFile } from './utils'; -import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; const insertChallenge = async () => { const path = getProjectPath(); const options = await newChallengePrompts(); - const challenges = getChallengeOrderFromMeta(); + const challenges = getMetaData().challengeOrder; const challengeAfter = await prompt<{ id: string }>({ name: 'id', @@ -38,7 +37,7 @@ const insertChallenge = async () => { id: challengeId.toString(), title: options.title }); - updateMetaData(meta); + await updateMetaData(meta); }; void insertChallenge(); diff --git a/tools/challenge-helper-scripts/insert-step.ts b/tools/challenge-helper-scripts/insert-step.ts index f8f339cca7f..068fb3d5aea 100644 --- a/tools/challenge-helper-scripts/insert-step.ts +++ b/tools/challenge-helper-scripts/insert-step.ts @@ -1,6 +1,4 @@ import { getArgValue } from './helpers/get-arg-value'; import { insertStep } from './commands'; -import { validateMetaData } from './helpers/project-metadata'; -validateMetaData(); -insertStep(getArgValue(process.argv)); +void insertStep(getArgValue(process.argv)); diff --git a/tools/challenge-helper-scripts/insert-task.ts b/tools/challenge-helper-scripts/insert-task.ts index 5944910b0dd..6a40fc8e5d9 100644 --- a/tools/challenge-helper-scripts/insert-task.ts +++ b/tools/challenge-helper-scripts/insert-task.ts @@ -3,19 +3,16 @@ import { prompt } from 'inquirer'; import { getTemplate } from './helpers/get-challenge-template'; import { newTaskPrompts } from './helpers/new-task-prompts'; import { getProjectPath } from './helpers/get-project-info'; -import { validateMetaData } from './helpers/project-metadata'; import { createChallengeFile, insertChallengeIntoMeta, updateTaskMeta, updateTaskMarkdownFiles } from './utils'; -import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; +import { getMetaData } from './helpers/project-metadata'; const insertChallenge = async () => { - validateMetaData(); - - const challenges = getChallengeOrderFromMeta(); + const challenges = getMetaData().challengeOrder; const challengeAfter = await prompt<{ id: string }>({ name: 'id', message: 'Which challenge should come AFTER this new one?', @@ -50,14 +47,14 @@ const insertChallenge = async () => { createChallengeFile(challengeIdString, challengeText, path); console.log('Finished creating new task markdown file.'); - insertChallengeIntoMeta({ + await insertChallengeIntoMeta({ index: indexToInsert, id: challengeId, title: newTaskTitle }); console.log(`Finished inserting task into 'meta.json' file.`); - updateTaskMeta(); + await updateTaskMeta(); console.log("Finished updating tasks in 'meta.json'."); updateTaskMarkdownFiles(); diff --git a/tools/challenge-helper-scripts/reorder-tasks.ts b/tools/challenge-helper-scripts/reorder-tasks.ts index 2d25e4165d7..a1655d16d04 100644 --- a/tools/challenge-helper-scripts/reorder-tasks.ts +++ b/tools/challenge-helper-scripts/reorder-tasks.ts @@ -1,10 +1,7 @@ -import { validateMetaData } from './helpers/project-metadata'; import { updateTaskMeta, updateTaskMarkdownFiles } from './utils'; -const reorderTasks = () => { - validateMetaData(); - - updateTaskMeta(); +const reorderTasks = async () => { + await updateTaskMeta(); console.log("Finished updating tasks in 'meta.json'."); updateTaskMarkdownFiles(); diff --git a/tools/challenge-helper-scripts/repair-meta.test.ts b/tools/challenge-helper-scripts/repair-meta.test.ts deleted file mode 100644 index 63352b54ffd..00000000000 --- a/tools/challenge-helper-scripts/repair-meta.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { join } from 'path'; -import fs from 'fs'; - -import { repairMeta } from './commands'; - -const basePath = join( - process.cwd(), - '__fixtures__' + process.env.JEST_WORKER_ID -); -const commonPath = join(basePath, 'curriculum', 'challenges'); - -const metaPath = join(commonPath, '_meta', 'project-repair-meta'); -const superBlockPath = join(commonPath, 'english', 'superblock-repair-meta'); -const projectPath = join(superBlockPath, 'project-repair-meta'); - -describe('Challenge utils helper scripts', () => { - beforeEach(() => { - process.env.CALLING_DIR = projectPath; - fs.mkdirSync(metaPath, { recursive: true }); - fs.mkdirSync(superBlockPath, { recursive: true }); - fs.mkdirSync(projectPath); - }); - - it('should restore the challenge order in the meta.json file', async () => { - fs.writeFileSync( - join(metaPath, 'meta.json'), - // all the challenges from step 1 to 30 in reverse order: - `{"challengeOrder": [${Array.from( - { length: 30 }, - (_, i) => `{"id": "id-${i + 1}", "title": "Step ${30 - i}"}` - ).join(',')}]}`, - 'utf-8' - ); - - // create all 30 challenges: - Array.from({ length: 30 }, (_, i) => { - fs.writeFileSync( - join(projectPath, `step-${i + 1}.md`), - `--- -id: id-${i + 1} -title: Step ${30 - i} ---- -`, - 'utf-8' - ); - }); - - // run the repair script: - await repairMeta(); - - // confirm that the meta.json file now has the correct challenge order: - const meta = JSON.parse( - fs.readFileSync(join(metaPath, 'meta.json'), 'utf-8') - ) as { challengeOrder: { id: string; title: string }[] }; - - expect(meta.challengeOrder).toEqual( - Array.from({ length: 30 }, (_, i) => ({ - id: `id-${30 - i}`, - title: `Step ${i + 1}` - })) - ); - }); - - afterEach(() => { - delete process.env.CALLING_DIR; - try { - fs.rmSync(basePath, { recursive: true }); - } catch (err) { - console.log(err); - console.log('Could not remove fixtures folder.'); - } - }); -}); diff --git a/tools/challenge-helper-scripts/repair-meta.ts b/tools/challenge-helper-scripts/repair-meta.ts deleted file mode 100644 index d5a55829a69..00000000000 --- a/tools/challenge-helper-scripts/repair-meta.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { repairMeta } from './commands'; - -void (() => repairMeta())(); diff --git a/tools/challenge-helper-scripts/update-challenge-order.ts b/tools/challenge-helper-scripts/update-challenge-order.ts index a66e039da36..c0af91180e8 100644 --- a/tools/challenge-helper-scripts/update-challenge-order.ts +++ b/tools/challenge-helper-scripts/update-challenge-order.ts @@ -1,10 +1,9 @@ import { prompt } from 'inquirer'; import { getMetaData, updateMetaData } from './helpers/project-metadata'; -import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; const updateChallengeOrder = async () => { - const oldChallengeOrder = getChallengeOrderFromMeta(); + const oldChallengeOrder = getMetaData().challengeOrder; console.log('Current challenge order is: '); console.table(oldChallengeOrder.map(({ title }) => ({ title }))); @@ -49,7 +48,7 @@ const updateChallengeOrder = async () => { const meta = getMetaData(); meta.challengeOrder = newChallengeOrder; - updateMetaData(meta); + await updateMetaData(meta); }; void (async () => await updateChallengeOrder())(); diff --git a/tools/challenge-helper-scripts/update-step-titles.ts b/tools/challenge-helper-scripts/update-step-titles.ts index 91dcaae74d8..8d47c10c17b 100644 --- a/tools/challenge-helper-scripts/update-step-titles.ts +++ b/tools/challenge-helper-scripts/update-step-titles.ts @@ -1,5 +1,3 @@ -import { validateMetaData } from './helpers/project-metadata'; import { updateStepTitles } from './utils'; -validateMetaData(); updateStepTitles(); diff --git a/tools/challenge-helper-scripts/utils.test.ts b/tools/challenge-helper-scripts/utils.test.ts index b65b1f18eca..ef455434c58 100644 --- a/tools/challenge-helper-scripts/utils.test.ts +++ b/tools/challenge-helper-scripts/utils.test.ts @@ -1,8 +1,21 @@ import fs from 'fs'; -import { join } from 'path'; -import ObjectID from 'bson-objectid'; -import glob from 'glob'; +import path, { join } from 'path'; import matter from 'gray-matter'; +import ObjectID from 'bson-objectid'; + +jest.mock('fs', () => { + return { + writeFileSync: jest.fn(), + readdirSync: jest.fn() + }; +}); + +jest.mock('gray-matter', () => { + return { + read: jest.fn(), + stringify: jest.fn() + }; +}); jest.mock('bson-objectid', () => { return jest.fn(() => ({ toString: () => mockChallengeId })); @@ -10,10 +23,20 @@ jest.mock('bson-objectid', () => { jest.mock('./helpers/get-step-template', () => { return { - getStepTemplate: jest.fn(() => 'Mock template...') + getStepTemplate: jest.fn() }; }); +const mockMeta = { + challengeOrder: [{ id: 'abc', title: 'mock title' }] +}; + +jest.mock('./helpers/project-metadata', () => ({ + // ...jest.requireActual('./helpers/project-metadata'), + getMetaData: jest.fn(() => mockMeta), + updateMetaData: jest.fn() +})); + const mockChallengeId = '60d35cf3fe32df2ce8e31b03'; import { getStepTemplate } from './helpers/get-step-template'; import { @@ -23,37 +46,26 @@ import { updateStepTitles, validateBlockName } from './utils'; +import { updateMetaData } from './helpers/project-metadata'; const basePath = join( process.cwd(), '__fixtures__' + process.env.JEST_WORKER_ID ); -const commonPath = join(basePath, 'curriculum', 'challenges'); +const commonPath = join(basePath, 'curriculum'); const block = 'utils-project'; -const metaPath = join(commonPath, '_meta', block); -const superBlockPath = join(commonPath, 'english', 'utils-superblock'); -const projectPath = join(superBlockPath, block); +const projectPath = join(commonPath, 'challenges', 'english', 'blocks', block); describe('Challenge utils helper scripts', () => { - beforeEach(() => { - fs.mkdirSync(superBlockPath, { recursive: true }); - fs.mkdirSync(projectPath, { recursive: true }); - fs.mkdirSync(metaPath, { recursive: true }); + afterEach(() => { + jest.clearAllMocks(); }); describe('createStepFile util', () => { it('should create next step and return its identifier', () => { - fs.writeFileSync( - join(projectPath, 'step-001.md'), - 'Lorem ipsum...', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'step-002.md'), - 'Lorem ipsum...', - 'utf-8' - ); process.env.CALLING_DIR = projectPath; + const mockTemplate = 'Mock template...'; + (getStepTemplate as jest.Mock).mockReturnValue(mockTemplate); const step = createStepFile({ stepNum: 3, challengeType: 0 @@ -66,15 +78,10 @@ describe('Challenge utils helper scripts', () => { // Internal tasks // - Should generate a template for the step that is being created expect(getStepTemplate).toHaveBeenCalledTimes(1); - - // - Should write a file with a given name and template - const files = glob.sync(`${projectPath}/*.md`); - - expect(files).toEqual([ + expect(fs.writeFileSync).toHaveBeenCalledWith( `${projectPath}/${mockChallengeId}.md`, - `${projectPath}/step-001.md`, - `${projectPath}/step-002.md` - ]); + mockTemplate + ); }); }); @@ -104,78 +111,31 @@ describe('Challenge utils helper scripts', () => { describe('createChallengeFile util', () => { it('should create the challenge', () => { - fs.writeFileSync( - join(projectPath, 'fake-challenge.md'), - 'Lorem ipsum...', - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'so-many-fakes.md'), - 'Lorem ipsum...', - 'utf-8' - ); - process.env.CALLING_DIR = projectPath; + const template = 'pretend this is a template'; - createChallengeFile('hi', 'pretend this is a template'); + createChallengeFile('hi', template); // - Should write a file with a given name and template - const files = glob.sync(`${projectPath}/*.md`); - - expect(files).toEqual([ - `${projectPath}/fake-challenge.md`, + expect(fs.writeFileSync).toHaveBeenCalledWith( `${projectPath}/hi.md`, - `${projectPath}/so-many-fakes.md` - ]); + template + ); }); }); describe('insertStepIntoMeta util', () => { - it('should update the meta with a new file id and name', () => { - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{"id": "mock-id", - "challengeOrder": [ - { - "id": "id-1", - "title": "Step 1" - }, - { - "id": "id-2", - "title": "Step 2" - }, - { - "id": "id-3", - "title": "Step 3" - } - ]}`, - 'utf-8' - ); + it('should call updateMetaData with a new file id and name', async () => { process.env.CALLING_DIR = projectPath; - insertStepIntoMeta({ stepNum: 3, stepId: new ObjectID(mockChallengeId) }); + await insertStepIntoMeta({ + stepNum: 3, + stepId: new ObjectID(mockChallengeId) + }); - const meta = JSON.parse( - fs.readFileSync(join(metaPath, 'meta.json'), 'utf-8') - ); - expect(meta).toEqual({ - id: 'mock-id', + expect(updateMetaData).toHaveBeenCalledWith({ challengeOrder: [ - { - id: 'id-1', - title: 'Step 1' - }, - { - id: 'id-2', - title: 'Step 2' - }, - { - id: mockChallengeId, - title: 'Step 3' - }, - { - id: 'id-3', - title: 'Step 4' - } + { id: 'abc', title: 'Step 1' }, // title gets overwritten + { id: mockChallengeId, title: 'Step 2' } ] }); }); @@ -183,76 +143,36 @@ describe('Challenge utils helper scripts', () => { describe('updateStepTitles util', () => { it('should apply meta.challengeOrder to step files', () => { - fs.writeFileSync( - join(metaPath, 'meta.json'), - `{"id": "mock-id", "challengeOrder": [{"id": "id-1", "title": "Step 1"}, {"id": "id-3", "title": "Step 2"}, {"id": "id-2", "title": "Step 3"}]}`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'id-1.md'), - `--- -id: id-1 -title: Step 2 -challengeType: a -dashedName: step-2 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'id-2.md'), - `--- -id: id-2 -title: Step 1 -challengeType: b -dashedName: step-1 ---- -`, - 'utf-8' - ); - fs.writeFileSync( - join(projectPath, 'id-3.md'), - `--- -id: id-3 -title: Step 3 -challengeType: c -dashedName: step-3 ---- -`, - 'utf-8' - ); - process.env.CALLING_DIR = projectPath; + (getStepTemplate as jest.Mock).mockReturnValue('Mock template...'); + (fs.readdirSync as jest.Mock).mockReturnValue([ + 'name.md', + 'another-name.md' + ]); + (matter.read as jest.Mock).mockReturnValue({ + data: { id: 'abc' }, + content: 'goes here' + }); updateStepTitles(); - expect(matter.read(join(projectPath, 'id-1.md')).data).toEqual({ - id: 'id-1', - title: 'Step 1', - challengeType: 'a', - dashedName: 'step-1' - }); - expect(matter.read(join(projectPath, 'id-2.md')).data).toEqual({ - id: 'id-2', - title: 'Step 3', - challengeType: 'b', - dashedName: 'step-3' - }); - expect(matter.read(join(projectPath, 'id-3.md')).data).toEqual({ - id: 'id-3', - title: 'Step 2', - challengeType: 'c', - dashedName: 'step-2' + 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.CALLING_DIR; - try { - fs.rmSync(basePath, { recursive: true }); - } catch (err) { - console.log(err); - console.log('Could not remove fixtures folder.'); - } }); }); diff --git a/tools/challenge-helper-scripts/utils.ts b/tools/challenge-helper-scripts/utils.ts index 86124363393..1b4f0e49b81 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -15,7 +15,7 @@ import { getTemplate } from './helpers/get-challenge-template'; interface Options { stepNum: number; - challengeType: number; + challengeType?: number; projectPath?: string; challengeSeeds?: Record; isFirstChallenge?: boolean; @@ -112,20 +112,20 @@ interface InsertChallengeOptions { title: string; } -function insertChallengeIntoMeta({ +async function insertChallengeIntoMeta({ index, id, title -}: InsertChallengeOptions): void { +}: InsertChallengeOptions) { const existingMeta = getMetaData(); const challengeOrder = [...existingMeta.challengeOrder]; // eslint-disable-next-line @typescript-eslint/no-base-to-string challengeOrder.splice(index, 0, { id: id.toString(), title }); - updateMetaData({ ...existingMeta, challengeOrder }); + await updateMetaData({ ...existingMeta, challengeOrder }); } -function insertStepIntoMeta({ stepNum, stepId }: InsertOptions): void { +async function insertStepIntoMeta({ stepNum, stepId }: InsertOptions) { const existingMeta = getMetaData(); const oldOrder = [...existingMeta.challengeOrder]; // eslint-disable-next-line @typescript-eslint/no-base-to-string @@ -136,10 +136,10 @@ function insertStepIntoMeta({ stepNum, stepId }: InsertOptions): void { title: `Step ${index + 1}` })); - updateMetaData({ ...existingMeta, challengeOrder }); + await updateMetaData({ ...existingMeta, challengeOrder }); } -function deleteStepFromMeta({ stepNum }: { stepNum: number }): void { +async function deleteStepFromMeta({ stepNum }: { stepNum: number }) { const existingMeta = getMetaData(); const oldOrder = [...existingMeta.challengeOrder]; oldOrder.splice(stepNum - 1, 1); @@ -149,17 +149,17 @@ function deleteStepFromMeta({ stepNum }: { stepNum: number }): void { title: `Step ${index + 1}` })); - updateMetaData({ ...existingMeta, challengeOrder }); + await updateMetaData({ ...existingMeta, challengeOrder }); } -function deleteChallengeFromMeta(challengeIndex: number): void { +async function deleteChallengeFromMeta(challengeIndex: number) { const existingMeta = getMetaData(); const challengeOrder = [...existingMeta.challengeOrder]; challengeOrder.splice(challengeIndex, 1); - updateMetaData({ ...existingMeta, challengeOrder }); + await updateMetaData({ ...existingMeta, challengeOrder }); } -function updateTaskMeta() { +async function updateTaskMeta() { const existingMeta = getMetaData(); const oldOrder = [...existingMeta.challengeOrder]; @@ -176,7 +176,7 @@ function updateTaskMeta() { } }); - updateMetaData({ ...existingMeta, challengeOrder }); + await updateMetaData({ ...existingMeta, challengeOrder }); } const updateStepTitles = (): void => {