refactor: migrate (some) curriculum files to TypeScript (#62228)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2025-10-23 07:24:57 +02:00
committed by GitHub
parent 0d44fff1ff
commit 4a635c1b32
62 changed files with 909 additions and 582 deletions

View File

@@ -155,7 +155,7 @@ jobs:
- name: Install and Build
run: |
pnpm install
pnpm run create:shared
pnpm compile:ts
pnpm run build:curriculum
- name: Start apps

View File

@@ -138,7 +138,7 @@ jobs:
- name: Install and Build
run: |
pnpm install
pnpm run create:shared
pnpm compile:ts
pnpm run build:curriculum
- name: Start apps

View File

@@ -68,7 +68,7 @@ jobs:
- name: Lint Source Files
run: |
echo pnpm version $(pnpm -v)
pnpm run create:shared
pnpm compile:ts
pnpm run build:curriculum
pnpm run lint

6
.gitignore vendored
View File

@@ -152,7 +152,8 @@ jspm_packages/
.netlify
### Generated config files ###
shared/config/curriculum.json
shared/tsconfig.tsbuildinfo
curriculum/tsconfig.tsbuildinfo
### Old Generated files ###
# These files are no longer generated by the client, but can
@@ -195,7 +196,7 @@ curriculum/curricula.json
### Additional Folders ###
curriculum/dist
curriculum/build
curriculum/test/blocks-generated
curriculum/src/test/blocks-generated
shared-dist
### Playwright ###
@@ -204,3 +205,4 @@ shared-dist
### Shadow Testing Log Files Folder ###
api/logs/

View File

@@ -52,6 +52,7 @@ tasks:
cp sample.env .env &&
pnpm install &&
gp sync-done pnpm-install &&
pnpm compile:ts &&
pnpm run build:curriculum &&
gp ports await 27017
command: >

View File

@@ -7,7 +7,7 @@ import { readFileSync } from 'fs';
import { fileURLToPath } from 'node:url';
import { join, dirname } from 'path';
const CURRICULUM_PATH = '../../../shared/config/curriculum.json';
const CURRICULUM_PATH = '../../../shared-dist/config/curriculum.json';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Curriculum is read using fs, because it is too large for VSCode's LSP to handle type inference which causes annoying behavior.
const curriculum = JSON.parse(

View File

@@ -23,7 +23,7 @@
"build": "NODE_OPTIONS=\"--max-old-space-size=7168 --no-deprecation\" gatsby build --prefix-paths",
"build:scripts": "pnpm run -F=browser-scripts build",
"clean": "gatsby clean",
"common-setup": "pnpm -w run create:shared && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder",
"common-setup": "pnpm -w run compile:ts && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder",
"create:env": "DEBUG=fcc:* tsx ./tools/create-env.ts",
"create:trending": "tsx ./tools/download-trending.ts",
"create:search-placeholder": "tsx ./tools/generate-search-placeholder",

View File

@@ -2,20 +2,24 @@ const path = require('path');
const _ = require('lodash');
const { getChallengesForLang } = require('../../curriculum/get-challenges');
const {
getChallengesForLang
} = require('../../curriculum/dist/get-challenges.js');
const {
getContentDir,
getBlockCreator,
getSuperblocks,
superBlockToFilename
} = require('../../curriculum/build-curriculum');
} = require('../../curriculum/dist/build-curriculum.js');
const {
getContentDir,
getBlockStructure,
getSuperblockStructure
} = require('../../curriculum/file-handler');
const { transformSuperBlock } = require('../../curriculum/build-superblock');
const { getSuperOrder } = require('../../curriculum/utils');
} = require('../../curriculum/dist/file-handler.js');
const {
transformSuperBlock
} = require('../../curriculum/dist/build-superblock.js');
const { getSuperOrder } = require('../../curriculum/dist/utils.js');
const curriculumLocale = process.env.CURRICULUM_LOCALE || 'english';

View File

@@ -1,8 +0,0 @@
const fs = require('fs');
const yaml = require('js-yaml');
const buildCertification = filePath => ({
challenges: [yaml.load(fs.readFileSync(filePath, 'utf8'))]
});
module.exports = { buildCertification };

View File

@@ -1,207 +0,0 @@
const path = require('node:path');
const assert = require('node:assert');
const fs = require('node:fs');
const fsP = require('node:fs/promises');
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 getBlockStructureDir() {
return BLOCK_STRUCTURE_DIR;
}
function getBlockStructure(block) {
return JSON.parse(fs.readFileSync(getBlockStructurePath(block), 'utf8'));
}
async function writeBlockStructure(block, structure) {
// dynamically importing prettier because Gatsby build and develop fail when
// it's required.
const prettier = await import('prettier');
const content = await prettier.format(JSON.stringify(structure), {
parser: 'json'
});
await fsP.writeFile(getBlockStructurePath(block), content, 'utf8');
}
async function writeSuperblockStructure(superblock, structure) {
// dynamically importing prettier because Gatsby build and develop fail when
// it's required.
const prettier = await import('prettier');
const content = await prettier.format(JSON.stringify(structure), {
parser: 'json'
});
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 } = {
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 i18n block content directory: ${i18nBlockContentDir}`);
debug(`Using dictionaries directory: ${dictionariesDir}`);
debug(`Using i18n dictionaries directory: ${i18nDictionariesDir}`);
return {
contentDir,
i18nContentDir,
blockContentDir,
i18nBlockContentDir,
dictionariesDir,
i18nDictionariesDir
};
}
exports.getContentConfig = getContentConfig;
exports.getContentDir = getContentDir;
exports.getBlockStructureDir = getBlockStructureDir;
exports.getBlockStructure = getBlockStructure;
exports.getBlockStructurePath = getBlockStructurePath;
exports.getSuperblockStructure = getSuperblockStructure;
exports.getCurriculumStructure = getCurriculumStructure;
exports.writeBlockStructure = writeBlockStructure;
exports.writeSuperblockStructure = writeSuperblockStructure;
exports.getLanguageConfig = getLanguageConfig;

View File

@@ -1,33 +0,0 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const { curriculum: curriculumLangs } =
require('../shared-dist/config/i18n').availableLangs;
const { buildCurriculum } = require('./build-curriculum');
const access = util.promisify(fs.access);
exports.getChallengesForLang = async function getChallengesForLang(
lang,
filters
) {
const invalidLang = !curriculumLangs.includes(lang);
if (invalidLang)
throw Error(`${lang} is not an accepted language.
Accepted languages are ${curriculumLangs.join(', ')}`);
return buildCurriculum(lang, filters);
};
async function hasEnglishSource(basePath, translationPath) {
const englishRoot = path.resolve(__dirname, basePath, 'english');
return await access(
path.join(englishRoot, translationPath),
fs.constants.F_OK
)
.then(() => true)
.catch(() => false);
}
exports.hasEnglishSource = hasEnglishSource;

View File

@@ -31,17 +31,20 @@
"delete-step": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-step",
"delete-challenge": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
"delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task",
"lint": "tsx --tsconfig ../tsconfig.json lint-localized",
"lint": "tsx --tsconfig ../tsconfig.json src/lint-localized",
"reorder-tasks": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks",
"update-challenge-order": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order",
"update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
"test-gen": "node ./test/utils/generate-block-tests.mjs",
"test": "NODE_OPTIONS='--max-old-space-size=7168' pnpm -s test-gen && vitest -c test/vitest.config.mjs"
"test-gen": "tsx ./src/test/utils/generate-block-tests.ts",
"test": "NODE_OPTIONS='--max-old-space-size=7168' pnpm -s test-gen && vitest -c src/test/vitest.config.mjs"
},
"devDependencies": {
"@babel/core": "7.23.7",
"@babel/register": "7.23.7",
"@types/polka": "^0.5.7",
"@total-typescript/ts-reset": "^0.6.1",
"@types/debug": "^4.1.12",
"@types/js-yaml": "4.0.5",
"@types/string-similarity": "^4.0.2",
"@vitest/ui": "^3.2.4",
"chai": "4.4.1",

View File

@@ -3,7 +3,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { describe, test, expect } from 'vitest';
import { allCerts } from '../client/config/cert-and-project-map.js';
import { allCerts } from '../../client/config/cert-and-project-map.js';
import { buildCertification } from './build-certification.js';
const __filename = fileURLToPath(import.meta.url);
@@ -12,6 +12,7 @@ const __dirname = path.dirname(__filename);
describe('build-certification', () => {
const certificationsDir = path.join(
__dirname,
'..',
'challenges/english/certifications'
);
const yamlFiles = fs

View File

@@ -0,0 +1,6 @@
import { readFileSync } from 'fs';
import { load } from 'js-yaml';
export const buildCertification = (filePath: string) => ({
challenges: [load(readFileSync(filePath, 'utf8'))]
});

View File

@@ -1,22 +1,27 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, vi } from 'vitest';
import { SuperBlocks } from '../../shared/config/curriculum.js';
import {
createCommentMap,
addBlockStructure,
getSuperblocks
getSuperblocks,
superBlockNames
} from './build-curriculum.js';
import { getCurriculumStructure } from './file-handler.js';
vi.mock('./file-handler');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('createCommentMap', () => {
const dictionaryDir = path.resolve(__dirname, '__fixtures__', 'dictionaries');
const dictionaryDir = path.resolve(
import.meta.dirname,
'..',
'__fixtures__',
'dictionaries'
);
const incompleteDictDir = path.resolve(
__dirname,
import.meta.dirname,
'..',
'__fixtures__',
'incomplete-dicts'
);
@@ -169,3 +174,13 @@ describe('getSuperblocks', () => {
]);
});
});
describe('superBlockNames', () => {
it('should have mappings for each SuperBlock', () => {
const superBlocks = Object.values(SuperBlocks).sort(); // sorting to make comparison clearer
const names = Object.values(superBlockNames).sort();
expect(names).toHaveLength(superBlocks.length);
expect(names).toEqual(expect.arrayContaining(superBlocks));
});
});

View File

@@ -1,26 +1,36 @@
const fs = require('fs');
const path = require('path');
import { readdirSync, readFileSync, existsSync } from 'fs';
import { resolve, basename } from 'path';
const { isEmpty, isUndefined } = require('lodash');
const debug = require('debug')('fcc:build-curriculum');
import { isEmpty, isUndefined } from 'lodash';
import debug from 'debug';
const {
import type { CommentDictionary } from '../../tools/challenge-parser/translation-parser/index.js';
import { SuperBlocks } from '../../shared-dist/config/curriculum.js';
import {
SuperblockCreator,
BlockCreator,
transformSuperBlock
} = require('./build-superblock');
transformSuperBlock,
BlockInfo
} from './build-superblock.js';
const { buildCertification } = require('./build-certification');
const { applyFilters, closestFilters, getSuperOrder } = require('./utils');
const {
import { buildCertification } from './build-certification.js';
import {
applyFilters,
closestFilters,
Filter,
getSuperOrder
} from './utils.js';
import {
getContentDir,
getLanguageConfig,
getCurriculumStructure,
getBlockStructure,
getSuperblockStructure,
getBlockStructurePath,
getBlockStructureDir
} = require('./file-handler');
getBlockStructureDir,
type BlockStructure
} from './file-handler.js';
const log = debug('fcc:build-curriculum');
/**
* Creates a BlockCreator instance for a specific language with appropriate configuration
@@ -32,7 +42,11 @@ const {
* @param {string} [opts.structureDir] - Directory containing curriculum structure
* @returns {BlockCreator} A configured BlockCreator instance
*/
const getBlockCreator = (lang, skipValidation, opts) => {
export const getBlockCreator = (
lang: string,
skipValidation?: boolean,
opts?: { baseDir: string; i18nBaseDir: string; structureDir: string }
) => {
const {
blockContentDir,
i18nBlockContentDir,
@@ -63,9 +77,12 @@ const getBlockCreator = (lang, skipValidation, opts) => {
* @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 }) {
export function getTranslationEntry(
dicts: Record<string, Record<string, unknown>>,
{ engId, text }: { engId: string; text: string }
) {
return Object.keys(dicts).reduce((acc, lang) => {
const entry = dicts[lang][engId];
const entry = dicts[lang]?.[engId];
if (entry) {
return { ...acc, [lang]: entry };
} else {
@@ -81,19 +98,18 @@ function getTranslationEntry(dicts, { engId, text }) {
* @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(
export function createCommentMap(
dictionariesDir: string,
targetDictionariesDir: string
): CommentDictionary {
log(
`Creating comment map from ${dictionariesDir} and ${targetDictionariesDir}`
);
const languages = fs.readdirSync(targetDictionariesDir);
const languages = readdirSync(targetDictionariesDir);
const dictionaries = languages.reduce((acc, lang) => {
const commentsPath = path.resolve(
targetDictionariesDir,
lang,
'comments.json'
);
const commentsData = JSON.parse(fs.readFileSync(commentsPath, 'utf8'));
const commentsPath = resolve(targetDictionariesDir, lang, 'comments.json');
const commentsData = JSON.parse(readFileSync(commentsPath, 'utf8'));
return {
...acc,
[lang]: commentsData
@@ -101,22 +117,15 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
}, {});
const COMMENTS_TO_TRANSLATE = JSON.parse(
fs.readFileSync(
path.resolve(dictionariesDir, 'english', 'comments.json'),
'utf8'
)
);
readFileSync(resolve(dictionariesDir, 'english', 'comments.json'), 'utf8')
) as Record<string, string>;
const COMMENTS_TO_NOT_TRANSLATE = JSON.parse(
fs.readFileSync(
path.resolve(
dictionariesDir,
'english',
'comments-to-not-translate.json'
),
readFileSync(
resolve(dictionariesDir, 'english', 'comments-to-not-translate.json'),
'utf8'
)
);
) as Record<string, string>;
// map from english comment text to translations
const translatedCommentMap = Object.entries(COMMENTS_TO_TRANSLATE).reduce(
@@ -126,7 +135,7 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
[text]: getTranslationEntry(dictionaries, { engId: id, text })
};
},
{}
{} as CommentDictionary
);
// map from english comment text to itself
@@ -144,63 +153,62 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
...acc,
[text]: englishEntry
};
}, {});
}, {} as CommentDictionary);
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;
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',
export const superBlockNames = {
'responsive-web-design': SuperBlocks.RespWebDesign,
'javascript-algorithms-and-data-structures': SuperBlocks.JsAlgoDataStruct,
'front-end-development-libraries': SuperBlocks.FrontEndDevLibs,
'data-visualization': SuperBlocks.DataVis,
'back-end-development-and-apis': SuperBlocks.BackEndDevApis,
'quality-assurance': SuperBlocks.QualityAssurance,
'scientific-computing-with-python': SuperBlocks.SciCompPy,
'data-analysis-with-python': SuperBlocks.DataAnalysisPy,
'information-security': SuperBlocks.InfoSec,
'coding-interview-prep': SuperBlocks.CodingInterviewPrep,
'machine-learning-with-python': SuperBlocks.MachineLearningPy,
'relational-databases': SuperBlocks.RelationalDb,
'responsive-web-design-22': SuperBlocks.RespWebDesignNew,
'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',
'a1-professional-spanish': 'a1-professional-spanish',
'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',
'full-stack-open': 'full-stack-open',
'responsive-web-design-v9': 'responsive-web-design-v9',
'javascript-v9': 'javascript-v9',
'front-end-development-libraries-v9': 'front-end-development-libraries-v9',
'python-v9': 'python-v9',
'relational-databases-v9': 'relational-databases-v9',
'back-end-development-and-apis-v9': 'back-end-development-and-apis-v9'
SuperBlocks.JsAlgoDataStructNew,
'javascript-v9': SuperBlocks.JsV9,
'the-odin-project': SuperBlocks.TheOdinProject,
'college-algebra-with-python': SuperBlocks.CollegeAlgebraPy,
'project-euler': SuperBlocks.ProjectEuler,
'foundational-c-sharp-with-microsoft': SuperBlocks.FoundationalCSharp,
'a2-english-for-developers': SuperBlocks.A2English,
'rosetta-code': SuperBlocks.RosettaCode,
'python-for-everybody': SuperBlocks.PythonForEverybody,
'b1-english-for-developers': SuperBlocks.B1English,
'full-stack-developer': SuperBlocks.FullStackDeveloper,
'a1-professional-spanish': SuperBlocks.A1Spanish,
'a2-professional-spanish': SuperBlocks.A2Spanish,
'a2-professional-chinese': SuperBlocks.A2Chinese,
'basic-html': SuperBlocks.BasicHtml,
'semantic-html': SuperBlocks.SemanticHtml,
'a1-professional-chinese': SuperBlocks.A1Chinese,
'dev-playground': SuperBlocks.DevPlayground,
'full-stack-open': SuperBlocks.FullStackOpen,
'responsive-web-design-v9': SuperBlocks.RespWebDesignV9,
'front-end-development-libraries-v9': SuperBlocks.FrontEndDevLibsV9,
'python-v9': SuperBlocks.PythonV9,
'relational-databases-v9': SuperBlocks.RelationalDbV9,
'back-end-development-and-apis-v9': SuperBlocks.BackEndDevApisV9
};
const superBlockToFilename = Object.entries(superBlockNames).reduce(
export const superBlockToFilename = Object.entries(superBlockNames).reduce(
(map, entry) => {
return { ...map, [entry[1]]: entry[0] };
},
@@ -210,41 +218,44 @@ const superBlockToFilename = Object.entries(superBlockNames).reduce(
/**
* Builds an array of superblock structures from a curriculum object
* @param {string[]} superblocks - Array of superblock filename strings
* @param {string[]} superBlockFilenames - 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,
export function addSuperblockStructure(
superBlockFilenames: string[],
showComingSoon = process.env.SHOW_UPCOMING_CHANGES === 'true'
) {
debug(`Building structure for ${superblocks.length} superblocks`);
log(`Building structure for ${superBlockFilenames.length} superblocks`);
const superblockStructures = superblocks.map(superblockFilename => {
const superblockName = superBlockNames[superblockFilename];
const superblockStructures = superBlockFilenames.map(filename => {
const superblockName =
superBlockNames[filename as keyof typeof superBlockNames];
if (!superblockName) {
throw new Error(`Superblock name not found for ${superblockFilename}`);
throw new Error(`Superblock name not found for ${filename}`);
}
return {
name: superblockName,
blocks: transformSuperBlock(getSuperblockStructure(superblockFilename), {
blocks: transformSuperBlock(getSuperblockStructure(filename), {
showComingSoon
})
};
});
debug(
log(
`Successfully built ${superblockStructures.length} superblock structures`
);
return superblockStructures;
}
function addBlockStructure(
superblocks,
type ProcessedBlock = BlockInfo & BlockStructure;
export function addBlockStructure(
superblocks: { name: SuperBlocks; blocks: BlockInfo[] }[],
_getBlockStructure = getBlockStructure
) {
): { name: SuperBlocks; blocks: ProcessedBlock[] }[] {
return superblocks.map(superblock => ({
...superblock,
blocks: superblock.blocks.map((block, index) => ({
@@ -260,8 +271,8 @@ function addBlockStructure(
* Returns a list of all the superblocks that contain the given block
* @param {string} block
*/
function getSuperblocks(
block,
export function getSuperblocks(
block: string,
_addSuperblockStructure = addSuperblockStructure
) {
const { superblocks } = getCurriculumStructure();
@@ -274,23 +285,23 @@ function getSuperblocks(
.map(({ name }) => name);
}
function validateBlocks(superblocks, blockStructureDir) {
function validateBlocks(superblocks: SuperBlocks[], blockStructureDir: string) {
const withSuperblockStructure = addSuperblockStructure(superblocks, true);
const blockInSuperblocks = withSuperblockStructure
.flatMap(({ blocks }) => blocks)
.map(b => b.dashedName);
for (const block of blockInSuperblocks) {
const blockPath = getBlockStructurePath(block);
if (!fs.existsSync(blockPath)) {
if (!existsSync(blockPath)) {
throw Error(
`Block "${block}" is in a superblock, but has no block structure file at ${blockPath}`
);
}
}
const blockStructureFiles = fs
.readdirSync(blockStructureDir)
.map(file => path.basename(file, '.json'));
const blockStructureFiles = readdirSync(blockStructureDir).map(file =>
basename(file, '.json')
);
for (const block of blockStructureFiles) {
if (!blockInSuperblocks.includes(block)) {
@@ -301,40 +312,45 @@ function validateBlocks(superblocks, blockStructureDir) {
}
}
async function parseCurriculumStructure(filters) {
export async function parseCurriculumStructure(filter?: Filter) {
const curriculum = getCurriculumStructure();
const blockStructureDir = getBlockStructureDir();
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`);
log(`Found ${curriculum.superblocks.length} superblocks to build`);
log(`Found ${curriculum.certifications.length} certifications to build`);
validateBlocks(curriculum.superblocks, blockStructureDir);
const superblockList = addBlockStructure(
addSuperblockStructure(curriculum.superblocks)
);
const refinedFilters = closestFilters(filters, superblockList);
const fullSuperblockList = applyFilters(superblockList, refinedFilters);
const refinedFilter = closestFilters(superblockList, filter);
const fullSuperblockList = applyFilters(superblockList, refinedFilter);
return {
fullSuperblockList,
certifications: curriculum.certifications
};
}
async function buildCurriculum(lang, filters) {
export async function buildCurriculum(lang: string, filters?: Filter) {
const contentDir = getContentDir(lang);
const builder = new SuperblockCreator({
blockCreator: getBlockCreator(lang, !isEmpty(filters))
});
const builder = new SuperblockCreator(
getBlockCreator(lang, !isEmpty(filters))
);
const { fullSuperblockList, certifications } =
await parseCurriculumStructure(filters);
const fullCurriculum = { certifications: { blocks: {} } };
const fullCurriculum: {
[key: string]: unknown;
certifications: { blocks: { [key: string]: unknown } };
} = {
certifications: { blocks: {} }
};
const liveSuperblocks = fullSuperblockList.filter(({ name }) => {
const superOrder = getSuperOrder(name);
@@ -353,27 +369,13 @@ async function buildCurriculum(lang, filters) {
}
for (const cert of certifications) {
const certPath = path.resolve(contentDir, 'certifications', `${cert}.yml`);
if (!fs.existsSync(certPath)) {
const certPath = resolve(contentDir, 'certifications', `${cert}.yml`);
if (!existsSync(certPath)) {
throw Error(`Certification file not found: ${certPath}`);
}
debug(`=== Processing certification ${cert} ===`);
log(`=== Processing certification ${cert} ===`);
fullCurriculum.certifications.blocks[cert] = buildCertification(certPath);
}
return fullCurriculum;
}
module.exports = {
addBlockStructure,
buildCurriculum,
getContentDir,
getBlockCreator,
getBlockStructure,
getSuperblockStructure,
createCommentMap,
superBlockToFilename,
getSuperblocks,
addSuperblockStructure,
parseCurriculumStructure
};

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, vi } from 'vitest';
import { isPoly } from '../shared-dist/utils/polyvinyl.js';
import { isPoly } from '../../shared-dist/utils/polyvinyl.js';
import {
validateChallenges,
buildBlock,
@@ -565,9 +565,7 @@ describe('SuperblockCreator class', () => {
{ dashedName: 'block-3' }
];
const parser = new SuperblockCreator({
blockCreator: mockBlockCreator
});
const parser = new SuperblockCreator(mockBlockCreator);
const result = await parser.processSuperblock({
blocks,

View File

@@ -1,37 +1,58 @@
const fs = require('fs');
const path = require('path');
const { isEmpty } = require('lodash');
const debug = require('debug')('fcc:build-superblock');
import { existsSync, readdirSync } from 'fs';
import { resolve } from 'path';
import { isEmpty } from 'lodash';
import debug from 'debug';
const { parseMD } = require('../tools/challenge-parser/parser');
const { createPoly } = require('../shared-dist/utils/polyvinyl');
const { isAuditedSuperBlock } = require('../shared-dist/utils/is-audited');
const {
import { parseMD } from '../../tools/challenge-parser/parser';
import { createPoly } from '../../shared-dist/utils/polyvinyl';
import { isAuditedSuperBlock } from '../../shared-dist/utils/is-audited';
import {
CommentDictionary,
translateCommentsInChallenge
} = require('../tools/challenge-parser/translation-parser');
const { getSuperOrder } = require('./utils');
} from '../../tools/challenge-parser/translation-parser';
import { SuperBlocks } from '../../shared-dist/config/curriculum';
import type { Chapter } from '../../shared-dist/config/chapters';
import { Certification } from '../../shared-dist/config/certification-settings';
import { getSuperOrder } from './utils.js';
import type {
BlockStructure,
Challenge,
ChallengeFile
} from './file-handler.js';
const duplicates = xs => xs.filter((x, i) => xs.indexOf(x) !== i);
const log = debug('fcc:build-superblock');
const createValidator = throwOnError => fn => {
const duplicates = <T>(xs: T[]) => xs.filter((x, i) => xs.indexOf(x) !== i);
const createValidator = (throwOnError?: boolean) => (fn: () => void) => {
try {
fn();
} catch (error) {
if (throwOnError) {
throw error;
} else {
console.error(error.message);
console.error((error as Error).message);
}
}
};
interface Meta extends BlockStructure {
order: number;
superBlock: SuperBlocks;
superOrder: number;
}
/**
* 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) {
export function validateChallenges(
foundChallenges: Challenge[],
meta: { challengeOrder: Challenge[]; dashedName: string },
throwOnError?: boolean
) {
const metaChallengeIds = new Set(meta.challengeOrder.map(c => c.id));
const foundChallengeIds = new Set(foundChallenges.map(c => c.id));
@@ -98,7 +119,7 @@ function validateChallenges(foundChallenges, meta, throwOnError) {
* @param {object} meta - Meta object with name, dashedName, and challengeOrder
* @returns {object} Block object with ordered challenges
*/
function buildBlock(foundChallenges, meta) {
export function buildBlock(foundChallenges: Challenge[], meta: Meta) {
const challenges = meta.challengeOrder.map(challengeInfo => {
const challenge = foundChallenges.find(c => c.id === challengeInfo.id);
if (!challenge) {
@@ -122,7 +143,10 @@ function buildBlock(foundChallenges, meta) {
* @param {object} meta - The meta information object
* @returns {object} The challenge object with added meta information
*/
function addMetaToChallenge(challenge, meta) {
export function addMetaToChallenge(
challenge: Partial<Challenge>,
meta: Meta
): Challenge {
const challengeOrderIndex = meta.challengeOrder.findIndex(
({ id }) => id === challenge.id
);
@@ -168,9 +192,22 @@ function addMetaToChallenge(challenge, meta) {
const hasDupe = dupeCertifications.find(
cert => cert.dupe === meta.superBlock
);
challenge.certification = hasDupe ? hasDupe.certification : meta.superBlock;
return challenge;
const maybeCert = (
hasDupe ? hasDupe.certification : meta.superBlock
) as Certification;
challenge.certification = maybeCert;
// TODO: reimplement after updating the client to expect Certification | null
// if (isCertification(maybeCert)) {
// challenge.certification = maybeCert;
// } else {
// throw Error(
// `Superblock ${meta.superBlock} does not map to a certification`
// );
// }
return challenge as Challenge;
}
/**
@@ -178,7 +215,7 @@ function addMetaToChallenge(challenge, meta) {
* @param {Array<object>} files - Array of challenge file objects
* @returns {Array<object>} Array of polyvinyl objects with seed property
*/
function challengeFilesToPolys(files) {
export function challengeFilesToPolys(files: ChallengeFile[]) {
return files.reduce((challengeFiles, challengeFile) => {
return [
...challengeFiles,
@@ -187,7 +224,7 @@ function challengeFilesToPolys(files) {
seed: challengeFile.contents.slice(0)
}
];
}, []);
}, [] as ChallengeFile[]);
}
/**
@@ -195,7 +232,7 @@ function challengeFilesToPolys(files) {
* @param {object} challenge - The challenge object to fix
* @returns {object} The challenge object with fixed properties
*/
function fixChallengeProperties(challenge) {
export function fixChallengeProperties(challenge: Challenge) {
const fixedChallenge = {
...challenge
};
@@ -219,10 +256,10 @@ function fixChallengeProperties(challenge) {
* @param {object} meta - The meta information object
* @returns {object} The finalized challenge object
*/
function finalizeChallenge(challenge, meta) {
export function finalizeChallenge(challenge: Challenge, meta: Meta) {
return addMetaToChallenge(fixChallengeProperties(challenge), meta);
}
class BlockCreator {
export class BlockCreator {
/**
* @param {object} options - Options object
* @param {string} options.blockContentDir - Directory containing block content files
@@ -234,12 +271,25 @@ class BlockCreator {
* This class is responsible for reading block directories, parsing challenges, and validating them
* against the meta information.
*/
blockContentDir: string;
i18nBlockContentDir: string;
lang: string;
commentTranslations: CommentDictionary;
skipValidation: boolean | undefined;
constructor({
blockContentDir,
i18nBlockContentDir,
lang,
commentTranslations,
skipValidation
}: {
blockContentDir: string;
i18nBlockContentDir: string;
lang: string;
commentTranslations: CommentDictionary;
skipValidation?: boolean;
}) {
this.blockContentDir = blockContentDir;
this.i18nBlockContentDir = i18nBlockContentDir;
@@ -259,18 +309,22 @@ class BlockCreator {
* @returns {Promise<object>} The finalized challenge object
*/
async createChallenge(
{ filename, block, meta, isAudited },
{
filename,
block,
meta,
isAudited
}: { filename: string; block: string; meta: Meta; isAudited: boolean },
parser = parseMD
) {
debug(
log(
`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 englishPath = resolve(this.blockContentDir, block, filename);
const i18nPath = resolve(this.i18nBlockContentDir, block, filename);
const langUsed =
isAudited && fs.existsSync(i18nPath) ? this.lang : 'english';
const langUsed = isAudited && existsSync(i18nPath) ? this.lang : 'english';
const challengePath = langUsed === 'english' ? englishPath : i18nPath;
@@ -292,11 +346,11 @@ class BlockCreator {
* @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'));
async readBlockChallenges(block: string, meta: Meta, isAudited: boolean) {
const blockDir = resolve(this.blockContentDir, block);
const challengeFiles = readdirSync(blockDir).filter(file =>
file.endsWith('.md')
);
return await Promise.all(
challengeFiles.map(filename =>
@@ -305,13 +359,16 @@ class BlockCreator {
);
}
async processBlock(block, { superBlock, order }) {
async processBlock(
block: BlockStructure,
{ superBlock, order }: { superBlock: SuperBlocks; order: number }
) {
const blockName = block.dashedName;
debug(`Processing block ${blockName} in superblock ${superBlock}`);
log(`Processing block ${blockName} in superblock ${superBlock}`);
// Check if block directory exists
const blockContentDir = path.resolve(this.blockContentDir, blockName);
if (!fs.existsSync(blockContentDir)) {
const blockContentDir = resolve(this.blockContentDir, blockName);
if (!existsSync(blockContentDir)) {
throw Error(`Block directory not found: ${blockContentDir}`);
}
@@ -319,11 +376,13 @@ class BlockCreator {
block.isUpcomingChange &&
process.env.SHOW_UPCOMING_CHANGES !== 'true'
) {
debug(`Ignoring upcoming block ${blockName}`);
log(`Ignoring upcoming block ${blockName}`);
return null;
}
const superOrder = getSuperOrder(superBlock);
if (superOrder === undefined)
throw Error(`Superblock not found: ${superBlock}`);
const meta = {
...block,
superOrder,
@@ -332,7 +391,7 @@ class BlockCreator {
...(block.chapter && { chapter: block.chapter }),
...(block.module && { module: block.module })
};
const isAudited = isAuditedSuperBlock(this.lang, superBlock);
const isAudited = isAuditedSuperBlock(this.lang, superBlock as SuperBlocks);
// Read challenges from directory
const foundChallenges = await this.readBlockChallenges(
@@ -340,11 +399,11 @@ class BlockCreator {
meta,
isAudited
);
debug(`Found ${foundChallenges.length} challenge files in directory`);
log(`Found ${foundChallenges.length} challenge files in directory`);
// Log found challenges
foundChallenges.forEach(challenge => {
debug(`Found challenge: ${challenge.title} (${challenge.id})`);
log(`Found challenge: ${challenge.title} (${challenge.id})`);
});
const throwOnError = this.lang === 'english';
@@ -355,7 +414,7 @@ class BlockCreator {
// Build the block object
const blockResult = buildBlock(foundChallenges, meta);
debug(
log(
`Completed block "${meta.name}" with ${blockResult.challenges.length} challenges (${blockResult.challenges.filter(c => !c.missing).length} built successfully)`
);
@@ -363,20 +422,29 @@ class BlockCreator {
}
}
class SuperblockCreator {
export class SuperblockCreator {
/**
* @param {object} options - Options object
* @param {BlockCreator} options.blockCreator - Instance of BlockCreator
*/
constructor({ blockCreator }) {
blockCreator: BlockCreator;
constructor(blockCreator: BlockCreator) {
this.blockCreator = blockCreator;
}
async processSuperblock({ blocks, name }) {
const superBlock = { blocks: {} };
async processSuperblock({
blocks,
name
}: {
blocks: BlockStructure[];
name: SuperBlocks;
}) {
const superBlock: { blocks: Record<string, unknown> } = { blocks: {} };
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const block: BlockStructure = blocks[i]!;
const blockResult = await this.blockCreator.processBlock(block, {
superBlock: name,
order: i
@@ -386,23 +454,32 @@ class SuperblockCreator {
}
}
debug(
log(
`Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}`
);
return superBlock;
}
}
export type BlockInfo = {
dashedName: string;
chapter?: string;
module?: string;
};
/**
* 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,
export function transformSuperBlock(
superblockData: {
blocks?: string[];
chapters?: Chapter[];
},
{ showComingSoon } = { showComingSoon: false }
) {
let blocks = [];
let blocks: BlockInfo[] = [];
// Handle simple blocks array format
if (superblockData.blocks) {
@@ -442,17 +519,6 @@ function transformSuperBlock(
}
const blockNames = blocks.map(block => block.dashedName);
debug(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
log(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
return blocks;
}
module.exports = {
SuperblockCreator,
BlockCreator,
addMetaToChallenge,
validateChallenges,
buildBlock,
finalizeChallenge,
transformSuperBlock,
fixChallengeProperties
};

View File

@@ -0,0 +1,276 @@
import { dirname, resolve } from 'node:path';
import assert from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import debug from 'debug';
import type { Chapter } from '../../shared-dist/config/chapters.js';
import type { SuperBlocks } from '../../shared-dist/config/curriculum.js';
import type { Certification } from '../../shared-dist/config/certification-settings.js';
const log = debug('fcc:file-handler');
let __dirnameCompat: string;
if (typeof __dirname !== 'undefined') {
// CJS
__dirnameCompat = __dirname;
} else {
// ESM wrap in Function so CJS parsers don't see it
const metaUrl = new Function('return import.meta.url')() as string;
__dirnameCompat = dirname(fileURLToPath(metaUrl));
}
const CURRICULUM_DIR = resolve(__dirnameCompat, '..');
const I18N_CURRICULUM_DIR = resolve(
CURRICULUM_DIR,
'i18n-curriculum',
'curriculum'
);
const STRUCTURE_DIR = resolve(CURRICULUM_DIR, 'structure');
const BLOCK_STRUCTURE_DIR = 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
*/
export function getContentConfig(
lang: string,
{ baseDir, i18nBaseDir } = {
baseDir: CURRICULUM_DIR,
i18nBaseDir: I18N_CURRICULUM_DIR
}
) {
const contentDir = resolve(baseDir, 'challenges', 'english');
const i18nContentDir = resolve(i18nBaseDir, 'challenges', lang);
const blockContentDir = resolve(contentDir, 'blocks');
const i18nBlockContentDir = resolve(i18nContentDir, 'blocks');
const dictionariesDir = resolve(baseDir, 'dictionaries');
const i18nDictionariesDir = resolve(i18nBaseDir, 'dictionaries');
if (lang !== 'english') {
assert(
existsSync(i18nContentDir),
`i18n content directory does not exist: ${i18nContentDir}`
);
assert(
existsSync(i18nBlockContentDir),
`i18n block content directory does not exist: ${i18nBlockContentDir}`
);
assert(
existsSync(i18nDictionariesDir),
`i18n dictionaries directory does not exist: ${i18nDictionariesDir}`
);
}
log(`Using content directory: ${contentDir}`);
log(`Using i18n content directory: ${i18nContentDir}`);
log(`Using block content directory: ${blockContentDir}`);
log(`Using i18n block content directory: ${i18nBlockContentDir}`);
log(`Using dictionaries directory: ${dictionariesDir}`);
log(`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
*/
export function getContentDir(lang: string) {
const { contentDir, i18nContentDir } = getContentConfig(lang);
return lang === 'english' ? contentDir : i18nContentDir;
}
export function getCurriculumStructure() {
const curriculumPath = resolve(STRUCTURE_DIR, 'curriculum.json');
if (!existsSync(curriculumPath)) {
throw new Error(`Curriculum file not found: ${curriculumPath}`);
}
return JSON.parse(readFileSync(curriculumPath, 'utf8')) as {
superblocks: SuperBlocks[];
certifications: string[];
};
}
export function getBlockStructurePath(block: string) {
return resolve(BLOCK_STRUCTURE_DIR, `${block}.json`);
}
export function getBlockStructureDir() {
return BLOCK_STRUCTURE_DIR;
}
export type ChallengeFile = {
contents: string;
ext: string;
name: string;
};
export type Challenge = {
id: string;
title: string;
// infer other properties:
description?: string;
instructions?: string;
questions?: string[];
block?: string;
blockType?: string;
blockLayout?: string;
hasEditableBoundaries?: boolean;
order?: number;
superBlock?: SuperBlocks;
superOrder?: number;
challengeOrder?: number;
isLastChallengeInBlock?: boolean;
required?: string[];
template?: string;
helpCategory?: string;
usesMultifileEditor?: boolean;
disableLoopProtectTests?: boolean;
disableLoopProtectPreview?: boolean;
chapter?: string;
module?: string;
certification?: Certification;
translationPending?: boolean;
missing?: boolean;
challengeFiles?: ChallengeFile[];
solutions?: ChallengeFile[][];
};
export interface BlockStructure {
name: string;
hasEditableBoundaries?: boolean;
required?: string[];
template?: string;
helpCategory?: string;
usesMultifileEditor?: boolean;
disableLoopProtectTests?: boolean;
disableLoopProtectPreview?: boolean;
blockLayout: string;
blockType: string;
challengeOrder: Challenge[];
dashedName: string;
isUpcomingChange?: boolean;
chapter?: string;
module?: string;
}
export function getBlockStructure(block: string) {
return JSON.parse(
readFileSync(getBlockStructurePath(block), 'utf8')
) as BlockStructure;
}
export async function writeBlockStructure(block: string, structure: unknown) {
// dynamically importing prettier because Gatsby build and develop fail when
// it's required.
const prettier = await import('prettier');
const content = await prettier.format(JSON.stringify(structure), {
parser: 'json'
});
await writeFile(getBlockStructurePath(block), content, 'utf8');
}
export async function writeSuperblockStructure(
superblock: string,
structure: unknown
) {
// dynamically importing prettier because Gatsby build and develop fail when
// it's required.
const prettier = await import('prettier');
const content = await prettier.format(JSON.stringify(structure), {
parser: 'json'
});
await writeFile(getSuperblockStructurePath(superblock), content);
}
export function getSuperblockStructure(superblockFilename: string) {
const superblockPath = getSuperblockStructurePath(superblockFilename);
if (!existsSync(superblockPath)) {
throw Error(`Superblock file not found: ${superblockPath}`);
}
return JSON.parse(readFileSync(superblockPath, 'utf8')) as {
blocks?: string[];
chapters?: Chapter[];
};
}
export function getSuperblockStructurePath(superblockFilename: string) {
return 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
*/
export function getLanguageConfig(
lang: string,
{ baseDir, i18nBaseDir } = {
baseDir: CURRICULUM_DIR,
i18nBaseDir: I18N_CURRICULUM_DIR
}
) {
const contentDir = resolve(baseDir, 'challenges', 'english');
const i18nContentDir = resolve(i18nBaseDir, 'challenges', lang);
const blockContentDir = resolve(contentDir, 'blocks');
const i18nBlockContentDir = resolve(i18nContentDir, 'blocks');
const dictionariesDir = resolve(baseDir, 'dictionaries');
const i18nDictionariesDir = resolve(i18nBaseDir, 'dictionaries');
if (lang !== 'english') {
assert(
existsSync(i18nContentDir),
`i18n content directory does not exist: ${i18nContentDir}`
);
assert(
existsSync(i18nBlockContentDir),
`i18n block content directory does not exist: ${i18nBlockContentDir}`
);
assert(
existsSync(i18nDictionariesDir),
`i18n dictionaries directory does not exist: ${i18nDictionariesDir}`
);
}
log(`Using content directory: ${contentDir}`);
log(`Using i18n content directory: ${i18nContentDir}`);
log(`Using block content directory: ${blockContentDir}`);
log(`Using i18n block content directory: ${i18nBlockContentDir}`);
log(`Using dictionaries directory: ${dictionariesDir}`);
log(`Using i18n dictionaries directory: ${i18nDictionariesDir}`);
return {
contentDir,
i18nContentDir,
blockContentDir,
i18nBlockContentDir,
dictionariesDir,
i18nDictionariesDir
};
}

View File

@@ -5,7 +5,7 @@ import { hasEnglishSource, getChallengesForLang } from './get-challenges.js';
const EXISTING_CHALLENGE_PATH = 'challenge.md';
const MISSING_CHALLENGE_PATH = 'no/challenge.md';
const basePath = '__fixtures__';
const basePath = '../__fixtures__';
describe('create non-English challenge', () => {
describe('getChallengesForLang', () => {

View File

@@ -0,0 +1,36 @@
import { access as _access, constants } from 'fs';
import { resolve, join } from 'path';
import { promisify } from 'util';
import { availableLangs } from '../../shared-dist/config/i18n.js';
import { buildCurriculum } from './build-curriculum.js';
const { curriculum: curriculumLangs } = availableLangs;
const access = promisify(_access);
export async function getChallengesForLang(
lang: string,
filters?: {
superBlock?: string;
block?: string;
challengeId?: string;
}
) {
const invalidLang = !curriculumLangs.includes(lang);
if (invalidLang)
throw Error(`${lang} is not an accepted language.
Accepted languages are ${curriculumLangs.join(', ')}`);
return buildCurriculum(lang, filters);
}
export async function hasEnglishSource(
basePath: string,
translationPath: string
) {
const englishRoot = resolve(__dirname, basePath, 'english');
return await access(join(englishRoot, translationPath), constants.F_OK)
.then(() => true)
.catch(() => false);
}

View File

@@ -1,5 +1,5 @@
var glob = require('glob');
const lint = require('../tools/scripts/lint');
const lint = require('../../tools/scripts/lint');
const { testedLang } = require('./utils');
glob(`challenges/${testedLang()}/**/*.md`, (err, files) => {

1
curriculum/src/reset.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import '@total-typescript/ts-reset';

View File

@@ -1,10 +1,10 @@
import { assert, describe, it, vi } from 'vitest';
import { testedLang } from '../utils';
vi.stubEnv('SHOW_UPCOMING_CHANGES', 'true');
// We need to use dynamic import here to ensure the environment variable is set
// We need to use dynamic imports here to ensure the environment variable is set
// before the module is loaded.
const { testedLang } = await import('../utils.js');
const { getChallenges } = await import('./test-challenges.js');
describe('Daily Coding Challenges', async () => {

View File

@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { describe, it, beforeAll, expect } from 'vitest';
import { assert, AssertionError } from 'chai';
import jsdom from 'jsdom';
@@ -8,28 +6,26 @@ import lodash from 'lodash';
import {
buildChallenge,
runnerTypes
} from '../../client/src/templates/Challenges/utils/build';
} from '../../../client/src/templates/Challenges/utils/build';
import {
challengeTypes,
hasNoSolution
} from '../../shared/config/challenge-types';
import { getLines } from '../../shared/utils/get-lines';
import { prefixDoctype } from '../../client/src/templates/Challenges/utils/frame';
} from '../../../shared/config/challenge-types';
import { getLines } from '../../../shared/utils/get-lines';
import { prefixDoctype } from '../../../client/src/templates/Challenges/utils/frame';
const require = createRequire(import.meta.url);
import { getChallengesForLang } from '../get-challenges.js';
import { challengeSchemaValidator } from '../../schema/challenge-schema.js';
import { testedLang } from '../utils.js';
const { getChallengesForLang } = require('../get-challenges');
const { challengeSchemaValidator } = require('../schema/challenge-schema');
const { testedLang } = require('../utils');
import { curriculumSchemaValidator } from '../../schema/curriculum-schema.js';
import { validateMetaSchema } from '../../schema/meta-schema.js';
import { getBlockStructure } from '../file-handler.js';
import ChallengeTitles from './utils/challenge-titles.js';
import MongoIds from './utils/mongo-ids.js';
import createPseudoWorker from './utils/pseudo-worker.js';
const { curriculumSchemaValidator } = require('../schema/curriculum-schema');
const { validateMetaSchema } = require('../schema/meta-schema');
const { getBlockStructure } = require('../file-handler');
const ChallengeTitles = require('./utils/challenge-titles');
const MongoIds = require('./utils/mongo-ids');
const createPseudoWorker = require('./utils/pseudo-worker');
const { sortChallenges } = require('./utils/sort-challenges');
import { sortChallenges } from './utils/sort-challenges.js';
const { flatten, isEmpty, cloneDeep } = lodash;

View File

@@ -4,10 +4,19 @@ import path from 'node:path';
import _ from 'lodash';
import { parseCurriculumStructure } from '../../build-curriculum.js';
import { Filter } from '../../utils.js';
const __dirname = import.meta.dirname;
let __dirnameCompat: string;
const testFilter = {
if (typeof __dirname !== 'undefined') {
// CJS
__dirnameCompat = __dirname;
} else {
// ESM wrap in Function so CJS parsers don't see it
__dirnameCompat = new Function('return import.meta.dirname')() as string;
}
const testFilter: Filter = {
block: process.env.FCC_BLOCK ? process.env.FCC_BLOCK.trim() : undefined,
challengeId: process.env.FCC_CHALLENGE_ID
? process.env.FCC_CHALLENGE_ID.trim()
@@ -17,7 +26,7 @@ const testFilter = {
: undefined
};
const GENERATED_DIR = path.resolve(__dirname, '../blocks-generated');
const GENERATED_DIR = path.resolve(__dirnameCompat, '../blocks-generated');
async function main() {
// clean and recreate directory
@@ -39,7 +48,7 @@ async function main() {
console.log(`Generated ${blocks.length} block test file(s).`);
}
function generateSingleBlockFile(testFilter) {
function generateSingleBlockFile(testFilter: Filter) {
return `import { defineTestsForBlock } from '../test-challenges.js';
await defineTestsForBlock(${JSON.stringify(testFilter)});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { shuffleArray } from '../../../shared-dist/utils/shuffle-array.js';
import { shuffleArray } from '../../../../shared-dist/utils/shuffle-array.js';
import { sortChallenges } from './sort-challenges.js';
const challenges = [

View File

@@ -4,9 +4,9 @@ import sirv from 'sirv';
import polka from 'polka';
import puppeteer from 'puppeteer';
import { helperVersion } from '../../client/src/templates/Challenges/utils/frame';
import { helperVersion } from '../../../client/src/templates/Challenges/utils/frame';
const clientPath = path.resolve(__dirname, '../../client');
const clientPath = path.resolve(__dirname, '../../../client');
async function createBrowser() {
return puppeteer.launch({

View File

@@ -2,12 +2,12 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/blocks-generated/**/*.test.js'],
include: ['src/test/blocks-generated/**/*.test.js'],
environment: 'node',
hookTimeout: 60000,
testTimeout: 30000,
isolate: false,
globalSetup: 'test/vitest-global-setup.mjs',
setupFiles: 'test/vitest-setup.mjs'
globalSetup: 'src/test/vitest-global-setup.mjs',
setupFiles: 'src/test/vitest-setup.mjs'
}
});

View File

@@ -2,7 +2,7 @@ import path from 'path';
import { config } from 'dotenv';
import { describe, it, expect } from 'vitest';
import { SuperBlocks } from '../shared-dist/config/curriculum';
import { SuperBlocks } from '../../shared-dist/config/curriculum';
import {
closestFilters,
closestMatch,
@@ -11,7 +11,7 @@ import {
filterByChallengeId,
filterBySuperblock,
getSuperOrder
} from './utils';
} from './utils.js';
config({ path: path.resolve(__dirname, '../.env') });
@@ -65,12 +65,6 @@ describe('createSuperOrder', () => {
it('should create the correct object given an array of SuperBlocks', () => {
expect(superOrder).toStrictEqual(fullSuperOrder);
});
it('throws when not given an array of SuperBlocks', () => {
expect(() => createSuperOrder()).toThrow();
expect(() => createSuperOrder(null)).toThrow();
expect(() => createSuperOrder('')).toThrow();
});
});
describe('getSuperOrder', () => {
@@ -79,8 +73,6 @@ describe('getSuperOrder', () => {
});
it('returns undefined for unknown curriculum', () => {
expect(getSuperOrder()).toBeUndefined();
expect(getSuperOrder(null)).toBeUndefined();
expect(getSuperOrder('')).toBeUndefined();
expect(getSuperOrder('respansive-wib-desoin')).toBeUndefined();
expect(getSuperOrder('certifications')).toBeUndefined();
@@ -299,18 +291,21 @@ describe('filter utils', () => {
{
name: 'responsive-web-design',
blocks: [
{ dashedName: 'basic-html-and-html5' },
{ dashedName: 'css-flexbox' }
{ dashedName: 'basic-html-and-html5', challengeOrder: [] },
{ dashedName: 'css-flexbox', challengeOrder: [] }
]
},
{
name: 'javascript-algorithms-and-data-structures',
blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }]
blocks: [
{ dashedName: 'basic-javascript', challengeOrder: [] },
{ dashedName: 'es6', challengeOrder: [] }
]
}
];
expect(
closestFilters({ superBlock: 'responsiv web design' }, superblocks)
closestFilters(superblocks, { superBlock: 'responsiv web design' })
).toEqual({ superBlock: 'responsive-web-design' });
});
@@ -319,17 +314,20 @@ describe('filter utils', () => {
{
name: 'responsive-web-design',
blocks: [
{ dashedName: 'basic-html-and-html5' },
{ dashedName: 'css-flexbox' }
{ dashedName: 'basic-html-and-html5', challengeOrder: [] },
{ dashedName: 'css-flexbox', challengeOrder: [] }
]
},
{
name: 'javascript-algorithms-and-data-structures',
blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }]
blocks: [
{ dashedName: 'basic-javascript', challengeOrder: [] },
{ dashedName: 'es6', challengeOrder: [] }
]
}
];
expect(closestFilters({ block: 'basic-javascr' }, superblocks)).toEqual({
expect(closestFilters(superblocks, { block: 'basic-javascr' })).toEqual({
block: 'basic-javascript'
});
});

View File

@@ -1,16 +1,18 @@
const path = require('path');
import { resolve } from 'path';
const comparison = require('string-similarity');
import comparison from 'string-similarity';
import { config } from 'dotenv';
const { generateSuperBlockList } = require('../shared-dist/config/curriculum');
import { generateSuperBlockList } from '../../shared-dist/config/curriculum.js';
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
config({ path: resolve(__dirname, '../../.env') });
import { availableLangs } from '../../shared-dist/config/i18n.js';
const { availableLangs } = require('../shared-dist/config/i18n');
const curriculumLangs = availableLangs.curriculum;
// checks that the CURRICULUM_LOCALE exists and is an available language
exports.testedLang = function testedLang() {
export function testedLang() {
if (process.env.CURRICULUM_LOCALE) {
if (curriculumLangs.includes(process.env.CURRICULUM_LOCALE)) {
return process.env.CURRICULUM_LOCALE;
@@ -21,10 +23,10 @@ exports.testedLang = function testedLang() {
} else {
throw Error('LOCALE must be set for testing');
}
};
}
function createSuperOrder(superBlocks) {
const superOrder = {};
export function createSuperOrder(superBlocks: string[]) {
const superOrder: { [sb: string]: number } = {};
superBlocks.forEach((superBlock, i) => {
superOrder[superBlock] = i;
@@ -33,8 +35,8 @@ function createSuperOrder(superBlocks) {
return superOrder;
}
function getSuperOrder(
superblock,
export function getSuperOrder(
superblock: string,
showUpcomingChanges = process.env.SHOW_UPCOMING_CHANGES === 'true'
) {
const flatSuperBlockMap = generateSuperBlockList({
@@ -54,7 +56,10 @@ function getSuperOrder(
* @param {string} [options.block] - The dashedName of the block to filter for (in kebab case).
* @returns {Array<Object>} Array with one superblock containing the specified block, or the original array if block is not provided.
*/
function filterByBlock(superblocks, { block } = {}) {
export function filterByBlock<T extends { blocks: { dashedName: string }[] }>(
superblocks: T[],
{ block }: { block?: string } = {}
): T[] {
if (!block) return superblocks;
const superblock = superblocks
@@ -76,7 +81,10 @@ function filterByBlock(superblocks, { block } = {}) {
* @param {string} [options.superBlock] - The name of the superblock to filter for.
* @returns {Array<Object>} Filtered array of superblocks containing only the specified superblock, or the original array if superBlock is not provided.
*/
function filterBySuperblock(superblocks, { superBlock } = {}) {
export function filterBySuperblock<T extends { name: string }>(
superblocks: T[],
{ superBlock }: { superBlock?: string } = {}
): T[] {
if (!superBlock) return superblocks;
return superblocks.filter(({ name }) => name === superBlock);
}
@@ -88,15 +96,20 @@ function filterBySuperblock(superblocks, { superBlock } = {}) {
* @param {string} [options.challengeId] - The specific challenge id to filter for
* @returns {Array<Object>} Filtered superblocks containing only the matching challenge
*/
function filterByChallengeId(superblocks, { challengeId } = {}) {
export function filterByChallengeId<
T extends { blocks: { challengeOrder: { id: string }[] }[] }
>(superblocks: T[], { challengeId }: { challengeId?: string } = {}): T[] {
if (!challengeId) {
return superblocks;
}
const findChallengeIndex = (challengeOrder, id) =>
const findChallengeIndex = (challengeOrder: { id: string }[], id: string) =>
challengeOrder.findIndex(challenge => challenge.id === id);
const filterChallengeOrder = (challengeOrder, id) => {
const filterChallengeOrder = (
challengeOrder: { id: string }[],
id: string
) => {
const index = findChallengeIndex(challengeOrder, id);
if (index === -1) return [];
@@ -121,20 +134,45 @@ function filterByChallengeId(superblocks, { challengeId } = {}) {
.filter(superblock => superblock.blocks.length > 0);
}
const createFilterPipeline = filterFunctions => (data, filters) =>
filterFunctions.reduce((acc, filterFn) => filterFn(acc, filters), data);
export interface Filter {
superBlock?: string;
block?: string;
challengeId?: string;
}
const applyFilters = createFilterPipeline([
interface Filterable {
name: string;
blocks: {
challengeOrder: { id: string }[];
dashedName: string;
}[];
}
interface GenericFilterFunction {
<T extends Filterable>(data: T[], filters?: Filter): T[];
}
function createFilterPipeline<T extends Filterable>(
filterFunctions: GenericFilterFunction[]
): (data: T[], filters?: Filter) => T[] {
return (data: T[], filters?: Filter) =>
filterFunctions.reduce((acc, filterFn) => filterFn(acc, filters), data);
}
export const applyFilters: GenericFilterFunction = createFilterPipeline([
filterBySuperblock,
filterByBlock,
filterByChallengeId
]);
function closestMatch(target, xs) {
export function closestMatch(target: string, xs: string[]): string {
return comparison.findBestMatch(target.toLowerCase(), xs).bestMatch.target;
}
function closestFilters(target, superblocks) {
export function closestFilters(
superblocks: Filterable[],
target?: Filter
): Filter | undefined {
if (target?.superBlock) {
const superblockNames = superblocks.map(({ name }) => name);
return {
@@ -155,12 +193,3 @@ function closestFilters(target, superblocks) {
return target;
}
exports.closestFilters = closestFilters;
exports.closestMatch = closestMatch;
exports.createSuperOrder = createSuperOrder;
exports.filterByBlock = filterByBlock;
exports.filterBySuperblock = filterBySuperblock;
exports.filterByChallengeId = filterByChallengeId;
exports.getSuperOrder = getSuperOrder;
exports.applyFilters = applyFilters;

17
curriculum/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"references": [{ "path": "../shared/tsconfig.json" }],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"target": "es2022",
"module": "nodenext",
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": false,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true // vitest cannot import cjs. The options are 1) migrate to esm, 2) don't type check tests and 3) skip lib checks
}
}

View File

@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: ['test/blocks-generated/**/*.test.js']
exclude: ['src/test/blocks-generated/**/*.test.js', 'dist']
}
});

View File

@@ -27,7 +27,7 @@ RUN cd api && pnpm prisma generate
ARG SHOW_UPCOMING_CHANGES=false
ENV SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES
RUN pnpm create:shared
RUN pnpm compile:ts
RUN pnpm build:curriculum
RUN pnpm -F=api build
@@ -52,7 +52,7 @@ USER node
WORKDIR /home/node/fcc
COPY --from=builder --chown=node:node /home/node/build/api/dist/ ./
COPY --from=builder --chown=node:node /home/node/build/api/package.json api/
COPY --from=builder --chown=node:node /home/node/build/shared/config/curriculum.json shared/config/
COPY --from=builder --chown=node:node /home/node/build/shared-dist/config/curriculum.json shared-dist/config/
COPY --from=deps --chown=node:node /home/node/build/node_modules/ node_modules/
COPY --from=deps --chown=node:node /home/node/build/api/node_modules/ api/node_modules/

View File

@@ -33,10 +33,12 @@ export default tseslint.config(
'client/.cache/**/*',
'client/public/**/*',
'shared/**/*.js',
'shared/**/*.d.ts',
'docs/**/*.md',
'**/playwright*.config.ts',
'playwright/**/*',
'shared-dist/**/*'
'shared-dist/**/*',
'curriculum/dist/**/*'
]
},
js.configs.recommended,

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/knip@5/schema.json",
"ignoreBinaries": ["create:shared", "install-puppeteer", "pm2"],
"ignoreBinaries": ["compile:ts", "install-puppeteer", "pm2"],
"workspaces": {
".": {
"playwright": ["playwright.config.ts"],

View File

@@ -19,9 +19,9 @@
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none",
"scripts": {
"audit-challenges": "pnpm run create:shared && tsx tools/challenge-auditor/index.ts",
"audit-challenges": "pnpm run compile:ts && tsx tools/challenge-auditor/index.ts",
"analyze-bundle": "webpack-bundle-analyzer",
"prebuild": "npm-run-all create:shared",
"prebuild": "npm-run-all compile:ts",
"build": "npm-run-all -p build:*",
"build-workers": "cd ./client && pnpm run prebuild",
"build:client": "cd ./client && pnpm run build",
@@ -34,13 +34,13 @@
"clean-and-develop": "pnpm run clean && pnpm install && pnpm run develop",
"clean:api": "cd api && pnpm clean",
"clean:client": "cd ./client && pnpm run clean",
"clean:curriculum": "rm -rf ./shared/config/curriculum.json",
"clean:curriculum": "rm -rf ./shared-dist/config/curriculum.json",
"clean:packages": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"create:shared": "tsc -p shared",
"compile:ts": "tsc --build --clean curriculum && tsc --build curriculum",
"create-new-project": "cd ./tools/challenge-helper-scripts/ && pnpm run create-project",
"create-new-language-block": "cd ./tools/challenge-helper-scripts/ && pnpm run create-language-block",
"create-new-quiz": "cd ./tools/challenge-helper-scripts/ && pnpm run create-quiz",
"predevelop": "npm-run-all -p create:shared -s build:curriculum",
"predevelop": "npm-run-all -p compile:ts -s build:curriculum",
"develop": "npm-run-all -p develop:*",
"develop:client": "cd ./client && pnpm run develop",
"develop:api": "cd ./api && pnpm run develop",
@@ -51,13 +51,13 @@
"knip": "npx -y knip@5 --include files",
"knip:all": "npx -y knip@5 ",
"prelint": "pnpm run -F=client predevelop",
"lint": "NODE_OPTIONS=\"--max-old-space-size=7168\" npm-run-all create:shared -p lint:*",
"lint": "NODE_OPTIONS=\"--max-old-space-size=7168\" npm-run-all compile:ts -p lint:*",
"lint:challenges": "cd ./curriculum && pnpm run lint",
"lint:js": "eslint --cache --max-warnings 0 .",
"lint:ts": "tsc && tsc -p shared && tsc -p api && tsc -p client",
"lint:ts": "tsc && tsc -p shared && tsc -p api && tsc -p client && tsc -p curriculum",
"lint:prettier": "prettier --list-different .",
"lint:css": "stylelint '**/*.css'",
"preseed": "npm-run-all create:shared",
"preseed": "npm-run-all compile:ts",
"playwright:install-build-tools": "npx playwright install --with-deps",
"rename-challenges": "tsx tools/challenge-helper-scripts/rename-challenge-files.ts",
"seed": "pnpm seed:surveys && pnpm seed:exams && DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user",
@@ -69,8 +69,8 @@
"seed:ms-username": "DEBUG=fcc:* node ./tools/scripts/seed/seed-ms-username",
"serve:client": "cd ./client && pnpm run serve",
"serve:client-ci": "cd ./client && pnpm run serve-ci",
"start": "npm-run-all create:shared -p develop:server serve:client",
"test": "NODE_OPTIONS='--max-old-space-size=7168' run-s create:shared build:curriculum build-workers test:**",
"start": "npm-run-all compile:ts -p develop:server serve:client",
"test": "NODE_OPTIONS='--max-old-space-size=7168' run-s compile:ts build:curriculum build-workers test:**",
"test:api": "cd api && pnpm test",
"test:tools:challenge-helper-scripts": "cd ./tools/challenge-helper-scripts && pnpm test run",
"test:tools:scripts-build": "cd ./tools/scripts/build && pnpm test run",

27
pnpm-lock.yaml generated
View File

@@ -712,6 +712,15 @@ importers:
'@babel/register':
specifier: 7.23.7
version: 7.23.7(@babel/core@7.23.7)
'@total-typescript/ts-reset':
specifier: ^0.6.1
version: 0.6.1
'@types/debug':
specifier: ^4.1.12
version: 4.1.12
'@types/js-yaml':
specifier: 4.0.5
version: 4.0.5
'@types/polka':
specifier: ^0.5.7
version: 0.5.7
@@ -4434,6 +4443,9 @@ packages:
'@total-typescript/ts-reset@0.5.1':
resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==}
'@total-typescript/ts-reset@0.6.1':
resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@@ -4515,6 +4527,9 @@ packages:
'@types/debug@0.0.30':
resolution: {integrity: sha512-orGL5LXERPYsLov6CWs3Fh6203+dXzJkR7OnddIr2514Hsecwc8xRpzCapshBbKFImCsvS/mk6+FWiN5LyZJAQ==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.9':
resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==}
@@ -19127,6 +19142,8 @@ snapshots:
'@total-typescript/ts-reset@0.5.1': {}
'@total-typescript/ts-reset@0.6.1': {}
'@trysound/sax@0.2.0': {}
'@turist/fetch@7.2.0(node-fetch@2.7.0)':
@@ -19231,6 +19248,10 @@ snapshots:
'@types/debug@0.0.30': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.32
'@types/debug@4.1.9':
dependencies:
'@types/ms': 0.7.32
@@ -26857,7 +26878,7 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.9
'@types/debug': 4.1.12
debug: 4.3.4(supports-color@8.1.1)
decode-named-character-reference: 1.0.2
micromark-core-commonmark: 1.1.0
@@ -31148,6 +31169,7 @@ snapshots:
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 16.7.0
@@ -31191,6 +31213,7 @@ snapshots:
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0
@@ -31234,6 +31257,7 @@ snapshots:
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0
@@ -31277,6 +31301,7 @@ snapshots:
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0

View File

@@ -47,6 +47,10 @@ export enum Certification {
LegacyFullStack = 'full-stack'
}
export function isCertification(x: string): x is Certification {
return Object.values(Certification).includes(x as Certification);
}
// "Current" certifications are the subset of standard certifications that are
// live and not legacy.
export const currentCertifications = [

View File

@@ -3,7 +3,7 @@
"extends": "../tsconfig-base.json",
"compilerOptions": {
"outDir": "../shared-dist",
"declaration": true,
"composite": true,
"noEmit": false,
"module": "CommonJS"
}

View File

@@ -8,7 +8,7 @@ const envPath = resolve(__dirname, '../../.env');
config({ path: envPath });
import { availableLangs } from '../../shared/config/i18n';
import { getChallengesForLang } from '../../curriculum/get-challenges';
import { getChallengesForLang } from '../../curriculum/src/get-challenges';
import {
SuperBlocks,
getAuditedSuperBlocks

View File

@@ -15,8 +15,8 @@ import {
getContentConfig,
writeBlockStructure,
getSuperblockStructure
} from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
} from '../../curriculum/src/file-handler';
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro';
import {

View File

@@ -13,8 +13,8 @@ import { BlockLayouts, BlockTypes } from '../../shared/config/blocks';
import {
getContentConfig,
writeBlockStructure
} from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
} from '../../curriculum/src/file-handler';
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
import {
createQuizFile,
createStepFile,

View File

@@ -8,8 +8,8 @@ import { SuperBlocks } from '../../shared/config/curriculum';
import {
getContentConfig,
writeBlockStructure
} from '../../curriculum/file-handler';
import { superBlockToFilename } from '../../curriculum/build-curriculum';
} from '../../curriculum/src/file-handler';
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
import { createQuizFile, getAllBlocks, validateBlockName } from './utils';
import { getBaseMeta } from './helpers/get-base-meta';
import { createIntroMD } from './helpers/create-intro';

View File

@@ -11,7 +11,7 @@ import ObjectID from 'bson-objectid';
import {
getBlockStructure,
writeBlockStructure
} from '../../curriculum/file-handler';
} from '../../curriculum/src/file-handler';
import { createChallengeFile } from './utils';
import { getProjectPath } from './helpers/get-project-info';
import { getBlock, type Meta } from './helpers/project-metadata';

View File

@@ -2,13 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getSuperblockStructure,
writeSuperblockStructure
} from '../../../curriculum/file-handler';
} from '../../../curriculum/src/file-handler';
import {
updateChapterModuleSuperblockStructure,
updateSimpleSuperblockStructure
} from './create-project';
vi.mock('../../../curriculum/file-handler');
vi.mock('../../../curriculum/src/file-handler');
const mockGetSuperblockStructure = vi.mocked(getSuperblockStructure);
const mockWriteSuperblockStructure = vi.mocked(writeSuperblockStructure);

View File

@@ -3,7 +3,7 @@
import {
getSuperblockStructure,
writeSuperblockStructure
} from '../../../curriculum/file-handler';
} from '../../../curriculum/src/file-handler';
import { insertInto } from './utils';
export async function updateSimpleSuperblockStructure(

View File

@@ -1,9 +1,9 @@
import { join } from 'path';
import { describe, it, expect, vi } from 'vitest';
import { getBlockStructure } from '../../../curriculum/file-handler';
import { getBlockStructure } from '../../../curriculum/src/file-handler';
import { getMetaData } from './project-metadata';
vi.mock('../../../curriculum/file-handler');
vi.mock('../../../curriculum/src/file-handler');
const commonPath = join('curriculum', 'challenges', 'blocks');
const block = 'block-name';

View File

@@ -2,7 +2,7 @@ import path from 'path';
import {
getBlockStructure,
writeBlockStructure
} from '../../../curriculum/file-handler';
} from '../../../curriculum/src/file-handler';
import { getProjectPath } from './get-project-info';
export type Meta = {

View File

@@ -5,7 +5,7 @@ import matter from 'gray-matter';
import { uniq } from 'lodash';
import { challengeTypes } from '../../shared/config/challenge-types';
import { parseCurriculumStructure } from '../../curriculum/build-curriculum';
import { parseCurriculumStructure } from '../../curriculum/src/build-curriculum';
import { parseMDSync } from '../challenge-parser/parser';
import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getProjectPath } from './helpers/get-project-info';

View File

@@ -0,0 +1,40 @@
// TypeScript declaration file for challenge-parser/parser
// This module exports functions to parse challenge markdown files
type ChallengeFile = {
name: string;
contents: string;
ext: string;
editableRegionBoundaries: number[];
head?: string;
tail?: string;
};
export interface ParsedChallenge {
id: string;
title: string;
challengeType: number;
description?: string;
instructions?: string;
questions?: string[];
challengeFiles?: ChallengeFile[];
solutions?: {
contents: string;
ext: string;
name: string;
}[][];
[key: string]: unknown; // Allow for additional properties that may be added by plugins
}
/**
* Parses a markdown challenge file asynchronously
* @param filename - Path to the markdown file to parse
* @returns Promise that resolves to the parsed challenge data
*/
export function parseMD(filename: string): Promise<ParsedChallenge>;
/**
* Parses a markdown challenge file synchronously
* @param filename - Path to the markdown file to parse
* @returns The parsed challenge data
*/
export function parseMDSync(filename: string): ParsedChallenge;

View File

@@ -0,0 +1,40 @@
export interface ChallengeFile {
contents: string;
ext: string;
name: string;
}
export interface Challenge {
id: string;
title: string;
challengeFiles?: ChallengeFile[];
[key: string]: unknown;
}
export interface CommentDictionary {
[comment: string]: {
[lang: string]: string;
};
}
export function translateComments(
text: string,
lang: string,
dict: CommentDictionary,
codeLang: string
): { text: string };
export function translateCommentsInChallenge(
challenge: Challenge,
lang: string,
dict: CommentDictionary
): Challenge;
export function translateGeneric(
input: { text: string },
config: {
knownComments: string[];
dict: CommentDictionary;
lang: string;
}
): { text: string };

View File

@@ -20,7 +20,9 @@ exports.translateCommentsInChallenge = (challenge, lang, dict) => {
if (challClone?.challengeFiles) {
challClone.challengeFiles.forEach(challengeFile => {
if (challengeFile.contents) {
let { text } = this.translateComments(
// It cannot be this.translateComments because 'this' does not exist
// when imported into an ES module.
let { text } = exports.translateComments(
challengeFile.contents,
lang,
dict,

View File

@@ -1,8 +1,10 @@
const chokidar = require('chokidar');
const { getSuperblockStructure } = require('../../../curriculum/file-handler');
const {
getSuperblockStructure
} = require('../../../curriculum/dist/file-handler');
const {
superBlockToFilename
} = require('../../../curriculum/build-curriculum');
} = require('../../../curriculum/dist/build-curriculum');
const { createChallengeNode } = require('./create-challenge-nodes');

View File

@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import { getChallengesForLang } from '../../../curriculum/get-challenges';
import { getChallengesForLang } from '../../../curriculum/src/get-challenges';
import {
buildExtCurriculumDataV1,
type Curriculum as CurriculumV1,
@@ -13,7 +13,7 @@ import {
type CurriculumProps as CurriculumPropsV2
} from './build-external-curricula-data-v2';
const globalConfigPath = path.resolve(__dirname, '../../../shared/config');
const globalConfigPath = path.resolve(__dirname, '../../../shared-dist/config');
// We are defaulting to English because the ids for the challenges are same
// across all languages.

View File

@@ -5,7 +5,7 @@ import { submitTypes } from '../../../shared-dist/config/challenge-types';
import { type ChallengeNode } from '../../../client/src/redux/prop-types';
import { SuperBlocks } from '../../../shared-dist/config/curriculum';
import type { Chapter } from '../../../shared-dist/config/chapters';
import { getSuperblockStructure } from '../../../curriculum/build-curriculum';
import { getSuperblockStructure } from '../../../curriculum/src/file-handler';
export type CurriculumIntros =
| BlockBasedCurriculumIntros