mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
refactor: split curriculum build in two (#63639)
This commit is contained in:
committed by
GitHub
parent
1212c78727
commit
960fd9e072
@@ -0,0 +1,171 @@
|
||||
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 '../../../shared-dist/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
|
||||
);
|
||||
})
|
||||
.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { omit } from 'lodash';
|
||||
import { submitTypes } from '../../../shared-dist/config/challenge-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<{ 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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) as Record<string, unknown>
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
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 '../../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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import curriculum from '../../../shared-dist/config/curriculum.json';
|
||||
import {
|
||||
buildExtCurriculumDataV1,
|
||||
Curriculum as CurriculumV1,
|
||||
CurriculumProps as CurriculumPropsV1
|
||||
} from './build-external-curricula-data-v1';
|
||||
import {
|
||||
buildExtCurriculumDataV2,
|
||||
Curriculum as CurriculumV2,
|
||||
CurriculumProps as CurriculumPropsV2
|
||||
} from './build-external-curricula-data-v2';
|
||||
|
||||
const isSelectiveBuild =
|
||||
process.env.FCC_SUPERBLOCK ||
|
||||
process.env.FCC_BLOCK ||
|
||||
process.env.FCC_CHALLENGE_ID;
|
||||
|
||||
if (isSelectiveBuild) {
|
||||
console.log(
|
||||
'Skipping external curriculum build (selective build mode active)'
|
||||
);
|
||||
} else {
|
||||
buildExtCurriculumDataV1(
|
||||
curriculum as unknown as CurriculumV1<CurriculumPropsV1>
|
||||
);
|
||||
buildExtCurriculumDataV2(
|
||||
curriculum as unknown as CurriculumV2<CurriculumPropsV2>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Joi from 'joi';
|
||||
import { chapterBasedSuperBlocks } from '../../../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()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
export const superblockSchemaValidator = () => (superblock: unknown) =>
|
||||
schema.validate(superblock);
|
||||
|
||||
export const availableSuperBlocksValidator = () => (data: unknown) =>
|
||||
availableSuperBlocksSchema.validate(data);
|
||||
@@ -0,0 +1,134 @@
|
||||
import Joi from 'joi';
|
||||
import { chapterBasedSuperBlocks } from '../../../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()
|
||||
})
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
export const superblockSchemaValidator =
|
||||
() => (superBlock: Record<string, unknown>) => {
|
||||
const superBlockName = Object.keys(superBlock)[0];
|
||||
|
||||
if (chapterBasedSuperBlocks.includes(superBlockName)) {
|
||||
return chapterBasedCurriculumSchema.validate(superBlock);
|
||||
}
|
||||
|
||||
return blockBasedCurriculumSchema.validate(superBlock);
|
||||
};
|
||||
|
||||
export const availableSuperBlocksValidator = () => (data: unknown) =>
|
||||
availableSuperBlocksSchema.validate(data);
|
||||
@@ -0,0 +1,7 @@
|
||||
export const patchBlock = (meta: Record<string, unknown>) => {
|
||||
const { blockLabel, ...rest } = meta;
|
||||
return {
|
||||
...rest,
|
||||
blockType: blockLabel
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user