diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index ad3142de9f2..f485083af73 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -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 diff --git a/.github/workflows/e2e-third-party.yml b/.github/workflows/e2e-third-party.yml index 5b529b09a3c..12c136f6431 100644 --- a/.github/workflows/e2e-third-party.yml +++ b/.github/workflows/e2e-third-party.yml @@ -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 diff --git a/.github/workflows/node.js-tests.yml b/.github/workflows/node.js-tests.yml index 03677fd9672..df8b5ba63b9 100644 --- a/.github/workflows/node.js-tests.yml +++ b/.github/workflows/node.js-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2c727bbe7ad..a0859b2a6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ + diff --git a/.gitpod.yml b/.gitpod.yml index bf61e9b5259..a795190474c 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -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: > diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index 14200375c06..5716cc81572 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -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( diff --git a/client/package.json b/client/package.json index f9be3e1e705..e1278433c97 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/utils/build-challenges.js b/client/utils/build-challenges.js index 207330f7782..575e61458e5 100644 --- a/client/utils/build-challenges.js +++ b/client/utils/build-challenges.js @@ -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'; diff --git a/curriculum/build-certification.js b/curriculum/build-certification.js deleted file mode 100644 index 36c6d3bdae5..00000000000 --- a/curriculum/build-certification.js +++ /dev/null @@ -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 }; diff --git a/curriculum/file-handler.js b/curriculum/file-handler.js deleted file mode 100644 index 1bfbf3f5368..00000000000 --- a/curriculum/file-handler.js +++ /dev/null @@ -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; diff --git a/curriculum/get-challenges.js b/curriculum/get-challenges.js deleted file mode 100644 index 9ea921565c5..00000000000 --- a/curriculum/get-challenges.js +++ /dev/null @@ -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; diff --git a/curriculum/package.json b/curriculum/package.json index 27ea35b9dd7..1d6e0377092 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -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", diff --git a/curriculum/build-certification.test.js b/curriculum/src/build-certification.test.js similarity index 97% rename from curriculum/build-certification.test.js rename to curriculum/src/build-certification.test.js index aea6b9a7efa..cf4081d7b7a 100644 --- a/curriculum/build-certification.test.js +++ b/curriculum/src/build-certification.test.js @@ -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 diff --git a/curriculum/src/build-certification.ts b/curriculum/src/build-certification.ts new file mode 100644 index 00000000000..9293ba1a06c --- /dev/null +++ b/curriculum/src/build-certification.ts @@ -0,0 +1,6 @@ +import { readFileSync } from 'fs'; +import { load } from 'js-yaml'; + +export const buildCertification = (filePath: string) => ({ + challenges: [load(readFileSync(filePath, 'utf8'))] +}); diff --git a/curriculum/build-curriculum.test.js b/curriculum/src/build-curriculum.test.js similarity index 88% rename from curriculum/build-curriculum.test.js rename to curriculum/src/build-curriculum.test.js index 9188334023d..6f9585be604 100644 --- a/curriculum/build-curriculum.test.js +++ b/curriculum/src/build-curriculum.test.js @@ -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)); + }); +}); diff --git a/curriculum/build-curriculum.js b/curriculum/src/build-curriculum.ts similarity index 56% rename from curriculum/build-curriculum.js rename to curriculum/src/build-curriculum.ts index 75c68365ff7..8e261a6eb57 100644 --- a/curriculum/build-curriculum.js +++ b/curriculum/src/build-curriculum.ts @@ -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>, + { 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; 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; // 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} 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 -}; diff --git a/curriculum/build-superblock.test.js b/curriculum/src/build-superblock.test.js similarity index 99% rename from curriculum/build-superblock.test.js rename to curriculum/src/build-superblock.test.js index c6dff65bda5..480964bdd44 100644 --- a/curriculum/build-superblock.test.js +++ b/curriculum/src/build-superblock.test.js @@ -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, diff --git a/curriculum/build-superblock.js b/curriculum/src/build-superblock.ts similarity index 72% rename from curriculum/build-superblock.js rename to curriculum/src/build-superblock.ts index d6f34dd2037..5421f120519 100644 --- a/curriculum/build-superblock.js +++ b/curriculum/src/build-superblock.ts @@ -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 = (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} 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, + 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} files - Array of challenge file objects * @returns {Array} 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} 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 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 } = { 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 -}; diff --git a/curriculum/src/file-handler.ts b/curriculum/src/file-handler.ts new file mode 100644 index 00000000000..084989e3dc4 --- /dev/null +++ b/curriculum/src/file-handler.ts @@ -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 + }; +} diff --git a/curriculum/get-challenges.test.js b/curriculum/src/get-challenges.test.js similarity index 97% rename from curriculum/get-challenges.test.js rename to curriculum/src/get-challenges.test.js index 30eb6cdcca9..c92fd5b0cfb 100644 --- a/curriculum/get-challenges.test.js +++ b/curriculum/src/get-challenges.test.js @@ -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', () => { diff --git a/curriculum/src/get-challenges.ts b/curriculum/src/get-challenges.ts new file mode 100644 index 00000000000..e2d3c8452cb --- /dev/null +++ b/curriculum/src/get-challenges.ts @@ -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); +} diff --git a/curriculum/lint-localized.js b/curriculum/src/lint-localized.js similarity index 82% rename from curriculum/lint-localized.js rename to curriculum/src/lint-localized.js index f45d436154f..d32da7fd5e7 100644 --- a/curriculum/lint-localized.js +++ b/curriculum/src/lint-localized.js @@ -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) => { diff --git a/curriculum/src/reset.d.ts b/curriculum/src/reset.d.ts new file mode 100644 index 00000000000..12bd3edc94a --- /dev/null +++ b/curriculum/src/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset'; diff --git a/curriculum/test/daily-challenges.test.js b/curriculum/src/test/daily-challenges.test.js similarity index 94% rename from curriculum/test/daily-challenges.test.js rename to curriculum/src/test/daily-challenges.test.js index b763a604072..cae0763772d 100644 --- a/curriculum/test/daily-challenges.test.js +++ b/curriculum/src/test/daily-challenges.test.js @@ -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 () => { diff --git a/curriculum/test/stubs/index.html b/curriculum/src/test/stubs/index.html similarity index 100% rename from curriculum/test/stubs/index.html rename to curriculum/src/test/stubs/index.html diff --git a/curriculum/test/test-challenges.js b/curriculum/src/test/test-challenges.js similarity index 94% rename from curriculum/test/test-challenges.js rename to curriculum/src/test/test-challenges.js index 0bd630c467a..22f1bda1c1f 100644 --- a/curriculum/test/test-challenges.js +++ b/curriculum/src/test/test-challenges.js @@ -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; diff --git a/curriculum/test/utils/challenge-titles.js b/curriculum/src/test/utils/challenge-titles.js similarity index 100% rename from curriculum/test/utils/challenge-titles.js rename to curriculum/src/test/utils/challenge-titles.js diff --git a/curriculum/test/utils/generate-block-tests.mjs b/curriculum/src/test/utils/generate-block-tests.ts similarity index 73% rename from curriculum/test/utils/generate-block-tests.mjs rename to curriculum/src/test/utils/generate-block-tests.ts index 60bb794cd15..6899d01ff46 100644 --- a/curriculum/test/utils/generate-block-tests.mjs +++ b/curriculum/src/test/utils/generate-block-tests.ts @@ -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)}); diff --git a/curriculum/test/utils/mongo-ids.js b/curriculum/src/test/utils/mongo-ids.js similarity index 100% rename from curriculum/test/utils/mongo-ids.js rename to curriculum/src/test/utils/mongo-ids.js diff --git a/curriculum/test/utils/pseudo-worker.js b/curriculum/src/test/utils/pseudo-worker.js similarity index 100% rename from curriculum/test/utils/pseudo-worker.js rename to curriculum/src/test/utils/pseudo-worker.js diff --git a/curriculum/test/utils/sort-challenges.js b/curriculum/src/test/utils/sort-challenges.js similarity index 100% rename from curriculum/test/utils/sort-challenges.js rename to curriculum/src/test/utils/sort-challenges.js diff --git a/curriculum/test/utils/sort-challenges.test.js b/curriculum/src/test/utils/sort-challenges.test.js similarity index 98% rename from curriculum/test/utils/sort-challenges.test.js rename to curriculum/src/test/utils/sort-challenges.test.js index 89d04fb2eb7..ab771e6173b 100644 --- a/curriculum/test/utils/sort-challenges.test.js +++ b/curriculum/src/test/utils/sort-challenges.test.js @@ -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 = [ diff --git a/curriculum/test/vitest-global-setup.mjs b/curriculum/src/test/vitest-global-setup.mjs similarity index 89% rename from curriculum/test/vitest-global-setup.mjs rename to curriculum/src/test/vitest-global-setup.mjs index c461f9ffac9..adec32c4ea1 100644 --- a/curriculum/test/vitest-global-setup.mjs +++ b/curriculum/src/test/vitest-global-setup.mjs @@ -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({ diff --git a/curriculum/test/vitest-setup.mjs b/curriculum/src/test/vitest-setup.mjs similarity index 100% rename from curriculum/test/vitest-setup.mjs rename to curriculum/src/test/vitest-setup.mjs diff --git a/curriculum/test/vitest.config.mjs b/curriculum/src/test/vitest.config.mjs similarity index 54% rename from curriculum/test/vitest.config.mjs rename to curriculum/src/test/vitest.config.mjs index f689fdd2130..97988cea0d5 100644 --- a/curriculum/test/vitest.config.mjs +++ b/curriculum/src/test/vitest.config.mjs @@ -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' } }); diff --git a/curriculum/utils.test.ts b/curriculum/src/utils.test.ts similarity index 91% rename from curriculum/utils.test.ts rename to curriculum/src/utils.test.ts index 282335dbbf3..21671263067 100644 --- a/curriculum/utils.test.ts +++ b/curriculum/src/utils.test.ts @@ -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' }); }); diff --git a/curriculum/utils.js b/curriculum/src/utils.ts similarity index 68% rename from curriculum/utils.js rename to curriculum/src/utils.ts index 1d900de1add..6e2f4396938 100644 --- a/curriculum/utils.js +++ b/curriculum/src/utils.ts @@ -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} Array with one superblock containing the specified block, or the original array if block is not provided. */ -function filterByBlock(superblocks, { block } = {}) { +export function filterByBlock( + 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} 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( + 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} 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 { + (data: T[], filters?: Filter): T[]; +} + +function createFilterPipeline( + 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; diff --git a/curriculum/tsconfig.json b/curriculum/tsconfig.json new file mode 100644 index 00000000000..427752c7a97 --- /dev/null +++ b/curriculum/tsconfig.json @@ -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 + } +} diff --git a/curriculum/vitest.config.mjs b/curriculum/vitest.config.mjs index b270042c180..9439a0f82b9 100644 --- a/curriculum/vitest.config.mjs +++ b/curriculum/vitest.config.mjs @@ -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'] } }); diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index f1095ff6f39..60eab7c98af 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -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/ diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b05a8ec071..9b0132b165b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/knip.jsonc b/knip.jsonc index 23973d70dbc..56bf643cd4b 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -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"], diff --git a/package.json b/package.json index e83b2a1cf1d..6ff1e93056e 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "author": "freeCodeCamp ", "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22bccafa36f..09b011de58f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/shared/config/certification-settings.ts b/shared/config/certification-settings.ts index b2efc7a151a..99293e9ad51 100644 --- a/shared/config/certification-settings.ts +++ b/shared/config/certification-settings.ts @@ -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 = [ diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 058aabd062b..d1c72a2c785 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -3,7 +3,7 @@ "extends": "../tsconfig-base.json", "compilerOptions": { "outDir": "../shared-dist", - "declaration": true, + "composite": true, "noEmit": false, "module": "CommonJS" } diff --git a/tools/challenge-auditor/index.ts b/tools/challenge-auditor/index.ts index 2c5569e6c25..7b5dfac4818 100644 --- a/tools/challenge-auditor/index.ts +++ b/tools/challenge-auditor/index.ts @@ -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 diff --git a/tools/challenge-helper-scripts/create-language-block.ts b/tools/challenge-helper-scripts/create-language-block.ts index 58e780a6013..50ebf9bbd59 100644 --- a/tools/challenge-helper-scripts/create-language-block.ts +++ b/tools/challenge-helper-scripts/create-language-block.ts @@ -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 { diff --git a/tools/challenge-helper-scripts/create-project.ts b/tools/challenge-helper-scripts/create-project.ts index d431d8aca51..5fa30a66deb 100644 --- a/tools/challenge-helper-scripts/create-project.ts +++ b/tools/challenge-helper-scripts/create-project.ts @@ -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, diff --git a/tools/challenge-helper-scripts/create-quiz.ts b/tools/challenge-helper-scripts/create-quiz.ts index d140b6bb34a..1eeff584e0d 100644 --- a/tools/challenge-helper-scripts/create-quiz.ts +++ b/tools/challenge-helper-scripts/create-quiz.ts @@ -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'; diff --git a/tools/challenge-helper-scripts/create-this-challenge.ts b/tools/challenge-helper-scripts/create-this-challenge.ts index d90cdca71fe..53d631b2256 100644 --- a/tools/challenge-helper-scripts/create-this-challenge.ts +++ b/tools/challenge-helper-scripts/create-this-challenge.ts @@ -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'; diff --git a/tools/challenge-helper-scripts/helpers/create-project.test.ts b/tools/challenge-helper-scripts/helpers/create-project.test.ts index f2941e39725..b19d7a1d699 100644 --- a/tools/challenge-helper-scripts/helpers/create-project.test.ts +++ b/tools/challenge-helper-scripts/helpers/create-project.test.ts @@ -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); diff --git a/tools/challenge-helper-scripts/helpers/create-project.ts b/tools/challenge-helper-scripts/helpers/create-project.ts index 05f36624077..e98bd79d66d 100644 --- a/tools/challenge-helper-scripts/helpers/create-project.ts +++ b/tools/challenge-helper-scripts/helpers/create-project.ts @@ -3,7 +3,7 @@ import { getSuperblockStructure, writeSuperblockStructure -} from '../../../curriculum/file-handler'; +} from '../../../curriculum/src/file-handler'; import { insertInto } from './utils'; export async function updateSimpleSuperblockStructure( diff --git a/tools/challenge-helper-scripts/helpers/project-metadata.test.ts b/tools/challenge-helper-scripts/helpers/project-metadata.test.ts index 7f8fd9f3a9a..4d3d7ee87e8 100644 --- a/tools/challenge-helper-scripts/helpers/project-metadata.test.ts +++ b/tools/challenge-helper-scripts/helpers/project-metadata.test.ts @@ -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'; diff --git a/tools/challenge-helper-scripts/helpers/project-metadata.ts b/tools/challenge-helper-scripts/helpers/project-metadata.ts index 41e8fb7b5d9..84fcd786349 100644 --- a/tools/challenge-helper-scripts/helpers/project-metadata.ts +++ b/tools/challenge-helper-scripts/helpers/project-metadata.ts @@ -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 = { diff --git a/tools/challenge-helper-scripts/utils.ts b/tools/challenge-helper-scripts/utils.ts index e5f418d2c83..4474ec33e6e 100644 --- a/tools/challenge-helper-scripts/utils.ts +++ b/tools/challenge-helper-scripts/utils.ts @@ -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'; diff --git a/tools/challenge-parser/parser/index.d.ts b/tools/challenge-parser/parser/index.d.ts new file mode 100644 index 00000000000..d81405dcc97 --- /dev/null +++ b/tools/challenge-parser/parser/index.d.ts @@ -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; + +/** + * 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; diff --git a/tools/challenge-parser/translation-parser/index.d.ts b/tools/challenge-parser/translation-parser/index.d.ts new file mode 100644 index 00000000000..10173c978df --- /dev/null +++ b/tools/challenge-parser/translation-parser/index.d.ts @@ -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 }; diff --git a/tools/challenge-parser/translation-parser/index.js b/tools/challenge-parser/translation-parser/index.js index f5ed617f7cc..cdb8e2219de 100644 --- a/tools/challenge-parser/translation-parser/index.js +++ b/tools/challenge-parser/translation-parser/index.js @@ -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, diff --git a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js index 99658762a57..5b4b80f2306 100644 --- a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js +++ b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js @@ -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'); diff --git a/tools/scripts/build/build-curriculum.ts b/tools/scripts/build/build-curriculum.ts index 7a7c8f94136..56321009e6e 100644 --- a/tools/scripts/build/build-curriculum.ts +++ b/tools/scripts/build/build-curriculum.ts @@ -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. diff --git a/tools/scripts/build/build-external-curricula-data-v2.ts b/tools/scripts/build/build-external-curricula-data-v2.ts index 66f83b21b5a..98fd7ce97cd 100644 --- a/tools/scripts/build/build-external-curricula-data-v2.ts +++ b/tools/scripts/build/build-external-curricula-data-v2.ts @@ -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