mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
refactor: migrate (some) curriculum files to TypeScript (#62228)
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0d44fff1ff
commit
4a635c1b32
2
.github/workflows/e2e-playwright.yml
vendored
2
.github/workflows/e2e-playwright.yml
vendored
@@ -155,7 +155,7 @@ jobs:
|
||||
- name: Install and Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run create:shared
|
||||
pnpm compile:ts
|
||||
pnpm run build:curriculum
|
||||
|
||||
- name: Start apps
|
||||
|
||||
2
.github/workflows/e2e-third-party.yml
vendored
2
.github/workflows/e2e-third-party.yml
vendored
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: Install and Build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run create:shared
|
||||
pnpm compile:ts
|
||||
pnpm run build:curriculum
|
||||
|
||||
- name: Start apps
|
||||
|
||||
2
.github/workflows/node.js-tests.yml
vendored
2
.github/workflows/node.js-tests.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Lint Source Files
|
||||
run: |
|
||||
echo pnpm version $(pnpm -v)
|
||||
pnpm run create:shared
|
||||
pnpm compile:ts
|
||||
pnpm run build:curriculum
|
||||
pnpm run lint
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -152,7 +152,8 @@ jspm_packages/
|
||||
.netlify
|
||||
|
||||
### Generated config files ###
|
||||
shared/config/curriculum.json
|
||||
shared/tsconfig.tsbuildinfo
|
||||
curriculum/tsconfig.tsbuildinfo
|
||||
|
||||
### Old Generated files ###
|
||||
# These files are no longer generated by the client, but can
|
||||
@@ -195,7 +196,7 @@ curriculum/curricula.json
|
||||
### Additional Folders ###
|
||||
curriculum/dist
|
||||
curriculum/build
|
||||
curriculum/test/blocks-generated
|
||||
curriculum/src/test/blocks-generated
|
||||
shared-dist
|
||||
|
||||
### Playwright ###
|
||||
@@ -204,3 +205,4 @@ shared-dist
|
||||
|
||||
### Shadow Testing Log Files Folder ###
|
||||
api/logs/
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ tasks:
|
||||
cp sample.env .env &&
|
||||
pnpm install &&
|
||||
gp sync-done pnpm-install &&
|
||||
pnpm compile:ts &&
|
||||
pnpm run build:curriculum &&
|
||||
gp ports await 27017
|
||||
command: >
|
||||
|
||||
@@ -7,7 +7,7 @@ import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
const CURRICULUM_PATH = '../../../shared/config/curriculum.json';
|
||||
const CURRICULUM_PATH = '../../../shared-dist/config/curriculum.json';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// Curriculum is read using fs, because it is too large for VSCode's LSP to handle type inference which causes annoying behavior.
|
||||
const curriculum = JSON.parse(
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"build": "NODE_OPTIONS=\"--max-old-space-size=7168 --no-deprecation\" gatsby build --prefix-paths",
|
||||
"build:scripts": "pnpm run -F=browser-scripts build",
|
||||
"clean": "gatsby clean",
|
||||
"common-setup": "pnpm -w run create:shared && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder",
|
||||
"common-setup": "pnpm -w run compile:ts && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder",
|
||||
"create:env": "DEBUG=fcc:* tsx ./tools/create-env.ts",
|
||||
"create:trending": "tsx ./tools/download-trending.ts",
|
||||
"create:search-placeholder": "tsx ./tools/generate-search-placeholder",
|
||||
|
||||
@@ -2,20 +2,24 @@ const path = require('path');
|
||||
|
||||
const _ = require('lodash');
|
||||
|
||||
const { getChallengesForLang } = require('../../curriculum/get-challenges');
|
||||
const {
|
||||
getChallengesForLang
|
||||
} = require('../../curriculum/dist/get-challenges.js');
|
||||
|
||||
const {
|
||||
getContentDir,
|
||||
getBlockCreator,
|
||||
getSuperblocks,
|
||||
superBlockToFilename
|
||||
} = require('../../curriculum/build-curriculum');
|
||||
} = require('../../curriculum/dist/build-curriculum.js');
|
||||
const {
|
||||
getContentDir,
|
||||
getBlockStructure,
|
||||
getSuperblockStructure
|
||||
} = require('../../curriculum/file-handler');
|
||||
const { transformSuperBlock } = require('../../curriculum/build-superblock');
|
||||
const { getSuperOrder } = require('../../curriculum/utils');
|
||||
} = require('../../curriculum/dist/file-handler.js');
|
||||
const {
|
||||
transformSuperBlock
|
||||
} = require('../../curriculum/dist/build-superblock.js');
|
||||
const { getSuperOrder } = require('../../curriculum/dist/utils.js');
|
||||
|
||||
const curriculumLocale = process.env.CURRICULUM_LOCALE || 'english';
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -31,17 +31,20 @@
|
||||
"delete-step": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-step",
|
||||
"delete-challenge": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
|
||||
"delete-task": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/delete-task",
|
||||
"lint": "tsx --tsconfig ../tsconfig.json lint-localized",
|
||||
"lint": "tsx --tsconfig ../tsconfig.json src/lint-localized",
|
||||
"reorder-tasks": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks",
|
||||
"update-challenge-order": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order",
|
||||
"update-step-titles": "CALLING_DIR=$INIT_CWD tsx --tsconfig ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
|
||||
"test-gen": "node ./test/utils/generate-block-tests.mjs",
|
||||
"test": "NODE_OPTIONS='--max-old-space-size=7168' pnpm -s test-gen && vitest -c test/vitest.config.mjs"
|
||||
"test-gen": "tsx ./src/test/utils/generate-block-tests.ts",
|
||||
"test": "NODE_OPTIONS='--max-old-space-size=7168' pnpm -s test-gen && vitest -c src/test/vitest.config.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.23.7",
|
||||
"@babel/register": "7.23.7",
|
||||
"@types/polka": "^0.5.7",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/string-similarity": "^4.0.2",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"chai": "4.4.1",
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { allCerts } from '../client/config/cert-and-project-map.js';
|
||||
import { allCerts } from '../../client/config/cert-and-project-map.js';
|
||||
import { buildCertification } from './build-certification.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -12,6 +12,7 @@ const __dirname = path.dirname(__filename);
|
||||
describe('build-certification', () => {
|
||||
const certificationsDir = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'challenges/english/certifications'
|
||||
);
|
||||
const yamlFiles = fs
|
||||
6
curriculum/src/build-certification.ts
Normal file
6
curriculum/src/build-certification.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
export const buildCertification = (filePath: string) => ({
|
||||
challenges: [load(readFileSync(filePath, 'utf8'))]
|
||||
});
|
||||
@@ -1,22 +1,27 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { SuperBlocks } from '../../shared/config/curriculum.js';
|
||||
import {
|
||||
createCommentMap,
|
||||
addBlockStructure,
|
||||
getSuperblocks
|
||||
getSuperblocks,
|
||||
superBlockNames
|
||||
} from './build-curriculum.js';
|
||||
import { getCurriculumStructure } from './file-handler.js';
|
||||
|
||||
vi.mock('./file-handler');
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
describe('createCommentMap', () => {
|
||||
const dictionaryDir = path.resolve(__dirname, '__fixtures__', 'dictionaries');
|
||||
const dictionaryDir = path.resolve(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'__fixtures__',
|
||||
'dictionaries'
|
||||
);
|
||||
const incompleteDictDir = path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'__fixtures__',
|
||||
'incomplete-dicts'
|
||||
);
|
||||
@@ -169,3 +174,13 @@ describe('getSuperblocks', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('superBlockNames', () => {
|
||||
it('should have mappings for each SuperBlock', () => {
|
||||
const superBlocks = Object.values(SuperBlocks).sort(); // sorting to make comparison clearer
|
||||
const names = Object.values(superBlockNames).sort();
|
||||
|
||||
expect(names).toHaveLength(superBlocks.length);
|
||||
expect(names).toEqual(expect.arrayContaining(superBlocks));
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,36 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import { readdirSync, readFileSync, existsSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
|
||||
const { isEmpty, isUndefined } = require('lodash');
|
||||
const debug = require('debug')('fcc:build-curriculum');
|
||||
import { isEmpty, isUndefined } from 'lodash';
|
||||
import debug from 'debug';
|
||||
|
||||
const {
|
||||
import type { CommentDictionary } from '../../tools/challenge-parser/translation-parser/index.js';
|
||||
import { SuperBlocks } from '../../shared-dist/config/curriculum.js';
|
||||
import {
|
||||
SuperblockCreator,
|
||||
BlockCreator,
|
||||
transformSuperBlock
|
||||
} = require('./build-superblock');
|
||||
transformSuperBlock,
|
||||
BlockInfo
|
||||
} from './build-superblock.js';
|
||||
|
||||
const { buildCertification } = require('./build-certification');
|
||||
const { applyFilters, closestFilters, getSuperOrder } = require('./utils');
|
||||
const {
|
||||
import { buildCertification } from './build-certification.js';
|
||||
import {
|
||||
applyFilters,
|
||||
closestFilters,
|
||||
Filter,
|
||||
getSuperOrder
|
||||
} from './utils.js';
|
||||
import {
|
||||
getContentDir,
|
||||
getLanguageConfig,
|
||||
getCurriculumStructure,
|
||||
getBlockStructure,
|
||||
getSuperblockStructure,
|
||||
getBlockStructurePath,
|
||||
getBlockStructureDir
|
||||
} = require('./file-handler');
|
||||
getBlockStructureDir,
|
||||
type BlockStructure
|
||||
} from './file-handler.js';
|
||||
const log = debug('fcc:build-curriculum');
|
||||
|
||||
/**
|
||||
* Creates a BlockCreator instance for a specific language with appropriate configuration
|
||||
@@ -32,7 +42,11 @@ const {
|
||||
* @param {string} [opts.structureDir] - Directory containing curriculum structure
|
||||
* @returns {BlockCreator} A configured BlockCreator instance
|
||||
*/
|
||||
const getBlockCreator = (lang, skipValidation, opts) => {
|
||||
export const getBlockCreator = (
|
||||
lang: string,
|
||||
skipValidation?: boolean,
|
||||
opts?: { baseDir: string; i18nBaseDir: string; structureDir: string }
|
||||
) => {
|
||||
const {
|
||||
blockContentDir,
|
||||
i18nBlockContentDir,
|
||||
@@ -63,9 +77,12 @@ const getBlockCreator = (lang, skipValidation, opts) => {
|
||||
* @param {string} params.text - The fallback English text to use if translation not found
|
||||
* @returns {Object} Object mapping language codes to translated text or fallback English text
|
||||
*/
|
||||
function getTranslationEntry(dicts, { engId, text }) {
|
||||
export function getTranslationEntry(
|
||||
dicts: Record<string, Record<string, unknown>>,
|
||||
{ engId, text }: { engId: string; text: string }
|
||||
) {
|
||||
return Object.keys(dicts).reduce((acc, lang) => {
|
||||
const entry = dicts[lang][engId];
|
||||
const entry = dicts[lang]?.[engId];
|
||||
if (entry) {
|
||||
return { ...acc, [lang]: entry };
|
||||
} else {
|
||||
@@ -81,19 +98,18 @@ function getTranslationEntry(dicts, { engId, text }) {
|
||||
* @param {string} targetDictionariesDir - Path to the target (i18n or english) dictionaries directory
|
||||
* @returns {Object} Object mapping English comment text to translations in all languages
|
||||
*/
|
||||
function createCommentMap(dictionariesDir, targetDictionariesDir) {
|
||||
debug(
|
||||
export function createCommentMap(
|
||||
dictionariesDir: string,
|
||||
targetDictionariesDir: string
|
||||
): CommentDictionary {
|
||||
log(
|
||||
`Creating comment map from ${dictionariesDir} and ${targetDictionariesDir}`
|
||||
);
|
||||
const languages = fs.readdirSync(targetDictionariesDir);
|
||||
const languages = readdirSync(targetDictionariesDir);
|
||||
|
||||
const dictionaries = languages.reduce((acc, lang) => {
|
||||
const commentsPath = path.resolve(
|
||||
targetDictionariesDir,
|
||||
lang,
|
||||
'comments.json'
|
||||
);
|
||||
const commentsData = JSON.parse(fs.readFileSync(commentsPath, 'utf8'));
|
||||
const commentsPath = resolve(targetDictionariesDir, lang, 'comments.json');
|
||||
const commentsData = JSON.parse(readFileSync(commentsPath, 'utf8'));
|
||||
return {
|
||||
...acc,
|
||||
[lang]: commentsData
|
||||
@@ -101,22 +117,15 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
|
||||
}, {});
|
||||
|
||||
const COMMENTS_TO_TRANSLATE = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(dictionariesDir, 'english', 'comments.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
readFileSync(resolve(dictionariesDir, 'english', 'comments.json'), 'utf8')
|
||||
) as Record<string, string>;
|
||||
|
||||
const COMMENTS_TO_NOT_TRANSLATE = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(
|
||||
dictionariesDir,
|
||||
'english',
|
||||
'comments-to-not-translate.json'
|
||||
),
|
||||
readFileSync(
|
||||
resolve(dictionariesDir, 'english', 'comments-to-not-translate.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
) as Record<string, string>;
|
||||
|
||||
// map from english comment text to translations
|
||||
const translatedCommentMap = Object.entries(COMMENTS_TO_TRANSLATE).reduce(
|
||||
@@ -126,7 +135,7 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
|
||||
[text]: getTranslationEntry(dictionaries, { engId: id, text })
|
||||
};
|
||||
},
|
||||
{}
|
||||
{} as CommentDictionary
|
||||
);
|
||||
|
||||
// map from english comment text to itself
|
||||
@@ -144,63 +153,62 @@ function createCommentMap(dictionariesDir, targetDictionariesDir) {
|
||||
...acc,
|
||||
[text]: englishEntry
|
||||
};
|
||||
}, {});
|
||||
}, {} as CommentDictionary);
|
||||
|
||||
const allComments = { ...translatedCommentMap, ...untranslatableCommentMap };
|
||||
|
||||
// the english entries need to be added here, because english is not in
|
||||
// languages
|
||||
Object.keys(allComments).forEach(comment => {
|
||||
allComments[comment].english = comment;
|
||||
allComments[comment]!.english = comment;
|
||||
});
|
||||
|
||||
return allComments;
|
||||
}
|
||||
|
||||
// Map of superblock filenames to their SuperBlocks enum values
|
||||
const superBlockNames = {
|
||||
'responsive-web-design': 'responsive-web-design',
|
||||
'javascript-algorithms-and-data-structures':
|
||||
'javascript-algorithms-and-data-structures',
|
||||
'front-end-development-libraries': 'front-end-development-libraries',
|
||||
'data-visualization': 'data-visualization',
|
||||
'back-end-development-and-apis': 'back-end-development-and-apis',
|
||||
'quality-assurance': 'quality-assurance',
|
||||
'scientific-computing-with-python': 'scientific-computing-with-python',
|
||||
'data-analysis-with-python': 'data-analysis-with-python',
|
||||
'information-security': 'information-security',
|
||||
'coding-interview-prep': 'coding-interview-prep',
|
||||
'machine-learning-with-python': 'machine-learning-with-python',
|
||||
'relational-databases': 'relational-database',
|
||||
'responsive-web-design-22': '2022/responsive-web-design',
|
||||
export const superBlockNames = {
|
||||
'responsive-web-design': SuperBlocks.RespWebDesign,
|
||||
'javascript-algorithms-and-data-structures': SuperBlocks.JsAlgoDataStruct,
|
||||
'front-end-development-libraries': SuperBlocks.FrontEndDevLibs,
|
||||
'data-visualization': SuperBlocks.DataVis,
|
||||
'back-end-development-and-apis': SuperBlocks.BackEndDevApis,
|
||||
'quality-assurance': SuperBlocks.QualityAssurance,
|
||||
'scientific-computing-with-python': SuperBlocks.SciCompPy,
|
||||
'data-analysis-with-python': SuperBlocks.DataAnalysisPy,
|
||||
'information-security': SuperBlocks.InfoSec,
|
||||
'coding-interview-prep': SuperBlocks.CodingInterviewPrep,
|
||||
'machine-learning-with-python': SuperBlocks.MachineLearningPy,
|
||||
'relational-databases': SuperBlocks.RelationalDb,
|
||||
'responsive-web-design-22': SuperBlocks.RespWebDesignNew,
|
||||
'javascript-algorithms-and-data-structures-22':
|
||||
'javascript-algorithms-and-data-structures-v8',
|
||||
'the-odin-project': 'the-odin-project',
|
||||
'college-algebra-with-python': 'college-algebra-with-python',
|
||||
'project-euler': 'project-euler',
|
||||
'foundational-c-sharp-with-microsoft': 'foundational-c-sharp-with-microsoft',
|
||||
'a2-english-for-developers': 'a2-english-for-developers',
|
||||
'rosetta-code': 'rosetta-code',
|
||||
'python-for-everybody': 'python-for-everybody',
|
||||
'b1-english-for-developers': 'b1-english-for-developers',
|
||||
'full-stack-developer': 'full-stack-developer',
|
||||
'a1-professional-spanish': 'a1-professional-spanish',
|
||||
'a2-professional-spanish': 'a2-professional-spanish',
|
||||
'a2-professional-chinese': 'a2-professional-chinese',
|
||||
'basic-html': 'basic-html',
|
||||
'semantic-html': 'semantic-html',
|
||||
'a1-professional-chinese': 'a1-professional-chinese',
|
||||
'dev-playground': 'dev-playground',
|
||||
'full-stack-open': 'full-stack-open',
|
||||
'responsive-web-design-v9': 'responsive-web-design-v9',
|
||||
'javascript-v9': 'javascript-v9',
|
||||
'front-end-development-libraries-v9': 'front-end-development-libraries-v9',
|
||||
'python-v9': 'python-v9',
|
||||
'relational-databases-v9': 'relational-databases-v9',
|
||||
'back-end-development-and-apis-v9': 'back-end-development-and-apis-v9'
|
||||
SuperBlocks.JsAlgoDataStructNew,
|
||||
'javascript-v9': SuperBlocks.JsV9,
|
||||
'the-odin-project': SuperBlocks.TheOdinProject,
|
||||
'college-algebra-with-python': SuperBlocks.CollegeAlgebraPy,
|
||||
'project-euler': SuperBlocks.ProjectEuler,
|
||||
'foundational-c-sharp-with-microsoft': SuperBlocks.FoundationalCSharp,
|
||||
'a2-english-for-developers': SuperBlocks.A2English,
|
||||
'rosetta-code': SuperBlocks.RosettaCode,
|
||||
'python-for-everybody': SuperBlocks.PythonForEverybody,
|
||||
'b1-english-for-developers': SuperBlocks.B1English,
|
||||
'full-stack-developer': SuperBlocks.FullStackDeveloper,
|
||||
'a1-professional-spanish': SuperBlocks.A1Spanish,
|
||||
'a2-professional-spanish': SuperBlocks.A2Spanish,
|
||||
'a2-professional-chinese': SuperBlocks.A2Chinese,
|
||||
'basic-html': SuperBlocks.BasicHtml,
|
||||
'semantic-html': SuperBlocks.SemanticHtml,
|
||||
'a1-professional-chinese': SuperBlocks.A1Chinese,
|
||||
'dev-playground': SuperBlocks.DevPlayground,
|
||||
'full-stack-open': SuperBlocks.FullStackOpen,
|
||||
'responsive-web-design-v9': SuperBlocks.RespWebDesignV9,
|
||||
'front-end-development-libraries-v9': SuperBlocks.FrontEndDevLibsV9,
|
||||
'python-v9': SuperBlocks.PythonV9,
|
||||
'relational-databases-v9': SuperBlocks.RelationalDbV9,
|
||||
'back-end-development-and-apis-v9': SuperBlocks.BackEndDevApisV9
|
||||
};
|
||||
|
||||
const superBlockToFilename = Object.entries(superBlockNames).reduce(
|
||||
export const superBlockToFilename = Object.entries(superBlockNames).reduce(
|
||||
(map, entry) => {
|
||||
return { ...map, [entry[1]]: entry[0] };
|
||||
},
|
||||
@@ -210,41 +218,44 @@ const superBlockToFilename = Object.entries(superBlockNames).reduce(
|
||||
/**
|
||||
* Builds an array of superblock structures from a curriculum object
|
||||
|
||||
* @param {string[]} superblocks - Array of superblock filename strings
|
||||
* @param {string[]} superBlockFilenames - Array of superblock filename strings
|
||||
* @returns {Array<Object>} Array of superblock structure objects with filename, name, and blocks
|
||||
* @throws {Error} When a superblock file is not found
|
||||
*/
|
||||
function addSuperblockStructure(
|
||||
superblocks,
|
||||
export function addSuperblockStructure(
|
||||
superBlockFilenames: string[],
|
||||
showComingSoon = process.env.SHOW_UPCOMING_CHANGES === 'true'
|
||||
) {
|
||||
debug(`Building structure for ${superblocks.length} superblocks`);
|
||||
log(`Building structure for ${superBlockFilenames.length} superblocks`);
|
||||
|
||||
const superblockStructures = superblocks.map(superblockFilename => {
|
||||
const superblockName = superBlockNames[superblockFilename];
|
||||
const superblockStructures = superBlockFilenames.map(filename => {
|
||||
const superblockName =
|
||||
superBlockNames[filename as keyof typeof superBlockNames];
|
||||
if (!superblockName) {
|
||||
throw new Error(`Superblock name not found for ${superblockFilename}`);
|
||||
throw new Error(`Superblock name not found for ${filename}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: superblockName,
|
||||
blocks: transformSuperBlock(getSuperblockStructure(superblockFilename), {
|
||||
blocks: transformSuperBlock(getSuperblockStructure(filename), {
|
||||
showComingSoon
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
debug(
|
||||
log(
|
||||
`Successfully built ${superblockStructures.length} superblock structures`
|
||||
);
|
||||
|
||||
return superblockStructures;
|
||||
}
|
||||
|
||||
function addBlockStructure(
|
||||
superblocks,
|
||||
type ProcessedBlock = BlockInfo & BlockStructure;
|
||||
|
||||
export function addBlockStructure(
|
||||
superblocks: { name: SuperBlocks; blocks: BlockInfo[] }[],
|
||||
_getBlockStructure = getBlockStructure
|
||||
) {
|
||||
): { name: SuperBlocks; blocks: ProcessedBlock[] }[] {
|
||||
return superblocks.map(superblock => ({
|
||||
...superblock,
|
||||
blocks: superblock.blocks.map((block, index) => ({
|
||||
@@ -260,8 +271,8 @@ function addBlockStructure(
|
||||
* Returns a list of all the superblocks that contain the given block
|
||||
* @param {string} block
|
||||
*/
|
||||
function getSuperblocks(
|
||||
block,
|
||||
export function getSuperblocks(
|
||||
block: string,
|
||||
_addSuperblockStructure = addSuperblockStructure
|
||||
) {
|
||||
const { superblocks } = getCurriculumStructure();
|
||||
@@ -274,23 +285,23 @@ function getSuperblocks(
|
||||
.map(({ name }) => name);
|
||||
}
|
||||
|
||||
function validateBlocks(superblocks, blockStructureDir) {
|
||||
function validateBlocks(superblocks: SuperBlocks[], blockStructureDir: string) {
|
||||
const withSuperblockStructure = addSuperblockStructure(superblocks, true);
|
||||
const blockInSuperblocks = withSuperblockStructure
|
||||
.flatMap(({ blocks }) => blocks)
|
||||
.map(b => b.dashedName);
|
||||
for (const block of blockInSuperblocks) {
|
||||
const blockPath = getBlockStructurePath(block);
|
||||
if (!fs.existsSync(blockPath)) {
|
||||
if (!existsSync(blockPath)) {
|
||||
throw Error(
|
||||
`Block "${block}" is in a superblock, but has no block structure file at ${blockPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blockStructureFiles = fs
|
||||
.readdirSync(blockStructureDir)
|
||||
.map(file => path.basename(file, '.json'));
|
||||
const blockStructureFiles = readdirSync(blockStructureDir).map(file =>
|
||||
basename(file, '.json')
|
||||
);
|
||||
|
||||
for (const block of blockStructureFiles) {
|
||||
if (!blockInSuperblocks.includes(block)) {
|
||||
@@ -301,40 +312,45 @@ function validateBlocks(superblocks, blockStructureDir) {
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCurriculumStructure(filters) {
|
||||
export async function parseCurriculumStructure(filter?: Filter) {
|
||||
const curriculum = getCurriculumStructure();
|
||||
const blockStructureDir = getBlockStructureDir();
|
||||
if (isEmpty(curriculum.superblocks))
|
||||
throw Error('No superblocks found in curriculum.json');
|
||||
if (isEmpty(curriculum.certifications))
|
||||
throw Error('No certifications found in curriculum.json');
|
||||
debug(`Found ${curriculum.superblocks.length} superblocks to build`);
|
||||
debug(`Found ${curriculum.certifications.length} certifications to build`);
|
||||
log(`Found ${curriculum.superblocks.length} superblocks to build`);
|
||||
log(`Found ${curriculum.certifications.length} certifications to build`);
|
||||
|
||||
validateBlocks(curriculum.superblocks, blockStructureDir);
|
||||
|
||||
const superblockList = addBlockStructure(
|
||||
addSuperblockStructure(curriculum.superblocks)
|
||||
);
|
||||
const refinedFilters = closestFilters(filters, superblockList);
|
||||
const fullSuperblockList = applyFilters(superblockList, refinedFilters);
|
||||
const refinedFilter = closestFilters(superblockList, filter);
|
||||
const fullSuperblockList = applyFilters(superblockList, refinedFilter);
|
||||
return {
|
||||
fullSuperblockList,
|
||||
certifications: curriculum.certifications
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCurriculum(lang, filters) {
|
||||
export async function buildCurriculum(lang: string, filters?: Filter) {
|
||||
const contentDir = getContentDir(lang);
|
||||
|
||||
const builder = new SuperblockCreator({
|
||||
blockCreator: getBlockCreator(lang, !isEmpty(filters))
|
||||
});
|
||||
const builder = new SuperblockCreator(
|
||||
getBlockCreator(lang, !isEmpty(filters))
|
||||
);
|
||||
|
||||
const { fullSuperblockList, certifications } =
|
||||
await parseCurriculumStructure(filters);
|
||||
|
||||
const fullCurriculum = { certifications: { blocks: {} } };
|
||||
const fullCurriculum: {
|
||||
[key: string]: unknown;
|
||||
certifications: { blocks: { [key: string]: unknown } };
|
||||
} = {
|
||||
certifications: { blocks: {} }
|
||||
};
|
||||
|
||||
const liveSuperblocks = fullSuperblockList.filter(({ name }) => {
|
||||
const superOrder = getSuperOrder(name);
|
||||
@@ -353,27 +369,13 @@ async function buildCurriculum(lang, filters) {
|
||||
}
|
||||
|
||||
for (const cert of certifications) {
|
||||
const certPath = path.resolve(contentDir, 'certifications', `${cert}.yml`);
|
||||
if (!fs.existsSync(certPath)) {
|
||||
const certPath = resolve(contentDir, 'certifications', `${cert}.yml`);
|
||||
if (!existsSync(certPath)) {
|
||||
throw Error(`Certification file not found: ${certPath}`);
|
||||
}
|
||||
debug(`=== Processing certification ${cert} ===`);
|
||||
log(`=== Processing certification ${cert} ===`);
|
||||
fullCurriculum.certifications.blocks[cert] = buildCertification(certPath);
|
||||
}
|
||||
|
||||
return fullCurriculum;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addBlockStructure,
|
||||
buildCurriculum,
|
||||
getContentDir,
|
||||
getBlockCreator,
|
||||
getBlockStructure,
|
||||
getSuperblockStructure,
|
||||
createCommentMap,
|
||||
superBlockToFilename,
|
||||
getSuperblocks,
|
||||
addSuperblockStructure,
|
||||
parseCurriculumStructure
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { isPoly } from '../shared-dist/utils/polyvinyl.js';
|
||||
import { isPoly } from '../../shared-dist/utils/polyvinyl.js';
|
||||
import {
|
||||
validateChallenges,
|
||||
buildBlock,
|
||||
@@ -565,9 +565,7 @@ describe('SuperblockCreator class', () => {
|
||||
{ dashedName: 'block-3' }
|
||||
];
|
||||
|
||||
const parser = new SuperblockCreator({
|
||||
blockCreator: mockBlockCreator
|
||||
});
|
||||
const parser = new SuperblockCreator(mockBlockCreator);
|
||||
|
||||
const result = await parser.processSuperblock({
|
||||
blocks,
|
||||
@@ -1,37 +1,58 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isEmpty } = require('lodash');
|
||||
const debug = require('debug')('fcc:build-superblock');
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { isEmpty } from 'lodash';
|
||||
import debug from 'debug';
|
||||
|
||||
const { parseMD } = require('../tools/challenge-parser/parser');
|
||||
const { createPoly } = require('../shared-dist/utils/polyvinyl');
|
||||
const { isAuditedSuperBlock } = require('../shared-dist/utils/is-audited');
|
||||
const {
|
||||
import { parseMD } from '../../tools/challenge-parser/parser';
|
||||
import { createPoly } from '../../shared-dist/utils/polyvinyl';
|
||||
import { isAuditedSuperBlock } from '../../shared-dist/utils/is-audited';
|
||||
import {
|
||||
CommentDictionary,
|
||||
translateCommentsInChallenge
|
||||
} = require('../tools/challenge-parser/translation-parser');
|
||||
const { getSuperOrder } = require('./utils');
|
||||
} from '../../tools/challenge-parser/translation-parser';
|
||||
import { SuperBlocks } from '../../shared-dist/config/curriculum';
|
||||
import type { Chapter } from '../../shared-dist/config/chapters';
|
||||
import { Certification } from '../../shared-dist/config/certification-settings';
|
||||
import { getSuperOrder } from './utils.js';
|
||||
import type {
|
||||
BlockStructure,
|
||||
Challenge,
|
||||
ChallengeFile
|
||||
} from './file-handler.js';
|
||||
|
||||
const duplicates = xs => xs.filter((x, i) => xs.indexOf(x) !== i);
|
||||
const log = debug('fcc:build-superblock');
|
||||
|
||||
const createValidator = throwOnError => fn => {
|
||||
const duplicates = <T>(xs: T[]) => xs.filter((x, i) => xs.indexOf(x) !== i);
|
||||
|
||||
const createValidator = (throwOnError?: boolean) => (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
if (throwOnError) {
|
||||
throw error;
|
||||
} else {
|
||||
console.error(error.message);
|
||||
console.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface Meta extends BlockStructure {
|
||||
order: number;
|
||||
superBlock: SuperBlocks;
|
||||
superOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates challenges against meta.json challengeOrder
|
||||
* @param {Array<object>} foundChallenges - Array of challenge objects
|
||||
* @param {object} meta - Meta object with challengeOrder array
|
||||
* @throws {Error} If validation fails (missing challenges, duplicates, etc.)
|
||||
*/
|
||||
function validateChallenges(foundChallenges, meta, throwOnError) {
|
||||
export function validateChallenges(
|
||||
foundChallenges: Challenge[],
|
||||
meta: { challengeOrder: Challenge[]; dashedName: string },
|
||||
throwOnError?: boolean
|
||||
) {
|
||||
const metaChallengeIds = new Set(meta.challengeOrder.map(c => c.id));
|
||||
const foundChallengeIds = new Set(foundChallenges.map(c => c.id));
|
||||
|
||||
@@ -98,7 +119,7 @@ function validateChallenges(foundChallenges, meta, throwOnError) {
|
||||
* @param {object} meta - Meta object with name, dashedName, and challengeOrder
|
||||
* @returns {object} Block object with ordered challenges
|
||||
*/
|
||||
function buildBlock(foundChallenges, meta) {
|
||||
export function buildBlock(foundChallenges: Challenge[], meta: Meta) {
|
||||
const challenges = meta.challengeOrder.map(challengeInfo => {
|
||||
const challenge = foundChallenges.find(c => c.id === challengeInfo.id);
|
||||
if (!challenge) {
|
||||
@@ -122,7 +143,10 @@ function buildBlock(foundChallenges, meta) {
|
||||
* @param {object} meta - The meta information object
|
||||
* @returns {object} The challenge object with added meta information
|
||||
*/
|
||||
function addMetaToChallenge(challenge, meta) {
|
||||
export function addMetaToChallenge(
|
||||
challenge: Partial<Challenge>,
|
||||
meta: Meta
|
||||
): Challenge {
|
||||
const challengeOrderIndex = meta.challengeOrder.findIndex(
|
||||
({ id }) => id === challenge.id
|
||||
);
|
||||
@@ -168,9 +192,22 @@ function addMetaToChallenge(challenge, meta) {
|
||||
const hasDupe = dupeCertifications.find(
|
||||
cert => cert.dupe === meta.superBlock
|
||||
);
|
||||
challenge.certification = hasDupe ? hasDupe.certification : meta.superBlock;
|
||||
|
||||
return challenge;
|
||||
const maybeCert = (
|
||||
hasDupe ? hasDupe.certification : meta.superBlock
|
||||
) as Certification;
|
||||
|
||||
challenge.certification = maybeCert;
|
||||
// TODO: reimplement after updating the client to expect Certification | null
|
||||
// if (isCertification(maybeCert)) {
|
||||
// challenge.certification = maybeCert;
|
||||
// } else {
|
||||
// throw Error(
|
||||
// `Superblock ${meta.superBlock} does not map to a certification`
|
||||
// );
|
||||
// }
|
||||
|
||||
return challenge as Challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +215,7 @@ function addMetaToChallenge(challenge, meta) {
|
||||
* @param {Array<object>} files - Array of challenge file objects
|
||||
* @returns {Array<object>} Array of polyvinyl objects with seed property
|
||||
*/
|
||||
function challengeFilesToPolys(files) {
|
||||
export function challengeFilesToPolys(files: ChallengeFile[]) {
|
||||
return files.reduce((challengeFiles, challengeFile) => {
|
||||
return [
|
||||
...challengeFiles,
|
||||
@@ -187,7 +224,7 @@ function challengeFilesToPolys(files) {
|
||||
seed: challengeFile.contents.slice(0)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
}, [] as ChallengeFile[]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +232,7 @@ function challengeFilesToPolys(files) {
|
||||
* @param {object} challenge - The challenge object to fix
|
||||
* @returns {object} The challenge object with fixed properties
|
||||
*/
|
||||
function fixChallengeProperties(challenge) {
|
||||
export function fixChallengeProperties(challenge: Challenge) {
|
||||
const fixedChallenge = {
|
||||
...challenge
|
||||
};
|
||||
@@ -219,10 +256,10 @@ function fixChallengeProperties(challenge) {
|
||||
* @param {object} meta - The meta information object
|
||||
* @returns {object} The finalized challenge object
|
||||
*/
|
||||
function finalizeChallenge(challenge, meta) {
|
||||
export function finalizeChallenge(challenge: Challenge, meta: Meta) {
|
||||
return addMetaToChallenge(fixChallengeProperties(challenge), meta);
|
||||
}
|
||||
class BlockCreator {
|
||||
export class BlockCreator {
|
||||
/**
|
||||
* @param {object} options - Options object
|
||||
* @param {string} options.blockContentDir - Directory containing block content files
|
||||
@@ -234,12 +271,25 @@ class BlockCreator {
|
||||
* This class is responsible for reading block directories, parsing challenges, and validating them
|
||||
* against the meta information.
|
||||
*/
|
||||
|
||||
blockContentDir: string;
|
||||
i18nBlockContentDir: string;
|
||||
lang: string;
|
||||
commentTranslations: CommentDictionary;
|
||||
skipValidation: boolean | undefined;
|
||||
|
||||
constructor({
|
||||
blockContentDir,
|
||||
i18nBlockContentDir,
|
||||
lang,
|
||||
commentTranslations,
|
||||
skipValidation
|
||||
}: {
|
||||
blockContentDir: string;
|
||||
i18nBlockContentDir: string;
|
||||
lang: string;
|
||||
commentTranslations: CommentDictionary;
|
||||
skipValidation?: boolean;
|
||||
}) {
|
||||
this.blockContentDir = blockContentDir;
|
||||
this.i18nBlockContentDir = i18nBlockContentDir;
|
||||
@@ -259,18 +309,22 @@ class BlockCreator {
|
||||
* @returns {Promise<object>} The finalized challenge object
|
||||
*/
|
||||
async createChallenge(
|
||||
{ filename, block, meta, isAudited },
|
||||
{
|
||||
filename,
|
||||
block,
|
||||
meta,
|
||||
isAudited
|
||||
}: { filename: string; block: string; meta: Meta; isAudited: boolean },
|
||||
parser = parseMD
|
||||
) {
|
||||
debug(
|
||||
log(
|
||||
`Creating challenge from file: ${filename} in block: ${block}, using lang: ${this.lang}`
|
||||
);
|
||||
|
||||
const englishPath = path.resolve(this.blockContentDir, block, filename);
|
||||
const i18nPath = path.resolve(this.i18nBlockContentDir, block, filename);
|
||||
const englishPath = resolve(this.blockContentDir, block, filename);
|
||||
const i18nPath = resolve(this.i18nBlockContentDir, block, filename);
|
||||
|
||||
const langUsed =
|
||||
isAudited && fs.existsSync(i18nPath) ? this.lang : 'english';
|
||||
const langUsed = isAudited && existsSync(i18nPath) ? this.lang : 'english';
|
||||
|
||||
const challengePath = langUsed === 'english' ? englishPath : i18nPath;
|
||||
|
||||
@@ -292,11 +346,11 @@ class BlockCreator {
|
||||
* @param {boolean} isAudited - Whether the block is audited for i18n
|
||||
* @returns {Promise<Array<object>>} Array of challenge objects
|
||||
*/
|
||||
async readBlockChallenges(block, meta, isAudited) {
|
||||
const blockDir = path.resolve(this.blockContentDir, block);
|
||||
const challengeFiles = fs
|
||||
.readdirSync(blockDir)
|
||||
.filter(file => file.endsWith('.md'));
|
||||
async readBlockChallenges(block: string, meta: Meta, isAudited: boolean) {
|
||||
const blockDir = resolve(this.blockContentDir, block);
|
||||
const challengeFiles = readdirSync(blockDir).filter(file =>
|
||||
file.endsWith('.md')
|
||||
);
|
||||
|
||||
return await Promise.all(
|
||||
challengeFiles.map(filename =>
|
||||
@@ -305,13 +359,16 @@ class BlockCreator {
|
||||
);
|
||||
}
|
||||
|
||||
async processBlock(block, { superBlock, order }) {
|
||||
async processBlock(
|
||||
block: BlockStructure,
|
||||
{ superBlock, order }: { superBlock: SuperBlocks; order: number }
|
||||
) {
|
||||
const blockName = block.dashedName;
|
||||
debug(`Processing block ${blockName} in superblock ${superBlock}`);
|
||||
log(`Processing block ${blockName} in superblock ${superBlock}`);
|
||||
|
||||
// Check if block directory exists
|
||||
const blockContentDir = path.resolve(this.blockContentDir, blockName);
|
||||
if (!fs.existsSync(blockContentDir)) {
|
||||
const blockContentDir = resolve(this.blockContentDir, blockName);
|
||||
if (!existsSync(blockContentDir)) {
|
||||
throw Error(`Block directory not found: ${blockContentDir}`);
|
||||
}
|
||||
|
||||
@@ -319,11 +376,13 @@ class BlockCreator {
|
||||
block.isUpcomingChange &&
|
||||
process.env.SHOW_UPCOMING_CHANGES !== 'true'
|
||||
) {
|
||||
debug(`Ignoring upcoming block ${blockName}`);
|
||||
log(`Ignoring upcoming block ${blockName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const superOrder = getSuperOrder(superBlock);
|
||||
if (superOrder === undefined)
|
||||
throw Error(`Superblock not found: ${superBlock}`);
|
||||
const meta = {
|
||||
...block,
|
||||
superOrder,
|
||||
@@ -332,7 +391,7 @@ class BlockCreator {
|
||||
...(block.chapter && { chapter: block.chapter }),
|
||||
...(block.module && { module: block.module })
|
||||
};
|
||||
const isAudited = isAuditedSuperBlock(this.lang, superBlock);
|
||||
const isAudited = isAuditedSuperBlock(this.lang, superBlock as SuperBlocks);
|
||||
|
||||
// Read challenges from directory
|
||||
const foundChallenges = await this.readBlockChallenges(
|
||||
@@ -340,11 +399,11 @@ class BlockCreator {
|
||||
meta,
|
||||
isAudited
|
||||
);
|
||||
debug(`Found ${foundChallenges.length} challenge files in directory`);
|
||||
log(`Found ${foundChallenges.length} challenge files in directory`);
|
||||
|
||||
// Log found challenges
|
||||
foundChallenges.forEach(challenge => {
|
||||
debug(`Found challenge: ${challenge.title} (${challenge.id})`);
|
||||
log(`Found challenge: ${challenge.title} (${challenge.id})`);
|
||||
});
|
||||
|
||||
const throwOnError = this.lang === 'english';
|
||||
@@ -355,7 +414,7 @@ class BlockCreator {
|
||||
// Build the block object
|
||||
const blockResult = buildBlock(foundChallenges, meta);
|
||||
|
||||
debug(
|
||||
log(
|
||||
`Completed block "${meta.name}" with ${blockResult.challenges.length} challenges (${blockResult.challenges.filter(c => !c.missing).length} built successfully)`
|
||||
);
|
||||
|
||||
@@ -363,20 +422,29 @@ class BlockCreator {
|
||||
}
|
||||
}
|
||||
|
||||
class SuperblockCreator {
|
||||
export class SuperblockCreator {
|
||||
/**
|
||||
* @param {object} options - Options object
|
||||
* @param {BlockCreator} options.blockCreator - Instance of BlockCreator
|
||||
*/
|
||||
constructor({ blockCreator }) {
|
||||
|
||||
blockCreator: BlockCreator;
|
||||
|
||||
constructor(blockCreator: BlockCreator) {
|
||||
this.blockCreator = blockCreator;
|
||||
}
|
||||
|
||||
async processSuperblock({ blocks, name }) {
|
||||
const superBlock = { blocks: {} };
|
||||
async processSuperblock({
|
||||
blocks,
|
||||
name
|
||||
}: {
|
||||
blocks: BlockStructure[];
|
||||
name: SuperBlocks;
|
||||
}) {
|
||||
const superBlock: { blocks: Record<string, unknown> } = { blocks: {} };
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
const block: BlockStructure = blocks[i]!;
|
||||
const blockResult = await this.blockCreator.processBlock(block, {
|
||||
superBlock: name,
|
||||
order: i
|
||||
@@ -386,23 +454,32 @@ class SuperblockCreator {
|
||||
}
|
||||
}
|
||||
|
||||
debug(
|
||||
log(
|
||||
`Completed parsing superblock. Total blocks: ${Object.keys(superBlock.blocks).length}`
|
||||
);
|
||||
return superBlock;
|
||||
}
|
||||
}
|
||||
|
||||
export type BlockInfo = {
|
||||
dashedName: string;
|
||||
chapter?: string;
|
||||
module?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms superblock data to extract blocks array
|
||||
* @param {object} superblockData - The superblock data object
|
||||
* @returns {object[]} Array of block objects with dashedName, chapter, and module properties
|
||||
*/
|
||||
function transformSuperBlock(
|
||||
superblockData,
|
||||
export function transformSuperBlock(
|
||||
superblockData: {
|
||||
blocks?: string[];
|
||||
chapters?: Chapter[];
|
||||
},
|
||||
{ showComingSoon } = { showComingSoon: false }
|
||||
) {
|
||||
let blocks = [];
|
||||
let blocks: BlockInfo[] = [];
|
||||
|
||||
// Handle simple blocks array format
|
||||
if (superblockData.blocks) {
|
||||
@@ -442,17 +519,6 @@ function transformSuperBlock(
|
||||
}
|
||||
|
||||
const blockNames = blocks.map(block => block.dashedName);
|
||||
debug(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
|
||||
log(`Found ${blocks.length} blocks: ${blockNames.join(', ')}`);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SuperblockCreator,
|
||||
BlockCreator,
|
||||
addMetaToChallenge,
|
||||
validateChallenges,
|
||||
buildBlock,
|
||||
finalizeChallenge,
|
||||
transformSuperBlock,
|
||||
fixChallengeProperties
|
||||
};
|
||||
276
curriculum/src/file-handler.ts
Normal file
276
curriculum/src/file-handler.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { hasEnglishSource, getChallengesForLang } from './get-challenges.js';
|
||||
const EXISTING_CHALLENGE_PATH = 'challenge.md';
|
||||
const MISSING_CHALLENGE_PATH = 'no/challenge.md';
|
||||
|
||||
const basePath = '__fixtures__';
|
||||
const basePath = '../__fixtures__';
|
||||
|
||||
describe('create non-English challenge', () => {
|
||||
describe('getChallengesForLang', () => {
|
||||
36
curriculum/src/get-challenges.ts
Normal file
36
curriculum/src/get-challenges.ts
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
var glob = require('glob');
|
||||
const lint = require('../tools/scripts/lint');
|
||||
const lint = require('../../tools/scripts/lint');
|
||||
const { testedLang } = require('./utils');
|
||||
|
||||
glob(`challenges/${testedLang()}/**/*.md`, (err, files) => {
|
||||
1
curriculum/src/reset.d.ts
vendored
Normal file
1
curriculum/src/reset.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import '@total-typescript/ts-reset';
|
||||
@@ -1,10 +1,10 @@
|
||||
import { assert, describe, it, vi } from 'vitest';
|
||||
import { testedLang } from '../utils';
|
||||
|
||||
vi.stubEnv('SHOW_UPCOMING_CHANGES', 'true');
|
||||
|
||||
// We need to use dynamic import here to ensure the environment variable is set
|
||||
// We need to use dynamic imports here to ensure the environment variable is set
|
||||
// before the module is loaded.
|
||||
const { testedLang } = await import('../utils.js');
|
||||
const { getChallenges } = await import('./test-challenges.js');
|
||||
|
||||
describe('Daily Coding Challenges', async () => {
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { describe, it, beforeAll, expect } from 'vitest';
|
||||
import { assert, AssertionError } from 'chai';
|
||||
import jsdom from 'jsdom';
|
||||
@@ -8,28 +6,26 @@ import lodash from 'lodash';
|
||||
import {
|
||||
buildChallenge,
|
||||
runnerTypes
|
||||
} from '../../client/src/templates/Challenges/utils/build';
|
||||
} from '../../../client/src/templates/Challenges/utils/build';
|
||||
import {
|
||||
challengeTypes,
|
||||
hasNoSolution
|
||||
} from '../../shared/config/challenge-types';
|
||||
import { getLines } from '../../shared/utils/get-lines';
|
||||
import { prefixDoctype } from '../../client/src/templates/Challenges/utils/frame';
|
||||
} from '../../../shared/config/challenge-types';
|
||||
import { getLines } from '../../../shared/utils/get-lines';
|
||||
import { prefixDoctype } from '../../../client/src/templates/Challenges/utils/frame';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
import { getChallengesForLang } from '../get-challenges.js';
|
||||
import { challengeSchemaValidator } from '../../schema/challenge-schema.js';
|
||||
import { testedLang } from '../utils.js';
|
||||
|
||||
const { getChallengesForLang } = require('../get-challenges');
|
||||
const { challengeSchemaValidator } = require('../schema/challenge-schema');
|
||||
const { testedLang } = require('../utils');
|
||||
import { curriculumSchemaValidator } from '../../schema/curriculum-schema.js';
|
||||
import { validateMetaSchema } from '../../schema/meta-schema.js';
|
||||
import { getBlockStructure } from '../file-handler.js';
|
||||
import ChallengeTitles from './utils/challenge-titles.js';
|
||||
import MongoIds from './utils/mongo-ids.js';
|
||||
import createPseudoWorker from './utils/pseudo-worker.js';
|
||||
|
||||
const { curriculumSchemaValidator } = require('../schema/curriculum-schema');
|
||||
const { validateMetaSchema } = require('../schema/meta-schema');
|
||||
const { getBlockStructure } = require('../file-handler');
|
||||
const ChallengeTitles = require('./utils/challenge-titles');
|
||||
const MongoIds = require('./utils/mongo-ids');
|
||||
const createPseudoWorker = require('./utils/pseudo-worker');
|
||||
|
||||
const { sortChallenges } = require('./utils/sort-challenges');
|
||||
import { sortChallenges } from './utils/sort-challenges.js';
|
||||
|
||||
const { flatten, isEmpty, cloneDeep } = lodash;
|
||||
|
||||
@@ -4,10 +4,19 @@ import path from 'node:path';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { parseCurriculumStructure } from '../../build-curriculum.js';
|
||||
import { Filter } from '../../utils.js';
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
let __dirnameCompat: string;
|
||||
|
||||
const testFilter = {
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
// CJS
|
||||
__dirnameCompat = __dirname;
|
||||
} else {
|
||||
// ESM – wrap in Function so CJS parsers don't see it
|
||||
__dirnameCompat = new Function('return import.meta.dirname')() as string;
|
||||
}
|
||||
|
||||
const testFilter: Filter = {
|
||||
block: process.env.FCC_BLOCK ? process.env.FCC_BLOCK.trim() : undefined,
|
||||
challengeId: process.env.FCC_CHALLENGE_ID
|
||||
? process.env.FCC_CHALLENGE_ID.trim()
|
||||
@@ -17,7 +26,7 @@ const testFilter = {
|
||||
: undefined
|
||||
};
|
||||
|
||||
const GENERATED_DIR = path.resolve(__dirname, '../blocks-generated');
|
||||
const GENERATED_DIR = path.resolve(__dirnameCompat, '../blocks-generated');
|
||||
|
||||
async function main() {
|
||||
// clean and recreate directory
|
||||
@@ -39,7 +48,7 @@ async function main() {
|
||||
console.log(`Generated ${blocks.length} block test file(s).`);
|
||||
}
|
||||
|
||||
function generateSingleBlockFile(testFilter) {
|
||||
function generateSingleBlockFile(testFilter: Filter) {
|
||||
return `import { defineTestsForBlock } from '../test-challenges.js';
|
||||
|
||||
await defineTestsForBlock(${JSON.stringify(testFilter)});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { shuffleArray } from '../../../shared-dist/utils/shuffle-array.js';
|
||||
import { shuffleArray } from '../../../../shared-dist/utils/shuffle-array.js';
|
||||
import { sortChallenges } from './sort-challenges.js';
|
||||
|
||||
const challenges = [
|
||||
@@ -4,9 +4,9 @@ import sirv from 'sirv';
|
||||
import polka from 'polka';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
import { helperVersion } from '../../client/src/templates/Challenges/utils/frame';
|
||||
import { helperVersion } from '../../../client/src/templates/Challenges/utils/frame';
|
||||
|
||||
const clientPath = path.resolve(__dirname, '../../client');
|
||||
const clientPath = path.resolve(__dirname, '../../../client');
|
||||
|
||||
async function createBrowser() {
|
||||
return puppeteer.launch({
|
||||
@@ -2,12 +2,12 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/blocks-generated/**/*.test.js'],
|
||||
include: ['src/test/blocks-generated/**/*.test.js'],
|
||||
environment: 'node',
|
||||
hookTimeout: 60000,
|
||||
testTimeout: 30000,
|
||||
isolate: false,
|
||||
globalSetup: 'test/vitest-global-setup.mjs',
|
||||
setupFiles: 'test/vitest-setup.mjs'
|
||||
globalSetup: 'src/test/vitest-global-setup.mjs',
|
||||
setupFiles: 'src/test/vitest-setup.mjs'
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
import { config } from 'dotenv';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { SuperBlocks } from '../shared-dist/config/curriculum';
|
||||
import { SuperBlocks } from '../../shared-dist/config/curriculum';
|
||||
import {
|
||||
closestFilters,
|
||||
closestMatch,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
filterByChallengeId,
|
||||
filterBySuperblock,
|
||||
getSuperOrder
|
||||
} from './utils';
|
||||
} from './utils.js';
|
||||
|
||||
config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
@@ -65,12 +65,6 @@ describe('createSuperOrder', () => {
|
||||
it('should create the correct object given an array of SuperBlocks', () => {
|
||||
expect(superOrder).toStrictEqual(fullSuperOrder);
|
||||
});
|
||||
|
||||
it('throws when not given an array of SuperBlocks', () => {
|
||||
expect(() => createSuperOrder()).toThrow();
|
||||
expect(() => createSuperOrder(null)).toThrow();
|
||||
expect(() => createSuperOrder('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuperOrder', () => {
|
||||
@@ -79,8 +73,6 @@ describe('getSuperOrder', () => {
|
||||
});
|
||||
|
||||
it('returns undefined for unknown curriculum', () => {
|
||||
expect(getSuperOrder()).toBeUndefined();
|
||||
expect(getSuperOrder(null)).toBeUndefined();
|
||||
expect(getSuperOrder('')).toBeUndefined();
|
||||
expect(getSuperOrder('respansive-wib-desoin')).toBeUndefined();
|
||||
expect(getSuperOrder('certifications')).toBeUndefined();
|
||||
@@ -299,18 +291,21 @@ describe('filter utils', () => {
|
||||
{
|
||||
name: 'responsive-web-design',
|
||||
blocks: [
|
||||
{ dashedName: 'basic-html-and-html5' },
|
||||
{ dashedName: 'css-flexbox' }
|
||||
{ dashedName: 'basic-html-and-html5', challengeOrder: [] },
|
||||
{ dashedName: 'css-flexbox', challengeOrder: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'javascript-algorithms-and-data-structures',
|
||||
blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }]
|
||||
blocks: [
|
||||
{ dashedName: 'basic-javascript', challengeOrder: [] },
|
||||
{ dashedName: 'es6', challengeOrder: [] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(
|
||||
closestFilters({ superBlock: 'responsiv web design' }, superblocks)
|
||||
closestFilters(superblocks, { superBlock: 'responsiv web design' })
|
||||
).toEqual({ superBlock: 'responsive-web-design' });
|
||||
});
|
||||
|
||||
@@ -319,17 +314,20 @@ describe('filter utils', () => {
|
||||
{
|
||||
name: 'responsive-web-design',
|
||||
blocks: [
|
||||
{ dashedName: 'basic-html-and-html5' },
|
||||
{ dashedName: 'css-flexbox' }
|
||||
{ dashedName: 'basic-html-and-html5', challengeOrder: [] },
|
||||
{ dashedName: 'css-flexbox', challengeOrder: [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'javascript-algorithms-and-data-structures',
|
||||
blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }]
|
||||
blocks: [
|
||||
{ dashedName: 'basic-javascript', challengeOrder: [] },
|
||||
{ dashedName: 'es6', challengeOrder: [] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(closestFilters({ block: 'basic-javascr' }, superblocks)).toEqual({
|
||||
expect(closestFilters(superblocks, { block: 'basic-javascr' })).toEqual({
|
||||
block: 'basic-javascript'
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
const path = require('path');
|
||||
import { resolve } from 'path';
|
||||
|
||||
const comparison = require('string-similarity');
|
||||
import comparison from 'string-similarity';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
const { generateSuperBlockList } = require('../shared-dist/config/curriculum');
|
||||
import { generateSuperBlockList } from '../../shared-dist/config/curriculum.js';
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { availableLangs } from '../../shared-dist/config/i18n.js';
|
||||
|
||||
const { availableLangs } = require('../shared-dist/config/i18n');
|
||||
const curriculumLangs = availableLangs.curriculum;
|
||||
|
||||
// checks that the CURRICULUM_LOCALE exists and is an available language
|
||||
exports.testedLang = function testedLang() {
|
||||
export function testedLang() {
|
||||
if (process.env.CURRICULUM_LOCALE) {
|
||||
if (curriculumLangs.includes(process.env.CURRICULUM_LOCALE)) {
|
||||
return process.env.CURRICULUM_LOCALE;
|
||||
@@ -21,10 +23,10 @@ exports.testedLang = function testedLang() {
|
||||
} else {
|
||||
throw Error('LOCALE must be set for testing');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createSuperOrder(superBlocks) {
|
||||
const superOrder = {};
|
||||
export function createSuperOrder(superBlocks: string[]) {
|
||||
const superOrder: { [sb: string]: number } = {};
|
||||
|
||||
superBlocks.forEach((superBlock, i) => {
|
||||
superOrder[superBlock] = i;
|
||||
@@ -33,8 +35,8 @@ function createSuperOrder(superBlocks) {
|
||||
return superOrder;
|
||||
}
|
||||
|
||||
function getSuperOrder(
|
||||
superblock,
|
||||
export function getSuperOrder(
|
||||
superblock: string,
|
||||
showUpcomingChanges = process.env.SHOW_UPCOMING_CHANGES === 'true'
|
||||
) {
|
||||
const flatSuperBlockMap = generateSuperBlockList({
|
||||
@@ -54,7 +56,10 @@ function getSuperOrder(
|
||||
* @param {string} [options.block] - The dashedName of the block to filter for (in kebab case).
|
||||
* @returns {Array<Object>} Array with one superblock containing the specified block, or the original array if block is not provided.
|
||||
*/
|
||||
function filterByBlock(superblocks, { block } = {}) {
|
||||
export function filterByBlock<T extends { blocks: { dashedName: string }[] }>(
|
||||
superblocks: T[],
|
||||
{ block }: { block?: string } = {}
|
||||
): T[] {
|
||||
if (!block) return superblocks;
|
||||
|
||||
const superblock = superblocks
|
||||
@@ -76,7 +81,10 @@ function filterByBlock(superblocks, { block } = {}) {
|
||||
* @param {string} [options.superBlock] - The name of the superblock to filter for.
|
||||
* @returns {Array<Object>} Filtered array of superblocks containing only the specified superblock, or the original array if superBlock is not provided.
|
||||
*/
|
||||
function filterBySuperblock(superblocks, { superBlock } = {}) {
|
||||
export function filterBySuperblock<T extends { name: string }>(
|
||||
superblocks: T[],
|
||||
{ superBlock }: { superBlock?: string } = {}
|
||||
): T[] {
|
||||
if (!superBlock) return superblocks;
|
||||
return superblocks.filter(({ name }) => name === superBlock);
|
||||
}
|
||||
@@ -88,15 +96,20 @@ function filterBySuperblock(superblocks, { superBlock } = {}) {
|
||||
* @param {string} [options.challengeId] - The specific challenge id to filter for
|
||||
* @returns {Array<Object>} Filtered superblocks containing only the matching challenge
|
||||
*/
|
||||
function filterByChallengeId(superblocks, { challengeId } = {}) {
|
||||
export function filterByChallengeId<
|
||||
T extends { blocks: { challengeOrder: { id: string }[] }[] }
|
||||
>(superblocks: T[], { challengeId }: { challengeId?: string } = {}): T[] {
|
||||
if (!challengeId) {
|
||||
return superblocks;
|
||||
}
|
||||
|
||||
const findChallengeIndex = (challengeOrder, id) =>
|
||||
const findChallengeIndex = (challengeOrder: { id: string }[], id: string) =>
|
||||
challengeOrder.findIndex(challenge => challenge.id === id);
|
||||
|
||||
const filterChallengeOrder = (challengeOrder, id) => {
|
||||
const filterChallengeOrder = (
|
||||
challengeOrder: { id: string }[],
|
||||
id: string
|
||||
) => {
|
||||
const index = findChallengeIndex(challengeOrder, id);
|
||||
if (index === -1) return [];
|
||||
|
||||
@@ -121,20 +134,45 @@ function filterByChallengeId(superblocks, { challengeId } = {}) {
|
||||
.filter(superblock => superblock.blocks.length > 0);
|
||||
}
|
||||
|
||||
const createFilterPipeline = filterFunctions => (data, filters) =>
|
||||
filterFunctions.reduce((acc, filterFn) => filterFn(acc, filters), data);
|
||||
export interface Filter {
|
||||
superBlock?: string;
|
||||
block?: string;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
const applyFilters = createFilterPipeline([
|
||||
interface Filterable {
|
||||
name: string;
|
||||
blocks: {
|
||||
challengeOrder: { id: string }[];
|
||||
dashedName: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface GenericFilterFunction {
|
||||
<T extends Filterable>(data: T[], filters?: Filter): T[];
|
||||
}
|
||||
|
||||
function createFilterPipeline<T extends Filterable>(
|
||||
filterFunctions: GenericFilterFunction[]
|
||||
): (data: T[], filters?: Filter) => T[] {
|
||||
return (data: T[], filters?: Filter) =>
|
||||
filterFunctions.reduce((acc, filterFn) => filterFn(acc, filters), data);
|
||||
}
|
||||
|
||||
export const applyFilters: GenericFilterFunction = createFilterPipeline([
|
||||
filterBySuperblock,
|
||||
filterByBlock,
|
||||
filterByChallengeId
|
||||
]);
|
||||
|
||||
function closestMatch(target, xs) {
|
||||
export function closestMatch(target: string, xs: string[]): string {
|
||||
return comparison.findBestMatch(target.toLowerCase(), xs).bestMatch.target;
|
||||
}
|
||||
|
||||
function closestFilters(target, superblocks) {
|
||||
export function closestFilters(
|
||||
superblocks: Filterable[],
|
||||
target?: Filter
|
||||
): Filter | undefined {
|
||||
if (target?.superBlock) {
|
||||
const superblockNames = superblocks.map(({ name }) => name);
|
||||
return {
|
||||
@@ -155,12 +193,3 @@ function closestFilters(target, superblocks) {
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
exports.closestFilters = closestFilters;
|
||||
exports.closestMatch = closestMatch;
|
||||
exports.createSuperOrder = createSuperOrder;
|
||||
exports.filterByBlock = filterByBlock;
|
||||
exports.filterBySuperblock = filterBySuperblock;
|
||||
exports.filterByChallengeId = filterByChallengeId;
|
||||
exports.getSuperOrder = getSuperOrder;
|
||||
exports.applyFilters = applyFilters;
|
||||
17
curriculum/tsconfig.json
Normal file
17
curriculum/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ['test/blocks-generated/**/*.test.js']
|
||||
exclude: ['src/test/blocks-generated/**/*.test.js', 'dist']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ RUN cd api && pnpm prisma generate
|
||||
ARG SHOW_UPCOMING_CHANGES=false
|
||||
ENV SHOW_UPCOMING_CHANGES=$SHOW_UPCOMING_CHANGES
|
||||
|
||||
RUN pnpm create:shared
|
||||
RUN pnpm compile:ts
|
||||
RUN pnpm build:curriculum
|
||||
RUN pnpm -F=api build
|
||||
|
||||
@@ -52,7 +52,7 @@ USER node
|
||||
WORKDIR /home/node/fcc
|
||||
COPY --from=builder --chown=node:node /home/node/build/api/dist/ ./
|
||||
COPY --from=builder --chown=node:node /home/node/build/api/package.json api/
|
||||
COPY --from=builder --chown=node:node /home/node/build/shared/config/curriculum.json shared/config/
|
||||
COPY --from=builder --chown=node:node /home/node/build/shared-dist/config/curriculum.json shared-dist/config/
|
||||
|
||||
COPY --from=deps --chown=node:node /home/node/build/node_modules/ node_modules/
|
||||
COPY --from=deps --chown=node:node /home/node/build/api/node_modules/ api/node_modules/
|
||||
|
||||
@@ -33,10 +33,12 @@ export default tseslint.config(
|
||||
'client/.cache/**/*',
|
||||
'client/public/**/*',
|
||||
'shared/**/*.js',
|
||||
'shared/**/*.d.ts',
|
||||
'docs/**/*.md',
|
||||
'**/playwright*.config.ts',
|
||||
'playwright/**/*',
|
||||
'shared-dist/**/*'
|
||||
'shared-dist/**/*',
|
||||
'curriculum/dist/**/*'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://cdn.jsdelivr.net/npm/knip@5/schema.json",
|
||||
"ignoreBinaries": ["create:shared", "install-puppeteer", "pm2"],
|
||||
"ignoreBinaries": ["compile:ts", "install-puppeteer", "pm2"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"playwright": ["playwright.config.ts"],
|
||||
|
||||
20
package.json
20
package.json
@@ -19,9 +19,9 @@
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"main": "none",
|
||||
"scripts": {
|
||||
"audit-challenges": "pnpm run create:shared && tsx tools/challenge-auditor/index.ts",
|
||||
"audit-challenges": "pnpm run compile:ts && tsx tools/challenge-auditor/index.ts",
|
||||
"analyze-bundle": "webpack-bundle-analyzer",
|
||||
"prebuild": "npm-run-all create:shared",
|
||||
"prebuild": "npm-run-all compile:ts",
|
||||
"build": "npm-run-all -p build:*",
|
||||
"build-workers": "cd ./client && pnpm run prebuild",
|
||||
"build:client": "cd ./client && pnpm run build",
|
||||
@@ -34,13 +34,13 @@
|
||||
"clean-and-develop": "pnpm run clean && pnpm install && pnpm run develop",
|
||||
"clean:api": "cd api && pnpm clean",
|
||||
"clean:client": "cd ./client && pnpm run clean",
|
||||
"clean:curriculum": "rm -rf ./shared/config/curriculum.json",
|
||||
"clean:curriculum": "rm -rf ./shared-dist/config/curriculum.json",
|
||||
"clean:packages": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
||||
"create:shared": "tsc -p shared",
|
||||
"compile:ts": "tsc --build --clean curriculum && tsc --build curriculum",
|
||||
"create-new-project": "cd ./tools/challenge-helper-scripts/ && pnpm run create-project",
|
||||
"create-new-language-block": "cd ./tools/challenge-helper-scripts/ && pnpm run create-language-block",
|
||||
"create-new-quiz": "cd ./tools/challenge-helper-scripts/ && pnpm run create-quiz",
|
||||
"predevelop": "npm-run-all -p create:shared -s build:curriculum",
|
||||
"predevelop": "npm-run-all -p compile:ts -s build:curriculum",
|
||||
"develop": "npm-run-all -p develop:*",
|
||||
"develop:client": "cd ./client && pnpm run develop",
|
||||
"develop:api": "cd ./api && pnpm run develop",
|
||||
@@ -51,13 +51,13 @@
|
||||
"knip": "npx -y knip@5 --include files",
|
||||
"knip:all": "npx -y knip@5 ",
|
||||
"prelint": "pnpm run -F=client predevelop",
|
||||
"lint": "NODE_OPTIONS=\"--max-old-space-size=7168\" npm-run-all create:shared -p lint:*",
|
||||
"lint": "NODE_OPTIONS=\"--max-old-space-size=7168\" npm-run-all compile:ts -p lint:*",
|
||||
"lint:challenges": "cd ./curriculum && pnpm run lint",
|
||||
"lint:js": "eslint --cache --max-warnings 0 .",
|
||||
"lint:ts": "tsc && tsc -p shared && tsc -p api && tsc -p client",
|
||||
"lint:ts": "tsc && tsc -p shared && tsc -p api && tsc -p client && tsc -p curriculum",
|
||||
"lint:prettier": "prettier --list-different .",
|
||||
"lint:css": "stylelint '**/*.css'",
|
||||
"preseed": "npm-run-all create:shared",
|
||||
"preseed": "npm-run-all compile:ts",
|
||||
"playwright:install-build-tools": "npx playwright install --with-deps",
|
||||
"rename-challenges": "tsx tools/challenge-helper-scripts/rename-challenge-files.ts",
|
||||
"seed": "pnpm seed:surveys && pnpm seed:exams && DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user",
|
||||
@@ -69,8 +69,8 @@
|
||||
"seed:ms-username": "DEBUG=fcc:* node ./tools/scripts/seed/seed-ms-username",
|
||||
"serve:client": "cd ./client && pnpm run serve",
|
||||
"serve:client-ci": "cd ./client && pnpm run serve-ci",
|
||||
"start": "npm-run-all create:shared -p develop:server serve:client",
|
||||
"test": "NODE_OPTIONS='--max-old-space-size=7168' run-s create:shared build:curriculum build-workers test:**",
|
||||
"start": "npm-run-all compile:ts -p develop:server serve:client",
|
||||
"test": "NODE_OPTIONS='--max-old-space-size=7168' run-s compile:ts build:curriculum build-workers test:**",
|
||||
"test:api": "cd api && pnpm test",
|
||||
"test:tools:challenge-helper-scripts": "cd ./tools/challenge-helper-scripts && pnpm test run",
|
||||
"test:tools:scripts-build": "cd ./tools/scripts/build && pnpm test run",
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -712,6 +712,15 @@ importers:
|
||||
'@babel/register':
|
||||
specifier: 7.23.7
|
||||
version: 7.23.7(@babel/core@7.23.7)
|
||||
'@total-typescript/ts-reset':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
'@types/debug':
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
'@types/js-yaml':
|
||||
specifier: 4.0.5
|
||||
version: 4.0.5
|
||||
'@types/polka':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
@@ -4434,6 +4443,9 @@ packages:
|
||||
'@total-typescript/ts-reset@0.5.1':
|
||||
resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==}
|
||||
|
||||
'@total-typescript/ts-reset@0.6.1':
|
||||
resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==}
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -4515,6 +4527,9 @@ packages:
|
||||
'@types/debug@0.0.30':
|
||||
resolution: {integrity: sha512-orGL5LXERPYsLov6CWs3Fh6203+dXzJkR7OnddIr2514Hsecwc8xRpzCapshBbKFImCsvS/mk6+FWiN5LyZJAQ==}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/debug@4.1.9':
|
||||
resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==}
|
||||
|
||||
@@ -19127,6 +19142,8 @@ snapshots:
|
||||
|
||||
'@total-typescript/ts-reset@0.5.1': {}
|
||||
|
||||
'@total-typescript/ts-reset@0.6.1': {}
|
||||
|
||||
'@trysound/sax@0.2.0': {}
|
||||
|
||||
'@turist/fetch@7.2.0(node-fetch@2.7.0)':
|
||||
@@ -19231,6 +19248,10 @@ snapshots:
|
||||
|
||||
'@types/debug@0.0.30': {}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
'@types/ms': 0.7.32
|
||||
|
||||
'@types/debug@4.1.9':
|
||||
dependencies:
|
||||
'@types/ms': 0.7.32
|
||||
@@ -26857,7 +26878,7 @@ snapshots:
|
||||
|
||||
micromark@3.2.0:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.9
|
||||
'@types/debug': 4.1.12
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
decode-named-character-reference: 1.0.2
|
||||
micromark-core-commonmark: 1.1.0
|
||||
@@ -31148,6 +31169,7 @@ snapshots:
|
||||
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.12.8
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 16.7.0
|
||||
@@ -31191,6 +31213,7 @@ snapshots:
|
||||
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.12.8
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 26.1.0
|
||||
@@ -31234,6 +31257,7 @@ snapshots:
|
||||
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.12.8
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 26.1.0
|
||||
@@ -31277,6 +31301,7 @@ snapshots:
|
||||
vite-node: 3.2.4(@types/node@20.12.8)(jiti@2.6.1)(terser@5.28.1)(tsx@4.19.1)(yaml@2.8.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 20.12.8
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 26.1.0
|
||||
|
||||
@@ -47,6 +47,10 @@ export enum Certification {
|
||||
LegacyFullStack = 'full-stack'
|
||||
}
|
||||
|
||||
export function isCertification(x: string): x is Certification {
|
||||
return Object.values(Certification).includes(x as Certification);
|
||||
}
|
||||
|
||||
// "Current" certifications are the subset of standard certifications that are
|
||||
// live and not legacy.
|
||||
export const currentCertifications = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"extends": "../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../shared-dist",
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"module": "CommonJS"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const envPath = resolve(__dirname, '../../.env');
|
||||
config({ path: envPath });
|
||||
|
||||
import { availableLangs } from '../../shared/config/i18n';
|
||||
import { getChallengesForLang } from '../../curriculum/get-challenges';
|
||||
import { getChallengesForLang } from '../../curriculum/src/get-challenges';
|
||||
import {
|
||||
SuperBlocks,
|
||||
getAuditedSuperBlocks
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
getContentConfig,
|
||||
writeBlockStructure,
|
||||
getSuperblockStructure
|
||||
} from '../../curriculum/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/build-curriculum';
|
||||
} from '../../curriculum/src/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
|
||||
import { getBaseMeta } from './helpers/get-base-meta';
|
||||
import { createIntroMD } from './helpers/create-intro';
|
||||
import {
|
||||
|
||||
@@ -13,8 +13,8 @@ import { BlockLayouts, BlockTypes } from '../../shared/config/blocks';
|
||||
import {
|
||||
getContentConfig,
|
||||
writeBlockStructure
|
||||
} from '../../curriculum/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/build-curriculum';
|
||||
} from '../../curriculum/src/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
|
||||
import {
|
||||
createQuizFile,
|
||||
createStepFile,
|
||||
|
||||
@@ -8,8 +8,8 @@ import { SuperBlocks } from '../../shared/config/curriculum';
|
||||
import {
|
||||
getContentConfig,
|
||||
writeBlockStructure
|
||||
} from '../../curriculum/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/build-curriculum';
|
||||
} from '../../curriculum/src/file-handler';
|
||||
import { superBlockToFilename } from '../../curriculum/src/build-curriculum';
|
||||
import { createQuizFile, getAllBlocks, validateBlockName } from './utils';
|
||||
import { getBaseMeta } from './helpers/get-base-meta';
|
||||
import { createIntroMD } from './helpers/create-intro';
|
||||
|
||||
@@ -11,7 +11,7 @@ import ObjectID from 'bson-objectid';
|
||||
import {
|
||||
getBlockStructure,
|
||||
writeBlockStructure
|
||||
} from '../../curriculum/file-handler';
|
||||
} from '../../curriculum/src/file-handler';
|
||||
import { createChallengeFile } from './utils';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
import { getBlock, type Meta } from './helpers/project-metadata';
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
getSuperblockStructure,
|
||||
writeSuperblockStructure
|
||||
} from '../../../curriculum/file-handler';
|
||||
} from '../../../curriculum/src/file-handler';
|
||||
import {
|
||||
updateChapterModuleSuperblockStructure,
|
||||
updateSimpleSuperblockStructure
|
||||
} from './create-project';
|
||||
|
||||
vi.mock('../../../curriculum/file-handler');
|
||||
vi.mock('../../../curriculum/src/file-handler');
|
||||
|
||||
const mockGetSuperblockStructure = vi.mocked(getSuperblockStructure);
|
||||
const mockWriteSuperblockStructure = vi.mocked(writeSuperblockStructure);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
getSuperblockStructure,
|
||||
writeSuperblockStructure
|
||||
} from '../../../curriculum/file-handler';
|
||||
} from '../../../curriculum/src/file-handler';
|
||||
import { insertInto } from './utils';
|
||||
|
||||
export async function updateSimpleSuperblockStructure(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { join } from 'path';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { getBlockStructure } from '../../../curriculum/file-handler';
|
||||
import { getBlockStructure } from '../../../curriculum/src/file-handler';
|
||||
import { getMetaData } from './project-metadata';
|
||||
|
||||
vi.mock('../../../curriculum/file-handler');
|
||||
vi.mock('../../../curriculum/src/file-handler');
|
||||
|
||||
const commonPath = join('curriculum', 'challenges', 'blocks');
|
||||
const block = 'block-name';
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
import {
|
||||
getBlockStructure,
|
||||
writeBlockStructure
|
||||
} from '../../../curriculum/file-handler';
|
||||
} from '../../../curriculum/src/file-handler';
|
||||
import { getProjectPath } from './get-project-info';
|
||||
|
||||
export type Meta = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import matter from 'gray-matter';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { challengeTypes } from '../../shared/config/challenge-types';
|
||||
import { parseCurriculumStructure } from '../../curriculum/build-curriculum';
|
||||
import { parseCurriculumStructure } from '../../curriculum/src/build-curriculum';
|
||||
import { parseMDSync } from '../challenge-parser/parser';
|
||||
import { getMetaData, updateMetaData } from './helpers/project-metadata';
|
||||
import { getProjectPath } from './helpers/get-project-info';
|
||||
|
||||
40
tools/challenge-parser/parser/index.d.ts
vendored
Normal file
40
tools/challenge-parser/parser/index.d.ts
vendored
Normal 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;
|
||||
40
tools/challenge-parser/translation-parser/index.d.ts
vendored
Normal file
40
tools/challenge-parser/translation-parser/index.d.ts
vendored
Normal 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 };
|
||||
@@ -20,7 +20,9 @@ exports.translateCommentsInChallenge = (challenge, lang, dict) => {
|
||||
if (challClone?.challengeFiles) {
|
||||
challClone.challengeFiles.forEach(challengeFile => {
|
||||
if (challengeFile.contents) {
|
||||
let { text } = this.translateComments(
|
||||
// It cannot be this.translateComments because 'this' does not exist
|
||||
// when imported into an ES module.
|
||||
let { text } = exports.translateComments(
|
||||
challengeFile.contents,
|
||||
lang,
|
||||
dict,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const chokidar = require('chokidar');
|
||||
const { getSuperblockStructure } = require('../../../curriculum/file-handler');
|
||||
const {
|
||||
getSuperblockStructure
|
||||
} = require('../../../curriculum/dist/file-handler');
|
||||
const {
|
||||
superBlockToFilename
|
||||
} = require('../../../curriculum/build-curriculum');
|
||||
} = require('../../../curriculum/dist/build-curriculum');
|
||||
|
||||
const { createChallengeNode } = require('./create-challenge-nodes');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getChallengesForLang } from '../../../curriculum/get-challenges';
|
||||
import { getChallengesForLang } from '../../../curriculum/src/get-challenges';
|
||||
import {
|
||||
buildExtCurriculumDataV1,
|
||||
type Curriculum as CurriculumV1,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type CurriculumProps as CurriculumPropsV2
|
||||
} from './build-external-curricula-data-v2';
|
||||
|
||||
const globalConfigPath = path.resolve(__dirname, '../../../shared/config');
|
||||
const globalConfigPath = path.resolve(__dirname, '../../../shared-dist/config');
|
||||
|
||||
// We are defaulting to English because the ids for the challenges are same
|
||||
// across all languages.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { submitTypes } from '../../../shared-dist/config/challenge-types';
|
||||
import { type ChallengeNode } from '../../../client/src/redux/prop-types';
|
||||
import { SuperBlocks } from '../../../shared-dist/config/curriculum';
|
||||
import type { Chapter } from '../../../shared-dist/config/chapters';
|
||||
import { getSuperblockStructure } from '../../../curriculum/build-curriculum';
|
||||
import { getSuperblockStructure } from '../../../curriculum/src/file-handler';
|
||||
|
||||
export type CurriculumIntros =
|
||||
| BlockBasedCurriculumIntros
|
||||
|
||||
Reference in New Issue
Block a user