refactor: split curriculum build in two (#63639)

This commit is contained in:
Oliver Eyton-Williams
2025-11-19 12:00:32 +01:00
committed by GitHub
parent 1212c78727
commit 960fd9e072
20 changed files with 86 additions and 141 deletions
-43
View File
@@ -1,43 +0,0 @@
import fs from 'fs';
import path from 'path';
import { getChallengesForLang } from '../../../curriculum/src/get-challenges';
import {
buildExtCurriculumDataV1,
type Curriculum as CurriculumV1,
type CurriculumProps as CurriculumPropsV1
} from './build-external-curricula-data-v1';
import {
buildExtCurriculumDataV2,
type Curriculum as CurriculumV2,
type CurriculumProps as CurriculumPropsV2
} from './build-external-curricula-data-v2';
const globalConfigPath = path.resolve(__dirname, '../../../shared-dist/config');
const isSelectiveBuild =
process.env.FCC_SUPERBLOCK ||
process.env.FCC_BLOCK ||
process.env.FCC_CHALLENGE_ID;
void getChallengesForLang('english')
.then(result => {
if (!isSelectiveBuild) {
console.log('Building external curriculum data...');
buildExtCurriculumDataV1(
result as unknown as CurriculumV1<CurriculumPropsV1>
);
buildExtCurriculumDataV2(
result as unknown as CurriculumV2<CurriculumPropsV2>
);
} else {
console.log(
'Skipping external curriculum build (selective build mode active)'
);
}
return result;
})
.then(JSON.stringify)
.then(json => {
fs.writeFileSync(`${globalConfigPath}/curriculum.json`, json);
});
@@ -1,172 +0,0 @@
import path from 'path';
import fs, { readFileSync } from 'fs';
import readdirp from 'readdirp';
import { describe, test, expect } from 'vitest';
import {
SuperBlocks,
SuperBlockStage,
superBlockStages
} from '../../../shared-dist/config/curriculum';
import {
superblockSchemaValidator,
availableSuperBlocksValidator
} from './external-data-schema-v1';
import {
type Curriculum,
type CurriculumIntros,
type GeneratedCurriculumProps,
orderedSuperBlockInfo
} from './build-external-curricula-data-v1';
const VERSION = 'v1';
const intros = JSON.parse(
readFileSync(
path.resolve(__dirname, '../../../client/i18n/locales/english/intro.json'),
'utf-8'
)
) as CurriculumIntros;
describe('external curriculum data build', () => {
const clientStaticPath = path.resolve(__dirname, '../../../client/static');
const validateSuperBlock = superblockSchemaValidator();
test("the external curriculum data should be in the client's static directory", () => {
expect(
fs.existsSync(`${clientStaticPath}/curriculum-data/${VERSION}`)
).toBe(true);
expect(
fs.readdirSync(`${clientStaticPath}/curriculum-data/${VERSION}`).length
).toBeGreaterThan(0);
});
test('there should be an endpoint to request submit types from', () => {
expect(
fs.existsSync(`${clientStaticPath}/curriculum-data/submit-types.json`)
).toBeTruthy();
});
test('the available-superblocks file should have the correct structure', async () => {
const validateAvailableSuperBlocks = availableSuperBlocksValidator();
const availableSuperblocks: unknown = JSON.parse(
await fs.promises.readFile(
`${clientStaticPath}/curriculum-data/${VERSION}/available-superblocks.json`,
'utf-8'
)
);
const result = validateAvailableSuperBlocks(availableSuperblocks);
expect(result.error?.details).toBeUndefined();
expect(result.error).toBeFalsy();
});
test('the super block files generated should have the correct schema', async () => {
const fileArray = (
await readdirp.promise(`${clientStaticPath}/curriculum-data/${VERSION}`, {
directoryFilter: ['!challenges'],
fileFilter: entry => {
// path without extension:
const filePath = entry.path.replace(/\.json$/, '');
// The directory contains super block files and other curriculum-related files.
// We're only interested in super block ones.
const superBlocks = Object.values(SuperBlocks);
return superBlocks.includes(filePath);
}
})
).map(file => file.path);
expect(fileArray.length).toBeGreaterThan(0);
fileArray.forEach(fileInArray => {
const fileContent = fs.readFileSync(
`${clientStaticPath}/curriculum-data/${VERSION}/${fileInArray}`,
'utf-8'
);
const result = validateSuperBlock(JSON.parse(fileContent));
expect(result.error?.details).toBeUndefined();
expect(result.error).toBeFalsy();
});
});
test('super blocks and blocks should have the correct data', async () => {
const superBlockFiles = (
await readdirp.promise(`${clientStaticPath}/curriculum-data/${VERSION}`, {
directoryFilter: ['!challenges'],
fileFilter: entry => {
// path without extension:
const filePath = entry.path.replace(/\.json$/, '');
// The directory contains super block files and other curriculum-related files.
// We're only interested in super block ones.
const superBlocks = Object.values(SuperBlocks);
return superBlocks.includes(filePath);
}
})
).map(file => file.path);
expect(superBlockFiles.length).toBeGreaterThan(0);
superBlockFiles.forEach(file => {
const fileContentJson = fs.readFileSync(
`${clientStaticPath}/curriculum-data/${VERSION}/${file}`,
'utf-8'
);
const fileContent = JSON.parse(
fileContentJson
) as Curriculum<GeneratedCurriculumProps>;
const superBlock = Object.keys(fileContent)[0] as SuperBlocks;
// Randomly pick a block to check its data.
const blocks = Object.keys(fileContent[superBlock].blocks);
const randomBlockIndex = Math.floor(Math.random() * blocks.length);
const randomBlock = blocks[randomBlockIndex];
expect(fileContent[superBlock].intro).toEqual(intros[superBlock].intro);
expect(fileContent[superBlock].blocks[randomBlock].desc).toEqual(
intros[superBlock].blocks[randomBlock].intro
);
});
});
test('All public SuperBlocks should be present in the SuperBlock object', () => {
const dashedNames = orderedSuperBlockInfo.map(
({ dashedName }) => dashedName
);
const publicSuperBlockNames = Object.entries(superBlockStages)
.filter(([key]) => {
const stage = Number(key) as SuperBlockStage;
return (
stage !== SuperBlockStage.Next &&
stage !== SuperBlockStage.Upcoming &&
stage !== SuperBlockStage.Catalog &&
stage !== SuperBlockStage.Core
);
})
.flatMap(([, superBlocks]) => superBlocks);
expect(dashedNames).toEqual(expect.arrayContaining(publicSuperBlockNames));
expect(Object.keys(orderedSuperBlockInfo)).toHaveLength(
publicSuperBlockNames.length
);
});
test('challenge files should be created and in the correct directory', () => {
expect(
fs.existsSync(`${clientStaticPath}/curriculum-data/${VERSION}/challenges`)
).toBe(true);
expect(
fs.readdirSync(
`${clientStaticPath}/curriculum-data/${VERSION}/challenges`
).length
).toBeGreaterThan(0);
});
});
@@ -1,162 +0,0 @@
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { omit } from 'lodash';
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 { patchBlock } from './patches';
export type CurriculumIntros = {
[keyValue in SuperBlocks]: {
title: string;
intro: string[];
blocks: Record<string, { title: string; intro: string[] }>;
};
};
export type Curriculum<T> = {
[keyValue in SuperBlocks]: T extends CurriculumProps
? CurriculumProps
: GeneratedCurriculumProps;
};
export interface CurriculumProps {
intro: string[];
blocks: Record<string, Block<ChallengeNode['challenge'][]>>;
}
export interface GeneratedCurriculumProps {
intro: string[];
blocks: Record<string, Block<Record<string, unknown>>>;
}
interface Block<T> {
desc: string[];
intro: string[];
challenges: T;
meta: Record<string, unknown>;
}
const ver = 'v1';
// NOTE: Please don't add new superblocks to this list as this version is being deprecated.
// New superblocks should be added to v2 of the external curriculum data at
// tools/scripts/build/build-external-curricula-data-v2.ts
export const orderedSuperBlockInfo = [
{ dashedName: SuperBlocks.RespWebDesignNew, public: true },
{ dashedName: SuperBlocks.DataAnalysisPy, public: true },
{ dashedName: SuperBlocks.MachineLearningPy, public: true },
{ dashedName: SuperBlocks.CollegeAlgebraPy, public: true },
{ dashedName: SuperBlocks.A2English, public: true },
{ dashedName: SuperBlocks.B1English, public: true },
{ dashedName: SuperBlocks.TheOdinProject, public: true },
{ dashedName: SuperBlocks.RespWebDesign, public: true },
{ dashedName: SuperBlocks.PythonForEverybody, public: true },
{ dashedName: SuperBlocks.JsAlgoDataStructNew, public: false },
{ dashedName: SuperBlocks.FrontEndDevLibs, public: false },
{ dashedName: SuperBlocks.DataVis, public: false },
{ dashedName: SuperBlocks.RelationalDb, public: false },
{ dashedName: SuperBlocks.BackEndDevApis, public: false },
{ dashedName: SuperBlocks.QualityAssurance, public: false },
{ dashedName: SuperBlocks.SciCompPy, public: false },
{ dashedName: SuperBlocks.InfoSec, public: false },
{ dashedName: SuperBlocks.FoundationalCSharp, public: false },
{ dashedName: SuperBlocks.CodingInterviewPrep, public: false },
{ dashedName: SuperBlocks.ProjectEuler, public: false },
{ dashedName: SuperBlocks.RosettaCode, public: false },
{ dashedName: SuperBlocks.JsAlgoDataStruct, public: false }
];
const dashedNames = orderedSuperBlockInfo.map(({ dashedName }) => dashedName);
export function buildExtCurriculumDataV1(
curriculum: Curriculum<CurriculumProps>
): void {
const staticFolderPath = resolve(__dirname, '../../../client/static');
const dataPath = `${staticFolderPath}/curriculum-data/`;
const blockIntroPath = resolve(
__dirname,
'../../../client/i18n/locales/english/intro.json'
);
const intros = JSON.parse(
readFileSync(blockIntroPath, 'utf-8')
) as CurriculumIntros;
mkdirSync(dataPath, { recursive: true });
parseCurriculumData();
getSubmitTypes();
function parseCurriculumData() {
const superBlockKeys = Object.values(SuperBlocks).filter(x =>
dashedNames.includes(x)
);
writeToFile('available-superblocks', {
superblocks: orderedSuperBlockInfo.map(x => ({
...x,
title: intros[x.dashedName].title
}))
});
for (const superBlockKey of superBlockKeys) {
const superBlock = <Curriculum<GeneratedCurriculumProps>>{};
const blockNames = Object.keys(curriculum[superBlockKey].blocks);
if (blockNames.length === 0) continue;
superBlock[superBlockKey] = <GeneratedCurriculumProps>{};
superBlock[superBlockKey].intro = intros[superBlockKey]['intro'];
superBlock[superBlockKey].blocks = {};
for (const blockName of blockNames) {
superBlock[superBlockKey]['blocks'][blockName] = <
Block<Record<string, unknown>>
>{};
const block = intros[superBlockKey]['blocks'][blockName];
if (!block) {
throw Error(
`Block ${blockName} not found in intros for ${superBlockKey}`
);
}
superBlock[superBlockKey]['blocks'][blockName]['desc'] = block['intro'];
superBlock[superBlockKey]['blocks'][blockName]['challenges'] =
patchBlock(
omit(curriculum[superBlockKey]['blocks'][blockName]['meta'], [
'chapter',
'module'
])
);
const blockChallenges =
curriculum[superBlockKey]['blocks'][blockName]['challenges'];
for (const challenge of blockChallenges) {
const challengeId = challenge.id;
const challengePath = `challenges/${superBlockKey}/${blockName}/${challengeId}`;
writeToFile(challengePath, challenge);
}
}
writeToFile(superBlockKey, superBlock);
}
}
function writeToFile(fileName: string, data: Record<string, unknown>): void {
const filePath = `${dataPath}/${ver}/${fileName}.json`;
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function getSubmitTypes() {
writeFileSync(
`${dataPath}/submit-types.json`,
JSON.stringify(submitTypes, null, 2)
);
}
}
@@ -1,309 +0,0 @@
import path from 'path';
import fs, { readFileSync } from 'fs';
import readdirp from 'readdirp';
import { describe, test, expect } from 'vitest';
import {
chapterBasedSuperBlocks,
SuperBlocks,
SuperBlockStage,
superBlockStages
} from '../../../shared-dist/config/curriculum';
import {
superblockSchemaValidator,
availableSuperBlocksValidator
} from './external-data-schema-v2';
import {
type CurriculumIntros,
type Curriculum,
type GeneratedCurriculumProps,
type GeneratedBlockBasedCurriculumProps,
type GeneratedChapterBasedCurriculumProps,
type ChapterBasedCurriculumIntros,
orderedSuperBlockInfo,
OrderedSuperBlocks
} from './build-external-curricula-data-v2';
const VERSION = 'v2';
const intros = JSON.parse(
readFileSync(
path.resolve(__dirname, '../../../client/i18n/locales/english/intro.json'),
'utf-8'
)
) as CurriculumIntros;
describe('external curriculum data build', () => {
const clientStaticPath = path.resolve(__dirname, '../../../client/static');
const validateSuperBlock = superblockSchemaValidator();
test("the external curriculum data should be in the client's static directory", () => {
expect(
fs.existsSync(`${clientStaticPath}/curriculum-data/${VERSION}`)
).toBe(true);
expect(
fs.readdirSync(`${clientStaticPath}/curriculum-data/${VERSION}`).length
).toBeGreaterThan(0);
});
test('there should be an endpoint to request submit types from', () => {
expect(
fs.existsSync(
`${clientStaticPath}/curriculum-data/${VERSION}/submit-types.json`
)
).toBe(true);
});
test('the available-superblocks file should have the correct structure', async () => {
const filteredSuperBlockStages: string[] = Object.keys(SuperBlockStage)
.filter(key => isNaN(Number(key))) // Filter out numeric keys to get only the names
.filter(
name => name !== 'Upcoming' && name !== 'Next' && name !== 'Catalog'
) // Filter out 'Upcoming', 'Next', and 'Catalog'
.map(name => name.toLowerCase());
const validateAvailableSuperBlocks = availableSuperBlocksValidator();
const availableSuperblocks = JSON.parse(
await fs.promises.readFile(
`${clientStaticPath}/curriculum-data/${VERSION}/available-superblocks.json`,
'utf-8'
)
) as { superblocks: OrderedSuperBlocks };
const result = validateAvailableSuperBlocks(availableSuperblocks);
expect(Object.keys(availableSuperblocks.superblocks)).toHaveLength(
filteredSuperBlockStages.length
);
expect(Object.keys(availableSuperblocks.superblocks)).toEqual(
expect.arrayContaining(filteredSuperBlockStages)
);
expect(result.error?.details).toBeUndefined();
expect(result.error).toBeFalsy();
});
test('the super block files generated should have the correct schema', async () => {
const superBlocks = Object.values(SuperBlocks);
const fileArray = (
await readdirp.promise(`${clientStaticPath}/curriculum-data/${VERSION}`, {
directoryFilter: ['!challenges'],
fileFilter: entry => {
// The directory contains super block files and other curriculum-related files.
// We're only interested in super block ones.
const isSuperBlock = superBlocks.some(superBlock =>
entry.basename.includes(superBlock)
);
return isSuperBlock;
}
})
).map(file => file.path);
expect(fileArray.length).toBeGreaterThan(0);
fileArray.forEach(fileInArray => {
const fileContent = fs.readFileSync(
`${clientStaticPath}/curriculum-data/${VERSION}/${fileInArray}`,
'utf-8'
);
const result = validateSuperBlock(JSON.parse(fileContent));
expect(result.error?.details).toBeUndefined();
expect(result.error).toBeFalsy();
});
});
test('block-based super blocks and blocks should have the correct data', async () => {
const superBlocks = Object.values(SuperBlocks);
const superBlockFiles = (
await readdirp.promise(`${clientStaticPath}/curriculum-data/${VERSION}`, {
directoryFilter: ['!challenges'],
fileFilter: entry => {
// The directory contains super block files and other curriculum-related files.
// We're only interested in super block ones.
const isSuperBlock = superBlocks.some(superBlock =>
entry.basename.includes(superBlock)
);
const isChapterBasedSuperBlock = chapterBasedSuperBlocks.some(
chapterBasedSuperBlock =>
entry.basename.includes(chapterBasedSuperBlock)
);
return isSuperBlock && !isChapterBasedSuperBlock;
}
})
).map(file => file.path);
expect(superBlockFiles.length).toBeGreaterThan(0);
superBlockFiles.forEach(file => {
const fileContentJson = fs.readFileSync(
`${clientStaticPath}/curriculum-data/${VERSION}/${file}`,
'utf-8'
);
const fileContent = JSON.parse(
fileContentJson
) as Curriculum<GeneratedCurriculumProps>;
const superBlock = Object.keys(fileContent)[0] as SuperBlocks;
const superBlockData = fileContent[
superBlock
] as GeneratedBlockBasedCurriculumProps;
// Temporary skip these checks to keep CI stable.
// TODO: uncomment these once https://github.com/freeCodeCamp/freeCodeCamp/issues/60660 is completed.
// Randomly pick a block to check its data.
// const blocks = superBlockData.blocks;
// const randomBlockIndex = Math.floor(Math.random() * blocks.length);
// const randomBlock = blocks[randomBlockIndex];
expect(superBlockData.intro).toEqual(intros[superBlock].intro);
// expect(superBlockData.blocks[randomBlockIndex].intro).toEqual(
// intros[superBlock].blocks[randomBlock.meta.dashedName as string].intro
// );
// expect(superBlockData.blocks[randomBlockIndex].meta.name).toEqual(
// intros[superBlock].blocks[randomBlock.meta.dashedName as string].title
// );
});
});
test('chapter-based super blocks and blocks should have the correct data', async () => {
const superBlocks = Object.values(SuperBlocks);
const superBlockFiles = (
await readdirp.promise(`${clientStaticPath}/curriculum-data/${VERSION}`, {
directoryFilter: ['!challenges'],
fileFilter: entry => {
// The directory contains super block files and other curriculum-related files.
// We're only interested in super block ones.
const isSuperBlock = superBlocks.some(superBlock =>
entry.basename.includes(superBlock)
);
const isChapterBasedSuperBlock = chapterBasedSuperBlocks.some(
chapterBasedSuperBlock =>
entry.basename.includes(chapterBasedSuperBlock)
);
return isSuperBlock && isChapterBasedSuperBlock;
}
})
).map(file => file.path);
expect(superBlockFiles.length).toBeGreaterThan(0);
superBlockFiles.forEach(file => {
const fileContentJson = fs.readFileSync(
`${clientStaticPath}/curriculum-data/${VERSION}/${file}`,
'utf-8'
);
const fileContent = JSON.parse(
fileContentJson
) as Curriculum<GeneratedCurriculumProps>;
const superBlock = Object.keys(fileContent)[0] as SuperBlocks;
const superBlockData = fileContent[
superBlock
] as GeneratedChapterBasedCurriculumProps;
const superBlockIntros = intros[
superBlock
] as ChapterBasedCurriculumIntros[SuperBlocks];
// Check super block data
expect(superBlockData.intro).toEqual(superBlockIntros.intro);
// Loop through all chapters
superBlockData.chapters
.filter(({ comingSoon }) => !comingSoon)
.forEach(chapter => {
expect(chapter.name).toEqual(
superBlockIntros.chapters[chapter.dashedName]
);
// Loop through all modules in the chapter
chapter.modules
.filter(({ comingSoon }) => !comingSoon)
.forEach(module => {
expect(module.name).toEqual(
superBlockIntros.modules[module.dashedName]
);
});
});
// Temporary skip these checks to keep CI stable.
// TODO: uncomment these once https://github.com/freeCodeCamp/freeCodeCamp/issues/60660 is completed.
// Check block data
// expect(
// superBlockData.chapters[randomChapterIndex].modules[randomModuleIndex]
// .blocks[randomBlockIndex].intro
// ).toEqual(
// superBlockIntros.blocks[randomBlock.meta.dashedName as string].intro
// );
// expect(
// superBlockData.chapters[randomChapterIndex].modules[randomModuleIndex]
// .blocks[randomBlockIndex].meta.name
// ).toEqual(
// superBlockIntros.blocks[randomBlock.meta.dashedName as string].title
// );
});
});
test('All public SuperBlocks should be present in the SuperBlock object', () => {
// Create a mapping from string to shared/config SuperBlockStage enum value
// so we can look up the enum value by string.
const superBlockStageStringMap: Record<string, SuperBlockStage> = {
core: SuperBlockStage.Core,
english: SuperBlockStage.English,
professional: SuperBlockStage.Professional,
extra: SuperBlockStage.Extra,
legacy: SuperBlockStage.Legacy,
upcoming: SuperBlockStage.Upcoming,
next: SuperBlockStage.Next
};
const stages = Object.keys(orderedSuperBlockInfo);
expect(stages).not.toContain('next');
expect(stages).not.toContain('upcoming');
for (const stage of stages) {
const superBlockDashedNames = orderedSuperBlockInfo[stage].map(
superBlock => superBlock.dashedName
);
const stageValueInNum = superBlockStageStringMap[stage];
expect(superBlockDashedNames).toEqual(
expect.arrayContaining(superBlockStages[stageValueInNum])
);
expect(superBlockDashedNames).toHaveLength(
superBlockStages[stageValueInNum].length
);
}
});
test('challenge files should be created and in the correct directory', () => {
expect(
fs.existsSync(`${clientStaticPath}/curriculum-data/${VERSION}/challenges`)
).toBe(true);
expect(
fs.readdirSync(
`${clientStaticPath}/curriculum-data/${VERSION}/challenges`
).length
).toBeGreaterThan(0);
});
});
@@ -1,419 +0,0 @@
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { omit } from 'lodash';
import { submitTypes } from '../../../shared-dist/config/challenge-types';
import { type ChallengeNode } from '../../../client/src/redux/prop-types';
import {
SuperBlocks,
chapterBasedSuperBlocks
} from '../../../shared-dist/config/curriculum';
import type { Chapter } from '../../../shared-dist/config/chapters';
import { getSuperblockStructure } from '../../../curriculum/src/file-handler';
import { patchBlock } from './patches';
export type CurriculumIntros =
| BlockBasedCurriculumIntros
| ChapterBasedCurriculumIntros;
type BlockBasedCurriculumIntros = {
[keyValue in SuperBlocks]: {
title: string;
intro: string[];
blocks: Record<string, { title: string; intro: string[] }>;
};
};
export type ChapterBasedCurriculumIntros = {
[keyValue in SuperBlocks]: {
title: string;
intro: string[];
chapters: Record<string, string>;
modules: Record<string, string>;
blocks: Record<string, { title: string; intro: string[] }>;
};
};
export type Curriculum<T> = {
[keyValue in SuperBlocks]: T extends CurriculumProps
? CurriculumProps
: GeneratedCurriculumProps;
};
export interface CurriculumProps {
intro: string[];
blocks: Record<string, Block<ChallengeNode['challenge'][]>>;
}
interface Block<T> {
desc: string[];
intro: string[];
challenges: T;
meta: Record<string, unknown>;
}
export type GeneratedCurriculumProps =
| GeneratedBlockBasedCurriculumProps
| GeneratedChapterBasedCurriculumProps;
export interface GeneratedBlockBasedCurriculumProps {
intro: string[];
blocks: GeneratedBlock[];
}
export interface GeneratedChapterBasedCurriculumProps {
intro: string[];
chapters: GeneratedChapter[];
}
interface GeneratedChapter {
dashedName: string;
name: string;
comingSoon?: boolean;
modules: GeneratedModule[];
chapterType?: string;
}
interface GeneratedModule {
dashedName: string;
name: string;
comingSoon?: boolean;
blocks: GeneratedBlock[];
moduleType?: string;
}
interface GeneratedBlock {
dashedName: string;
intro: string;
meta: Record<string, unknown>;
}
// This enum is based on the `SuperBlockStage` enum in shared/config,
// but with string value instead of number.
enum SuperBlockStage {
Core = 'core',
English = 'english',
Professional = 'professional',
Extra = 'extra',
Legacy = 'legacy'
}
export type OrderedSuperBlocks = Record<
string,
Array<{ dashedName: SuperBlocks; public: boolean; title: string }>
>;
const ver = 'v2';
const staticFolderPath = resolve(__dirname, '../../../client/static');
const dataPath = `${staticFolderPath}/curriculum-data/`;
const blockIntroPath = resolve(
__dirname,
'../../../client/i18n/locales/english/intro.json'
);
const intros = JSON.parse(
readFileSync(blockIntroPath, 'utf-8')
) as CurriculumIntros;
export const orderedSuperBlockInfo: OrderedSuperBlocks = {
[SuperBlockStage.Core]: [
{
dashedName: SuperBlocks.RespWebDesignV9,
public: false,
title: intros[SuperBlocks.RespWebDesignV9].title
},
{
dashedName: SuperBlocks.JsV9,
public: false,
title: intros[SuperBlocks.JsV9].title
},
{
dashedName: SuperBlocks.FrontEndDevLibsV9,
public: false,
title: intros[SuperBlocks.FrontEndDevLibsV9].title
},
{
dashedName: SuperBlocks.PythonV9,
public: false,
title: intros[SuperBlocks.PythonV9].title
},
{
dashedName: SuperBlocks.RelationalDbV9,
public: false,
title: intros[SuperBlocks.RelationalDbV9].title
},
{
dashedName: SuperBlocks.BackEndDevApisV9,
public: false,
title: intros[SuperBlocks.BackEndDevApisV9].title
},
{
dashedName: SuperBlocks.FullStackDeveloperV9,
public: false,
title: intros[SuperBlocks.FullStackDeveloperV9].title
}
],
[SuperBlockStage.English]: [
{
dashedName: SuperBlocks.A2English,
public: true,
title: intros[SuperBlocks.A2English].title
},
{
dashedName: SuperBlocks.B1English,
public: true,
title: intros[SuperBlocks.B1English].title
}
],
[SuperBlockStage.Extra]: [
{
dashedName: SuperBlocks.TheOdinProject,
public: true,
title: intros[SuperBlocks.TheOdinProject].title
},
{
dashedName: SuperBlocks.CodingInterviewPrep,
public: false,
title: intros[SuperBlocks.CodingInterviewPrep].title
},
{
dashedName: SuperBlocks.ProjectEuler,
public: false,
title: intros[SuperBlocks.ProjectEuler].title
},
{
dashedName: SuperBlocks.RosettaCode,
public: false,
title: intros[SuperBlocks.RosettaCode].title
}
],
[SuperBlockStage.Legacy]: [
{
dashedName: SuperBlocks.RespWebDesignNew,
public: true,
title: intros[SuperBlocks.RespWebDesignNew].title
},
{
dashedName: SuperBlocks.JsAlgoDataStructNew,
public: false,
title: intros[SuperBlocks.JsAlgoDataStructNew].title
},
{
dashedName: SuperBlocks.FrontEndDevLibs,
public: false,
title: intros[SuperBlocks.FrontEndDevLibs].title
},
{
dashedName: SuperBlocks.DataVis,
public: false,
title: intros[SuperBlocks.DataVis].title
},
{
dashedName: SuperBlocks.RelationalDb,
public: false,
title: intros[SuperBlocks.RelationalDb].title
},
{
dashedName: SuperBlocks.BackEndDevApis,
public: false,
title: intros[SuperBlocks.BackEndDevApis].title
},
{
dashedName: SuperBlocks.QualityAssurance,
public: false,
title: intros[SuperBlocks.QualityAssurance].title
},
{
dashedName: SuperBlocks.SciCompPy,
public: false,
title: intros[SuperBlocks.SciCompPy].title
},
{
dashedName: SuperBlocks.DataAnalysisPy,
public: true,
title: intros[SuperBlocks.DataAnalysisPy].title
},
{
dashedName: SuperBlocks.InfoSec,
public: false,
title: intros[SuperBlocks.InfoSec].title
},
{
dashedName: SuperBlocks.MachineLearningPy,
public: true,
title: intros[SuperBlocks.MachineLearningPy].title
},
{
dashedName: SuperBlocks.CollegeAlgebraPy,
public: true,
title: intros[SuperBlocks.CollegeAlgebraPy].title
},
{
dashedName: SuperBlocks.RespWebDesign,
public: true,
title: intros[SuperBlocks.RespWebDesign].title
},
{
dashedName: SuperBlocks.JsAlgoDataStruct,
public: false,
title: intros[SuperBlocks.JsAlgoDataStruct].title
},
{
dashedName: SuperBlocks.PythonForEverybody,
public: true,
title: intros[SuperBlocks.PythonForEverybody].title
}
],
[SuperBlockStage.Professional]: [
{
dashedName: SuperBlocks.FoundationalCSharp,
public: false,
title: intros[SuperBlocks.FoundationalCSharp].title
}
]
};
export const superBlockDashedNames = Object.keys(orderedSuperBlockInfo).reduce(
(acc, superBlockStage) => {
const dashedNames = orderedSuperBlockInfo[superBlockStage].map(
superBlock => superBlock.dashedName
);
acc.push(...dashedNames);
return acc;
},
[] as SuperBlocks[]
);
export function buildExtCurriculumDataV2(
curriculum: Curriculum<CurriculumProps>
): void {
mkdirSync(dataPath, { recursive: true });
parseCurriculumData();
getSubmitTypes();
function parseCurriculumData() {
const superBlockKeys = Object.values(SuperBlocks).filter(x =>
superBlockDashedNames.includes(x)
);
writeToFile('available-superblocks', {
superblocks: orderedSuperBlockInfo
});
for (const superBlockKey of superBlockKeys) {
if (chapterBasedSuperBlocks.includes(superBlockKey)) {
buildChapterBasedCurriculum(superBlockKey);
} else {
buildBlockBasedCurriculum(superBlockKey);
}
buildChallengeFiles(superBlockKey);
}
}
function buildChapterBasedCurriculum(superBlockKey: SuperBlocks) {
const { chapters } = getSuperblockStructure(superBlockKey) as {
chapters: Chapter[];
};
const blocksWithData = curriculum[superBlockKey].blocks;
const superBlockIntros = intros[
superBlockKey
] as ChapterBasedCurriculumIntros[SuperBlocks];
// Skip upcoming chapter/module as the metadata of their blocks
// is not included in the `curriculum` object.
const allChapters = chapters.map(chapter => ({
dashedName: chapter.dashedName,
name: superBlockIntros.chapters[chapter.dashedName],
comingSoon: chapter.comingSoon,
chapterType: chapter.chapterType,
modules: chapter.comingSoon
? []
: chapter.modules.map(module => ({
dashedName: module.dashedName,
name: superBlockIntros.modules[module.dashedName],
comingSoon: module.comingSoon,
moduleType: module.moduleType,
blocks: module.comingSoon
? []
: module.blocks
// Upcoming blocks aren't included in blocksWithData
// and thus they have no metadata and need to be filtered out.
.filter(block => blocksWithData[block])
.map(block => {
const blockData = blocksWithData[block];
return {
intro: superBlockIntros.blocks[block].intro,
meta: patchBlock(
omit(blockData.meta, ['chapter', 'module'])
)
};
})
}))
}));
const superBlock = {
[superBlockKey]: {
intro: intros[superBlockKey].intro,
chapters: allChapters
}
};
writeToFile(superBlockKey, superBlock);
}
function buildBlockBasedCurriculum(superBlockKey: SuperBlocks) {
const blockNames = Object.keys(curriculum[superBlockKey].blocks);
const blocks = blockNames.map(blockName => {
const blockData = curriculum[superBlockKey].blocks[blockName];
return {
intro: intros[superBlockKey].blocks[blockName].intro,
meta: patchBlock(blockData.meta)
};
});
const superBlock = {
[superBlockKey]: {
intro: intros[superBlockKey].intro,
blocks
}
};
writeToFile(superBlockKey, superBlock);
}
function buildChallengeFiles(superBlockKey: SuperBlocks) {
const blocks = Object.keys(curriculum[superBlockKey].blocks);
for (const block of blocks) {
const challenges = curriculum[superBlockKey]['blocks'][block].challenges;
for (const challenge of challenges) {
const challengeId = challenge.id;
const challengePath = `challenges/${superBlockKey}/${block}/${challengeId}`;
writeToFile(challengePath, challenge);
}
}
}
function writeToFile(fileName: string, data: Record<string, unknown>): void {
const filePath = `${dataPath}/${ver}/${fileName}.json`;
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function getSubmitTypes() {
writeFileSync(
`${dataPath}/${ver}/submit-types.json`,
JSON.stringify(submitTypes, null, 2)
);
}
}
@@ -1,95 +0,0 @@
const Joi = require('joi');
const {
chapterBasedSuperBlocks
} = require('../../../shared-dist/config/curriculum');
const blockSchema = Joi.object({}).keys({
desc: Joi.array().min(1),
challenges: Joi.object({})
.keys({
name: Joi.string().required(),
isUpcomingChange: Joi.bool().required(),
usesMultifileEditor: Joi.bool().optional(),
hasEditableBoundaries: Joi.bool().optional(),
dashedName: Joi.string().required(),
helpCategory: Joi.valid(
'JavaScript',
'HTML-CSS',
'Python',
'Backend Development',
'C-Sharp',
'English',
'Odin',
'Euler',
'Rosetta'
).required(),
order: Joi.number().required(),
template: Joi.string().allow(''),
required: Joi.array(),
superBlock: Joi.string().required(),
blockLayout: Joi.valid(
'challenge-list',
'challenge-grid',
'dialogue-grid',
'link',
'project-list',
'legacy-challenge-list',
'legacy-link',
'legacy-challenge-grid'
).required(),
blockType: Joi.valid(
'lecture',
'workshop',
'lab',
'review',
'quiz',
'exam',
'warm-up',
'learn',
'practice'
).when('superBlock', {
is: chapterBasedSuperBlocks,
then: Joi.required(),
otherwise: Joi.optional()
}),
challengeOrder: Joi.array()
.items(
Joi.object({}).keys({
id: Joi.string(),
title: Joi.string()
})
)
.min(1)
.required(),
disableLoopProtectTests: Joi.boolean(),
disableLoopProtectPreview: Joi.boolean(),
superOrder: Joi.number()
})
.required()
});
const subSchema = Joi.object({}).keys({
intro: Joi.array(),
blocks: Joi.object({}).pattern(Joi.string(), Joi.object().concat(blockSchema))
});
const schema = Joi.object({}).pattern(
Joi.string(),
Joi.object().concat(subSchema)
);
const availableSuperBlocksSchema = Joi.object({
superblocks: Joi.array().items(
Joi.object({
dashedName: Joi.string().required(),
title: Joi.string().required(),
public: Joi.bool().required()
})
)
});
exports.superblockSchemaValidator = () => superblock =>
schema.validate(superblock);
exports.availableSuperBlocksValidator = () => data =>
availableSuperBlocksSchema.validate(data);
@@ -1,135 +0,0 @@
const Joi = require('joi');
const {
chapterBasedSuperBlocks
} = require('../../../shared-dist/config/curriculum');
const slugRE = new RegExp('^[a-z0-9-]+$');
const blockSchema = Joi.object().keys({
intro: Joi.array().min(1),
meta: Joi.object({})
.keys({
name: Joi.string().required(),
isUpcomingChange: Joi.bool().required(),
usesMultifileEditor: Joi.bool().optional(),
hasEditableBoundaries: Joi.bool().optional(),
dashedName: Joi.string().required(),
helpCategory: Joi.valid(
'JavaScript',
'HTML-CSS',
'Python',
'Backend Development',
'C-Sharp',
'English',
'Odin',
'Euler',
'Rosetta'
).required(),
order: Joi.number().required(),
template: Joi.string().allow(''),
required: Joi.array(),
superBlock: Joi.string().required(),
blockLayout: Joi.valid(
'challenge-list',
'challenge-grid',
'dialogue-grid',
'link',
'project-list',
'legacy-challenge-list',
'legacy-link',
'legacy-challenge-grid'
).required(),
blockType: Joi.valid(
'lecture',
'workshop',
'lab',
'review',
'quiz',
'exam',
'warm-up',
'learn',
'practice'
).when('superBlock', {
is: chapterBasedSuperBlocks,
then: Joi.required(),
otherwise: Joi.optional()
}),
challengeOrder: Joi.array().items(
Joi.object({})
.keys({
id: Joi.string(),
title: Joi.string()
})
.min(1)
.required()
),
disableLoopProtectTests: Joi.boolean(),
disableLoopProtectPreview: Joi.boolean(),
superOrder: Joi.number()
})
.required()
});
const blockBasedCurriculumSchema = Joi.object().pattern(
Joi.string(),
Joi.object().keys({
intro: Joi.array(),
blocks: Joi.array().items(blockSchema)
})
);
const chapterBasedCurriculumSchema = Joi.object().pattern(
Joi.string(),
Joi.object().keys({
intro: Joi.array(),
chapters: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required(),
name: Joi.string().required(),
comingSoon: Joi.boolean().optional(),
chapterType: Joi.valid('exam').optional(),
modules: Joi.array()
.items(
Joi.object().keys({
moduleType: Joi.valid(
'review',
'exam',
'cert-project'
).optional(),
name: Joi.string().required(),
comingSoon: Joi.boolean().optional(),
dashedName: Joi.string().regex(slugRE).required(),
blocks: Joi.array().items(blockSchema)
})
)
.required()
})
)
})
);
const availableSuperBlocksSchema = Joi.object({
superblocks: Joi.object().pattern(
Joi.string(),
Joi.array().items(
Joi.object({
dashedName: Joi.string().required(),
title: Joi.string().required(),
public: Joi.bool().required()
})
)
)
});
exports.superblockSchemaValidator = () => superBlock => {
const superBlockName = Object.keys(superBlock)[0];
if (chapterBasedSuperBlocks.includes(superBlockName)) {
return chapterBasedCurriculumSchema.validate(superBlock);
}
return blockBasedCurriculumSchema.validate(superBlock);
};
exports.availableSuperBlocksValidator = () => data =>
availableSuperBlocksSchema.validate(data);
-31
View File
@@ -1,31 +0,0 @@
{
"name": "@freecodecamp/scripts-build",
"version": "0.0.1",
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"license": "BSD-3-Clause",
"private": true,
"engines": {
"node": ">=16",
"pnpm": ">=10"
},
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"author": "freeCodeCamp <team@freecodecamp.org>",
"main": "none",
"scripts": {
"test": "vitest"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.5.0",
"@vitest/ui": "^3.2.4",
"joi": "17.12.2",
"readdirp": "3.6.0",
"vitest": "^3.2.4"
}
}
-7
View File
@@ -1,7 +0,0 @@
export const patchBlock = (meta: Record<string, unknown>) => {
const { blockLabel, ...rest } = meta;
return {
...rest,
blockType: blockLabel
};
};
-1
View File
@@ -1 +0,0 @@
import '@total-typescript/ts-reset';