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 - name: Install and Build
run: | run: |
pnpm install pnpm install
pnpm run create:shared pnpm compile:ts
pnpm run build:curriculum pnpm run build:curriculum
- name: Start apps - name: Start apps

View File

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

View File

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

6
.gitignore vendored
View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { readFileSync } from 'fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, dirname } from 'path'; 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)); 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. // 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( 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": "NODE_OPTIONS=\"--max-old-space-size=7168 --no-deprecation\" gatsby build --prefix-paths",
"build:scripts": "pnpm run -F=browser-scripts build", "build:scripts": "pnpm run -F=browser-scripts build",
"clean": "gatsby clean", "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:env": "DEBUG=fcc:* tsx ./tools/create-env.ts",
"create:trending": "tsx ./tools/download-trending.ts", "create:trending": "tsx ./tools/download-trending.ts",
"create:search-placeholder": "tsx ./tools/generate-search-placeholder", "create:search-placeholder": "tsx ./tools/generate-search-placeholder",

View File

@@ -2,20 +2,24 @@ const path = require('path');
const _ = require('lodash'); const _ = require('lodash');
const { getChallengesForLang } = require('../../curriculum/get-challenges'); const {
getChallengesForLang
} = require('../../curriculum/dist/get-challenges.js');
const { const {
getContentDir,
getBlockCreator, getBlockCreator,
getSuperblocks, getSuperblocks,
superBlockToFilename superBlockToFilename
} = require('../../curriculum/build-curriculum'); } = require('../../curriculum/dist/build-curriculum.js');
const { const {
getContentDir,
getBlockStructure, getBlockStructure,
getSuperblockStructure getSuperblockStructure
} = require('../../curriculum/file-handler'); } = require('../../curriculum/dist/file-handler.js');
const { transformSuperBlock } = require('../../curriculum/build-superblock'); const {
const { getSuperOrder } = require('../../curriculum/utils'); transformSuperBlock
} = require('../../curriculum/dist/build-superblock.js');
const { getSuperOrder } = require('../../curriculum/dist/utils.js');
const curriculumLocale = process.env.CURRICULUM_LOCALE || 'english'; 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-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-challenge": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
"delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task", "delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task",
"lint": "tsx --tsconfig ../tsconfig.json lint-localized", "lint": "tsx --tsconfig ../tsconfig.json src/lint-localized",
"reorder-tasks": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks", "reorder-tasks": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks",
"update-challenge-order": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order", "update-challenge-order": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order",
"update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles", "update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
"test-gen": "node ./test/utils/generate-block-tests.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 test/vitest.config.mjs" "test": "NODE_OPTIONS='--max-old-space-size=7168' pnpm -s test-gen && vitest -c src/test/vitest.config.mjs"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.7", "@babel/core": "7.23.7",
"@babel/register": "7.23.7", "@babel/register": "7.23.7",
"@types/polka": "^0.5.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", "@types/string-similarity": "^4.0.2",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"chai": "4.4.1", "chai": "4.4.1",

View File

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

View File

@@ -1,37 +1,58 @@
const fs = require('fs'); import { existsSync, readdirSync } from 'fs';
const path = require('path'); import { resolve } from 'path';
const { isEmpty } = require('lodash'); import { isEmpty } from 'lodash';
const debug = require('debug')('fcc:build-superblock'); import debug from 'debug';
const { parseMD } = require('../tools/challenge-parser/parser'); import { parseMD } from '../../tools/challenge-parser/parser';
const { createPoly } = require('../shared-dist/utils/polyvinyl'); import { createPoly } from '../../shared-dist/utils/polyvinyl';
const { isAuditedSuperBlock } = require('../shared-dist/utils/is-audited'); import { isAuditedSuperBlock } from '../../shared-dist/utils/is-audited';
const { import {
CommentDictionary,
translateCommentsInChallenge translateCommentsInChallenge
} = require('../tools/challenge-parser/translation-parser'); } from '../../tools/challenge-parser/translation-parser';
const { getSuperOrder } = require('./utils'); 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 { try {
fn(); fn();
} catch (error) { } catch (error) {
if (throwOnError) { if (throwOnError) {
throw error; throw error;
} else { } 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 * Validates challenges against meta.json challengeOrder
* @param {Array<object>} foundChallenges - Array of challenge objects * @param {Array<object>} foundChallenges - Array of challenge objects
* @param {object} meta - Meta object with challengeOrder array * @param {object} meta - Meta object with challengeOrder array
* @throws {Error} If validation fails (missing challenges, duplicates, etc.) * @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 metaChallengeIds = new Set(meta.challengeOrder.map(c => c.id));
const foundChallengeIds = new Set(foundChallenges.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 * @param {object} meta - Meta object with name, dashedName, and challengeOrder
* @returns {object} Block object with ordered challenges * @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 challenges = meta.challengeOrder.map(challengeInfo => {
const challenge = foundChallenges.find(c => c.id === challengeInfo.id); const challenge = foundChallenges.find(c => c.id === challengeInfo.id);
if (!challenge) { if (!challenge) {
@@ -122,7 +143,10 @@ function buildBlock(foundChallenges, meta) {
* @param {object} meta - The meta information object * @param {object} meta - The meta information object
* @returns {object} The challenge object with added meta information * @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( const challengeOrderIndex = meta.challengeOrder.findIndex(
({ id }) => id === challenge.id ({ id }) => id === challenge.id
); );
@@ -168,9 +192,22 @@ function addMetaToChallenge(challenge, meta) {
const hasDupe = dupeCertifications.find( const hasDupe = dupeCertifications.find(
cert => cert.dupe === meta.superBlock 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 * @param {Array<object>} files - Array of challenge file objects
* @returns {Array<object>} Array of polyvinyl objects with seed property * @returns {Array<object>} Array of polyvinyl objects with seed property
*/ */
function challengeFilesToPolys(files) { export function challengeFilesToPolys(files: ChallengeFile[]) {
return files.reduce((challengeFiles, challengeFile) => { return files.reduce((challengeFiles, challengeFile) => {
return [ return [
...challengeFiles, ...challengeFiles,
@@ -187,7 +224,7 @@ function challengeFilesToPolys(files) {
seed: challengeFile.contents.slice(0) seed: challengeFile.contents.slice(0)
} }
]; ];
}, []); }, [] as ChallengeFile[]);
} }
/** /**
@@ -195,7 +232,7 @@ function challengeFilesToPolys(files) {
* @param {object} challenge - The challenge object to fix * @param {object} challenge - The challenge object to fix
* @returns {object} The challenge object with fixed properties * @returns {object} The challenge object with fixed properties
*/ */
function fixChallengeProperties(challenge) { export function fixChallengeProperties(challenge: Challenge) {
const fixedChallenge = { const fixedChallenge = {
...challenge ...challenge
}; };
@@ -219,10 +256,10 @@ function fixChallengeProperties(challenge) {
* @param {object} meta - The meta information object * @param {object} meta - The meta information object
* @returns {object} The finalized challenge object * @returns {object} The finalized challenge object
*/ */
function finalizeChallenge(challenge, meta) { export function finalizeChallenge(challenge: Challenge, meta: Meta) {
return addMetaToChallenge(fixChallengeProperties(challenge), meta); return addMetaToChallenge(fixChallengeProperties(challenge), meta);
} }
class BlockCreator { export class BlockCreator {
/** /**
* @param {object} options - Options object * @param {object} options - Options object
* @param {string} options.blockContentDir - Directory containing block content files * @param {string} options.blockContentDir - Directory containing block content files
@@ -234,12 +271,25 @@ class BlockCreator {
* This class is responsible for reading block directories, parsing challenges, and validating them * This class is responsible for reading block directories, parsing challenges, and validating them
* against the meta information. * against the meta information.
*/ */
blockContentDir: string;
i18nBlockContentDir: string;
lang: string;
commentTranslations: CommentDictionary;
skipValidation: boolean | undefined;
constructor({ constructor({
blockContentDir, blockContentDir,
i18nBlockContentDir, i18nBlockContentDir,
lang, lang,
commentTranslations, commentTranslations,
skipValidation skipValidation
}: {
blockContentDir: string;
i18nBlockContentDir: string;
lang: string;
commentTranslations: CommentDictionary;
skipValidation?: boolean;
}) { }) {
this.blockContentDir = blockContentDir; this.blockContentDir = blockContentDir;
this.i18nBlockContentDir = i18nBlockContentDir; this.i18nBlockContentDir = i18nBlockContentDir;
@@ -259,18 +309,22 @@ class BlockCreator {
* @returns {Promise<object>} The finalized challenge object * @returns {Promise<object>} The finalized challenge object
*/ */
async createChallenge( async createChallenge(
{ filename, block, meta, isAudited }, {
filename,
block,
meta,
isAudited
}: { filename: string; block: string; meta: Meta; isAudited: boolean },
parser = parseMD parser = parseMD
) { ) {
debug( log(
`Creating challenge from file: ${filename} in block: ${block}, using lang: ${this.lang}` `Creating challenge from file: ${filename} in block: ${block}, using lang: ${this.lang}`
); );
const englishPath = path.resolve(this.blockContentDir, block, filename); const englishPath = resolve(this.blockContentDir, block, filename);
const i18nPath = path.resolve(this.i18nBlockContentDir, block, filename); const i18nPath = resolve(this.i18nBlockContentDir, block, filename);
const langUsed = const langUsed = isAudited && existsSync(i18nPath) ? this.lang : 'english';
isAudited && fs.existsSync(i18nPath) ? this.lang : 'english';
const challengePath = langUsed === 'english' ? englishPath : i18nPath; const challengePath = langUsed === 'english' ? englishPath : i18nPath;
@@ -292,11 +346,11 @@ class BlockCreator {
* @param {boolean} isAudited - Whether the block is audited for i18n * @param {boolean} isAudited - Whether the block is audited for i18n
* @returns {Promise<Array<object>>} Array of challenge objects * @returns {Promise<Array<object>>} Array of challenge objects
*/ */
async readBlockChallenges(block, meta, isAudited) { async readBlockChallenges(block: string, meta: Meta, isAudited: boolean) {
const blockDir = path.resolve(this.blockContentDir, block); const blockDir = resolve(this.blockContentDir, block);
const challengeFiles = fs const challengeFiles = readdirSync(blockDir).filter(file =>
.readdirSync(blockDir) file.endsWith('.md')
.filter(file => file.endsWith('.md')); );
return await Promise.all( return await Promise.all(
challengeFiles.map(filename => 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; const blockName = block.dashedName;
debug(`Processing block ${blockName} in superblock ${superBlock}`); log(`Processing block ${blockName} in superblock ${superBlock}`);
// Check if block directory exists // Check if block directory exists
const blockContentDir = path.resolve(this.blockContentDir, blockName); const blockContentDir = resolve(this.blockContentDir, blockName);
if (!fs.existsSync(blockContentDir)) { if (!existsSync(blockContentDir)) {
throw Error(`Block directory not found: ${blockContentDir}`); throw Error(`Block directory not found: ${blockContentDir}`);
} }
@@ -319,11 +376,13 @@ class BlockCreator {
block.isUpcomingChange && block.isUpcomingChange &&
process.env.SHOW_UPCOMING_CHANGES !== 'true' process.env.SHOW_UPCOMING_CHANGES !== 'true'
) { ) {
debug(`Ignoring upcoming block ${blockName}`); log(`Ignoring upcoming block ${blockName}`);
return null; return null;
} }
const superOrder = getSuperOrder(superBlock); const superOrder = getSuperOrder(superBlock);
if (superOrder === undefined)
throw Error(`Superblock not found: ${superBlock}`);
const meta = { const meta = {
...block, ...block,
superOrder, superOrder,
@@ -332,7 +391,7 @@ class BlockCreator {
...(block.chapter && { chapter: block.chapter }), ...(block.chapter && { chapter: block.chapter }),
...(block.module && { module: block.module }) ...(block.module && { module: block.module })
}; };
const isAudited = isAuditedSuperBlock(this.lang, superBlock); const isAudited = isAuditedSuperBlock(this.lang, superBlock as SuperBlocks);
// Read challenges from directory // Read challenges from directory
const foundChallenges = await this.readBlockChallenges( const foundChallenges = await this.readBlockChallenges(
@@ -340,11 +399,11 @@ class BlockCreator {
meta, meta,
isAudited isAudited
); );
debug(`Found ${foundChallenges.length} challenge files in directory`); log(`Found ${foundChallenges.length} challenge files in directory`);
// Log found challenges // Log found challenges
foundChallenges.forEach(challenge => { foundChallenges.forEach(challenge => {
debug(`Found challenge: ${challenge.title} (${challenge.id})`); log(`Found challenge: ${challenge.title} (${challenge.id})`);
}); });
const throwOnError = this.lang === 'english'; const throwOnError = this.lang === 'english';
@@ -355,7 +414,7 @@ class BlockCreator {
// Build the block object // Build the block object
const blockResult = buildBlock(foundChallenges, meta); 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)` `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 {object} options - Options object
* @param {BlockCreator} options.blockCreator - Instance of BlockCreator * @param {BlockCreator} options.blockCreator - Instance of BlockCreator
*/ */
constructor({ blockCreator }) {
blockCreator: BlockCreator;
constructor(blockCreator: BlockCreator) {
this.blockCreator = blockCreator; this.blockCreator = blockCreator;
} }
async processSuperblock({ blocks, name }) { async processSuperblock({
const superBlock = { blocks: {} }; blocks,
name
}: {
blocks: BlockStructure[];
name: SuperBlocks;
}) {
const superBlock: { blocks: Record<string, unknown> } = { blocks: {} };
for (let i = 0; i < blocks.length; i++) { for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]; const block: BlockStructure = blocks[i]!;
const blockResult = await this.blockCreator.processBlock(block, { const blockResult = await this.blockCreator.processBlock(block, {
superBlock: name, superBlock: name,
order: i order: i
@@ -386,23 +454,32 @@ class SuperblockCreator {
} }
} }
debug( log(
`Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}` `Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}`
); );
return superBlock; return superBlock;
} }
} }
export type BlockInfo = {
dashedName: string;
chapter?: string;
module?: string;
};
/** /**
* Transforms superblock data to extract blocks array * Transforms superblock data to extract blocks array
* @param {object} superblockData - The superblock data object * @param {object} superblockData - The superblock data object
* @returns {object[]} Array of block objects with dashedName, chapter, and module properties * @returns {object[]} Array of block objects with dashedName, chapter, and module properties
*/ */
function transformSuperBlock( export function transformSuperBlock(
superblockData, superblockData: {
blocks?: string[];
chapters?: Chapter[];
},
{ showComingSoon } = { showComingSoon: false } { showComingSoon } = { showComingSoon: false }
) { ) {
let blocks = []; let blocks: BlockInfo[] = [];
// Handle simple blocks array format // Handle simple blocks array format
if (superblockData.blocks) { if (superblockData.blocks) {
@@ -442,17 +519,6 @@ function transformSuperBlock(
} }
const blockNames = blocks.map(block => block.dashedName); const blockNames = blocks.map(block => block.dashedName);
debug(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`); log(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
return blocks; 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 EXISTING_CHALLENGE_PATH = 'challenge.md';
const MISSING_CHALLENGE_PATH = 'no/challenge.md'; const MISSING_CHALLENGE_PATH = 'no/challenge.md';
const basePath = '__fixtures__'; const basePath = '../__fixtures__';
describe('create non-English challenge', () => { describe('create non-English challenge', () => {
describe('getChallengesForLang', () => { 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'); var glob = require('glob');
const lint = require('../tools/scripts/lint'); const lint = require('../../tools/scripts/lint');
const { testedLang } = require('./utils'); const { testedLang } = require('./utils');
glob(`challenges/${testedLang()}/**/*.md`, (err, files) => { 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 { assert, describe, it, vi } from 'vitest';
import { testedLang } from '../utils';
vi.stubEnv('SHOW_UPCOMING_CHANGES', 'true'); 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. // before the module is loaded.
const { testedLang } = await import('../utils.js');
const { getChallenges } = await import('./test-challenges.js'); const { getChallenges } = await import('./test-challenges.js');
describe('Daily Coding Challenges', async () => { describe('Daily Coding Challenges', async () => {

View File

@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { describe, it, beforeAll, expect } from 'vitest'; import { describe, it, beforeAll, expect } from 'vitest';
import { assert, AssertionError } from 'chai'; import { assert, AssertionError } from 'chai';
import jsdom from 'jsdom'; import jsdom from 'jsdom';
@@ -8,28 +6,26 @@ import lodash from 'lodash';
import { import {
buildChallenge, buildChallenge,
runnerTypes runnerTypes
} from '../../client/src/templates/Challenges/utils/build'; } from '../../../client/src/templates/Challenges/utils/build';
import { import {
challengeTypes, challengeTypes,
hasNoSolution hasNoSolution
} from '../../shared/config/challenge-types'; } from '../../../shared/config/challenge-types';
import { getLines } from '../../shared/utils/get-lines'; import { getLines } from '../../../shared/utils/get-lines';
import { prefixDoctype } from '../../client/src/templates/Challenges/utils/frame'; 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'); import { curriculumSchemaValidator } from '../../schema/curriculum-schema.js';
const { challengeSchemaValidator } = require('../schema/challenge-schema'); import { validateMetaSchema } from '../../schema/meta-schema.js';
const { testedLang } = require('../utils'); 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'); import { sortChallenges } from './utils/sort-challenges.js';
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');
const { flatten, isEmpty, cloneDeep } = lodash; const { flatten, isEmpty, cloneDeep } = lodash;

View File

@@ -4,10 +4,19 @@ import path from 'node:path';
import _ from 'lodash'; import _ from 'lodash';
import { parseCurriculumStructure } from '../../build-curriculum.js'; 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, block: process.env.FCC_BLOCK ? process.env.FCC_BLOCK.trim() : undefined,
challengeId: process.env.FCC_CHALLENGE_ID challengeId: process.env.FCC_CHALLENGE_ID
? process.env.FCC_CHALLENGE_ID.trim() ? process.env.FCC_CHALLENGE_ID.trim()
@@ -17,7 +26,7 @@ const testFilter = {
: undefined : undefined
}; };
const GENERATED_DIR = path.resolve(__dirname, '../blocks-generated'); const GENERATED_DIR = path.resolve(__dirnameCompat, '../blocks-generated');
async function main() { async function main() {
// clean and recreate directory // clean and recreate directory
@@ -39,7 +48,7 @@ async function main() {
console.log(`Generated ${blocks.length} block test file(s).`); console.log(`Generated ${blocks.length} block test file(s).`);
} }
function generateSingleBlockFile(testFilter) { function generateSingleBlockFile(testFilter: Filter) {
return `import { defineTestsForBlock } from '../test-challenges.js'; return `import { defineTestsForBlock } from '../test-challenges.js';
await defineTestsForBlock(${JSON.stringify(testFilter)}); await defineTestsForBlock(${JSON.stringify(testFilter)});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; 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'; import { sortChallenges } from './sort-challenges.js';
const challenges = [ const challenges = [

View File

@@ -4,9 +4,9 @@ import sirv from 'sirv';
import polka from 'polka'; import polka from 'polka';
import puppeteer from 'puppeteer'; 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() { async function createBrowser() {
return puppeteer.launch({ return puppeteer.launch({

View File

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

View File

@@ -2,7 +2,7 @@ import path from 'path';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { SuperBlocks } from '../shared-dist/config/curriculum'; import { SuperBlocks } from '../../shared-dist/config/curriculum';
import { import {
closestFilters, closestFilters,
closestMatch, closestMatch,
@@ -11,7 +11,7 @@ import {
filterByChallengeId, filterByChallengeId,
filterBySuperblock, filterBySuperblock,
getSuperOrder getSuperOrder
} from './utils'; } from './utils.js';
config({ path: path.resolve(__dirname, '../.env') }); config({ path: path.resolve(__dirname, '../.env') });
@@ -65,12 +65,6 @@ describe('createSuperOrder', () => {
it('should create the correct object given an array of SuperBlocks', () => { it('should create the correct object given an array of SuperBlocks', () => {
expect(superOrder).toStrictEqual(fullSuperOrder); 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', () => { describe('getSuperOrder', () => {
@@ -79,8 +73,6 @@ describe('getSuperOrder', () => {
}); });
it('returns undefined for unknown curriculum', () => { it('returns undefined for unknown curriculum', () => {
expect(getSuperOrder()).toBeUndefined();
expect(getSuperOrder(null)).toBeUndefined();
expect(getSuperOrder('')).toBeUndefined(); expect(getSuperOrder('')).toBeUndefined();
expect(getSuperOrder('respansive-wib-desoin')).toBeUndefined(); expect(getSuperOrder('respansive-wib-desoin')).toBeUndefined();
expect(getSuperOrder('certifications')).toBeUndefined(); expect(getSuperOrder('certifications')).toBeUndefined();
@@ -299,18 +291,21 @@ describe('filter utils', () => {
{ {
name: 'responsive-web-design', name: 'responsive-web-design',
blocks: [ blocks: [
{ dashedName: 'basic-html-and-html5' }, { dashedName: 'basic-html-and-html5', challengeOrder: [] },
{ dashedName: 'css-flexbox' } { dashedName: 'css-flexbox', challengeOrder: [] }
] ]
}, },
{ {
name: 'javascript-algorithms-and-data-structures', name: 'javascript-algorithms-and-data-structures',
blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }] blocks: [
{ dashedName: 'basic-javascript', challengeOrder: [] },
{ dashedName: 'es6', challengeOrder: [] }
]
} }
]; ];
expect( expect(
closestFilters({ superBlock: 'responsiv web design' }, superblocks) closestFilters(superblocks, { superBlock: 'responsiv web design' })
).toEqual({ superBlock: 'responsive-web-design' }); ).toEqual({ superBlock: 'responsive-web-design' });
}); });
@@ -319,17 +314,20 @@ describe('filter utils', () => {
{ {
name: 'responsive-web-design', name: 'responsive-web-design',
blocks: [ blocks: [
{ dashedName: 'basic-html-and-html5' }, { dashedName: 'basic-html-and-html5', challengeOrder: [] },
{ dashedName: 'css-flexbox' } { dashedName: 'css-flexbox', challengeOrder: [] }
] ]
}, },
{ {
name: 'javascript-algorithms-and-data-structures', 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' 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; const curriculumLangs = availableLangs.curriculum;
// checks that the CURRICULUM_LOCALE exists and is an available language // checks that the CURRICULUM_LOCALE exists and is an available language
exports.testedLang = function testedLang() { export function testedLang() {
if (process.env.CURRICULUM_LOCALE) { if (process.env.CURRICULUM_LOCALE) {
if (curriculumLangs.includes(process.env.CURRICULUM_LOCALE)) { if (curriculumLangs.includes(process.env.CURRICULUM_LOCALE)) {
return process.env.CURRICULUM_LOCALE; return process.env.CURRICULUM_LOCALE;
@@ -21,10 +23,10 @@ exports.testedLang = function testedLang() {
} else { } else {
throw Error('LOCALE must be set for testing'); throw Error('LOCALE must be set for testing');
} }
}; }
function createSuperOrder(superBlocks) { export function createSuperOrder(superBlocks: string[]) {
const superOrder = {}; const superOrder: { [sb: string]: number } = {};
superBlocks.forEach((superBlock, i) => { superBlocks.forEach((superBlock, i) => {
superOrder[superBlock] = i; superOrder[superBlock] = i;
@@ -33,8 +35,8 @@ function createSuperOrder(superBlocks) {
return superOrder; return superOrder;
} }
function getSuperOrder( export function getSuperOrder(
superblock, superblock: string,
showUpcomingChanges = process.env.SHOW_UPCOMING_CHANGES === 'true' showUpcomingChanges = process.env.SHOW_UPCOMING_CHANGES === 'true'
) { ) {
const flatSuperBlockMap = generateSuperBlockList({ const flatSuperBlockMap = generateSuperBlockList({
@@ -54,7 +56,10 @@ function getSuperOrder(
* @param {string} [options.block] - The dashedName of the block to filter for (in kebab case). * @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. * @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; if (!block) return superblocks;
const superblock = superblocks const superblock = superblocks
@@ -76,7 +81,10 @@ function filterByBlock(superblocks, { block } = {}) {
* @param {string} [options.superBlock] - The name of the superblock to filter for. * @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. * @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; if (!superBlock) return superblocks;
return superblocks.filter(({ name }) => name === superBlock); 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 * @param {string} [options.challengeId] - The specific challenge id to filter for
* @returns {Array<Object>} Filtered superblocks containing only the matching challenge * @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) { if (!challengeId) {
return superblocks; return superblocks;
} }
const findChallengeIndex = (challengeOrder, id) => const findChallengeIndex = (challengeOrder: { id: string }[], id: string) =>
challengeOrder.findIndex(challenge => challenge.id === id); challengeOrder.findIndex(challenge => challenge.id === id);
const filterChallengeOrder = (challengeOrder, id) => { const filterChallengeOrder = (
challengeOrder: { id: string }[],
id: string
) => {
const index = findChallengeIndex(challengeOrder, id); const index = findChallengeIndex(challengeOrder, id);
if (index === -1) return []; if (index === -1) return [];
@@ -121,20 +134,45 @@ function filterByChallengeId(superblocks, { challengeId } = {}) {
.filter(superblock => superblock.blocks.length > 0); .filter(superblock => superblock.blocks.length > 0);
} }
const createFilterPipeline = filterFunctions => (data, filters) => export interface Filter {
filterFunctions.reduce((acc, filterFn) => filterFn(acc, filters), data); 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, filterBySuperblock,
filterByBlock, filterByBlock,
filterByChallengeId filterByChallengeId
]); ]);
function closestMatch(target, xs) { export function closestMatch(target: string, xs: string[]): string {
return comparison.findBestMatch(target.toLowerCase(), xs).bestMatch.target; return comparison.findBestMatch(target.toLowerCase(), xs).bestMatch.target;
} }
function closestFilters(target, superblocks) { export function closestFilters(
superblocks: Filterable[],
target?: Filter
): Filter | undefined {
if (target?.superBlock) { if (target?.superBlock) {
const superblockNames = superblocks.map(({ name }) => name); const superblockNames = superblocks.map(({ name }) => name);
return { return {
@@ -155,12 +193,3 @@ function closestFilters(target, superblocks) {
return target; 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({ export default defineConfig({
test: { 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 ARG SHOW_UPCOMING_CHANGES=false
ENV SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES ENV SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES
RUN pnpm create:shared RUN pnpm compile:ts
RUN pnpm build:curriculum RUN pnpm build:curriculum
RUN pnpm -F=api build RUN pnpm -F=api build
@@ -52,7 +52,7 @@ USER node
WORKDIR /home/node/fcc 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/dist/ ./
COPY --from=builder --chown=node:node /home/node/build/api/package.json api/ 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/node_modules/ node_modules/
COPY --from=deps --chown=node:node /home/node/build/api/node_modules/ api/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/.cache/**/*',
'client/public/**/*', 'client/public/**/*',
'shared/**/*.js', 'shared/**/*.js',
'shared/**/*.d.ts',
'docs/**/*.md', 'docs/**/*.md',
'**/playwright*.config.ts', '**/playwright*.config.ts',
'playwright/**/*', 'playwright/**/*',
'shared-dist/**/*' 'shared-dist/**/*',
'curriculum/dist/**/*'
] ]
}, },
js.configs.recommended, js.configs.recommended,

View File

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

View File

@@ -19,9 +19,9 @@
"author": "freeCodeCamp <team@freecodecamp.org>", "author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none", "main": "none",
"scripts": { "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", "analyze-bundle": "webpack-bundle-analyzer",
"prebuild": "npm-run-all create:shared", "prebuild": "npm-run-all compile:ts",
"build": "npm-run-all -p build:*", "build": "npm-run-all -p build:*",
"build-workers": "cd ./client && pnpm run prebuild", "build-workers": "cd ./client && pnpm run prebuild",
"build:client": "cd ./client && pnpm run build", "build:client": "cd ./client && pnpm run build",
@@ -34,13 +34,13 @@
"clean-and-develop": "pnpm run clean && pnpm install && pnpm run develop", "clean-and-develop": "pnpm run clean && pnpm install && pnpm run develop",
"clean:api": "cd api && pnpm clean", "clean:api": "cd api && pnpm clean",
"clean:client": "cd ./client && pnpm run 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 '{}' +", "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-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-language-block": "cd ./tools/challenge-helper-scripts/ && pnpm run create-language-block",
"create-new-quiz": "cd ./tools/challenge-helper-scripts/ && pnpm run create-quiz", "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": "npm-run-all -p develop:*",
"develop:client": "cd ./client && pnpm run develop", "develop:client": "cd ./client && pnpm run develop",
"develop:api": "cd ./api && pnpm run develop", "develop:api": "cd ./api && pnpm run develop",
@@ -51,13 +51,13 @@
"knip": "npx -y knip@5 --include files", "knip": "npx -y knip@5 --include files",
"knip:all": "npx -y knip@5 ", "knip:all": "npx -y knip@5 ",
"prelint": "pnpm run -F=client predevelop", "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:challenges": "cd ./curriculum && pnpm run lint",
"lint:js": "eslint --cache --max-warnings 0 .", "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:prettier": "prettier --list-different .",
"lint:css": "stylelint '**/*.css'", "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", "playwright:install-build-tools": "npx playwright install --with-deps",
"rename-challenges": "tsx tools/challenge-helper-scripts/rename-challenge-files.ts", "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", "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", "seed:ms-username": "DEBUG=fcc:* node ./tools/scripts/seed/seed-ms-username",
"serve:client": "cd ./client && pnpm run serve", "serve:client": "cd ./client && pnpm run serve",
"serve:client-ci": "cd ./client && pnpm run serve-ci", "serve:client-ci": "cd ./client && pnpm run serve-ci",
"start": "npm-run-all create:shared -p develop:server serve:client", "start": "npm-run-all compile:ts -p develop:server serve:client",
"test": "NODE_OPTIONS='--max-old-space-size=7168' run-s create:shared build:curriculum build-workers test:**", "test": "NODE_OPTIONS='--max-old-space-size=7168' run-s compile:ts build:curriculum build-workers test:**",
"test:api": "cd api && pnpm test", "test:api": "cd api && pnpm test",
"test:tools:challenge-helper-scripts": "cd ./tools/challenge-helper-scripts && pnpm test run", "test:tools:challenge-helper-scripts": "cd ./tools/challenge-helper-scripts && pnpm test run",
"test:tools:scripts-build": "cd ./tools/scripts/build && 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': '@babel/register':
specifier: 7.23.7 specifier: 7.23.7
version: 7.23.7(@babel/core@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': '@types/polka':
specifier: ^0.5.7 specifier: ^0.5.7
version: 0.5.7 version: 0.5.7
@@ -4434,6 +4443,9 @@ packages:
'@total-typescript/ts-reset@0.5.1': '@total-typescript/ts-reset@0.5.1':
resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==} resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==}
'@total-typescript/ts-reset@0.6.1':
resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==}
'@trysound/sax@0.2.0': '@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -4515,6 +4527,9 @@ packages:
'@types/debug@0.0.30': '@types/debug@0.0.30':
resolution: {integrity: sha512-orGL5LXERPYsLov6CWs3Fh6203+dXzJkR7OnddIr2514Hsecwc8xRpzCapshBbKFImCsvS/mk6+FWiN5LyZJAQ==} resolution: {integrity: sha512-orGL5LXERPYsLov6CWs3Fh6203+dXzJkR7OnddIr2514Hsecwc8xRpzCapshBbKFImCsvS/mk6+FWiN5LyZJAQ==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.9': '@types/debug@4.1.9':
resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==} resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==}
@@ -19127,6 +19142,8 @@ snapshots:
'@total-typescript/ts-reset@0.5.1': {} '@total-typescript/ts-reset@0.5.1': {}
'@total-typescript/ts-reset@0.6.1': {}
'@trysound/sax@0.2.0': {} '@trysound/sax@0.2.0': {}
'@turist/fetch@7.2.0(node-fetch@2.7.0)': '@turist/fetch@7.2.0(node-fetch@2.7.0)':
@@ -19231,6 +19248,10 @@ snapshots:
'@types/debug@0.0.30': {} '@types/debug@0.0.30': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.32
'@types/debug@4.1.9': '@types/debug@4.1.9':
dependencies: dependencies:
'@types/ms': 0.7.32 '@types/ms': 0.7.32
@@ -26857,7 +26878,7 @@ snapshots:
micromark@3.2.0: micromark@3.2.0:
dependencies: dependencies:
'@types/debug': 4.1.9 '@types/debug': 4.1.12
debug: 4.3.4(supports-color@8.1.1) debug: 4.3.4(supports-color@8.1.1)
decode-named-character-reference: 1.0.2 decode-named-character-reference: 1.0.2
micromark-core-commonmark: 1.1.0 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) 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8 '@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 16.7.0 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) 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8 '@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0 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) 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8 '@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0 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) 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.12.8 '@types/node': 20.12.8
'@vitest/ui': 3.2.4(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0 jsdom: 26.1.0

View File

@@ -47,6 +47,10 @@ export enum Certification {
LegacyFullStack = 'full-stack' 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 // "Current" certifications are the subset of standard certifications that are
// live and not legacy. // live and not legacy.
export const currentCertifications = [ export const currentCertifications = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import matter from 'gray-matter';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { challengeTypes } from '../../shared/config/challenge-types'; 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 { parseMDSync } from '../challenge-parser/parser';
import { getMetaData, updateMetaData } from './helpers/project-metadata'; import { getMetaData, updateMetaData } from './helpers/project-metadata';
import { getProjectPath } from './helpers/get-project-info'; 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) { if (challClone?.challengeFiles) {
challClone.challengeFiles.forEach(challengeFile => { challClone.challengeFiles.forEach(challengeFile => {
if (challengeFile.contents) { 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, challengeFile.contents,
lang, lang,
dict, dict,

View File

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

View File

@@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { getChallengesForLang } from '../../../curriculum/get-challenges'; import { getChallengesForLang } from '../../../curriculum/src/get-challenges';
import { import {
buildExtCurriculumDataV1, buildExtCurriculumDataV1,
type Curriculum as CurriculumV1, type Curriculum as CurriculumV1,
@@ -13,7 +13,7 @@ import {
type CurriculumProps as CurriculumPropsV2 type CurriculumProps as CurriculumPropsV2
} from './build-external-curricula-data-v2'; } 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 // We are defaulting to English because the ids for the challenges are same
// across all languages. // 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 { type ChallengeNode } from '../../../client/src/redux/prop-types';
import { SuperBlocks } from '../../../shared-dist/config/curriculum'; import { SuperBlocks } from '../../../shared-dist/config/curriculum';
import type { Chapter } from '../../../shared-dist/config/chapters'; import type { Chapter } from '../../../shared-dist/config/chapters';
import { getSuperblockStructure } from '../../../curriculum/build-curriculum'; import { getSuperblockStructure } from '../../../curriculum/src/file-handler';
export type CurriculumIntros = export type CurriculumIntros =
| BlockBasedCurriculumIntros | BlockBasedCurriculumIntros