From 5cd4e8abe6021861b911016416fbfa547980af98 Mon Sep 17 00:00:00 2001 From: Sem Bauke Date: Mon, 1 Jun 2026 11:47:33 +0200 Subject: [PATCH] fix(client): localize external curriculum structure (#67638) Co-authored-by: Claude Sonnet 4.6 --- .../build-external-curricula-data-v2.test.ts | 30 +- .../build-external-curricula-data-v2.ts | 386 +++++++++--------- 2 files changed, 227 insertions(+), 189 deletions(-) diff --git a/client/tools/external-curriculum/build-external-curricula-data-v2.test.ts b/client/tools/external-curriculum/build-external-curricula-data-v2.test.ts index 23abb438e7f..d8cbd5bb4fc 100644 --- a/client/tools/external-curriculum/build-external-curricula-data-v2.test.ts +++ b/client/tools/external-curriculum/build-external-curricula-data-v2.test.ts @@ -2,7 +2,7 @@ import path from 'path'; import fs, { readFileSync } from 'fs'; import readdirp from 'readdirp'; -import { describe, test, expect } from 'vitest'; +import { afterEach, describe, test, expect, vi } from 'vitest'; import { chapterBasedSuperBlocks, @@ -10,6 +10,7 @@ import { SuperBlockStage, superBlockStages } from '@freecodecamp/shared/config/curriculum'; +import { Languages } from '@freecodecamp/shared/config/i18n'; import { superblockSchemaValidator, availableSuperBlocksValidator @@ -22,7 +23,8 @@ import { type GeneratedChapterBasedCurriculumProps, type ChapterBasedCurriculumIntros, orderedSuperBlockInfo, - OrderedSuperBlocks + OrderedSuperBlocks, + readCurriculumIntros } from './build-external-curricula-data-v2'; const VERSION = 'v2'; @@ -34,6 +36,9 @@ const intros = JSON.parse( ) as CurriculumIntros; describe('external curriculum data build', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); const clientStaticPath = path.resolve(__dirname, '../../../client/static'); const validateSuperBlock = superblockSchemaValidator(); @@ -274,13 +279,14 @@ describe('external curriculum data build', () => { next: SuperBlockStage.Next }; - const stages = Object.keys(orderedSuperBlockInfo); + 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 = orderedSuperBlockInfo[stage]?.map( + const superBlockDashedNames = info[stage]?.map( superBlock => superBlock.dashedName ); @@ -306,4 +312,20 @@ describe('external curriculum data build', () => { ).length ).toBeGreaterThan(0); }); + + test('available superblocks should use the configured curriculum locale', () => { + vi.stubEnv('CURRICULUM_LOCALE', Languages.Espanol); + + const spanishOrderedSuperBlockInfo = orderedSuperBlockInfo(); + const spanishIntros = readCurriculumIntros(Languages.Espanol); + const englishIntros = readCurriculumIntros(Languages.English); + + expect(spanishOrderedSuperBlockInfo.core[0]).toMatchObject({ + dashedName: SuperBlocks.RespWebDesignV9, + title: spanishIntros[SuperBlocks.RespWebDesignV9].title + }); + expect(spanishOrderedSuperBlockInfo.core[0]?.title).not.toEqual( + englishIntros[SuperBlocks.RespWebDesignV9].title + ); + }); }); diff --git a/client/tools/external-curriculum/build-external-curricula-data-v2.ts b/client/tools/external-curriculum/build-external-curricula-data-v2.ts index 69eff47a090..bcd11ee2bd5 100644 --- a/client/tools/external-curriculum/build-external-curricula-data-v2.ts +++ b/client/tools/external-curriculum/build-external-curricula-data-v2.ts @@ -7,6 +7,7 @@ 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 { @@ -117,203 +118,218 @@ 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; +const intros = readCurriculumIntros(getCurriculumLocale()); -export const orderedSuperBlockInfo: OrderedSuperBlocks = { - [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 - } - ], +export function getCurriculumLocale(): Languages { + const { CURRICULUM_LOCALE } = process.env; - [SuperBlockStage.English]: [ - { - dashedName: SuperBlocks.A2English, - public: true, - title: intros[SuperBlocks.A2English].title - }, - { - dashedName: SuperBlocks.B1English, - public: true, - title: intros[SuperBlocks.B1English].title - } - ], + return availableLangs.curriculum.includes(CURRICULUM_LOCALE as Languages) + ? (CURRICULUM_LOCALE as Languages) + : Languages.English; +} - [SuperBlockStage.Spanish]: [ - { - dashedName: SuperBlocks.A1Spanish, - public: true, - title: intros[SuperBlocks.A1Spanish].title - } - ], +export function readCurriculumIntros(lang: Languages): CurriculumIntros { + const blockIntroPath = resolve( + __dirname, + `../../../client/i18n/locales/${lang}/intro.json` + ); - [SuperBlockStage.Chinese]: [ - { - dashedName: SuperBlocks.A1Chinese, - public: false, - title: intros[SuperBlocks.A1Chinese].title - } - ], + return JSON.parse(readFileSync(blockIntroPath, 'utf-8')) as CurriculumIntros; +} - [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 - } - ], +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.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.English]: [ + { + dashedName: SuperBlocks.A2English, + public: true, + title: intros[SuperBlocks.A2English].title + }, + { + dashedName: SuperBlocks.B1English, + public: true, + title: intros[SuperBlocks.B1English].title + } + ], - [SuperBlockStage.Professional]: [ - { - dashedName: SuperBlocks.FoundationalCSharp, - public: false, - title: intros[SuperBlocks.FoundationalCSharp].title - } - ] -}; + [SuperBlockStage.Spanish]: [ + { + dashedName: SuperBlocks.A1Spanish, + public: true, + title: intros[SuperBlocks.A1Spanish].title + } + ], -export const superBlockDashedNames = Object.keys(orderedSuperBlockInfo).reduce( - (acc, superBlockStage) => { - const dashedNames = orderedSuperBlockInfo[superBlockStage].map( + [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[] -); + }, [] as SuperBlocks[]); +})(); export function buildExtCurriculumDataV2( curriculum: Curriculum @@ -330,7 +346,7 @@ export function buildExtCurriculumDataV2( ); writeToFile('available-superblocks', { - superblocks: orderedSuperBlockInfo + superblocks: orderedSuperBlockInfo() }); for (const superBlockKey of superBlockKeys) {