chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const browserScriptDist = resolve(
|
||||
__dirname,
|
||||
'../../tools/client-plugins/browser-scripts/dist'
|
||||
);
|
||||
|
||||
const destJsDir = resolve(__dirname, '../static/js');
|
||||
const srcJsDir = resolve(browserScriptDist, './js');
|
||||
|
||||
// Everything is done synchronously to keep the script simple. There's no
|
||||
// performance benefit to doing this asynchronously since it's already so fast.
|
||||
rmSync(destJsDir, { recursive: true, force: true });
|
||||
mkdirSync(destJsDir, { recursive: true });
|
||||
|
||||
cpSync(srcJsDir, destJsDir, {
|
||||
recursive: true
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { availableLangs, Languages } from '@freecodecamp/shared/config/i18n';
|
||||
import env from './read-env';
|
||||
|
||||
const configPath = path.resolve(__dirname, '../config');
|
||||
|
||||
const { FREECODECAMP_NODE_ENV } = process.env;
|
||||
|
||||
function checkClientLocale() {
|
||||
if (!process.env.CLIENT_LOCALE) throw Error('CLIENT_LOCALE is not set');
|
||||
if (!availableLangs.client.includes(process.env.CLIENT_LOCALE as Languages)) {
|
||||
throw Error(`
|
||||
|
||||
CLIENT_LOCALE, ${process.env.CLIENT_LOCALE}, is not an available language in packages/shared/src/config/i18n.ts
|
||||
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkCurriculumLocale() {
|
||||
if (!process.env.CURRICULUM_LOCALE)
|
||||
throw Error('CURRICULUM_LOCALE is not set');
|
||||
if (
|
||||
!availableLangs.curriculum.includes(
|
||||
process.env.CURRICULUM_LOCALE as Languages
|
||||
)
|
||||
) {
|
||||
throw Error(`
|
||||
|
||||
CURRICULUM_LOCALE, ${process.env.CURRICULUM_LOCALE}, is not an available language in packages/shared/src/config/i18n.ts
|
||||
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkDeploymentEnv() {
|
||||
if (!process.env.DEPLOYMENT_ENV) throw Error('DEPLOYMENT_ENV is not set');
|
||||
if (!['staging', 'production'].includes(process.env.DEPLOYMENT_ENV)) {
|
||||
throw Error(`
|
||||
|
||||
${process.env.DEPLOYMENT_ENV} is not a valid value for DEPLOYMENT_ENV.
|
||||
Only 'staging' and 'production' are valid deployment environments.
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
checkClientLocale();
|
||||
checkCurriculumLocale();
|
||||
checkDeploymentEnv();
|
||||
|
||||
if (FREECODECAMP_NODE_ENV !== 'development') {
|
||||
const locationKeys = [
|
||||
'homeLocation',
|
||||
'apiLocation',
|
||||
'forumLocation',
|
||||
'newsLocation',
|
||||
'radioLocation'
|
||||
];
|
||||
const deploymentKeys = [
|
||||
'clientLocale',
|
||||
'curriculumLocale',
|
||||
'deploymentEnv',
|
||||
'deploymentVersion',
|
||||
'environment',
|
||||
'showUpcomingChanges'
|
||||
];
|
||||
const searchKeys = ['algoliaAppId', 'algoliaAPIKey'];
|
||||
const donationKeys = ['stripePublicKey', 'paypalClientId', 'patreonClientId'];
|
||||
const abTestingKeys = ['growthbookUri'];
|
||||
|
||||
const expectedVariables = locationKeys.concat(
|
||||
deploymentKeys,
|
||||
searchKeys,
|
||||
donationKeys,
|
||||
abTestingKeys
|
||||
);
|
||||
const actualVariables = Object.keys(env);
|
||||
if (expectedVariables.length !== actualVariables.length) {
|
||||
const extraVariables = actualVariables
|
||||
.filter(x => !expectedVariables.includes(x))
|
||||
.toString();
|
||||
const missingVariables = expectedVariables
|
||||
.filter(x => !actualVariables.includes(x))
|
||||
.toString();
|
||||
|
||||
throw Error(
|
||||
`
|
||||
|
||||
Env. variable validation failed. Make sure only expected variables are used and configured.
|
||||
|
||||
` +
|
||||
(extraVariables ? `Extra variables: ${extraVariables}\n` : '') +
|
||||
(missingVariables ? `Missing variables: ${missingVariables}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of expectedVariables) {
|
||||
const envVal = env[key as keyof typeof env];
|
||||
if (typeof envVal === 'undefined' || envVal === null) {
|
||||
throw Error(`
|
||||
|
||||
Env. variable ${key} is missing, build cannot continue
|
||||
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (env['environment'] !== 'production')
|
||||
throw Error(`
|
||||
|
||||
Production environment should be 'production'
|
||||
|
||||
`);
|
||||
|
||||
if (env['showUpcomingChanges'] && env['deploymentEnv'] !== 'staging')
|
||||
throw Error(`
|
||||
|
||||
SHOW_UPCOMING_CHANGES should never be 'true' in production
|
||||
|
||||
`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${configPath}/env.json`, JSON.stringify(env));
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copies non-English client translation files from the i18n-curriculum
|
||||
// submodule into client/i18n/locales/<locale>/ so that all translations
|
||||
// resolve from a single base path regardless of locale. English files
|
||||
// already live in client/i18n/locales/english/ and are skipped.
|
||||
//
|
||||
// This script expects client/config/env.json to exist (created by
|
||||
// create-env.ts) and reads clientLocale from it.
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const envPath = path.resolve(__dirname, '../config/env.json');
|
||||
const env = JSON.parse(fs.readFileSync(envPath, 'utf8')) as {
|
||||
clientLocale: string;
|
||||
};
|
||||
const { clientLocale } = env;
|
||||
|
||||
if (clientLocale === 'english') {
|
||||
// If the locale is english the files already exist.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filesToCopy = [
|
||||
'translations.json',
|
||||
'intro.json',
|
||||
'meta-tags.json',
|
||||
'motivation.json'
|
||||
];
|
||||
|
||||
const srcDir = path.resolve(
|
||||
__dirname,
|
||||
`../../curriculum/i18n-curriculum/client/${clientLocale}`
|
||||
);
|
||||
const destDir = path.resolve(__dirname, `../i18n/locales/${clientLocale}`);
|
||||
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.error(
|
||||
`Source directory not found: ${srcDir}\n` +
|
||||
'Has the i18n-curriculum submodule been initialized? ' +
|
||||
'Run: git submodule update --init'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
for (const file of filesToCopy) {
|
||||
const src = path.join(srcDir, file);
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error(`Source file not found: ${src}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.copyFileSync(src, path.join(destDir, file));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
import { trendingSchemaValidator } from './schema/trending-schema';
|
||||
|
||||
config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const createCdnUrl = (lang: string) =>
|
||||
`https://cdn.freecodecamp.org/universal/trending/${lang}.yaml`;
|
||||
|
||||
const download = async (clientLocale: string) => {
|
||||
const url = createCdnUrl(clientLocale);
|
||||
|
||||
const trendingLocation = path.resolve(
|
||||
__dirname,
|
||||
`../i18n/locales/${clientLocale}/trending.json`
|
||||
);
|
||||
|
||||
const loadLocalTrendingJSON = () => {
|
||||
const localTrendingJSON = readFileSync(trendingLocation, 'utf8');
|
||||
|
||||
if (!localTrendingJSON) {
|
||||
throw new Error(
|
||||
`
|
||||
----------------------------------------------------
|
||||
Error: ${trendingLocation} is missing.
|
||||
----------------------------------------------------
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
return localTrendingJSON;
|
||||
};
|
||||
|
||||
const loadTrendingJSON = async () => {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`
|
||||
----------------------------------------------------
|
||||
Error: The CDN is missing the trending YAML file.
|
||||
----------------------------------------------------
|
||||
Unable to fetch the ${clientLocale} footer: ${res.statusText}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.text();
|
||||
const trendingJSON = JSON.stringify(yaml.load(data));
|
||||
|
||||
return trendingJSON;
|
||||
} catch (error) {
|
||||
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
|
||||
throw new Error((error as Error).message);
|
||||
}
|
||||
|
||||
return loadLocalTrendingJSON();
|
||||
}
|
||||
};
|
||||
|
||||
const trendingJSON = await loadTrendingJSON();
|
||||
|
||||
writeFileSync(trendingLocation, trendingJSON);
|
||||
|
||||
const trendingObject = JSON.parse(trendingJSON) as Record<string, string>;
|
||||
const validationError =
|
||||
(trendingSchemaValidator(trendingObject).error as Error) || null;
|
||||
|
||||
if (validationError) {
|
||||
throw new Error(
|
||||
`
|
||||
----------------------------------------------------
|
||||
Error: The trending JSON is invalid.
|
||||
----------------------------------------------------
|
||||
Unable to validate the ${clientLocale} trending JSON schema: ${validationError.message}
|
||||
`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const locale = process.env.CLIENT_LOCALE;
|
||||
|
||||
if (!locale) throw Error('CLIENT_LOCALE must be set to a valid locale');
|
||||
|
||||
void download(locale);
|
||||
// TODO: remove the need to fallback to english once we're confident it's
|
||||
// unnecessary (client/i18n/config.js will need all references to 'en' removing)
|
||||
if (locale !== 'english') void download('english');
|
||||
@@ -0,0 +1,323 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
import readdirp from 'readdirp';
|
||||
import { afterEach, describe, test, expect, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
chapterBasedSuperBlocks,
|
||||
SuperBlocks,
|
||||
SuperBlockStage,
|
||||
superBlockStages
|
||||
} from '@freecodecamp/shared/config/curriculum';
|
||||
import {
|
||||
superblockSchemaValidator,
|
||||
availableSuperBlocksValidator
|
||||
} from './external-data-schema-v2';
|
||||
import {
|
||||
type Curriculum,
|
||||
type GeneratedCurriculumProps,
|
||||
type GeneratedBlockBasedCurriculumProps,
|
||||
type GeneratedChapterBasedCurriculumProps,
|
||||
type ChapterBasedCurriculumIntros,
|
||||
orderedSuperBlockInfo,
|
||||
OrderedSuperBlocks,
|
||||
readCurriculumIntros,
|
||||
getCurriculumLocale,
|
||||
CurriculumIntros
|
||||
} from './build-external-curricula-data-v2';
|
||||
|
||||
const VERSION = 'v2';
|
||||
const intros = readCurriculumIntros(getCurriculumLocale());
|
||||
|
||||
const dummyIntro = Object.values(SuperBlocks)
|
||||
.map(s => ({ [s]: { title: s } }))
|
||||
.reduce((prev, curr) => ({ ...prev, ...curr }), {}) as CurriculumIntros;
|
||||
|
||||
describe('external curriculum data build', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
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;
|
||||
|
||||
expect(superBlockData.intro).toEqual(intros[superBlock].intro);
|
||||
const blocks = superBlockData.blocks;
|
||||
|
||||
for (const block of blocks) {
|
||||
expect(block.intro).toEqual(
|
||||
intros[superBlock].blocks[block.meta.dashedName as string].intro
|
||||
);
|
||||
expect(block.meta.name).toEqual(
|
||||
intros[superBlock].blocks[block.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]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
for (const chapter of superBlockData.chapters) {
|
||||
if (chapter.comingSoon) continue;
|
||||
|
||||
for (const module of chapter.modules) {
|
||||
if (module.comingSoon) continue;
|
||||
|
||||
for (const block of module.blocks) {
|
||||
expect(block.intro).toEqual(
|
||||
superBlockIntros.blocks[block.meta.dashedName as string].intro
|
||||
);
|
||||
expect(block.meta.name).toEqual(
|
||||
superBlockIntros.blocks[block.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,
|
||||
spanish: SuperBlockStage.Spanish,
|
||||
chinese: SuperBlockStage.Chinese,
|
||||
professional: SuperBlockStage.Professional,
|
||||
extra: SuperBlockStage.Extra,
|
||||
legacy: SuperBlockStage.Legacy,
|
||||
upcoming: SuperBlockStage.Upcoming,
|
||||
next: SuperBlockStage.Next
|
||||
};
|
||||
|
||||
const info = orderedSuperBlockInfo();
|
||||
const stages = Object.keys(info);
|
||||
|
||||
expect(stages).not.toContain('next');
|
||||
expect(stages).not.toContain('upcoming');
|
||||
|
||||
for (const stage of stages) {
|
||||
const superBlockDashedNames = info[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);
|
||||
});
|
||||
|
||||
test('orderedSuperBlockInfo should use intro argument', () => {
|
||||
const info = orderedSuperBlockInfo(dummyIntro);
|
||||
|
||||
expect(info.core[0]).toMatchObject({
|
||||
dashedName: SuperBlocks.RespWebDesignV9,
|
||||
title: dummyIntro[SuperBlocks.RespWebDesignV9].title
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { omit } from 'lodash';
|
||||
import { submitTypes } from '@freecodecamp/shared/config/challenge-types';
|
||||
import { type ChallengeNode } from '../../src/redux/prop-types';
|
||||
import {
|
||||
SuperBlocks,
|
||||
chapterBasedSuperBlocks
|
||||
} from '@freecodecamp/shared/config/curriculum';
|
||||
import { availableLangs, Languages } from '@freecodecamp/shared/config/i18n';
|
||||
import type { Chapter } from '@freecodecamp/shared/config/chapters';
|
||||
import { getSuperblockStructure } from '@freecodecamp/curriculum/file-handler';
|
||||
import {
|
||||
availableBackgrounds,
|
||||
availableAudios
|
||||
} from '../../../curriculum/schema/scene-assets.js';
|
||||
import {
|
||||
characterAssets,
|
||||
sounds,
|
||||
backgrounds,
|
||||
domain
|
||||
} from '../../src/templates/Challenges/components/scene/scene-assets.js';
|
||||
|
||||
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',
|
||||
Spanish = 'spanish',
|
||||
Chinese = 'chinese',
|
||||
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 intros = readCurriculumIntros(getCurriculumLocale());
|
||||
|
||||
export function getCurriculumLocale(): Languages {
|
||||
const { CURRICULUM_LOCALE } = process.env;
|
||||
|
||||
return availableLangs.curriculum.includes(CURRICULUM_LOCALE as Languages)
|
||||
? (CURRICULUM_LOCALE as Languages)
|
||||
: Languages.English;
|
||||
}
|
||||
|
||||
export function readCurriculumIntros(lang: Languages): CurriculumIntros {
|
||||
const blockIntroPath = resolve(
|
||||
__dirname,
|
||||
`../../../client/i18n/locales/${lang}/intro.json`
|
||||
);
|
||||
|
||||
return JSON.parse(readFileSync(blockIntroPath, 'utf-8')) as CurriculumIntros;
|
||||
}
|
||||
|
||||
export function orderedSuperBlockInfo(
|
||||
intros: CurriculumIntros = readCurriculumIntros(getCurriculumLocale())
|
||||
): OrderedSuperBlocks {
|
||||
return {
|
||||
[SuperBlockStage.Core]: [
|
||||
{
|
||||
dashedName: SuperBlocks.RespWebDesignV9,
|
||||
public: true,
|
||||
title: intros[SuperBlocks.RespWebDesignV9].title
|
||||
},
|
||||
{
|
||||
dashedName: SuperBlocks.JsV9,
|
||||
public: true,
|
||||
title: intros[SuperBlocks.JsV9].title
|
||||
},
|
||||
{
|
||||
dashedName: SuperBlocks.PythonV9,
|
||||
public: true,
|
||||
title: intros[SuperBlocks.PythonV9].title
|
||||
},
|
||||
{
|
||||
dashedName: SuperBlocks.FrontEndDevLibsV9,
|
||||
public: false,
|
||||
title: intros[SuperBlocks.FrontEndDevLibsV9].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.Spanish]: [
|
||||
{
|
||||
dashedName: SuperBlocks.A1Spanish,
|
||||
public: true,
|
||||
title: intros[SuperBlocks.A1Spanish].title
|
||||
}
|
||||
],
|
||||
|
||||
[SuperBlockStage.Chinese]: [
|
||||
{
|
||||
dashedName: SuperBlocks.A1Chinese,
|
||||
public: false,
|
||||
title: intros[SuperBlocks.A1Chinese].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 = (() => {
|
||||
const info = orderedSuperBlockInfo();
|
||||
return Object.keys(info).reduce((acc, superBlockStage) => {
|
||||
const dashedNames = info[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();
|
||||
getSceneAssets();
|
||||
|
||||
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];
|
||||
const blockIntro = superBlockIntros.blocks[block];
|
||||
return {
|
||||
intro: blockIntro.intro,
|
||||
// Keep `meta.name` for backward compatibility with
|
||||
// consumers that have not migrated to intro-based titles.
|
||||
meta: {
|
||||
...omit(blockData.meta, ['chapter', 'module']),
|
||||
name: blockIntro.title
|
||||
}
|
||||
};
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
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];
|
||||
const blockIntro = intros[superBlockKey].blocks[blockName];
|
||||
|
||||
return {
|
||||
intro: blockIntro.intro,
|
||||
// Keep `meta.name` for backward compatibility with
|
||||
// consumers that have not migrated to intro-based titles.
|
||||
meta: { ...blockData.meta, name: blockIntro.title }
|
||||
};
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
function getSceneAssets() {
|
||||
const sceneAssets = {
|
||||
domain,
|
||||
backgrounds,
|
||||
sounds,
|
||||
availableBackgrounds,
|
||||
availableAudios,
|
||||
characterAssets
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
`${dataPath}/${ver}/scene-assets.json`,
|
||||
JSON.stringify(sceneAssets, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getCurriculum } from '../get-curriculum';
|
||||
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 {
|
||||
buildExtCurriculumDataV2(getCurriculum() as CurriculumV2<CurriculumPropsV2>);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Joi from 'joi';
|
||||
import { chapterBasedSuperBlocks } from '@freecodecamp/shared/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',
|
||||
'Chinese Curriculum',
|
||||
'Spanish Curriculum',
|
||||
'Odin',
|
||||
'Euler',
|
||||
'Rosetta',
|
||||
'General'
|
||||
).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(),
|
||||
blockLabel: 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,177 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { clientLocale } from '../config/env.json';
|
||||
import {
|
||||
convertToLocalizedString,
|
||||
generateSearchPlaceholder,
|
||||
roundDownToNearestHundred
|
||||
} from './generate-search-placeholder';
|
||||
|
||||
describe('Search bar placeholder tests:', () => {
|
||||
describe('Number rounding', () => {
|
||||
test('Numbers less than 100 return 0', () => {
|
||||
const testArr = [0, 1, 50, 99];
|
||||
|
||||
testArr.forEach(num => {
|
||||
expect(roundDownToNearestHundred(num)).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Numbers greater than 100 return a number rounded down to the nearest 100', () => {
|
||||
const testArr = [
|
||||
{
|
||||
num: 100,
|
||||
expected: 100
|
||||
},
|
||||
{
|
||||
num: 101,
|
||||
expected: 100
|
||||
},
|
||||
{
|
||||
num: 199,
|
||||
expected: 100
|
||||
},
|
||||
{
|
||||
num: 999,
|
||||
expected: 900
|
||||
},
|
||||
{
|
||||
num: 1000,
|
||||
expected: 1000
|
||||
},
|
||||
{
|
||||
num: 1001,
|
||||
expected: 1000
|
||||
},
|
||||
{
|
||||
num: 1999,
|
||||
expected: 1900
|
||||
},
|
||||
{
|
||||
num: 10000,
|
||||
expected: 10000
|
||||
},
|
||||
{
|
||||
num: 10001,
|
||||
expected: 10000
|
||||
},
|
||||
{
|
||||
num: 19999,
|
||||
expected: 19900
|
||||
}
|
||||
];
|
||||
|
||||
testArr.forEach(obj => {
|
||||
expect(roundDownToNearestHundred(obj.num)).toEqual(obj.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number formatting', () => {
|
||||
test('Numbers are converted to the correct decimal or comma format for each locale', () => {
|
||||
const testArr = [
|
||||
{
|
||||
num: 100,
|
||||
locale: 'en',
|
||||
expected: '100'
|
||||
},
|
||||
{
|
||||
num: 100,
|
||||
locale: 'zh',
|
||||
expected: '100'
|
||||
},
|
||||
{
|
||||
num: 100,
|
||||
locale: 'de',
|
||||
expected: '100'
|
||||
},
|
||||
{
|
||||
num: 1000,
|
||||
locale: 'en',
|
||||
expected: '1,000'
|
||||
},
|
||||
{
|
||||
num: 1000,
|
||||
locale: 'zh',
|
||||
expected: '1,000'
|
||||
},
|
||||
{
|
||||
num: 1000,
|
||||
locale: 'de',
|
||||
expected: '1.000'
|
||||
},
|
||||
{
|
||||
num: 10000,
|
||||
locale: 'en',
|
||||
expected: '10,000'
|
||||
},
|
||||
{
|
||||
num: 10000,
|
||||
locale: 'zh',
|
||||
expected: '10,000'
|
||||
},
|
||||
{
|
||||
num: 10000,
|
||||
locale: 'de',
|
||||
expected: '10.000'
|
||||
},
|
||||
{
|
||||
num: 100000,
|
||||
locale: 'en',
|
||||
expected: '100,000'
|
||||
},
|
||||
{
|
||||
num: 100000,
|
||||
locale: 'zh',
|
||||
expected: '100,000'
|
||||
},
|
||||
{
|
||||
num: 100000,
|
||||
locale: 'de',
|
||||
expected: '100.000'
|
||||
}
|
||||
];
|
||||
|
||||
testArr.forEach(obj => {
|
||||
const { num, locale, expected } = obj;
|
||||
expect(convertToLocalizedString(num, locale)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Only test the English locale to prevent duplicate tests,
|
||||
// and just to ensure the logic is working as expected.
|
||||
if (clientLocale === 'english') {
|
||||
describe('Placeholder strings', () => {
|
||||
test('When the total number of hits is less than 100 the expected placeholder is generated', async () => {
|
||||
const expected = 'Search our books and courses';
|
||||
const placeholderText = await generateSearchPlaceholder({
|
||||
mockRecordsNum: 99,
|
||||
locale: 'english'
|
||||
});
|
||||
|
||||
expect(placeholderText).toEqual(expected);
|
||||
});
|
||||
|
||||
test('When the total number of hits is equal to 100 the expected placeholder is generated', async () => {
|
||||
const placeholderText = await generateSearchPlaceholder({
|
||||
mockRecordsNum: 100,
|
||||
locale: 'english'
|
||||
});
|
||||
const expected = 'Search 100+ of our books and courses';
|
||||
|
||||
expect(placeholderText).toEqual(expected);
|
||||
});
|
||||
|
||||
test('When the total number of hits is greater than 100 the expected placeholder is generated', async () => {
|
||||
const placeholderText = await generateSearchPlaceholder({
|
||||
mockRecordsNum: 11000,
|
||||
locale: 'english'
|
||||
});
|
||||
const expected = 'Search 11,000+ of our books and courses';
|
||||
|
||||
expect(placeholderText).toEqual(expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { writeFileSync, readdirSync, lstatSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import algoliasearch from 'algoliasearch';
|
||||
import i18n from 'i18next';
|
||||
import backend from 'i18next-fs-backend';
|
||||
|
||||
import {
|
||||
algoliaAppId,
|
||||
algoliaAPIKey,
|
||||
clientLocale,
|
||||
environment
|
||||
} from '../config/env.json';
|
||||
import { newsIndex } from '../src/utils/algolia-locale-setup';
|
||||
import { i18nextCodes } from '@freecodecamp/shared/config/i18n';
|
||||
|
||||
const i18nextCode = i18nextCodes[clientLocale as keyof typeof i18nextCodes];
|
||||
|
||||
i18n
|
||||
.use(backend)
|
||||
.init({
|
||||
defaultNS: 'translations',
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
initImmediate: false,
|
||||
preload: readdirSync(join(__dirname, '../i18n/locales')).filter(
|
||||
fileName => {
|
||||
const joinedPath = join(__dirname, '../i18n/locales', fileName);
|
||||
const isDirectory = lstatSync(joinedPath).isDirectory();
|
||||
return isDirectory;
|
||||
}
|
||||
),
|
||||
lng: i18nextCode,
|
||||
ns: ['translations'],
|
||||
backend: {
|
||||
loadPath: resolve(
|
||||
__dirname,
|
||||
`../i18n/locales/${clientLocale}/translations.json`
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
throw Error(error.message);
|
||||
});
|
||||
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
export const roundDownToNearestHundred = (num: number) =>
|
||||
Math.floor(num / 100) * 100;
|
||||
|
||||
export const convertToLocalizedString = (num: number, ISOCode: string) =>
|
||||
num.toLocaleString(ISOCode);
|
||||
|
||||
interface GenerateSearchPlaceholderOptions {
|
||||
locale?: string;
|
||||
mockRecordsNum?: number;
|
||||
}
|
||||
|
||||
export const generateSearchPlaceholder = async (
|
||||
options: GenerateSearchPlaceholderOptions = {}
|
||||
) => {
|
||||
const { locale, mockRecordsNum } = options;
|
||||
let placeholderText = t('search.placeholder.default');
|
||||
|
||||
try {
|
||||
let totalRecords = mockRecordsNum || 0;
|
||||
if (!mockRecordsNum) {
|
||||
const algoliaClient = algoliasearch(algoliaAppId, algoliaAPIKey);
|
||||
const index = algoliaClient.initIndex(newsIndex);
|
||||
const res = await index.search('');
|
||||
totalRecords = res.nbHits;
|
||||
}
|
||||
const roundedTotalRecords = roundDownToNearestHundred(totalRecords);
|
||||
|
||||
if (roundedTotalRecords >= 100) {
|
||||
placeholderText = i18n.t('search.placeholder.numbered', {
|
||||
roundedTotalRecords: convertToLocalizedString(
|
||||
roundedTotalRecords,
|
||||
i18nextCode
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
if (environment === 'production') {
|
||||
console.warn(`
|
||||
----------------------------------------------------------
|
||||
Warning: Could not get the total number of Algolia records
|
||||
----------------------------------------------------------
|
||||
Make sure that Algolia keys and index are set up correctly.
|
||||
|
||||
Falling back to the default search placeholder text.
|
||||
----------------------------------------------------------
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
resolve(
|
||||
__dirname,
|
||||
`../i18n/locales/${locale ? locale : clientLocale}/search-bar.json`
|
||||
),
|
||||
JSON.stringify({
|
||||
placeholder: placeholderText
|
||||
})
|
||||
);
|
||||
|
||||
return placeholderText; // for testing
|
||||
};
|
||||
|
||||
void generateSearchPlaceholder();
|
||||
// TODO: remove the need to fallback to english once we're confident it's
|
||||
// unnecessary (client/i18n/config.js will need all references to 'en' removing)
|
||||
if (clientLocale !== 'english')
|
||||
void generateSearchPlaceholder({ locale: 'english' });
|
||||
@@ -0,0 +1,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CURRICULUM_PATH = '../../curriculum/generated/curriculum.json';
|
||||
|
||||
// Curriculum is read using fs, because it is too large for VSCode's LSP to handle type inference which causes annoying behavior.
|
||||
export const getCurriculum = () =>
|
||||
JSON.parse(readFileSync(join(__dirname, CURRICULUM_PATH), 'utf-8'));
|
||||
@@ -0,0 +1,80 @@
|
||||
import path from 'path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
const envPath = path.resolve(__dirname, '../../.env');
|
||||
const { error } = config({ path: envPath });
|
||||
|
||||
if (error) {
|
||||
console.warn(`
|
||||
----------------------------------------------------
|
||||
Warning: .env file not found.
|
||||
----------------------------------------------------
|
||||
Please copy sample.env to .env
|
||||
|
||||
You can ignore this warning if using a different way
|
||||
to setup this environment.
|
||||
----------------------------------------------------
|
||||
`);
|
||||
}
|
||||
|
||||
const {
|
||||
HOME_LOCATION: homeLocation,
|
||||
API_LOCATION: apiLocation,
|
||||
FORUM_LOCATION: forumLocation,
|
||||
NEWS_LOCATION: newsLocation,
|
||||
RADIO_LOCATION: radioLocation,
|
||||
CLIENT_LOCALE: clientLocale,
|
||||
CURRICULUM_LOCALE: curriculumLocale,
|
||||
ALGOLIA_APP_ID: algoliaAppId,
|
||||
ALGOLIA_API_KEY: algoliaAPIKey,
|
||||
STRIPE_PUBLIC_KEY: stripePublicKey,
|
||||
PAYPAL_CLIENT_ID: paypalClientId,
|
||||
PATREON_CLIENT_ID: patreonClientId,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
SHOW_UPCOMING_CHANGES: showUpcomingChanges,
|
||||
GROWTHBOOK_URI: growthbookUri,
|
||||
DEPLOYMENT_VERSION: deploymentVersion
|
||||
} = process.env;
|
||||
|
||||
const locations = {
|
||||
homeLocation,
|
||||
apiLocation,
|
||||
forumLocation,
|
||||
newsLocation,
|
||||
radioLocation: !radioLocation
|
||||
? 'https://coderadio.freecodecamp.org'
|
||||
: radioLocation
|
||||
};
|
||||
|
||||
export default Object.assign(locations, {
|
||||
clientLocale,
|
||||
curriculumLocale,
|
||||
deploymentEnv,
|
||||
environment: process.env.FREECODECAMP_NODE_ENV || 'development',
|
||||
algoliaAppId:
|
||||
!algoliaAppId || algoliaAppId === 'app_id_from_algolia_dashboard'
|
||||
? ''
|
||||
: algoliaAppId,
|
||||
algoliaAPIKey:
|
||||
!algoliaAPIKey || algoliaAPIKey === 'api_key_from_algolia_dashboard'
|
||||
? ''
|
||||
: algoliaAPIKey,
|
||||
stripePublicKey:
|
||||
!stripePublicKey || stripePublicKey === 'pk_from_stripe_dashboard'
|
||||
? null
|
||||
: stripePublicKey,
|
||||
paypalClientId:
|
||||
!paypalClientId || paypalClientId === 'id_from_paypal_dashboard'
|
||||
? null
|
||||
: paypalClientId,
|
||||
patreonClientId:
|
||||
!patreonClientId || patreonClientId === 'id_from_patreon_dashboard'
|
||||
? null
|
||||
: patreonClientId,
|
||||
showUpcomingChanges: showUpcomingChanges === 'true',
|
||||
growthbookUri:
|
||||
!growthbookUri || growthbookUri === 'api_URI_from_Growthbook_dashboard'
|
||||
? null
|
||||
: growthbookUri,
|
||||
deploymentVersion: deploymentVersion || 'unknown'
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import Joi from 'joi';
|
||||
|
||||
const schema = Joi.object().keys({
|
||||
article0title: Joi.string().required(),
|
||||
article0link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article1title: Joi.string().required(),
|
||||
article1link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article2title: Joi.string().required(),
|
||||
article2link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article3title: Joi.string().required(),
|
||||
article3link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article4title: Joi.string().required(),
|
||||
article4link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article5title: Joi.string().required(),
|
||||
article5link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article6title: Joi.string().required(),
|
||||
article6link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article7title: Joi.string().required(),
|
||||
article7link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article8title: Joi.string().required(),
|
||||
article8link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article9title: Joi.string().required(),
|
||||
article9link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article10title: Joi.string().required(),
|
||||
article10link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article11title: Joi.string().required(),
|
||||
article11link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article12title: Joi.string().required(),
|
||||
article12link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article13title: Joi.string().required(),
|
||||
article13link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article14title: Joi.string().required(),
|
||||
article14link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article15title: Joi.string().required(),
|
||||
article15link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article16title: Joi.string().required(),
|
||||
article16link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article17title: Joi.string().required(),
|
||||
article17link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article18title: Joi.string().required(),
|
||||
article18link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article19title: Joi.string().required(),
|
||||
article19link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article20title: Joi.string().required(),
|
||||
article20link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article21title: Joi.string().required(),
|
||||
article21link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article22title: Joi.string().required(),
|
||||
article22link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article23title: Joi.string().required(),
|
||||
article23link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article24title: Joi.string().required(),
|
||||
article24link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article25title: Joi.string().required(),
|
||||
article25link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article26title: Joi.string().required(),
|
||||
article26link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article27title: Joi.string().required(),
|
||||
article27link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article28title: Joi.string().required(),
|
||||
article28link: Joi.string().uri({ scheme: 'https' }).required(),
|
||||
article29title: Joi.string().required(),
|
||||
article29link: Joi.string().uri({ scheme: 'https' }).required()
|
||||
});
|
||||
|
||||
export const trendingSchemaValidator = (
|
||||
trendingObj: Record<string, string>
|
||||
): Joi.ValidationResult => {
|
||||
return schema.validate(trendingObj);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('turbo.json env configuration', () => {
|
||||
test('setup task should include all env vars used by read-env.ts', () => {
|
||||
const readEnvPath = path.resolve(__dirname, 'read-env.ts');
|
||||
const turboJsonPath = path.resolve(__dirname, '../turbo.json');
|
||||
|
||||
const readEnvContent = fs.readFileSync(readEnvPath, 'utf-8');
|
||||
const turboJson = JSON.parse(fs.readFileSync(turboJsonPath, 'utf-8')) as {
|
||||
tasks?: { setup?: { env?: string[] } };
|
||||
};
|
||||
|
||||
// Extract env var names from read-env.ts destructuring pattern
|
||||
// Matches patterns like: HOME_LOCATION: homeLocation, or just HOME_LOCATION,
|
||||
const envVarPattern = /(\b[A-Z][A-Z0-9_]+)(?::\s*\w+)?[,\s]/g;
|
||||
const processEnvSection = readEnvContent.match(
|
||||
/const\s*\{[\s\S]*?\}\s*=\s*process\.env/
|
||||
);
|
||||
|
||||
if (!processEnvSection) {
|
||||
throw new Error(
|
||||
'Could not find process.env destructuring in read-env.ts'
|
||||
);
|
||||
}
|
||||
|
||||
const envVarsInReadEnv: string[] = [];
|
||||
let match;
|
||||
while ((match = envVarPattern.exec(processEnvSection[0])) !== null) {
|
||||
envVarsInReadEnv.push(match[1]);
|
||||
}
|
||||
|
||||
// Get env array from turbo.json setup task
|
||||
const setupEnv = turboJson?.tasks?.setup?.env || [];
|
||||
|
||||
// Check if FCC_* wildcard is present (which covers FCC_ prefixed vars)
|
||||
const hasFccWildcard = setupEnv.includes('FCC_*');
|
||||
|
||||
// Filter out FCC_ prefixed vars if wildcard is present
|
||||
const requiredVars = envVarsInReadEnv.filter(
|
||||
v => !(hasFccWildcard && v.startsWith('FCC_'))
|
||||
);
|
||||
|
||||
const missingVars = requiredVars.filter(v => !setupEnv.includes(v));
|
||||
|
||||
// Provide a detailed error message if vars are missing
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing env vars in client/turbo.json setup.env: ${missingVars.join(', ')}\n` +
|
||||
`These variables are used in read-env.ts but not passed through by Turborepo.\n` +
|
||||
`Add them to client/turbo.json tasks.setup.env array.`
|
||||
);
|
||||
}
|
||||
|
||||
expect(missingVars).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user