mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
refactor: top-down curriculum build (#61459)
This commit is contained in:
committed by
GitHub
parent
45c098d506
commit
a801d503bc
@@ -1,6 +1,8 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json';
|
||||
// TODO: source the superblock structure via a GQL query, rather than directly
|
||||
// from the curriculum
|
||||
import superBlockStructure from '../../../curriculum/structure/superblocks/full-stack-developer.json';
|
||||
import { randomBetween } from '../utils/random-between';
|
||||
import { getSessionChallengeData } from '../utils/session-storage';
|
||||
import { ns as MainApp } from './action-types';
|
||||
@@ -144,7 +146,7 @@ export const completionStateSelector = createSelector(
|
||||
const populateBlocks = blocks =>
|
||||
blocks.map(block => {
|
||||
const blockChallenges = challenges.filter(
|
||||
({ block: blockName }) => blockName === block.dashedName
|
||||
({ block: blockName }) => blockName === block
|
||||
);
|
||||
|
||||
const completedBlockChallenges = blockChallenges.every(({ id }) =>
|
||||
@@ -152,7 +154,7 @@ export const completionStateSelector = createSelector(
|
||||
);
|
||||
|
||||
return {
|
||||
name: block.dashedName,
|
||||
name: block,
|
||||
isCompleted:
|
||||
completedBlockChallenges.length === blockChallenges.length
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Disclosure } from '@headlessui/react';
|
||||
|
||||
import { SuperBlocks } from '../../../../../shared/config/curriculum';
|
||||
import DropDown from '../../../assets/icons/dropdown';
|
||||
// TODO: See if there's a nice way to incorporate the structure into data Gatsby
|
||||
// sources from the curriculum, rather than importing it directly.
|
||||
import superBlockStructure from '../../../../../curriculum/superblock-structure/full-stack.json';
|
||||
// TODO: source the superblock structure via a GQL query, rather than directly
|
||||
// from the curriculum
|
||||
import superBlockStructure from '../../../../../curriculum/structure/superblocks/full-stack-developer.json';
|
||||
import { ChapterIcon } from '../../../assets/chapter-icon';
|
||||
import { BlockLayouts, BlockTypes } from '../../../../../shared/config/blocks';
|
||||
import { FsdChapters } from '../../../../../shared/config/chapters';
|
||||
@@ -74,7 +74,7 @@ const getBlockToChapterMap = () => {
|
||||
chapters.forEach(chapter => {
|
||||
chapter.modules.forEach(module => {
|
||||
module.blocks.forEach(block => {
|
||||
blockToChapterMap.set(block.dashedName, chapter.dashedName);
|
||||
blockToChapterMap.set(block, chapter.dashedName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ const getBlockToModuleMap = () => {
|
||||
const blockToModuleMap = new Map<string, string>();
|
||||
modules.forEach(module => {
|
||||
module.blocks.forEach(block => {
|
||||
blockToModuleMap.set(block.dashedName, module.dashedName);
|
||||
blockToModuleMap.set(block, module.dashedName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,14 +212,14 @@ export const SuperBlockAccordion = ({
|
||||
}: SuperBlockAccordionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { allChapters } = useMemo(() => {
|
||||
const populateBlocks = (blocks: { dashedName: string }[]) =>
|
||||
const populateBlocks = (blocks: string[]) =>
|
||||
blocks.map(block => {
|
||||
const blockChallenges = challenges.filter(
|
||||
({ block: blockName }) => blockName === block.dashedName
|
||||
({ block: blockName }) => blockName === block
|
||||
);
|
||||
|
||||
return {
|
||||
name: block.dashedName,
|
||||
name: block,
|
||||
blockType: blockChallenges[0]?.blockType ?? null,
|
||||
challenges: blockChallenges
|
||||
};
|
||||
|
||||
@@ -3,45 +3,34 @@ const path = require('path');
|
||||
const _ = require('lodash');
|
||||
|
||||
const envData = require('../config/env.json');
|
||||
const { getChallengesForLang } = require('../../curriculum/get-challenges');
|
||||
|
||||
const {
|
||||
getChallengesForLang,
|
||||
generateChallengeCreator,
|
||||
ENGLISH_CHALLENGES_DIR,
|
||||
META_DIR,
|
||||
I18N_CHALLENGES_DIR,
|
||||
getChallengesDirForLang
|
||||
} = require('../../curriculum/get-challenges');
|
||||
getContentDir,
|
||||
getBlockCreator
|
||||
} = require('../../curriculum/build-curriculum');
|
||||
|
||||
const { curriculumLocale } = envData;
|
||||
|
||||
exports.localeChallengesRootDir = getChallengesDirForLang(curriculumLocale);
|
||||
exports.localeChallengesRootDir = getContentDir(curriculumLocale);
|
||||
|
||||
const blockCreator = getBlockCreator(curriculumLocale);
|
||||
|
||||
exports.replaceChallengeNode = () => {
|
||||
return async function replaceChallengeNode(filePath) {
|
||||
// get the meta so that challengeOrder is accurate
|
||||
const blockNameRe = /\d\d-[-\w]+\/([^/]+)\//;
|
||||
const posix = path.normalize(filePath).split(path.sep).join(path.posix.sep);
|
||||
const blockName = posix.match(blockNameRe)[1];
|
||||
const metaPath = path.resolve(META_DIR, `${blockName}/meta.json`);
|
||||
delete require.cache[require.resolve(metaPath)];
|
||||
const meta = require(metaPath);
|
||||
const englishPath = path.resolve(
|
||||
ENGLISH_CHALLENGES_DIR,
|
||||
'english',
|
||||
filePath
|
||||
);
|
||||
const i18nPath = path.resolve(
|
||||
I18N_CHALLENGES_DIR,
|
||||
curriculumLocale,
|
||||
filePath
|
||||
);
|
||||
// TODO: reimplement hot-reloading of certifications
|
||||
const createChallenge = generateChallengeCreator(
|
||||
curriculumLocale,
|
||||
englishPath,
|
||||
i18nPath
|
||||
);
|
||||
return await createChallenge(filePath, meta);
|
||||
const parentDir = path.dirname(filePath);
|
||||
const block = path.basename(parentDir);
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
console.log(`Replacing challenge node for ${filePath}`);
|
||||
const meta = blockCreator.getMetaForBlock(block);
|
||||
|
||||
return await blockCreator.createChallenge({
|
||||
filename,
|
||||
block,
|
||||
meta,
|
||||
isAudited: true
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
8
curriculum/build-certification.js
Normal file
8
curriculum/build-certification.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const buildCertification = filePath => ({
|
||||
challenges: [yaml.load(fs.readFileSync(filePath, 'utf8'))]
|
||||
});
|
||||
|
||||
module.exports = { buildCertification };
|
||||
387
curriculum/build-curriculum.js
Normal file
387
curriculum/build-curriculum.js
Normal file
@@ -0,0 +1,387 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const { isEmpty } = require('lodash');
|
||||
const debug = require('debug')('fcc:build-curriculum');
|
||||
|
||||
const {
|
||||
SuperblockCreator,
|
||||
BlockCreator,
|
||||
transformSuperBlock
|
||||
} = require('./build-superblock');
|
||||
|
||||
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');
|
||||
|
||||
/**
|
||||
* Creates a BlockCreator instance for a specific language with appropriate configuration
|
||||
* @param {string} lang - The language code (e.g., 'english', 'spanish', etc.)
|
||||
* @param {boolean} [skipValidation=false] - Whether to skip validation of challenges
|
||||
* @param {Object} [opts] - Optional configuration object
|
||||
* @param {string} [opts.baseDir] - Base directory for curriculum content
|
||||
* @param {string} [opts.i18nBaseDir] - Base directory for i18n curriculum content
|
||||
* @param {string} [opts.structureDir] - Directory containing curriculum structure
|
||||
* @returns {BlockCreator} A configured BlockCreator instance
|
||||
*/
|
||||
const getBlockCreator = (lang, skipValidation, opts) => {
|
||||
const {
|
||||
blockContentDir,
|
||||
blockStructureDir,
|
||||
i18nBlockContentDir,
|
||||
dictionariesDir,
|
||||
i18nDictionariesDir
|
||||
} = getLanguageConfig(lang, opts);
|
||||
|
||||
const targetDictionariesDir =
|
||||
lang === 'english' ? dictionariesDir : i18nDictionariesDir;
|
||||
|
||||
return new BlockCreator({
|
||||
lang,
|
||||
blockContentDir,
|
||||
blockStructureDir,
|
||||
i18nBlockContentDir,
|
||||
commentTranslations: createCommentMap(
|
||||
dictionariesDir,
|
||||
targetDictionariesDir
|
||||
),
|
||||
skipValidation: skipValidation ?? false
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a translation entry for a specific English ID and text across all languages
|
||||
* @param {Object} dicts - Dictionary object containing translations for each language
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {string} params.engId - The English ID to look up in dictionaries
|
||||
* @param {string} params.text - The fallback English text to use if translation not found
|
||||
* @returns {Object} Object mapping language codes to translated text or fallback English text
|
||||
*/
|
||||
function getTranslationEntry(dicts, { engId, text }) {
|
||||
return Object.keys(dicts).reduce((acc, lang) => {
|
||||
const entry = dicts[lang][engId];
|
||||
if (entry) {
|
||||
return { ...acc, [lang]: entry };
|
||||
} else {
|
||||
// default to english
|
||||
return { ...acc, [lang]: text };
|
||||
}
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mapping of English comments to their translations across all supported languages
|
||||
* @param {string} dictionariesDir - Path to the main (english) dictionaries directory
|
||||
* @param {string} targetDictionariesDir - Path to the target (i18n or english) dictionaries directory
|
||||
* @returns {Object} Object mapping English comment text to translations in all languages
|
||||
*/
|
||||
function createCommentMap(dictionariesDir, targetDictionariesDir) {
|
||||
debug(
|
||||
`Creating comment map from ${dictionariesDir} and ${targetDictionariesDir}`
|
||||
);
|
||||
const languages = fs.readdirSync(targetDictionariesDir);
|
||||
|
||||
const dictionaries = languages.reduce((acc, lang) => {
|
||||
const commentsPath = path.resolve(
|
||||
targetDictionariesDir,
|
||||
lang,
|
||||
'comments.json'
|
||||
);
|
||||
const commentsData = JSON.parse(fs.readFileSync(commentsPath, 'utf8'));
|
||||
return {
|
||||
...acc,
|
||||
[lang]: commentsData
|
||||
};
|
||||
}, {});
|
||||
|
||||
const COMMENTS_TO_TRANSLATE = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(dictionariesDir, 'english', 'comments.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
const COMMENTS_TO_NOT_TRANSLATE = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(
|
||||
dictionariesDir,
|
||||
'english',
|
||||
'comments-to-not-translate.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
// map from english comment text to translations
|
||||
const translatedCommentMap = Object.entries(COMMENTS_TO_TRANSLATE).reduce(
|
||||
(acc, [id, text]) => {
|
||||
return {
|
||||
...acc,
|
||||
[text]: getTranslationEntry(dictionaries, { engId: id, text })
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// map from english comment text to itself
|
||||
const untranslatableCommentMap = Object.values(
|
||||
COMMENTS_TO_NOT_TRANSLATE
|
||||
).reduce((acc, text) => {
|
||||
const englishEntry = languages.reduce(
|
||||
(acc, lang) => ({
|
||||
...acc,
|
||||
[lang]: text
|
||||
}),
|
||||
{}
|
||||
);
|
||||
return {
|
||||
...acc,
|
||||
[text]: englishEntry
|
||||
};
|
||||
}, {});
|
||||
|
||||
const allComments = { ...translatedCommentMap, ...untranslatableCommentMap };
|
||||
|
||||
// the english entries need to be added here, because english is not in
|
||||
// languages
|
||||
Object.keys(allComments).forEach(comment => {
|
||||
allComments[comment].english = comment;
|
||||
});
|
||||
|
||||
return allComments;
|
||||
}
|
||||
|
||||
// Map of superblock filenames to their SuperBlocks enum values
|
||||
const superBlockNames = {
|
||||
'responsive-web-design': 'responsive-web-design',
|
||||
'javascript-algorithms-and-data-structures':
|
||||
'javascript-algorithms-and-data-structures',
|
||||
'front-end-development-libraries': 'front-end-development-libraries',
|
||||
'data-visualization': 'data-visualization',
|
||||
'back-end-development-and-apis': 'back-end-development-and-apis',
|
||||
'quality-assurance': 'quality-assurance',
|
||||
'scientific-computing-with-python': 'scientific-computing-with-python',
|
||||
'data-analysis-with-python': 'data-analysis-with-python',
|
||||
'information-security': 'information-security',
|
||||
'coding-interview-prep': 'coding-interview-prep',
|
||||
'machine-learning-with-python': 'machine-learning-with-python',
|
||||
'relational-databases': 'relational-database',
|
||||
'responsive-web-design-22': '2022/responsive-web-design',
|
||||
'javascript-algorithms-and-data-structures-22':
|
||||
'javascript-algorithms-and-data-structures-v8',
|
||||
'the-odin-project': 'the-odin-project',
|
||||
'college-algebra-with-python': 'college-algebra-with-python',
|
||||
'project-euler': 'project-euler',
|
||||
'foundational-c-sharp-with-microsoft': 'foundational-c-sharp-with-microsoft',
|
||||
'a2-english-for-developers': 'a2-english-for-developers',
|
||||
'rosetta-code': 'rosetta-code',
|
||||
'python-for-everybody': 'python-for-everybody',
|
||||
'b1-english-for-developers': 'b1-english-for-developers',
|
||||
'full-stack-developer': 'full-stack-developer',
|
||||
'a2-professional-spanish': 'a2-professional-spanish',
|
||||
'a2-professional-chinese': 'a2-professional-chinese',
|
||||
'basic-html': 'basic-html',
|
||||
'semantic-html': 'semantic-html',
|
||||
'a1-professional-chinese': 'a1-professional-chinese',
|
||||
'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'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an array of superblock structures from a curriculum object
|
||||
|
||||
* @param {string[]} superblocks - Array of superblock filename strings
|
||||
* @returns {Array<Object>} Array of superblock structure objects with filename, name, and blocks
|
||||
* @throws {Error} When a superblock file is not found
|
||||
*/
|
||||
function addSuperblockStructure(superblocks) {
|
||||
debug(`Building structure for ${superblocks.length} superblocks`);
|
||||
|
||||
const superblockStructures = superblocks.map(superblockFilename => {
|
||||
const superblockName = superBlockNames[superblockFilename];
|
||||
if (!superblockName) {
|
||||
throw new Error(`Superblock name not found for ${superblockFilename}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: superblockName,
|
||||
blocks: transformSuperBlock(getSuperblockStructure(superblockFilename), {
|
||||
showComingSoon: process.env.SHOW_UPCOMING_CHANGES === 'true'
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
debug(
|
||||
`Successfully built ${superblockStructures.length} superblock structures`
|
||||
);
|
||||
|
||||
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
|
||||
) {
|
||||
return superblocks.map(superblock => ({
|
||||
...superblock,
|
||||
blocks: superblock.blocks.map((block, index) => ({
|
||||
...block,
|
||||
..._getBlockStructure(block.dashedName),
|
||||
order: index,
|
||||
superBlock: superblock.name
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
async function buildCurriculum(lang, filters) {
|
||||
const contentDir = getContentDir(lang);
|
||||
const builder = new SuperblockCreator({
|
||||
blockCreator: getBlockCreator(lang, !isEmpty(filters))
|
||||
});
|
||||
|
||||
const curriculum = getCurriculumStructure();
|
||||
if (isEmpty(curriculum.superblocks))
|
||||
throw Error('No superblocks found in curriculum.json');
|
||||
if (isEmpty(curriculum.certifications))
|
||||
throw Error('No certifications found in curriculum.json');
|
||||
debug(`Found ${curriculum.superblocks.length} superblocks to build`);
|
||||
debug(`Found ${curriculum.certifications.length} certifications to build`);
|
||||
|
||||
const superblockList = addBlockStructure(
|
||||
addSuperblockStructure(curriculum.superblocks)
|
||||
);
|
||||
const fullSuperblockList = applyFilters(superblockList, filters);
|
||||
const fullCurriculum = { certifications: { blocks: {} } };
|
||||
|
||||
for (const superblock of fullSuperblockList) {
|
||||
fullCurriculum[superblock.name] =
|
||||
await builder.processSuperblock(superblock);
|
||||
}
|
||||
|
||||
for (const cert of curriculum.certifications) {
|
||||
const certPath = path.resolve(contentDir, 'certifications', `${cert}.yml`);
|
||||
if (!fs.existsSync(certPath)) {
|
||||
throw Error(`Certification file not found: ${certPath}`);
|
||||
}
|
||||
debug(`=== Processing certification ${cert} ===`);
|
||||
fullCurriculum.certifications.blocks[cert] = buildCertification(certPath);
|
||||
}
|
||||
|
||||
return fullCurriculum;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addBlockStructure,
|
||||
buildCurriculum,
|
||||
getContentDir,
|
||||
getBlockCreator,
|
||||
getBlockStructure,
|
||||
getSuperblockStructure,
|
||||
createCommentMap
|
||||
};
|
||||
105
curriculum/build-curriculum.test.js
Normal file
105
curriculum/build-curriculum.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const path = require('node:path');
|
||||
|
||||
const { createCommentMap, addBlockStructure } = require('./build-curriculum');
|
||||
|
||||
describe('createCommentMap', () => {
|
||||
const dictionaryDir = path.resolve(__dirname, '__fixtures__', 'dictionaries');
|
||||
const incompleteDictDir = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__',
|
||||
'incomplete-dicts'
|
||||
);
|
||||
|
||||
it('returns an object', () => {
|
||||
expect(typeof createCommentMap(dictionaryDir, dictionaryDir)).toBe(
|
||||
'object'
|
||||
);
|
||||
});
|
||||
|
||||
it('fallback to the untranslated string', () => {
|
||||
expect.assertions(2);
|
||||
const commentMap = createCommentMap(incompleteDictDir, incompleteDictDir);
|
||||
expect(commentMap['To be translated one'].spanish).toEqual(
|
||||
'Spanish translation one'
|
||||
);
|
||||
expect(commentMap['To be translated two'].spanish).toEqual(
|
||||
'To be translated two'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an object with an expected form', () => {
|
||||
expect.assertions(4);
|
||||
const expectedIds = [
|
||||
'To be translated one',
|
||||
'To be translated two',
|
||||
'Not translated one',
|
||||
'Not translated two'
|
||||
];
|
||||
const map = createCommentMap(dictionaryDir, dictionaryDir);
|
||||
expect(Object.keys(map)).toEqual(expect.arrayContaining(expectedIds));
|
||||
|
||||
const mapValue = map['To be translated one'];
|
||||
|
||||
expect(Object.keys(mapValue)).toEqual(
|
||||
expect.arrayContaining(['chinese', 'spanish'])
|
||||
);
|
||||
expect(typeof mapValue.chinese).toBe('string');
|
||||
expect(typeof mapValue.spanish).toBe('string');
|
||||
});
|
||||
|
||||
it('returns an object with expected values', () => {
|
||||
expect.assertions(9);
|
||||
const expectedIds = [
|
||||
'To be translated one',
|
||||
'To be translated two',
|
||||
'Not translated one',
|
||||
'Not translated two'
|
||||
];
|
||||
const map = createCommentMap(dictionaryDir, dictionaryDir);
|
||||
expect(Object.keys(map)).toEqual(expect.arrayContaining(expectedIds));
|
||||
|
||||
const translatedOne = map['To be translated one'];
|
||||
|
||||
expect(translatedOne.chinese).toBe('Chinese translation one');
|
||||
expect(translatedOne.spanish).toBe('Spanish translation one');
|
||||
|
||||
const translatedTwo = map['To be translated two'];
|
||||
|
||||
expect(translatedTwo.chinese).toBe('Chinese translation two');
|
||||
expect(translatedTwo.spanish).toBe('Spanish translation two');
|
||||
|
||||
const untranslatedOne = map['Not translated one'];
|
||||
|
||||
expect(untranslatedOne.chinese).toBe('Not translated one');
|
||||
expect(untranslatedOne.spanish).toBe('Not translated one');
|
||||
|
||||
const untranslatedTwo = map['Not translated two'];
|
||||
|
||||
expect(untranslatedTwo.chinese).toBe('Not translated two');
|
||||
expect(untranslatedTwo.spanish).toBe('Not translated two');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBlockStructure', () => {
|
||||
it('should override the order and superblock coming from getBlockStructure', () => {
|
||||
const mockGetBlockStructure = () => ({
|
||||
order: 5,
|
||||
superBlock: 'no'
|
||||
});
|
||||
|
||||
const result = addBlockStructure(
|
||||
[{ name: 'yes', blocks: [{ a: 1 }, { b: 1 }] }],
|
||||
mockGetBlockStructure
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'yes',
|
||||
blocks: [
|
||||
{ a: 1, order: 0, superBlock: 'yes' },
|
||||
{ b: 1, order: 1, superBlock: 'yes' }
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
486
curriculum/build-superblock.js
Normal file
486
curriculum/build-superblock.js
Normal file
@@ -0,0 +1,486 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isEmpty } = require('lodash');
|
||||
const debug = require('debug')('fcc:build-superblock');
|
||||
|
||||
const { parseMD } = require('../tools/challenge-parser/parser');
|
||||
const { createPoly } = require('../shared/utils/polyvinyl');
|
||||
const { isAuditedSuperBlock } = require('../shared/utils/is-audited');
|
||||
const {
|
||||
translateCommentsInChallenge
|
||||
} = require('../tools/challenge-parser/translation-parser');
|
||||
const { getSuperOrder } = require('./utils');
|
||||
|
||||
const duplicates = xs => xs.filter((x, i) => xs.indexOf(x) !== i);
|
||||
|
||||
const createValidator = throwOnError => fn => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
if (throwOnError) {
|
||||
throw error;
|
||||
} else {
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates challenges against meta.json challengeOrder
|
||||
* @param {Array<object>} foundChallenges - Array of challenge objects
|
||||
* @param {object} meta - Meta object with challengeOrder array
|
||||
* @throws {Error} If validation fails (missing challenges, duplicates, etc.)
|
||||
*/
|
||||
function validateChallenges(foundChallenges, meta, throwOnError) {
|
||||
const metaChallengeIds = new Set(meta.challengeOrder.map(c => c.id));
|
||||
const foundChallengeIds = new Set(foundChallenges.map(c => c.id));
|
||||
|
||||
const throwOrLog = createValidator(throwOnError);
|
||||
|
||||
throwOrLog(() => {
|
||||
const missingFromMeta = Array.from(foundChallengeIds).filter(
|
||||
id => !metaChallengeIds.has(id)
|
||||
);
|
||||
if (missingFromMeta.length > 0)
|
||||
throw Error(
|
||||
`Challenges found in directory but missing from meta: ${missingFromMeta.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
throwOrLog(() => {
|
||||
const missingFromFiles = Array.from(metaChallengeIds).filter(
|
||||
id => !foundChallengeIds.has(id)
|
||||
);
|
||||
if (missingFromFiles.length > 0)
|
||||
throw Error(
|
||||
`Challenges in meta but missing files with id(s): ${missingFromFiles.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
throwOrLog(() => {
|
||||
const duplicateIds = duplicates(foundChallenges.map(c => c.id));
|
||||
if (duplicateIds.length > 0)
|
||||
throw Error(
|
||||
`Duplicate challenges found in found challenges with id(s): ${duplicateIds.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
throwOrLog(() => {
|
||||
const duplicateMetaIds = duplicates(meta.challengeOrder.map(c => c.id));
|
||||
if (duplicateMetaIds.length > 0)
|
||||
throw Error(
|
||||
`Duplicate challenges found in meta with id(s): ${duplicateMetaIds.join(', ')}`
|
||||
);
|
||||
});
|
||||
|
||||
throwOrLog(() => {
|
||||
const duplicateTitles = duplicates(foundChallenges.map(c => c.title));
|
||||
if (duplicateTitles.length > 0)
|
||||
throw Error(
|
||||
`Duplicate titles found in found challenges with title(s): ${duplicateTitles.join(', ')} in block ${meta.dashedName}`
|
||||
);
|
||||
});
|
||||
|
||||
throwOrLog(() => {
|
||||
const duplicateMetaTitles = duplicates(
|
||||
meta.challengeOrder.map(c => c.title)
|
||||
);
|
||||
if (duplicateMetaTitles.length > 0)
|
||||
throw Error(
|
||||
`Duplicate titles found in meta with title(s): ${duplicateMetaTitles.join(', ')}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a block object from challenges and meta data
|
||||
* @param {Array<object>} foundChallenges - Array of challenge objects
|
||||
* @param {object} meta - Meta object with name, dashedName, and challengeOrder
|
||||
* @returns {object} Block object with ordered challenges
|
||||
*/
|
||||
function buildBlock(foundChallenges, meta) {
|
||||
const challenges = meta.challengeOrder.map(challengeInfo => {
|
||||
const challenge = foundChallenges.find(c => c.id === challengeInfo.id);
|
||||
if (!challenge) {
|
||||
throw Error(
|
||||
`Challenge ${challengeInfo.id} (${challengeInfo.title}) not found in block`
|
||||
);
|
||||
}
|
||||
|
||||
return challenge;
|
||||
});
|
||||
|
||||
return {
|
||||
challenges,
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the meta information to a challenge
|
||||
* @param {object} challenge - The challenge object
|
||||
* @param {object} meta - The meta information object
|
||||
* @returns {object} The challenge object with added meta information
|
||||
*/
|
||||
function addMetaToChallenge(challenge, meta) {
|
||||
const challengeOrderIndex = meta.challengeOrder.findIndex(
|
||||
({ id }) => id === challenge.id
|
||||
);
|
||||
|
||||
const isLastChallengeInBlock =
|
||||
meta.challengeOrder.length - 1 === challengeOrderIndex;
|
||||
|
||||
// Add basic meta properties
|
||||
challenge.block = meta.dashedName;
|
||||
challenge.blockType = meta.blockType;
|
||||
challenge.blockLayout = meta.blockLayout;
|
||||
challenge.hasEditableBoundaries = !!meta.hasEditableBoundaries;
|
||||
challenge.order = meta.order;
|
||||
|
||||
// Ensure required properties exist
|
||||
if (!challenge.description) challenge.description = '';
|
||||
if (!challenge.instructions) challenge.instructions = '';
|
||||
if (!challenge.questions) challenge.questions = [];
|
||||
|
||||
// Set superblock-related properties
|
||||
challenge.superBlock = meta.superBlock;
|
||||
challenge.superOrder = meta.superOrder;
|
||||
challenge.challengeOrder = challengeOrderIndex;
|
||||
challenge.isLastChallengeInBlock = isLastChallengeInBlock;
|
||||
challenge.isPrivate = challenge.isPrivate || meta.isPrivate;
|
||||
challenge.required = (meta.required || []).concat(challenge.required || []);
|
||||
challenge.template = meta.template;
|
||||
challenge.helpCategory = challenge.helpCategory || meta.helpCategory;
|
||||
challenge.usesMultifileEditor = !!meta.usesMultifileEditor;
|
||||
challenge.disableLoopProtectTests = !!meta.disableLoopProtectTests;
|
||||
challenge.disableLoopProtectPreview = !!meta.disableLoopProtectPreview;
|
||||
|
||||
// Add chapter and module if present in meta
|
||||
if (meta.chapter) challenge.chapter = meta.chapter;
|
||||
if (meta.module) challenge.module = meta.module;
|
||||
|
||||
// Handle certification field for legacy support
|
||||
const dupeCertifications = [
|
||||
{
|
||||
certification: 'responsive-web-design',
|
||||
dupe: '2022/responsive-web-design'
|
||||
}
|
||||
];
|
||||
const hasDupe = dupeCertifications.find(
|
||||
cert => cert.dupe === meta.superBlock
|
||||
);
|
||||
challenge.certification = hasDupe ? hasDupe.certification : meta.superBlock;
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts challenge files to polyvinyl objects with seed property
|
||||
* @param {Array<object>} files - Array of challenge file objects
|
||||
* @returns {Array<object>} Array of polyvinyl objects with seed property
|
||||
*/
|
||||
function challengeFilesToPolys(files) {
|
||||
return files.reduce((challengeFiles, challengeFile) => {
|
||||
return [
|
||||
...challengeFiles,
|
||||
{
|
||||
...createPoly(challengeFile),
|
||||
seed: challengeFile.contents.slice(0)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes challenge properties by converting files to polyvinyl objects
|
||||
* @param {object} challenge - The challenge object to fix
|
||||
* @returns {object} The challenge object with fixed properties
|
||||
*/
|
||||
function fixChallengeProperties(challenge) {
|
||||
const fixedChallenge = {
|
||||
...challenge
|
||||
};
|
||||
|
||||
if (challenge.challengeFiles) {
|
||||
fixedChallenge.challengeFiles = challengeFilesToPolys(
|
||||
challenge.challengeFiles
|
||||
);
|
||||
}
|
||||
if (challenge.solutions?.length) {
|
||||
// The test runner needs the solutions to be arrays of polyvinyls so it
|
||||
// can sort them correctly.
|
||||
fixedChallenge.solutions = challenge.solutions.map(challengeFilesToPolys);
|
||||
}
|
||||
return fixedChallenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes a challenge by fixing properties and adding meta information
|
||||
* @param {object} challenge - The challenge object to finalize
|
||||
* @param {object} meta - The meta information object
|
||||
* @returns {object} The finalized challenge object
|
||||
*/
|
||||
function finalizeChallenge(challenge, meta) {
|
||||
return addMetaToChallenge(fixChallengeProperties(challenge), meta);
|
||||
}
|
||||
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
|
||||
* @constructor
|
||||
* @description Initializes the BlockCreator with directories for block content and structure.
|
||||
* This class is responsible for reading block directories, parsing challenges, and validating them
|
||||
* against the meta information.
|
||||
*/
|
||||
constructor({
|
||||
blockContentDir,
|
||||
blockStructureDir,
|
||||
i18nBlockContentDir,
|
||||
lang,
|
||||
commentTranslations,
|
||||
skipValidation
|
||||
}) {
|
||||
this.blockContentDir = blockContentDir;
|
||||
this.blockStructureDir = blockStructureDir;
|
||||
this.i18nBlockContentDir = i18nBlockContentDir;
|
||||
this.lang = lang;
|
||||
this.commentTranslations = commentTranslations;
|
||||
this.skipValidation = skipValidation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a challenge from a markdown file
|
||||
* @param {object} options - Options object
|
||||
* @param {string} options.filename - Name of the challenge file
|
||||
* @param {string} options.block - Name of the block
|
||||
* @param {object} options.meta - Meta information for the block
|
||||
* @param {boolean} options.isAudited - Whether the block is audited for i18n
|
||||
* @param {Function} parser - Parser function to use (defaults to parseMD)
|
||||
* @returns {Promise<object>} The finalized challenge object
|
||||
*/
|
||||
async createChallenge(
|
||||
{ filename, block, meta, isAudited },
|
||||
parser = parseMD
|
||||
) {
|
||||
debug(
|
||||
`Creating challenge from file: ${filename} in block: ${block}, using lang: ${this.lang}`
|
||||
);
|
||||
|
||||
const englishPath = path.resolve(this.blockContentDir, block, filename);
|
||||
const i18nPath = path.resolve(this.i18nBlockContentDir, block, filename);
|
||||
|
||||
const langUsed =
|
||||
isAudited && fs.existsSync(i18nPath) ? this.lang : 'english';
|
||||
|
||||
const challengePath = langUsed === 'english' ? englishPath : i18nPath;
|
||||
|
||||
const challenge = translateCommentsInChallenge(
|
||||
await parser(challengePath),
|
||||
langUsed,
|
||||
this.commentTranslations
|
||||
);
|
||||
|
||||
challenge.translationPending = this.lang !== 'english' && !isAudited;
|
||||
|
||||
return finalizeChallenge(challenge, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and builds all challenges from a block directory
|
||||
* @param {string} block - Name of the block
|
||||
* @param {object} meta - Meta object for the block
|
||||
* @param {boolean} isAudited - Whether the block is audited for i18n
|
||||
* @returns {Promise<Array<object>>} Array of challenge objects
|
||||
*/
|
||||
async readBlockChallenges(block, meta, isAudited) {
|
||||
const blockDir = path.resolve(this.blockContentDir, block);
|
||||
const challengeFiles = fs
|
||||
.readdirSync(blockDir)
|
||||
.filter(file => file.endsWith('.md'));
|
||||
|
||||
return await Promise.all(
|
||||
challengeFiles.map(filename =>
|
||||
this.createChallenge({ filename, block, meta, isAudited })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
// Check if block directory exists
|
||||
const blockContentDir = path.resolve(this.blockContentDir, blockName);
|
||||
if (!fs.existsSync(blockContentDir)) {
|
||||
throw Error(`Block directory not found: ${blockContentDir}`);
|
||||
}
|
||||
|
||||
if (
|
||||
block.isUpcomingChange &&
|
||||
process.env.SHOW_UPCOMING_CHANGES !== 'true'
|
||||
) {
|
||||
debug(`Ignoring upcoming block ${blockName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const superOrder = getSuperOrder(superBlock);
|
||||
const meta = {
|
||||
...block,
|
||||
superOrder,
|
||||
superBlock,
|
||||
order,
|
||||
...(block.chapter && { chapter: block.chapter }),
|
||||
...(block.module && { module: block.module })
|
||||
};
|
||||
const isAudited = isAuditedSuperBlock(this.lang, superBlock);
|
||||
|
||||
// Read challenges from directory
|
||||
const foundChallenges = await this.readBlockChallenges(
|
||||
blockName,
|
||||
meta,
|
||||
isAudited
|
||||
);
|
||||
debug(`Found ${foundChallenges.length} challenge files in directory`);
|
||||
|
||||
// Log found challenges
|
||||
foundChallenges.forEach(challenge => {
|
||||
debug(`Found challenge: ${challenge.title} (${challenge.id})`);
|
||||
});
|
||||
|
||||
const throwOnError = this.lang === 'english';
|
||||
// Validate challenges against meta
|
||||
if (!this.skipValidation)
|
||||
validateChallenges(foundChallenges, meta, throwOnError);
|
||||
|
||||
// Build the block object
|
||||
const blockResult = buildBlock(foundChallenges, meta);
|
||||
|
||||
debug(
|
||||
`Completed block "${meta.name}" with ${blockResult.challenges.length} challenges (${blockResult.challenges.filter(c => !c.missing).length} built successfully)`
|
||||
);
|
||||
|
||||
return blockResult;
|
||||
}
|
||||
}
|
||||
|
||||
class SuperblockCreator {
|
||||
/**
|
||||
* @param {object} options - Options object
|
||||
* @param {BlockCreator} options.blockCreator - Instance of BlockCreator
|
||||
*/
|
||||
constructor({ blockCreator }) {
|
||||
this.blockCreator = blockCreator;
|
||||
}
|
||||
|
||||
async processSuperblock({ blocks, name }) {
|
||||
const superBlock = { blocks: {} };
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
const blockResult = await this.blockCreator.processBlock(block, {
|
||||
superBlock: name,
|
||||
order: i
|
||||
});
|
||||
if (blockResult) {
|
||||
superBlock.blocks[block.dashedName] = blockResult;
|
||||
}
|
||||
}
|
||||
|
||||
debug(
|
||||
`Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}`
|
||||
);
|
||||
return superBlock;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms superblock data to extract blocks array
|
||||
* @param {object} superblockData - The superblock data object
|
||||
* @returns {object[]} Array of block objects with dashedName, chapter, and module properties
|
||||
*/
|
||||
function transformSuperBlock(
|
||||
superblockData,
|
||||
{ showComingSoon } = { showComingSoon: false }
|
||||
) {
|
||||
let blocks = [];
|
||||
|
||||
// Handle simple blocks array format
|
||||
if (superblockData.blocks) {
|
||||
blocks = superblockData.blocks.map(dashedName => ({
|
||||
dashedName
|
||||
}));
|
||||
}
|
||||
// Handle nested chapters/modules/blocks format
|
||||
else if (superblockData.chapters) {
|
||||
for (const chapter of superblockData.chapters) {
|
||||
if (chapter.comingSoon && !showComingSoon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chapter.modules) {
|
||||
for (const module of chapter.modules) {
|
||||
if (module.comingSoon && !showComingSoon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (module.blocks) {
|
||||
for (const block of module.blocks) {
|
||||
blocks.push({
|
||||
dashedName: block,
|
||||
chapter: chapter.dashedName,
|
||||
module: module.dashedName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(blocks)) {
|
||||
throw Error(`No blocks found in superblock data`);
|
||||
}
|
||||
|
||||
const blockNames = blocks.map(block => block.dashedName);
|
||||
debug(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SuperblockCreator,
|
||||
BlockCreator,
|
||||
addMetaToChallenge,
|
||||
validateChallenges,
|
||||
buildBlock,
|
||||
finalizeChallenge,
|
||||
transformSuperBlock,
|
||||
fixChallengeProperties
|
||||
};
|
||||
586
curriculum/build-superblock.test.js
Normal file
586
curriculum/build-superblock.test.js
Normal file
@@ -0,0 +1,586 @@
|
||||
const { isPoly } = require('../shared/utils/polyvinyl');
|
||||
const {
|
||||
validateChallenges,
|
||||
buildBlock,
|
||||
transformSuperBlock,
|
||||
addMetaToChallenge,
|
||||
fixChallengeProperties,
|
||||
SuperblockCreator,
|
||||
finalizeChallenge
|
||||
} = require('./build-superblock');
|
||||
|
||||
const dummyFullStackSuperBlock = {
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter-1',
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module-1',
|
||||
blocks: ['block-1', 'block-2']
|
||||
},
|
||||
{
|
||||
dashedName: 'module-2',
|
||||
blocks: ['block-3', 'block-4']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
dashedName: 'chapter-2',
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module-3',
|
||||
blocks: ['block-5', 'block-6']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Includes "comingSoon" modules and chapters
|
||||
const dummyUnfinishedSuperBlock = {
|
||||
chapters: [
|
||||
{
|
||||
dashedName: 'chapter-1',
|
||||
modules: [
|
||||
{
|
||||
comingSoon: true,
|
||||
dashedName: 'module-1',
|
||||
blocks: ['block-1', 'block-2']
|
||||
},
|
||||
{
|
||||
dashedName: 'module-2',
|
||||
blocks: ['block-3', 'block-4']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
dashedName: 'chapter-2',
|
||||
comingSoon: true,
|
||||
modules: [
|
||||
{
|
||||
dashedName: 'module-3',
|
||||
blocks: ['block-5', 'block-6']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const dummyBlockMeta = {
|
||||
name: 'Test Block',
|
||||
blockLayout: 'challenge-list',
|
||||
blockType: 'workshop',
|
||||
isUpcomingChange: false,
|
||||
dashedName: 'test-block',
|
||||
superBlock: 'responsive-web-design',
|
||||
order: 1,
|
||||
superOrder: 2,
|
||||
usesMultifileEditor: true,
|
||||
hasEditableBoundaries: false,
|
||||
disableLoopProtectTests: true,
|
||||
template: 'html/css',
|
||||
required: [
|
||||
{
|
||||
link: 'https://example.com/style.css',
|
||||
raw: false,
|
||||
src: 'style.css'
|
||||
}
|
||||
],
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
],
|
||||
helpCategory: 'HTML-CSS'
|
||||
};
|
||||
|
||||
const dummyChallenge = {
|
||||
challengeFiles: [
|
||||
{
|
||||
spuriousProp: '1',
|
||||
name: 'file1',
|
||||
ext: 'js',
|
||||
history: [],
|
||||
contents: 'console.log("Hello")',
|
||||
// head and tail should not be required, but they currently are
|
||||
head: '',
|
||||
tail: ''
|
||||
},
|
||||
{
|
||||
spuriousProp: '2',
|
||||
name: 'file2',
|
||||
ext: 'css',
|
||||
history: [],
|
||||
contents: 'body { background: red; }',
|
||||
head: '',
|
||||
tail: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const expectedChallengeProperties = {
|
||||
id: expect.any(String),
|
||||
challengeOrder: expect.any(Number),
|
||||
isLastChallengeInBlock: expect.any(Boolean),
|
||||
block: dummyBlockMeta.dashedName,
|
||||
blockLayout: dummyBlockMeta.blockLayout,
|
||||
blockType: dummyBlockMeta.blockType,
|
||||
hasEditableBoundaries: dummyBlockMeta.hasEditableBoundaries,
|
||||
order: dummyBlockMeta.order,
|
||||
description: '',
|
||||
instructions: '',
|
||||
questions: [],
|
||||
superOrder: dummyBlockMeta.superOrder,
|
||||
certification: dummyBlockMeta.superBlock,
|
||||
superBlock: dummyBlockMeta.superBlock,
|
||||
required: dummyBlockMeta.required,
|
||||
template: dummyBlockMeta.template,
|
||||
helpCategory: dummyBlockMeta.helpCategory,
|
||||
usesMultifileEditor: dummyBlockMeta.usesMultifileEditor,
|
||||
disableLoopProtectTests: dummyBlockMeta.disableLoopProtectTests,
|
||||
disableLoopProtectPreview: false
|
||||
};
|
||||
|
||||
describe('buildSuperblock pure functions', () => {
|
||||
describe('validateChallenges', () => {
|
||||
test('should not throw when all challenges match meta', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateChallenges(foundChallenges, meta, true)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should throw when challenges are missing from meta', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' },
|
||||
{ id: '3', title: 'Challenge 3' } // Extra challenge
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Challenges found in directory but missing from meta: 3'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when challenge files are missing', () => {
|
||||
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' } // Missing file
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Challenges in meta but missing files with id(s): 2'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when multiple challenges are missing from meta', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' },
|
||||
{ id: '3', title: 'Challenge 3' },
|
||||
{ id: '4', title: 'Challenge 4' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Challenges found in directory but missing from meta: 3, 4'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when multiple challenge files are missing', () => {
|
||||
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' },
|
||||
{ id: '3', title: 'Challenge 3' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Challenges in meta but missing files with id(s): 2, 3'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw if there are duplicate challenges in the meta', () => {
|
||||
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '1', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Duplicate challenges found in meta with id(s): 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw if there are duplicate found challenges', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '1', title: 'Challenge 2' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [{ id: '1', title: 'Challenge 1' }]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Duplicate challenges found in found challenges with id(s): 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw if there are duplicate titles in the meta', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 1' } // Duplicate title
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Duplicate titles found in meta with title(s): Challenge 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw if there are duplicate titles in the found challenges', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 1' } // Duplicate title
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => validateChallenges(foundChallenges, meta, true)).toThrow(
|
||||
'Duplicate titles found in found challenges with title(s): Challenge 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('should log errors for duplicate titles in meta if shouldThrow is false', () => {
|
||||
jest.spyOn(console, 'error');
|
||||
const foundChallenges = [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 1' } // Duplicate title
|
||||
]
|
||||
};
|
||||
|
||||
validateChallenges(foundChallenges, meta, false);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Duplicate titles found in meta with title(s): Challenge 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlock', () => {
|
||||
test('should build block with ordered challenges', () => {
|
||||
const foundChallenges = [
|
||||
{ id: '2', title: 'Challenge 2' },
|
||||
{ id: '1', title: 'Challenge 1' }
|
||||
];
|
||||
|
||||
const meta = {
|
||||
name: 'Test Block',
|
||||
dashedName: 'test-block',
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' },
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildBlock(foundChallenges, meta);
|
||||
|
||||
expect(result.challenges).toHaveLength(2);
|
||||
expect(result.challenges[0].id).toBe('1'); // First in order
|
||||
expect(result.challenges[1].id).toBe('2'); // Second in order
|
||||
});
|
||||
|
||||
test('should throw if challenges are missing', () => {
|
||||
const foundChallenges = [{ id: '2', title: 'Challenge 2' }];
|
||||
|
||||
const meta = {
|
||||
name: 'Test Block',
|
||||
dashedName: 'test-block',
|
||||
challengeOrder: [
|
||||
{ id: '1', title: 'Challenge 1' }, // Missing
|
||||
{ id: '2', title: 'Challenge 2' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => buildBlock(foundChallenges, meta)).toThrow(
|
||||
'Challenge 1 (Challenge 1) not found in block'
|
||||
);
|
||||
});
|
||||
|
||||
test('should return the passed in meta', () => {
|
||||
const foundChallenges = [{ id: '1', title: 'Challenge 1' }];
|
||||
|
||||
const meta = {
|
||||
name: 'Test Block',
|
||||
dashedName: 'test-block',
|
||||
challengeOrder: [{ id: '1', title: 'Challenge 1' }]
|
||||
};
|
||||
|
||||
const result = buildBlock(foundChallenges, meta);
|
||||
|
||||
expect(result.meta).toEqual(meta);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMetaToChallenge', () => {
|
||||
test('should add meta properties to challenge', () => {
|
||||
const challenge = { id: '1' };
|
||||
|
||||
addMetaToChallenge(challenge, dummyBlockMeta);
|
||||
|
||||
expect(challenge).toEqual(expectedChallengeProperties);
|
||||
});
|
||||
|
||||
test('should add chapter and module properties when present in meta', () => {
|
||||
const challenge = { id: '1' };
|
||||
const metaWithChapterAndModule = {
|
||||
...dummyBlockMeta,
|
||||
chapter: 'chapter-1',
|
||||
module: 'module-1'
|
||||
};
|
||||
|
||||
addMetaToChallenge(challenge, metaWithChapterAndModule);
|
||||
|
||||
expect(challenge).toMatchObject({
|
||||
...expectedChallengeProperties,
|
||||
chapter: 'chapter-1',
|
||||
module: 'module-1'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeChallenge', () => {
|
||||
it('should add meta properties', async () => {
|
||||
const challenge = finalizeChallenge(
|
||||
{
|
||||
id: '1'
|
||||
},
|
||||
{ challengeOrder: [{ id: '1' }] }
|
||||
);
|
||||
|
||||
expect(challenge).toMatchObject({
|
||||
id: '1',
|
||||
isLastChallengeInBlock: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixChallengeProperties', () => {
|
||||
test("should ensure all challengeFiles are 'polyvinyls'", () => {
|
||||
dummyChallenge.challengeFiles.forEach(file => {
|
||||
expect(isPoly(file)).toBe(false);
|
||||
});
|
||||
|
||||
const fixedChallenge = fixChallengeProperties(dummyChallenge);
|
||||
expect(fixedChallenge.challengeFiles).toHaveLength(2);
|
||||
fixedChallenge.challengeFiles.forEach(file =>
|
||||
expect(isPoly(file)).toBe(true)
|
||||
);
|
||||
|
||||
const seeds = fixedChallenge.challengeFiles.map(file => file.seed);
|
||||
expect(seeds[0]).toBe(dummyChallenge.challengeFiles[0].contents);
|
||||
expect(seeds[1]).toBe(dummyChallenge.challengeFiles[1].contents);
|
||||
});
|
||||
|
||||
test("should ensure all the solutions are arrays of 'polyvinyls'", () => {
|
||||
const challengeWithSolutions = {
|
||||
...dummyChallenge,
|
||||
solutions: [dummyChallenge.challengeFiles]
|
||||
};
|
||||
|
||||
const fixedChallenge = fixChallengeProperties(challengeWithSolutions);
|
||||
expect(fixedChallenge.solutions).toHaveLength(1);
|
||||
fixedChallenge.solutions.forEach(solution => {
|
||||
expect(Array.isArray(solution)).toBe(true);
|
||||
solution.forEach(file => expect(isPoly(file)).toBe(true));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformSuperBlock', () => {
|
||||
test('should return the blocks array when valid superblock data is provided', () => {
|
||||
const superblockData = {
|
||||
blocks: ['basic-html-and-html5', 'basic-css', 'applied-visual-design']
|
||||
};
|
||||
|
||||
const result = transformSuperBlock(superblockData);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ dashedName: 'basic-html-and-html5' },
|
||||
{ dashedName: 'basic-css' },
|
||||
{ dashedName: 'applied-visual-design' }
|
||||
]);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should return single block when superblock has one block', () => {
|
||||
const superblockData = {
|
||||
blocks: ['basic-html-and-html5']
|
||||
};
|
||||
|
||||
const result = transformSuperBlock(superblockData);
|
||||
|
||||
expect(result).toEqual([{ dashedName: 'basic-html-and-html5' }]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should throw an error when the blocks array is empty', () => {
|
||||
const superblockData = {
|
||||
blocks: []
|
||||
};
|
||||
|
||||
expect(() => transformSuperBlock(superblockData)).toThrow(
|
||||
'No blocks found in superblock data'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle superblock data with other properties', () => {
|
||||
const superblockData = {
|
||||
name: 'Responsive Web Design',
|
||||
dashedName: 'responsive-web-design',
|
||||
blocks: ['basic-html-and-html5', 'basic-css'],
|
||||
otherProperty: 'should be ignored'
|
||||
};
|
||||
|
||||
const result = transformSuperBlock(superblockData);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ dashedName: 'basic-html-and-html5' },
|
||||
{ dashedName: 'basic-css' }
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should transform superblocks with nested chapters and modules', () => {
|
||||
const result = transformSuperBlock(dummyFullStackSuperBlock);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ dashedName: 'block-1', chapter: 'chapter-1', module: 'module-1' },
|
||||
{ dashedName: 'block-2', chapter: 'chapter-1', module: 'module-1' },
|
||||
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
|
||||
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' },
|
||||
{ dashedName: 'block-5', chapter: 'chapter-2', module: 'module-3' },
|
||||
{ dashedName: 'block-6', chapter: 'chapter-2', module: 'module-3' }
|
||||
]);
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("should omit 'comingSoon' modules and chapters", () => {
|
||||
const result = transformSuperBlock(dummyUnfinishedSuperBlock);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
|
||||
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' }
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("should NOT omit 'comingSoon' modules and chapters if showComingSoon is true", () => {
|
||||
const result = transformSuperBlock(dummyUnfinishedSuperBlock, {
|
||||
showComingSoon: true
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ dashedName: 'block-1', chapter: 'chapter-1', module: 'module-1' },
|
||||
{ dashedName: 'block-2', chapter: 'chapter-1', module: 'module-1' },
|
||||
{ dashedName: 'block-3', chapter: 'chapter-1', module: 'module-2' },
|
||||
{ dashedName: 'block-4', chapter: 'chapter-1', module: 'module-2' },
|
||||
{ dashedName: 'block-5', chapter: 'chapter-2', module: 'module-3' },
|
||||
{ dashedName: 'block-6', chapter: 'chapter-2', module: 'module-3' }
|
||||
]);
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SuperblockCreator class', () => {
|
||||
describe('processSuperblock', () => {
|
||||
test('should process blocks using the provided processing function', async () => {
|
||||
const mockProcessBlockFn = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Block 1')
|
||||
.mockResolvedValueOnce('Block 2')
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const mockBlockCreator = {
|
||||
processBlock: mockProcessBlockFn
|
||||
};
|
||||
|
||||
const blocks = [
|
||||
{ dashedName: 'block-1' },
|
||||
{ dashedName: 'block-2' },
|
||||
{ dashedName: 'block-3' }
|
||||
];
|
||||
|
||||
const parser = new SuperblockCreator({
|
||||
blockCreator: mockBlockCreator
|
||||
});
|
||||
|
||||
const result = await parser.processSuperblock({
|
||||
blocks,
|
||||
name: 'test-superblock'
|
||||
});
|
||||
|
||||
expect(mockProcessBlockFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(result).toEqual({
|
||||
blocks: {
|
||||
'block-1': 'Block 1',
|
||||
'block-2': 'Block 2'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "Build a Color Picker App",
|
||||
"isUpcomingChange": false,
|
||||
"usesMultifileEditor": true,
|
||||
"dashedName": "lab-color-picker",
|
||||
"superBlock": "full-stack-developer",
|
||||
"challengeOrder": [{ "id": "67bf4350777ac6ffdc027745", "title": "Build a Color Picker App" }],
|
||||
"helpCategory": "JavaScript",
|
||||
"blockType": "lab",
|
||||
"blockLayout": "link",
|
||||
"disableLoopProtectPreview": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user