mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(tools): introduce generic data API with versioning (#45989)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -205,6 +205,7 @@ curriculum/dist
|
||||
curriculum/build
|
||||
client/static/_redirects
|
||||
client/static/mobile
|
||||
client/static/curriculum-data
|
||||
|
||||
### UI Components ###
|
||||
tools/ui-components/dist
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const { getChallengesForLang } = require('../../../curriculum/getChallenges');
|
||||
const { buildMobileCurriculum } = require('./build-mobile-curriculum');
|
||||
import { getChallengesForLang } from '../../../curriculum/getChallenges';
|
||||
import {
|
||||
buildExtCurriculumData,
|
||||
Curriculum
|
||||
} from './build-external-curricula-data';
|
||||
|
||||
const { CURRICULUM_LOCALE } = process.env;
|
||||
|
||||
@@ -10,10 +13,10 @@ const globalConfigPath = path.resolve(__dirname, '../../../config');
|
||||
|
||||
// We are defaulting to English because the ids for the challenges are same
|
||||
// across all languages.
|
||||
getChallengesForLang('english')
|
||||
.then(result => {
|
||||
void getChallengesForLang('english')
|
||||
.then((result: Record<string, unknown>) => {
|
||||
if (CURRICULUM_LOCALE === 'english') {
|
||||
buildMobileCurriculum(result);
|
||||
buildExtCurriculumData('v1.0.0', result as Curriculum);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { SuperBlocks } from '../../../config/certification-settings';
|
||||
|
||||
type Intro = { [keyValue in SuperBlocks]: IntroProps };
|
||||
export type Curriculum = { [keyValue in SuperBlocks]: CurriculumProps };
|
||||
type SuperBlockKeys = keyof typeof SuperBlocks;
|
||||
type SuperBlockValues = typeof SuperBlocks[SuperBlockKeys];
|
||||
|
||||
interface IntroProps extends CurriculumProps {
|
||||
title: string;
|
||||
intro: string[];
|
||||
}
|
||||
|
||||
interface CurriculumProps {
|
||||
blocks: Record<string, Block>;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
desc: string[];
|
||||
intro: string[];
|
||||
challenges: Record<string, unknown>;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const superBlockMobileAppOrder = {
|
||||
'responsive-web-design': { public: true },
|
||||
'2022/responsive-web-design': { public: false },
|
||||
'javascript-algorithms-and-data-structures': { public: true },
|
||||
'2022/javascript-algorithms-and-data-structures': { public: false },
|
||||
'front-end-development-libraries': { public: false },
|
||||
'data-visualization': { public: false },
|
||||
'back-end-development-and-apis': { public: false },
|
||||
'quality-assurance': { public: false },
|
||||
'scientific-computing-with-python': { public: false },
|
||||
'data-analysis-with-python': { public: false },
|
||||
'information-security': { public: false },
|
||||
'machine-learning-with-python': { public: false },
|
||||
'coding-interview-prep': { public: false },
|
||||
'relational-database': { public: false }
|
||||
};
|
||||
|
||||
export function buildExtCurriculumData(
|
||||
ver: string,
|
||||
curriculum: Curriculum
|
||||
): void {
|
||||
const staticFolderPath = resolve(__dirname, '../../../client/static');
|
||||
const versionPath = `${staticFolderPath}/curriculum-data/${ver}`;
|
||||
const blockIntroPath = resolve(
|
||||
__dirname,
|
||||
'../../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
|
||||
mkdirSync(versionPath, { recursive: true });
|
||||
|
||||
parseCurriculumData();
|
||||
|
||||
function parseCurriculumData() {
|
||||
const superBlockKeys = Object.values(SuperBlocks);
|
||||
|
||||
writeToFile('availableSuperblocks', {
|
||||
superblocks: [
|
||||
superBlockMobileAppOrder,
|
||||
Object.values(SuperBlocks).map(superblock =>
|
||||
getSuperBlockName(superblock)
|
||||
)
|
||||
]
|
||||
});
|
||||
|
||||
for (let i = 0; i < superBlockKeys.length; i++) {
|
||||
const superBlock = <Curriculum>{};
|
||||
const superBlockKey = Object.values(SuperBlocks)[i];
|
||||
const blockNames = Object.keys(curriculum[superBlockKeys[i]].blocks);
|
||||
|
||||
if (blockNames.length === 0) continue;
|
||||
|
||||
superBlock[superBlockKey] = <CurriculumProps>{};
|
||||
superBlock[superBlockKey]['blocks'] = {};
|
||||
|
||||
for (let j = 0; j < blockNames.length; j++) {
|
||||
superBlock[superBlockKey]['blocks'][blockNames[j]] = <Block>{};
|
||||
|
||||
superBlock[superBlockKey]['blocks'][blockNames[j]]['desc'] =
|
||||
getBlockDescription(superBlockKey, blockNames[j]);
|
||||
|
||||
superBlock[superBlockKey]['blocks'][blockNames[j]]['challenges'] =
|
||||
curriculum[superBlockKey]['blocks'][blockNames[j]]['meta'];
|
||||
}
|
||||
|
||||
writeToFile(superBlockKeys[i].replace(/\//, '-'), superBlock);
|
||||
}
|
||||
}
|
||||
|
||||
function writeToFile(fileName: string, data: Record<string, unknown>): void {
|
||||
mkdirSync(versionPath, { recursive: true });
|
||||
|
||||
const filePath = `${versionPath}/${fileName}.json`;
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function getBlockDescription(
|
||||
superBlockKeys: SuperBlockValues,
|
||||
blockKey: string
|
||||
): string[] {
|
||||
const intros = JSON.parse(readFileSync(blockIntroPath, 'utf-8')) as Intro;
|
||||
|
||||
return intros[superBlockKeys]['blocks'][blockKey]['intro'];
|
||||
}
|
||||
|
||||
function getSuperBlockName(superBlockKeys: SuperBlockValues): string {
|
||||
const superBlocks = JSON.parse(
|
||||
readFileSync(blockIntroPath, 'utf-8')
|
||||
) as Intro;
|
||||
|
||||
return superBlocks[superBlockKeys].title;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
exports.buildMobileCurriculum = function buildMobileCurriculum(json) {
|
||||
const mobileStaticPath = path.resolve(__dirname, '../../../client/static');
|
||||
const blockIntroPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
|
||||
fs.mkdirSync(`${mobileStaticPath}/mobile`, { recursive: true });
|
||||
writeAndParseCurriculumJson(json);
|
||||
|
||||
function writeAndParseCurriculumJson(curriculum) {
|
||||
const superBlockKeys = Object.keys(curriculum).filter(
|
||||
key => key !== 'certifications'
|
||||
);
|
||||
|
||||
writeToFile('availableSuperblocks', {
|
||||
// removing "/" as it will create an extra sub-path when accessed via an endpoint
|
||||
|
||||
superblocks: [
|
||||
superBlockKeys.map(key => key.replace(/\//, '-')),
|
||||
getSuperBlockNames(superBlockKeys)
|
||||
]
|
||||
});
|
||||
|
||||
for (let i = 0; i < superBlockKeys.length; i++) {
|
||||
const superBlock = {};
|
||||
const blockNames = Object.keys(curriculum[superBlockKeys[i]].blocks);
|
||||
|
||||
if (blockNames.length === 0) continue;
|
||||
|
||||
superBlock[superBlockKeys[i]] = {};
|
||||
superBlock[superBlockKeys[i]]['blocks'] = {};
|
||||
|
||||
for (let j = 0; j < blockNames.length; j++) {
|
||||
superBlock[superBlockKeys[i]]['blocks'][blockNames[j]] = {};
|
||||
|
||||
superBlock[superBlockKeys[i]]['blocks'][blockNames[j]]['desc'] =
|
||||
getBlockDescription(superBlockKeys[i], blockNames[j]);
|
||||
|
||||
superBlock[superBlockKeys[i]]['blocks'][blockNames[j]]['challenges'] =
|
||||
curriculum[superBlockKeys[i]]['blocks'][blockNames[j]]['meta'];
|
||||
}
|
||||
|
||||
writeToFile(superBlockKeys[i].replace(/\//, '-'), superBlock);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockDescription(superBlockKey, blockKey) {
|
||||
const intros = JSON.parse(fs.readFileSync(blockIntroPath));
|
||||
|
||||
return intros[superBlockKey]['blocks'][blockKey]['intro'];
|
||||
}
|
||||
|
||||
function getSuperBlockNames(superBlockKeys) {
|
||||
const superBlocks = JSON.parse(fs.readFileSync(blockIntroPath));
|
||||
|
||||
return superBlockKeys.map(key => superBlocks[key].title);
|
||||
}
|
||||
|
||||
function writeToFile(fileName, json) {
|
||||
const fullPath = `${mobileStaticPath}/mobile/${fileName}.json`;
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, JSON.stringify(json, null, 2));
|
||||
}
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { AssertionError } = require('chai');
|
||||
const envData = require('../../../config/env.json');
|
||||
const { mobileSchemaValidator } = require('./mobileSchema');
|
||||
|
||||
if (envData.clientLocale == 'english' && !envData.showUpcomingChanges) {
|
||||
describe('mobile curriculum build', () => {
|
||||
const mobileStaticPath = path.resolve(__dirname, '../../../client/static');
|
||||
const blockIntroPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
|
||||
const validateMobileSuperBlock = mobileSchemaValidator();
|
||||
|
||||
test('the mobile curriculum should have a static folder with multiple files', () => {
|
||||
expect(fs.existsSync(`${mobileStaticPath}/mobile`)).toBe(true);
|
||||
|
||||
expect(
|
||||
fs.readdirSync(`${mobileStaticPath}/mobile`).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the mobile curriculum should have access to the intro.json file', () => {
|
||||
expect(fs.existsSync(blockIntroPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('the files generated should have the correct schema', () => {
|
||||
const fileArray = fs.readdirSync(`${mobileStaticPath}/mobile`);
|
||||
|
||||
fileArray
|
||||
.filter(fileInArray => fileInArray !== 'availableSuperblocks.json')
|
||||
.forEach(fileInArray => {
|
||||
const fileContent = fs.readFileSync(
|
||||
`${mobileStaticPath}/mobile/${fileInArray}`
|
||||
);
|
||||
|
||||
const result = validateMobileSuperBlock(JSON.parse(fileContent));
|
||||
|
||||
if (result.error) {
|
||||
throw new AssertionError(result.error, `file: ${fileInArray}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
describe.skip('Mobile curriculum is not localized', () => {
|
||||
test.todo('localized tests');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AssertionError } from 'chai';
|
||||
import envData from '../../../config/env.json';
|
||||
import { SuperBlocks } from '../../../config/certification-settings';
|
||||
import { mobileSchemaValidator } from './mobileSchema';
|
||||
import { superBlockMobileAppOrder } from './build-external-curricula-data';
|
||||
|
||||
if (envData.clientLocale == 'english' && !envData.showUpcomingChanges) {
|
||||
const VERSION = 'v1.0.0';
|
||||
|
||||
describe('mobile curriculum build', () => {
|
||||
const mobileStaticPath = path.resolve(__dirname, '../../../client/static');
|
||||
const blockIntroPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../client/i18n/locales/english/intro.json'
|
||||
);
|
||||
|
||||
const validateMobileSuperBlock = mobileSchemaValidator();
|
||||
|
||||
test('the mobile curriculum should have a static folder with multiple files', () => {
|
||||
expect(
|
||||
fs.existsSync(`${mobileStaticPath}/curriculum-data/${VERSION}`)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
fs.readdirSync(`${mobileStaticPath}/curriculum-data/${VERSION}`).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the mobile curriculum should have access to the intro.json file', () => {
|
||||
expect(fs.existsSync(blockIntroPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('the files generated should have the correct schema', () => {
|
||||
const fileArray = fs.readdirSync(
|
||||
`${mobileStaticPath}/curriculum-data/${VERSION}`
|
||||
);
|
||||
|
||||
fileArray
|
||||
.filter(fileInArray => fileInArray !== 'availableSuperblocks.json')
|
||||
.forEach(fileInArray => {
|
||||
const fileContent = fs.readFileSync(
|
||||
`${mobileStaticPath}/curriculum-data/${VERSION}/${fileInArray}`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = validateMobileSuperBlock(JSON.parse(fileContent));
|
||||
|
||||
if (result.error) {
|
||||
throw new AssertionError(
|
||||
result.error.toString(),
|
||||
`file: ${fileInArray}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('All SuperBlocks should be present in the mobile SuperBlock object', () => {
|
||||
expect(Object.keys(superBlockMobileAppOrder)).toEqual(
|
||||
expect.arrayContaining(Object.values(SuperBlocks))
|
||||
);
|
||||
expect(Object.keys(superBlockMobileAppOrder)).toHaveLength(
|
||||
Object.values(SuperBlocks).length
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
describe.skip('Mobile curriculum is not localized', () => {
|
||||
test.todo('localized tests');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user