fix(tools): curriculum command line helpers (#61831)

This commit is contained in:
Oliver Eyton-Williams
2025-09-02 16:03:28 +02:00
committed by GitHub
parent c58ba56eeb
commit 10c565828e
40 changed files with 773 additions and 1010 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ const {
getContentDir, getContentDir,
getBlockCreator getBlockCreator
} = require('../../curriculum/build-curriculum'); } = require('../../curriculum/build-curriculum');
const { getBlockStructure } = require('../../curriculum/file-handler');
const { curriculumLocale } = envData; const { curriculumLocale } = envData;
@@ -23,7 +24,7 @@ exports.replaceChallengeNode = () => {
const filename = path.basename(filePath); const filename = path.basename(filePath);
console.log(`Replacing challenge node for ${filePath}`); console.log(`Replacing challenge node for ${filePath}`);
const meta = blockCreator.getMetaForBlock(block); const meta = getBlockStructure(block);
return await blockCreator.createChallenge({ return await blockCreator.createChallenge({
filename, filename,
+15 -110
View File
@@ -1,6 +1,5 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const assert = require('assert');
const { isEmpty } = require('lodash'); const { isEmpty } = require('lodash');
const debug = require('debug')('fcc:build-curriculum'); const debug = require('debug')('fcc:build-curriculum');
@@ -13,14 +12,13 @@ const {
const { buildCertification } = require('./build-certification'); const { buildCertification } = require('./build-certification');
const { applyFilters } = require('./utils'); const { applyFilters } = require('./utils');
const {
const CURRICULUM_DIR = __dirname; getContentDir,
const I18N_CURRICULUM_DIR = path.resolve( getLanguageConfig,
CURRICULUM_DIR, getCurriculumStructure,
'i18n-curriculum', getBlockStructure,
'curriculum' getSuperblockStructure
); } = require('./file-handler');
const STRUCTURE_DIR = path.resolve(CURRICULUM_DIR, 'structure');
/** /**
* Creates a BlockCreator instance for a specific language with appropriate configuration * 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 getBlockCreator = (lang, skipValidation, opts) => {
const { const {
blockContentDir, blockContentDir,
blockStructureDir,
i18nBlockContentDir, i18nBlockContentDir,
dictionariesDir, dictionariesDir,
i18nDictionariesDir i18nDictionariesDir
@@ -47,7 +44,6 @@ const getBlockCreator = (lang, skipValidation, opts) => {
return new BlockCreator({ return new BlockCreator({
lang, lang,
blockContentDir, blockContentDir,
blockStructureDir,
i18nBlockContentDir, i18nBlockContentDir,
commentTranslations: createCommentMap( commentTranslations: createCommentMap(
dictionariesDir, dictionariesDir,
@@ -194,84 +190,12 @@ const superBlockNames = {
'dev-playground': 'dev-playground' 'dev-playground': 'dev-playground'
}; };
/** const superBlockToFilename = Object.entries(superBlockNames).reduce(
* Gets language-specific configuration paths for curriculum content (map, entry) => {
* @param {string} lang - The language code (e.g., 'english', 'spanish', etc.) return { ...map, [entry[1]]: entry[0] };
* @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'));
};
/** /**
* Builds an array of superblock structures from a curriculum object * Builds an array of superblock structures from a curriculum object
@@ -304,26 +228,6 @@ function addSuperblockStructure(superblocks) {
return superblockStructures; 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( function addBlockStructure(
superblocks, superblocks,
_getBlockStructure = getBlockStructure _getBlockStructure = getBlockStructure
@@ -383,5 +287,6 @@ module.exports = {
getBlockCreator, getBlockCreator,
getBlockStructure, getBlockStructure,
getSuperblockStructure, getSuperblockStructure,
createCommentMap createCommentMap,
superBlockToFilename
}; };
-27
View File
@@ -226,8 +226,6 @@ class BlockCreator {
/** /**
* @param {object} options - Options object * @param {object} options - Options object
* @param {string} options.blockContentDir - Directory containing block content files * @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.i18nBlockContentDir - Directory containing i18n block content files
* @param {string} options.lang - Language code for the block content * @param {string} options.lang - Language code for the block content
* @param {object} options.commentTranslations - Translations for comments in challenges * @param {object} options.commentTranslations - Translations for comments in challenges
@@ -238,14 +236,12 @@ class BlockCreator {
*/ */
constructor({ constructor({
blockContentDir, blockContentDir,
blockStructureDir,
i18nBlockContentDir, i18nBlockContentDir,
lang, lang,
commentTranslations, commentTranslations,
skipValidation skipValidation
}) { }) {
this.blockContentDir = blockContentDir; this.blockContentDir = blockContentDir;
this.blockStructureDir = blockStructureDir;
this.i18nBlockContentDir = i18nBlockContentDir; this.i18nBlockContentDir = i18nBlockContentDir;
this.lang = lang; this.lang = lang;
this.commentTranslations = commentTranslations; 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 }) { async processBlock(block, { superBlock, order }) {
const blockName = block.dashedName; const blockName = block.dashedName;
debug(`Processing block ${blockName} in superblock ${superBlock}`); debug(`Processing block ${blockName} in superblock ${superBlock}`);
+199
View File
@@ -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;
-1
View File
@@ -32,7 +32,6 @@
"delete-challenge": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge", "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", "delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task",
"lint": "tsx --tsconfig ../tsconfig.json lint-localized", "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", "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-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", "update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
+3 -5
View File
@@ -37,9 +37,10 @@ const {
prefixDoctype, prefixDoctype,
helperVersion helperVersion
} = require('../../client/src/templates/Challenges/utils/frame'); } = require('../../client/src/templates/Challenges/utils/frame');
const { STRUCTURE_DIR, getBlockCreator } = require('../build-curriculum');
const { curriculumSchemaValidator } = require('../schema/curriculum-schema'); const { curriculumSchemaValidator } = require('../schema/curriculum-schema');
const { validateMetaSchema } = require('../schema/meta-schema'); const { validateMetaSchema } = require('../schema/meta-schema');
const { getBlockStructure } = require('../file-handler');
const ChallengeTitles = require('./utils/challenge-titles'); const ChallengeTitles = require('./utils/challenge-titles');
const MongoIds = require('./utils/mongo-ids'); const MongoIds = require('./utils/mongo-ids');
const createPseudoWorker = require('./utils/pseudo-worker'); const createPseudoWorker = require('./utils/pseudo-worker');
@@ -168,10 +169,7 @@ async function setup() {
// we can skip them. // we can skip them.
// TODO: omit certifications from the list of challenges // TODO: omit certifications from the list of challenges
if (dashedBlockName && !meta[dashedBlockName]) { if (dashedBlockName && !meta[dashedBlockName]) {
meta[dashedBlockName] = await getBlockCreator(lang).getMetaForBlock( meta[dashedBlockName] = getBlockStructure(dashedBlockName);
dashedBlockName,
STRUCTURE_DIR
);
const result = validateMetaSchema(meta[dashedBlockName]); const result = validateMetaSchema(meta[dashedBlockName]);
if (result.error) { if (result.error) {
-35
View File
@@ -35,41 +35,6 @@ export enum SuperBlocks {
DevPlayground = 'dev-playground' 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 = [ export const languageSuperBlocks = [
SuperBlocks.A2English, SuperBlocks.A2English,
SuperBlocks.B1English, SuperBlocks.B1English,
+12 -11
View File
@@ -11,8 +11,7 @@ import { availableLangs } from '../../shared/config/i18n';
import { getChallengesForLang } from '../../curriculum/get-challenges'; import { getChallengesForLang } from '../../curriculum/get-challenges';
import { import {
SuperBlocks, SuperBlocks,
getAuditedSuperBlocks, getAuditedSuperBlocks
superBlockToFolderMap
} from '../../shared/config/curriculum'; } from '../../shared/config/curriculum';
// TODO: re-organise the types to a common 'types' folder that can be shared // TODO: re-organise the types to a common 'types' folder that can be shared
@@ -91,15 +90,17 @@ void (async () => {
'challenges', 'challenges',
language language
); );
const auditedFiles = englishFilePaths.filter(file => // TODO: decide if we need to audit files at all.
certs.some( const auditedFiles = englishFilePaths;
cert => // const auditedFiles = englishFilePaths.filter(file =>
// we're not ready to audit the new curriculum yet // certs.some(
(cert !== SuperBlocks.JsAlgoDataStructNew || // cert =>
process.env.SHOW_UPCOMING_CHANGES === 'true') && // // we're not ready to audit the new curriculum yet
file.startsWith(superBlockToFolderMap[cert]) // (cert !== SuperBlocks.JsAlgoDataStructNew ||
) // process.env.SHOW_UPCOMING_CHANGES === 'true') &&
); // file.startsWith(superBlockToFolderMap[cert])
// )
// );
const noMissingFiles = await auditChallengeFiles(auditedFiles, { const noMissingFiles = await auditChallengeFiles(auditedFiles, {
langCurriculumDirectory langCurriculumDirectory
}); });
+9 -42
View File
@@ -1,9 +1,6 @@
import fs from 'fs'; 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 { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromFileTree } from './helpers/get-challenge-order';
import { import {
createStepFile, createStepFile,
deleteStepFromMeta, deleteStepFromMeta,
@@ -12,7 +9,7 @@ import {
updateStepTitles updateStepTitles
} from './utils'; } from './utils';
function deleteStep(stepNum: number): void { async function deleteStep(stepNum: number): Promise<void> {
if (stepNum < 1) { if (stepNum < 1) {
throw Error('Step not deleted. Step num must be a number greater than 0.'); 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; const stepId = challengeOrder[stepNum - 1].id;
fs.unlinkSync(`${getProjectPath()}${stepId}.md`); fs.unlinkSync(`${getProjectPath()}${stepId}.md`);
deleteStepFromMeta({ stepNum }); await deleteStepFromMeta({ stepNum });
updateStepTitles(); updateStepTitles();
console.log(`Successfully deleted step #${stepNum}`); console.log(`Successfully deleted step #${stepNum}`);
} }
function insertStep(stepNum: number): void { async function insertStep(stepNum: number): Promise<void> {
if (stepNum < 1) { if (stepNum < 1) {
throw Error('Step not inserted. New step number must be greater than 0.'); 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 challengeOrder.length + 2
}.` }.`
); );
const challengeType = [SuperBlocks.SciCompPy].includes(
getMetaData().superBlock
)
? challengeTypes.python
: challengeTypes.html;
const challengeSeeds = const challengeSeeds =
stepNum > 1 stepNum > 1
@@ -60,16 +52,15 @@ function insertStep(stepNum: number): void {
const stepId = createStepFile({ const stepId = createStepFile({
stepNum, stepNum,
challengeType,
challengeSeeds challengeSeeds
}); });
insertStepIntoMeta({ stepNum, stepId }); await insertStepIntoMeta({ stepNum, stepId });
updateStepTitles(); updateStepTitles();
console.log(`Successfully inserted new step #${stepNum}`); console.log(`Successfully inserted new step #${stepNum}`);
} }
function createEmptySteps(num: number): void { async function createEmptySteps(num: number): Promise<void> {
if (num < 1 || num > 1000) { if (num < 1 || num > 1000) {
throw Error( throw Error(
`No steps created. arg 'num' must be between 1 and 1000 inclusive` `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 nextStepNum = getMetaData().challengeOrder.length + 1;
const challengeType = [SuperBlocks.SciCompPy].includes(
getMetaData().superBlock
)
? challengeTypes.python
: challengeTypes.html;
for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) { for (let stepNum = nextStepNum; stepNum < nextStepNum + num; stepNum++) {
const stepId = createStepFile({ stepNum, challengeType }); const stepId = createStepFile({ stepNum });
insertStepIntoMeta({ stepNum, stepId }); await insertStepIntoMeta({ stepNum, stepId });
} }
console.log(`Successfully added ${num} steps`); console.log(`Successfully added ${num} steps`);
} }
const repairMeta = async () => { export { deleteStep, insertStep, createEmptySteps };
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 };
@@ -1,6 +1,4 @@
import { getArgValue } from './helpers/get-arg-value'; import { getArgValue } from './helpers/get-arg-value';
import { createEmptySteps } from './commands'; import { createEmptySteps } from './commands';
import { validateMetaData } from './helpers/project-metadata';
validateMetaData(); void createEmptySteps(getArgValue(process.argv));
createEmptySteps(getArgValue(process.argv));
@@ -1,4 +1,3 @@
import { existsSync } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
@@ -7,12 +6,17 @@ import ObjectID from 'bson-objectid';
import { import {
SuperBlocks, SuperBlocks,
languageSuperBlocks, languageSuperBlocks
superBlockToFolderMap
} from '../../shared/config/curriculum'; } 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 { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro'; import { createIntroMD } from './helpers/create-intro';
import { createDialogueFile, validateBlockName } from './utils';
import { updateSimpleSuperblockStructure } from './helpers/create-project';
const helpCategories = ['English'] as const; const helpCategories = ['English'] as const;
@@ -46,7 +50,11 @@ async function createLanguageBlock(
await updateIntroJson(superBlock, block, title); await updateIntroJson(superBlock, block, title);
const challengeId = await createDialogueChallenge(superBlock, block); const challengeId = await createDialogueChallenge(superBlock, block);
await createMetaJson(superBlock, block, title, helpCategory, challengeId); await createMetaJson(block, title, helpCategory, challengeId);
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
void updateSimpleSuperblockStructure(block, { order: 0 }, superblockFilename);
// TODO: remove once we stop relying on markdown in the client. // TODO: remove once we stop relying on markdown in the client.
await createIntroMD(superBlock, block, title); await createIntroMD(superBlock, block, title);
} }
@@ -73,46 +81,34 @@ async function updateIntroJson(
} }
async function createMetaJson( async function createMetaJson(
superBlock: SuperBlocks,
block: string, block: string,
title: string, title: string,
helpCategory: string, helpCategory: string,
challengeId: ObjectID challengeId: ObjectID
) { ) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = getBaseMeta('Language'); const newMeta = getBaseMeta('Language');
newMeta.name = title; newMeta.name = title;
newMeta.dashedName = block; newMeta.dashedName = block;
newMeta.helpCategory = helpCategory; newMeta.helpCategory = helpCategory;
newMeta.superBlock = superBlock;
newMeta.challengeOrder = [ newMeta.challengeOrder = [
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
{ id: challengeId.toString(), title: "Dialogue 1: I'm Tom" } { 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( await writeBlockStructure(block, newMeta);
fs.writeFile,
path.resolve(metaDir, `${block}/meta.json`),
await format(JSON.stringify(newMeta), { parser: 'json' })
);
} }
async function createDialogueChallenge( async function createDialogueChallenge(
superBlock: SuperBlocks, superBlock: SuperBlocks,
block: string block: string
): Promise<ObjectID> { ): Promise<ObjectID> {
const superBlockSubPath = superBlockToFolderMap[superBlock]; const { blockContentDir } = getContentConfig('english') as {
const newChallengeDir = path.resolve( blockContentDir: string;
__dirname, };
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
); const newChallengeDir = path.resolve(blockContentDir, block);
if (!existsSync(newChallengeDir)) { await fs.mkdir(newChallengeDir, { recursive: true });
await withTrace(fs.mkdir, newChallengeDir);
}
return createDialogueFile({ return createDialogueFile({
projectPath: newChallengeDir + '/' projectPath: newChallengeDir + '/'
}); });
@@ -22,7 +22,7 @@ const createNextChallenge = async () => {
id: challengeId.toString(), id: challengeId.toString(),
title: options.title title: options.title
}); });
updateMetaData(meta); await updateMetaData(meta);
}; };
void createNextChallenge(); void createNextChallenge();
@@ -1,6 +1,4 @@
import { getLastStep } from './helpers/get-last-step-file-number'; import { getLastStep } from './helpers/get-last-step-file-number';
import { insertStep } from './commands'; import { insertStep } from './commands';
import { validateMetaData } from './helpers/project-metadata';
validateMetaData(); void insertStep(getLastStep().stepNum + 1);
insertStep(getLastStep().stepNum + 1);
@@ -2,11 +2,7 @@ import ObjectID from 'bson-objectid';
import { getTemplate } from './helpers/get-challenge-template'; import { getTemplate } from './helpers/get-challenge-template';
import { newTaskPrompts } from './helpers/new-task-prompts'; import { newTaskPrompts } from './helpers/new-task-prompts';
import { getProjectPath } from './helpers/get-project-info'; import { getProjectPath } from './helpers/get-project-info';
import { import { getMetaData, updateMetaData } from './helpers/project-metadata';
getMetaData,
updateMetaData,
validateMetaData
} from './helpers/project-metadata';
import { import {
createChallengeFile, createChallengeFile,
updateTaskMeta, updateTaskMeta,
@@ -14,8 +10,6 @@ import {
} from './utils'; } from './utils';
const createNextTask = async () => { const createNextTask = async () => {
validateMetaData();
const { challengeType } = await newTaskPrompts(); const { challengeType } = await newTaskPrompts();
// Placeholder title, to be replaced by updateTaskMarkdownFiles // Placeholder title, to be replaced by updateTaskMarkdownFiles
@@ -40,10 +34,10 @@ const createNextTask = async () => {
id: challengeIdString, id: challengeIdString,
title: options.title title: options.title
}); });
updateMetaData(meta); await updateMetaData(meta);
console.log(`Finished inserting task into 'meta.json' file.`); console.log(`Finished inserting task into 'meta.json' file.`);
updateTaskMeta(); await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'."); console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles(); updateTaskMarkdownFiles();
@@ -1,17 +1,19 @@
import { existsSync } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
import { format } from 'prettier'; import { format } from 'prettier';
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import { SuperBlocks } from '../../shared/config/curriculum';
import { import {
SuperBlocks, getContentConfig,
superBlockToFolderMap writeBlockStructure
} from '../../shared/config/curriculum'; } from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
import { createStepFile, validateBlockName } from './utils'; import { createStepFile, validateBlockName } from './utils';
import { getBaseMeta } from './helpers/get-base-meta'; import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro'; import { createIntroMD } from './helpers/create-intro';
import { updateSimpleSuperblockStructure } from './helpers/create-project';
const helpCategories = [ const helpCategories = [
'HTML-CSS', 'HTML-CSS',
@@ -56,14 +58,15 @@ async function createProject(
void updateIntroJson(superBlock, block, title); void updateIntroJson(superBlock, block, title);
const challengeId = await createFirstChallenge(superBlock, block); const challengeId = await createFirstChallenge(superBlock, block);
void createMetaJson( void createMetaJson(block, title, helpCategory, challengeId);
superBlock, const superblockFilename = (
block, superBlockToFilename as Record<SuperBlocks, string>
title, )[superBlock];
helpCategory, // TODO: handle full-stack-developer (createProjects needs calling with a
order, // chapter and module name as well)
challengeId if (superBlock !== SuperBlocks.FullStackDeveloper) {
); void updateSimpleSuperblockStructure(block, { order }, superblockFilename);
}
// TODO: remove once we stop relying on markdown in the client. // TODO: remove once we stop relying on markdown in the client.
void createIntroMD(superBlock, block, title); void createIntroMD(superBlock, block, title);
} }
@@ -90,46 +93,31 @@ async function updateIntroJson(
} }
async function createMetaJson( async function createMetaJson(
superBlock: SuperBlocks,
block: string, block: string,
title: string, title: string,
helpCategory: string, helpCategory: string,
order: number,
challengeId: ObjectID challengeId: ObjectID
) { ) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = getBaseMeta('Step'); const newMeta = getBaseMeta('Step');
newMeta.name = title; newMeta.name = title;
newMeta.dashedName = block; newMeta.dashedName = block;
newMeta.helpCategory = helpCategory; newMeta.helpCategory = helpCategory;
newMeta.order = order;
newMeta.superBlock = superBlock;
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
newMeta.challengeOrder = [{ id: challengeId.toString(), title: 'Step 1' }]; newMeta.challengeOrder = [{ id: challengeId.toString(), title: 'Step 1' }];
const newMetaDir = path.resolve(metaDir, block);
if (!existsSync(newMetaDir)) {
await withTrace(fs.mkdir, newMetaDir);
}
void withTrace( await writeBlockStructure(block, newMeta);
fs.writeFile,
path.resolve(metaDir, `${block}/meta.json`),
await format(JSON.stringify(newMeta), { parser: 'json' })
);
} }
async function createFirstChallenge( async function createFirstChallenge(
superBlock: SuperBlocks, superBlock: SuperBlocks,
block: string block: string
): Promise<ObjectID> { ): Promise<ObjectID> {
const superBlockSubPath = superBlockToFolderMap[superBlock]; const { blockContentDir } = getContentConfig('english') as {
const newChallengeDir = path.resolve( blockContentDir: string;
__dirname, };
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
); const newChallengeDir = path.resolve(blockContentDir, block);
if (!existsSync(newChallengeDir)) { await fs.mkdir(newChallengeDir, { recursive: true });
await withTrace(fs.mkdir, newChallengeDir);
}
// TODO: would be nice if the extension made sense for the challenge, but, at // 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. // least until react I think they're all going to be html anyway.
+19 -25
View File
@@ -1,17 +1,19 @@
import { existsSync } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
import { format } from 'prettier'; import { format } from 'prettier';
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import { SuperBlocks } from '../../shared/config/curriculum';
import { import {
SuperBlocks, getContentConfig,
superBlockToFolderMap writeBlockStructure
} from '../../shared/config/curriculum'; } from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
import { createQuizFile, validateBlockName } from './utils'; import { createQuizFile, validateBlockName } from './utils';
import { getBaseMeta } from './helpers/get-base-meta'; import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro'; import { createIntroMD } from './helpers/create-intro';
import { updateSimpleSuperblockStructure } from './helpers/create-project';
const helpCategories = [ const helpCategories = [
'HTML-CSS', 'HTML-CSS',
@@ -57,7 +59,11 @@ async function createQuiz(
title, title,
questionCount questionCount
); );
await createMetaJson(superBlock, block, title, helpCategory, challengeId); await createMetaJson(block, title, helpCategory, challengeId);
const superblockFilename = (
superBlockToFilename as Record<SuperBlocks, string>
)[superBlock];
void updateSimpleSuperblockStructure(block, { order: 0 }, superblockFilename);
// TODO: remove once we stop relying on markdown in the client. // TODO: remove once we stop relying on markdown in the client.
await createIntroMD(superBlock, block, title); await createIntroMD(superBlock, block, title);
} }
@@ -84,30 +90,19 @@ async function updateIntroJson(
} }
async function createMetaJson( async function createMetaJson(
superBlock: SuperBlocks,
block: string, block: string,
title: string, title: string,
helpCategory: string, helpCategory: string,
challengeId: ObjectID challengeId: ObjectID
) { ) {
const metaDir = path.resolve(__dirname, '../../curriculum/challenges/_meta');
const newMeta = getBaseMeta('Quiz'); const newMeta = getBaseMeta('Quiz');
newMeta.name = title; newMeta.name = title;
newMeta.dashedName = block; newMeta.dashedName = block;
newMeta.helpCategory = helpCategory; newMeta.helpCategory = helpCategory;
newMeta.superBlock = superBlock;
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
newMeta.challengeOrder = [{ id: challengeId.toString(), title: title }]; newMeta.challengeOrder = [{ id: challengeId.toString(), title: title }];
const newMetaDir = path.resolve(metaDir, block);
if (!existsSync(newMetaDir)) {
await withTrace(fs.mkdir, newMetaDir);
}
void withTrace( await writeBlockStructure(block, newMeta);
fs.writeFile,
path.resolve(metaDir, `${block}/meta.json`),
await format(JSON.stringify(newMeta), { parser: 'json' })
);
} }
async function createQuizChallenge( async function createQuizChallenge(
@@ -116,14 +111,13 @@ async function createQuizChallenge(
title: string, title: string,
questionCount: number questionCount: number
): Promise<ObjectID> { ): Promise<ObjectID> {
const superBlockSubPath = superBlockToFolderMap[superBlock]; const { blockContentDir } = getContentConfig('english') as {
const newChallengeDir = path.resolve( blockContentDir: string;
__dirname, };
`../../curriculum/challenges/english/${superBlockSubPath}/${block}`
); const newChallengeDir = path.resolve(blockContentDir, block);
if (!existsSync(newChallengeDir)) { await fs.mkdir(newChallengeDir, { recursive: true });
await withTrace(fs.mkdir, newChallengeDir);
}
return createQuizFile({ return createQuizFile({
projectPath: newChallengeDir + '/', projectPath: newChallengeDir + '/',
title: title, title: title,
@@ -7,9 +7,14 @@
* you want that. * you want that.
*/ */
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import {
getBlockStructure,
writeBlockStructure
} from '../../curriculum/file-handler';
import { createChallengeFile } from './utils'; import { createChallengeFile } from './utils';
import { getProjectPath } from './helpers/get-project-info'; 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 // eslint-disable-next-line @typescript-eslint/no-base-to-string
const challengeId = new ObjectID().toString(); const challengeId = new ObjectID().toString();
@@ -141,16 +146,18 @@ Watch the video
const path = getProjectPath(); const path = getProjectPath();
if ( if (
!/freeCodeCamp\/curriculum\/challenges\/english\/[^/]+\/[^/]+\/$/.test(path) !/freeCodeCamp\/curriculum\/challenges\/english\/blocks\/[^/]+\/$/.test(path)
) { ) {
throw Error(` throw Error(`
You cannot run this script from anywhere other than a block folder of the English curriculum. 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. 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)) { if (meta.challengeOrder.some(c => c.title === title)) {
throw Error(` throw Error(`
A challenge with the title ${title} already exists in this block. A challenge with the title ${title} already exists in this block.
@@ -162,8 +169,6 @@ meta.challengeOrder.push({
title title
}); });
// write the meta.json file void writeBlockStructure(block, meta);
updateMetaData(meta);
// write the challenge file, the first argument is the filename
createChallengeFile(challengeId, template, path); createChallengeFile(challengeId, template, path);
@@ -2,13 +2,12 @@ import { unlink } from 'fs/promises';
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
import { getProjectPath } from './helpers/get-project-info'; import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
import { getFileName } from './helpers/get-file-name'; import { getFileName } from './helpers/get-file-name';
const deleteChallenge = async () => { const deleteChallenge = async () => {
const path = getProjectPath(); const path = getProjectPath();
const challenges = getChallengeOrderFromMeta(); const challenges = getMetaData().challengeOrder;
const challengeToDelete = (await prompt({ const challengeToDelete = (await prompt({
name: 'id', name: 'id',
@@ -32,7 +31,7 @@ const deleteChallenge = async () => {
const meta = getMetaData(); const meta = getMetaData();
meta.challengeOrder.splice(indexToDelete, 1); meta.challengeOrder.splice(indexToDelete, 1);
updateMetaData(meta); await updateMetaData(meta);
}; };
void deleteChallenge(); void deleteChallenge();
@@ -1,6 +1,4 @@
import { deleteStep } from './commands'; import { deleteStep } from './commands';
import { getArgValue } from './helpers/get-arg-value'; import { getArgValue } from './helpers/get-arg-value';
import { validateMetaData } from './helpers/project-metadata';
validateMetaData(); void deleteStep(getArgValue(process.argv));
deleteStep(getArgValue(process.argv));
@@ -1,21 +1,18 @@
import { unlink } from 'fs/promises'; import { unlink } from 'fs/promises';
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
import { getProjectPath } from './helpers/get-project-info'; import { getProjectPath } from './helpers/get-project-info';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
import { getFileName } from './helpers/get-file-name'; import { getFileName } from './helpers/get-file-name';
import { validateMetaData } from './helpers/project-metadata';
import { import {
deleteChallengeFromMeta, deleteChallengeFromMeta,
updateTaskMarkdownFiles, updateTaskMarkdownFiles,
updateTaskMeta updateTaskMeta
} from './utils'; } from './utils';
import { isTaskChallenge } from './helpers/task-helpers'; import { isTaskChallenge } from './helpers/task-helpers';
import { getMetaData } from './helpers/project-metadata';
const deleteTask = async () => { const deleteTask = async () => {
validateMetaData();
const path = getProjectPath(); const path = getProjectPath();
const challenges = getChallengeOrderFromMeta(); const challenges = getMetaData().challengeOrder;
const challengeToDelete = (await prompt({ const challengeToDelete = (await prompt({
name: 'id', name: 'id',
@@ -39,11 +36,11 @@ const deleteTask = async () => {
await unlink(`${path}${fileToDelete}`); await unlink(`${path}${fileToDelete}`);
console.log(`Finished deleting file: '${fileToDelete}'.`); console.log(`Finished deleting file: '${fileToDelete}'.`);
deleteChallengeFromMeta(indexToDelete); await deleteChallengeFromMeta(indexToDelete);
console.log(`Finished removing challenge from 'meta.json'.`); console.log(`Finished removing challenge from 'meta.json'.`);
if (isTaskChallenge(challenges[indexToDelete].title)) { if (isTaskChallenge(challenges[indexToDelete].title)) {
updateTaskMeta(); await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'."); console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles(); updateTaskMarkdownFiles();
@@ -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<typeof getSuperblockStructure>;
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']
}
]
}
]
}
);
});
});
@@ -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);
}
@@ -2,9 +2,8 @@ const baseMeta = {
name: '', name: '',
isUpcomingChange: true, isUpcomingChange: true,
dashedName: '', dashedName: '',
superBlock: '',
order: 42,
helpCategory: '', helpCategory: '',
blockLayout: 'legacy-challenge-list',
challengeOrder: [ challengeOrder: [
{ {
id: '', id: '',
@@ -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.');
}
});
});
@@ -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
}));
};
@@ -24,7 +24,7 @@ type StepOptions = {
challengeId: ObjectID; challengeId: ObjectID;
challengeSeeds: Record<string, ChallengeSeed>; challengeSeeds: Record<string, ChallengeSeed>;
stepNum: number; stepNum: number;
challengeType: number; challengeType?: number;
isFirstChallenge?: boolean; isFirstChallenge?: boolean;
}; };
@@ -79,7 +79,7 @@ demoType: onClick`
`--- `---
id: ${challengeId.toString()} id: ${challengeId.toString()}
title: Step ${stepNum} title: Step ${stepNum}
challengeType: ${challengeType} challengeType: ${challengeType ?? 'placeholder'}
dashedName: step-${stepNum}${demoString} dashedName: step-${stepNum}${demoString}
--- ---
@@ -1,202 +1,18 @@
import fs from 'fs';
import { join } from 'path'; import { join } from 'path';
import { import { getBlockStructure } from '../../../curriculum/file-handler';
getMetaData, import { getMetaData } from './project-metadata';
getProjectMetaPath,
validateMetaData
} from './project-metadata';
const basePath = join( jest.mock('../../../curriculum/file-handler');
process.cwd(),
'__fixtures__' + process.env.JEST_WORKER_ID
);
const commonPath = join(basePath, 'curriculum', 'challenges');
const block = 'project-project-metadata'; const commonPath = join('curriculum', 'challenges', 'blocks');
const metaPath = join(commonPath, '_meta', block); const block = 'block-name';
const superBlockPath = join(
commonPath,
'english',
'superblock-project-metadata'
);
const projectPath = join(superBlockPath, block);
describe('project-metadata helper', () => { 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', () => { describe('getMetaData helper', () => {
beforeEach(() => { it('should call getBlockStructure with the correct path', () => {
fs.writeFileSync( process.env.CALLING_DIR = join(commonPath, block);
join(projectPath, 'step-001.md'), getMetaData();
'Lorem ipsum...', expect(getBlockStructure).toHaveBeenCalledWith(block);
'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 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.');
}
}); });
}); });
@@ -1,7 +1,10 @@
import fs from 'fs';
import path from 'path'; 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 = { export type Meta = {
name: string; name: string;
@@ -10,65 +13,24 @@ export type Meta = {
isUpcomingChange: boolean; isUpcomingChange: boolean;
dashedName: string; dashedName: string;
helpCategory: string; helpCategory: string;
order: number;
time: string; time: string;
template: string; template: string;
required: string[]; required: string[];
superBlock: string;
challengeOrder: { id: string; title: string }[]; challengeOrder: { id: string; title: string }[];
}; };
function getMetaData(): Meta { function getMetaData() {
const metaData = fs.readFileSync(getProjectMetaPath(), 'utf-8'); const block = getBlock(getProjectPath());
return JSON.parse(metaData) as Meta; return getBlockStructure(block) as Meta;
} }
function updateMetaData(newMetaData: Record<string, unknown>): void { function getBlock(filePath: string) {
fs.writeFileSync(getProjectMetaPath(), JSON.stringify(newMetaData, null, 2)); return path.basename(filePath);
} }
function getProjectMetaPath(): string { async function updateMetaData(newMetaData: Record<string, unknown>) {
return path.join( const block = getBlock(getProjectPath());
getProjectPath(), await writeBlockStructure(block, newMetaData);
'../../..',
'_meta',
getProjectName(),
'meta.json'
);
} }
// This (and everything else) should be async, but it's fast enough export { getMetaData, updateMetaData, getBlock };
// 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 };
@@ -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]);
});
});
@@ -0,0 +1,8 @@
export function insertInto<T>(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;
});
}
@@ -5,14 +5,13 @@ import { newChallengePrompts } from './helpers/new-challenge-prompts';
import { getProjectPath } from './helpers/get-project-info'; import { getProjectPath } from './helpers/get-project-info';
import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { createChallengeFile } from './utils'; import { createChallengeFile } from './utils';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
const insertChallenge = async () => { const insertChallenge = async () => {
const path = getProjectPath(); const path = getProjectPath();
const options = await newChallengePrompts(); const options = await newChallengePrompts();
const challenges = getChallengeOrderFromMeta(); const challenges = getMetaData().challengeOrder;
const challengeAfter = await prompt<{ id: string }>({ const challengeAfter = await prompt<{ id: string }>({
name: 'id', name: 'id',
@@ -38,7 +37,7 @@ const insertChallenge = async () => {
id: challengeId.toString(), id: challengeId.toString(),
title: options.title title: options.title
}); });
updateMetaData(meta); await updateMetaData(meta);
}; };
void insertChallenge(); void insertChallenge();
@@ -1,6 +1,4 @@
import { getArgValue } from './helpers/get-arg-value'; import { getArgValue } from './helpers/get-arg-value';
import { insertStep } from './commands'; import { insertStep } from './commands';
import { validateMetaData } from './helpers/project-metadata';
validateMetaData(); void insertStep(getArgValue(process.argv));
insertStep(getArgValue(process.argv));
@@ -3,19 +3,16 @@ import { prompt } from 'inquirer';
import { getTemplate } from './helpers/get-challenge-template'; import { getTemplate } from './helpers/get-challenge-template';
import { newTaskPrompts } from './helpers/new-task-prompts'; import { newTaskPrompts } from './helpers/new-task-prompts';
import { getProjectPath } from './helpers/get-project-info'; import { getProjectPath } from './helpers/get-project-info';
import { validateMetaData } from './helpers/project-metadata';
import { import {
createChallengeFile, createChallengeFile,
insertChallengeIntoMeta, insertChallengeIntoMeta,
updateTaskMeta, updateTaskMeta,
updateTaskMarkdownFiles updateTaskMarkdownFiles
} from './utils'; } from './utils';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order'; import { getMetaData } from './helpers/project-metadata';
const insertChallenge = async () => { const insertChallenge = async () => {
validateMetaData(); const challenges = getMetaData().challengeOrder;
const challenges = getChallengeOrderFromMeta();
const challengeAfter = await prompt<{ id: string }>({ const challengeAfter = await prompt<{ id: string }>({
name: 'id', name: 'id',
message: 'Which challenge should come AFTER this new one?', message: 'Which challenge should come AFTER this new one?',
@@ -50,14 +47,14 @@ const insertChallenge = async () => {
createChallengeFile(challengeIdString, challengeText, path); createChallengeFile(challengeIdString, challengeText, path);
console.log('Finished creating new task markdown file.'); console.log('Finished creating new task markdown file.');
insertChallengeIntoMeta({ await insertChallengeIntoMeta({
index: indexToInsert, index: indexToInsert,
id: challengeId, id: challengeId,
title: newTaskTitle title: newTaskTitle
}); });
console.log(`Finished inserting task into 'meta.json' file.`); console.log(`Finished inserting task into 'meta.json' file.`);
updateTaskMeta(); await updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'."); console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles(); updateTaskMarkdownFiles();
@@ -1,10 +1,7 @@
import { validateMetaData } from './helpers/project-metadata';
import { updateTaskMeta, updateTaskMarkdownFiles } from './utils'; import { updateTaskMeta, updateTaskMarkdownFiles } from './utils';
const reorderTasks = () => { const reorderTasks = async () => {
validateMetaData(); await updateTaskMeta();
updateTaskMeta();
console.log("Finished updating tasks in 'meta.json'."); console.log("Finished updating tasks in 'meta.json'.");
updateTaskMarkdownFiles(); updateTaskMarkdownFiles();
@@ -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.');
}
});
});
@@ -1,3 +0,0 @@
import { repairMeta } from './commands';
void (() => repairMeta())();
@@ -1,10 +1,9 @@
import { prompt } from 'inquirer'; import { prompt } from 'inquirer';
import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getChallengeOrderFromMeta } from './helpers/get-challenge-order';
const updateChallengeOrder = async () => { const updateChallengeOrder = async () => {
const oldChallengeOrder = getChallengeOrderFromMeta(); const oldChallengeOrder = getMetaData().challengeOrder;
console.log('Current challenge order is: '); console.log('Current challenge order is: ');
console.table(oldChallengeOrder.map(({ title }) => ({ title }))); console.table(oldChallengeOrder.map(({ title }) => ({ title })));
@@ -49,7 +48,7 @@ const updateChallengeOrder = async () => {
const meta = getMetaData(); const meta = getMetaData();
meta.challengeOrder = newChallengeOrder; meta.challengeOrder = newChallengeOrder;
updateMetaData(meta); await updateMetaData(meta);
}; };
void (async () => await updateChallengeOrder())(); void (async () => await updateChallengeOrder())();
@@ -1,5 +1,3 @@
import { validateMetaData } from './helpers/project-metadata';
import { updateStepTitles } from './utils'; import { updateStepTitles } from './utils';
validateMetaData();
updateStepTitles(); updateStepTitles();
+72 -152
View File
@@ -1,8 +1,21 @@
import fs from 'fs'; import fs from 'fs';
import { join } from 'path'; import path, { join } from 'path';
import ObjectID from 'bson-objectid';
import glob from 'glob';
import matter from 'gray-matter'; 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', () => { jest.mock('bson-objectid', () => {
return jest.fn(() => ({ toString: () => mockChallengeId })); return jest.fn(() => ({ toString: () => mockChallengeId }));
@@ -10,10 +23,20 @@ jest.mock('bson-objectid', () => {
jest.mock('./helpers/get-step-template', () => { jest.mock('./helpers/get-step-template', () => {
return { 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'; const mockChallengeId = '60d35cf3fe32df2ce8e31b03';
import { getStepTemplate } from './helpers/get-step-template'; import { getStepTemplate } from './helpers/get-step-template';
import { import {
@@ -23,37 +46,26 @@ import {
updateStepTitles, updateStepTitles,
validateBlockName validateBlockName
} from './utils'; } from './utils';
import { updateMetaData } from './helpers/project-metadata';
const basePath = join( const basePath = join(
process.cwd(), process.cwd(),
'__fixtures__' + process.env.JEST_WORKER_ID '__fixtures__' + process.env.JEST_WORKER_ID
); );
const commonPath = join(basePath, 'curriculum', 'challenges'); const commonPath = join(basePath, 'curriculum');
const block = 'utils-project'; const block = 'utils-project';
const metaPath = join(commonPath, '_meta', block); const projectPath = join(commonPath, 'challenges', 'english', 'blocks', block);
const superBlockPath = join(commonPath, 'english', 'utils-superblock');
const projectPath = join(superBlockPath, block);
describe('Challenge utils helper scripts', () => { describe('Challenge utils helper scripts', () => {
beforeEach(() => { afterEach(() => {
fs.mkdirSync(superBlockPath, { recursive: true }); jest.clearAllMocks();
fs.mkdirSync(projectPath, { recursive: true });
fs.mkdirSync(metaPath, { recursive: true });
}); });
describe('createStepFile util', () => { describe('createStepFile util', () => {
it('should create next step and return its identifier', () => { 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; process.env.CALLING_DIR = projectPath;
const mockTemplate = 'Mock template...';
(getStepTemplate as jest.Mock).mockReturnValue(mockTemplate);
const step = createStepFile({ const step = createStepFile({
stepNum: 3, stepNum: 3,
challengeType: 0 challengeType: 0
@@ -66,15 +78,10 @@ describe('Challenge utils helper scripts', () => {
// Internal tasks // Internal tasks
// - Should generate a template for the step that is being created // - Should generate a template for the step that is being created
expect(getStepTemplate).toHaveBeenCalledTimes(1); expect(getStepTemplate).toHaveBeenCalledTimes(1);
expect(fs.writeFileSync).toHaveBeenCalledWith(
// - Should write a file with a given name and template
const files = glob.sync(`${projectPath}/*.md`);
expect(files).toEqual([
`${projectPath}/${mockChallengeId}.md`, `${projectPath}/${mockChallengeId}.md`,
`${projectPath}/step-001.md`, mockTemplate
`${projectPath}/step-002.md` );
]);
}); });
}); });
@@ -104,78 +111,31 @@ describe('Challenge utils helper scripts', () => {
describe('createChallengeFile util', () => { describe('createChallengeFile util', () => {
it('should create the challenge', () => { 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; 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 // - Should write a file with a given name and template
const files = glob.sync(`${projectPath}/*.md`); expect(fs.writeFileSync).toHaveBeenCalledWith(
expect(files).toEqual([
`${projectPath}/fake-challenge.md`,
`${projectPath}/hi.md`, `${projectPath}/hi.md`,
`${projectPath}/so-many-fakes.md` template
]); );
}); });
}); });
describe('insertStepIntoMeta util', () => { describe('insertStepIntoMeta util', () => {
it('should update the meta with a new file id and name', () => { it('should call updateMetaData with a new file id and name', async () => {
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'
);
process.env.CALLING_DIR = projectPath; process.env.CALLING_DIR = projectPath;
insertStepIntoMeta({ stepNum: 3, stepId: new ObjectID(mockChallengeId) }); await insertStepIntoMeta({
stepNum: 3,
stepId: new ObjectID(mockChallengeId)
});
const meta = JSON.parse( expect(updateMetaData).toHaveBeenCalledWith({
fs.readFileSync(join(metaPath, 'meta.json'), 'utf-8')
);
expect(meta).toEqual({
id: 'mock-id',
challengeOrder: [ challengeOrder: [
{ { id: 'abc', title: 'Step 1' }, // title gets overwritten
id: 'id-1', { id: mockChallengeId, title: 'Step 2' }
title: 'Step 1'
},
{
id: 'id-2',
title: 'Step 2'
},
{
id: mockChallengeId,
title: 'Step 3'
},
{
id: 'id-3',
title: 'Step 4'
}
] ]
}); });
}); });
@@ -183,76 +143,36 @@ describe('Challenge utils helper scripts', () => {
describe('updateStepTitles util', () => { describe('updateStepTitles util', () => {
it('should apply meta.challengeOrder to step files', () => { 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; 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(); updateStepTitles();
expect(matter.read(join(projectPath, 'id-1.md')).data).toEqual({ expect(fs.readdirSync).toHaveBeenCalledWith(projectPath + '/');
id: 'id-1', expect(fs.writeFileSync).toHaveBeenCalledWith(
title: 'Step 1', path.join(projectPath, 'name.md'),
challengeType: 'a', undefined
dashedName: 'step-1' );
}); expect(fs.writeFileSync).toHaveBeenCalledWith(
expect(matter.read(join(projectPath, 'id-2.md')).data).toEqual({ path.join(projectPath, 'another-name.md'),
id: 'id-2', undefined
title: 'Step 3', );
challengeType: 'b', expect(matter.stringify).toHaveBeenCalledWith('goes here', {
dashedName: 'step-3' dashedName: 'step-1',
}); id: 'abc',
expect(matter.read(join(projectPath, 'id-3.md')).data).toEqual({ title: 'Step 1'
id: 'id-3',
title: 'Step 2',
challengeType: 'c',
dashedName: 'step-2'
}); });
}); });
}); });
afterEach(() => { afterEach(() => {
delete process.env.CALLING_DIR; delete process.env.CALLING_DIR;
try {
fs.rmSync(basePath, { recursive: true });
} catch (err) {
console.log(err);
console.log('Could not remove fixtures folder.');
}
}); });
}); });
+12 -12
View File
@@ -15,7 +15,7 @@ import { getTemplate } from './helpers/get-challenge-template';
interface Options { interface Options {
stepNum: number; stepNum: number;
challengeType: number; challengeType?: number;
projectPath?: string; projectPath?: string;
challengeSeeds?: Record<string, ChallengeSeed>; challengeSeeds?: Record<string, ChallengeSeed>;
isFirstChallenge?: boolean; isFirstChallenge?: boolean;
@@ -112,20 +112,20 @@ interface InsertChallengeOptions {
title: string; title: string;
} }
function insertChallengeIntoMeta({ async function insertChallengeIntoMeta({
index, index,
id, id,
title title
}: InsertChallengeOptions): void { }: InsertChallengeOptions) {
const existingMeta = getMetaData(); const existingMeta = getMetaData();
const challengeOrder = [...existingMeta.challengeOrder]; const challengeOrder = [...existingMeta.challengeOrder];
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
challengeOrder.splice(index, 0, { id: id.toString(), title }); 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 existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder]; const oldOrder = [...existingMeta.challengeOrder];
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -136,10 +136,10 @@ function insertStepIntoMeta({ stepNum, stepId }: InsertOptions): void {
title: `Step ${index + 1}` 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 existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder]; const oldOrder = [...existingMeta.challengeOrder];
oldOrder.splice(stepNum - 1, 1); oldOrder.splice(stepNum - 1, 1);
@@ -149,17 +149,17 @@ function deleteStepFromMeta({ stepNum }: { stepNum: number }): void {
title: `Step ${index + 1}` 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 existingMeta = getMetaData();
const challengeOrder = [...existingMeta.challengeOrder]; const challengeOrder = [...existingMeta.challengeOrder];
challengeOrder.splice(challengeIndex, 1); challengeOrder.splice(challengeIndex, 1);
updateMetaData({ ...existingMeta, challengeOrder }); await updateMetaData({ ...existingMeta, challengeOrder });
} }
function updateTaskMeta() { async function updateTaskMeta() {
const existingMeta = getMetaData(); const existingMeta = getMetaData();
const oldOrder = [...existingMeta.challengeOrder]; const oldOrder = [...existingMeta.challengeOrder];
@@ -176,7 +176,7 @@ function updateTaskMeta() {
} }
}); });
updateMetaData({ ...existingMeta, challengeOrder }); await updateMetaData({ ...existingMeta, challengeOrder });
} }
const updateStepTitles = (): void => { const updateStepTitles = (): void => {