mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
refactor: delete external curriculum v1 (#65650)
This commit is contained in:
@@ -1,173 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
import readdirp from 'readdirp';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import intros from '../../i18n/locales/english/intro.json';
|
||||
import {
|
||||
SuperBlocks,
|
||||
SuperBlockStage,
|
||||
superBlockStages
|
||||
} from '@freecodecamp/shared/config/curriculum';
|
||||
import {
|
||||
superblockSchemaValidator,
|
||||
availableSuperBlocksValidator
|
||||
} from './external-data-schema-v1';
|
||||
import {
|
||||
type Curriculum,
|
||||
type GeneratedCurriculumProps,
|
||||
orderedSuperBlockInfo
|
||||
} from './build-external-curricula-data-v1';
|
||||
|
||||
const VERSION = 'v1';
|
||||
|
||||
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 as unknown as Record<
|
||||
string,
|
||||
{ intro: unknown }
|
||||
>
|
||||
)[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 &&
|
||||
stage !== SuperBlockStage.Spanish &&
|
||||
stage !== SuperBlockStage.Chinese
|
||||
);
|
||||
})
|
||||
.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,161 +0,0 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { omit } from 'lodash';
|
||||
import { submitTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
import { SuperBlocks } from '@freecodecamp/shared/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<{ id: string }[]>>;
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@ const curriculum = JSON.parse(
|
||||
readFileSync(join(__dirname, CURRICULUM_PATH), 'utf-8')
|
||||
);
|
||||
|
||||
import {
|
||||
buildExtCurriculumDataV1,
|
||||
Curriculum as CurriculumV1,
|
||||
CurriculumProps as CurriculumPropsV1
|
||||
} from './build-external-curricula-data-v1';
|
||||
import {
|
||||
buildExtCurriculumDataV2,
|
||||
Curriculum as CurriculumV2,
|
||||
@@ -29,6 +24,5 @@ if (isSelectiveBuild) {
|
||||
'Skipping external curriculum build (selective build mode active)'
|
||||
);
|
||||
} else {
|
||||
buildExtCurriculumDataV1(curriculum as CurriculumV1<CurriculumPropsV1>);
|
||||
buildExtCurriculumDataV2(curriculum as CurriculumV2<CurriculumPropsV2>);
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import Joi from 'joi';
|
||||
import { chapterBasedSuperBlocks } from '@freecodecamp/shared/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()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export const superblockSchemaValidator = () => (superblock: unknown) =>
|
||||
schema.validate(superblock);
|
||||
|
||||
export const availableSuperBlocksValidator = () => (data: unknown) =>
|
||||
availableSuperBlocksSchema.validate(data);
|
||||
@@ -1,7 +0,0 @@
|
||||
export const patchBlock = (meta: Record<string, unknown>) => {
|
||||
const { blockLabel, ...rest } = meta;
|
||||
return {
|
||||
...rest,
|
||||
blockType: blockLabel
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user