Skip to content

Commit 6cfe536

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(i18n): per-language static pages with silent EN fallback (#185)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent 963b50c commit 6cfe536

5 files changed

Lines changed: 225 additions & 41 deletions

File tree

scripts/generate-lesson-pages.mjs

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,34 @@ function stripHtml(s) {
4848
}
4949

5050
/**
51-
* Read src/config/lessons.js and return the set of English module
52-
* file basenames that are actually wired into the app. Anything not
53-
* imported there is invisible to the SPA — generating a static page
54-
* for it would let users land on a lesson the app can't load.
51+
* Read src/config/lessons.js and return module file basenames imported
52+
* into the SPA, keyed by language. EN files live at lessons/*.json;
53+
* other languages at lessons/<lang>/*.json. Only imported files generate
54+
* static pages — anything else is invisible to the SPA.
5555
*/
5656
function getPublishedFileNames() {
5757
const src = readFileSync(join(ROOT, "src/config/lessons.js"), "utf8");
5858
const re = /import\s+\w+\s+from\s+["']\.\.\/\.\.\/lessons\/([0-9a-z][^"']+\.json)["']/g;
59-
const out = new Set();
60-
for (const m of src.matchAll(re)) out.add(m[1]);
61-
return out;
59+
const byLang = { en: new Set() };
60+
for (const m of src.matchAll(re)) {
61+
const f = m[1];
62+
if (f.includes("/")) {
63+
const lang = f.split("/")[0];
64+
if (!byLang[lang]) byLang[lang] = new Set();
65+
byLang[lang].add(f);
66+
} else {
67+
byLang.en.add(f);
68+
}
69+
}
70+
return byLang;
6271
}
6372

6473
function loadModules() {
65-
const published = getPublishedFileNames();
74+
const { en } = getPublishedFileNames();
6675
const out = [];
6776
for (const f of readdirSync(LESSONS_DIR)) {
6877
if (!f.endsWith(".json")) continue;
69-
if (!published.has(f)) continue;
78+
if (!en.has(f)) continue;
7079
try {
7180
const m = JSON.parse(readFileSync(join(LESSONS_DIR, f), "utf8"));
7281
if (!m.id || !Array.isArray(m.lessons)) continue;
@@ -78,6 +87,29 @@ function loadModules() {
7887
return out;
7988
}
8089

90+
/**
91+
* Load the imported subset of modules for a non-English locale. Any
92+
* module missing a translation falls back to the English module so the
93+
* SPA's silent-EN-fallback strategy is mirrored in the static pages.
94+
*/
95+
function loadLocalizedModules(lang, enModules) {
96+
const published = getPublishedFileNames();
97+
const langSet = published[lang];
98+
if (!langSet || langSet.size === 0) return null;
99+
const localizedById = new Map();
100+
for (const f of langSet) {
101+
try {
102+
const m = JSON.parse(readFileSync(join(LESSONS_DIR, f), "utf8"));
103+
if (m.id && Array.isArray(m.lessons)) localizedById.set(m.id, m);
104+
} catch (e) {
105+
console.warn(`skip ${f}: ${e.message}`);
106+
}
107+
}
108+
// Preserve the EN module ordering; replace modules where a translation
109+
// exists, keep EN for the rest.
110+
return enModules.map((enModule) => localizedById.get(enModule.id) || enModule);
111+
}
112+
81113
/**
82114
* Replace the SPA shell <head> tags with page-specific ones.
83115
* Uses regex on a small set of known tags so we don't reparse HTML.
@@ -101,9 +133,13 @@ function fillPrerender(html, contentHtml) {
101133
);
102134
}
103135

104-
function rewriteHead(shellHtml, { title, description, canonical, jsonLd }) {
136+
function rewriteHead(shellHtml, { title, description, canonical, jsonLd, lang = "en", alternates = [] }) {
105137
let html = shellHtml;
106138

139+
// <html lang="..."> — flip to per-page locale so screen readers and
140+
// auto-translators pick the right voice/dictionary.
141+
html = html.replace(/<html\s+lang="[^"]*"/i, `<html lang="${lang}"`);
142+
107143
// Title
108144
html = html.replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(title)}</title>`);
109145

@@ -120,6 +156,16 @@ function rewriteHead(shellHtml, { title, description, canonical, jsonLd }) {
120156
const newCanon = `<link rel="canonical" href="${canonical}" />`;
121157
html = html.replace("</head>", `\t\t${newCanon}\n\t</head>`);
122158

159+
// Strip any existing hreflang alternates, then emit the new set.
160+
// Each per-page generator passes the alternates it knows about so
161+
// crawlers can find the localized versions.
162+
const alternateRe = /<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*"\s*\/?>\s*/gi;
163+
html = html.replace(alternateRe, "");
164+
if (alternates.length) {
165+
const tags = alternates.map((a) => `<link rel="alternate" hreflang="${a.lang}" href="${a.href}" />`).join("\n\t\t");
166+
html = html.replace("</head>", `\t\t${tags}\n\t</head>`);
167+
}
168+
123169
// og:url
124170
html = html.replace(/<meta\s+property="og:url"\s+content="[^"]*"\s*\/?>/i,
125171
`<meta property="og:url" content="${canonical}" />`);
@@ -333,6 +379,16 @@ function sectionJsonLd({ sectionId, canonical, modules }) {
333379
const shellHtml = readFileSync(join(DIST, "index.html"), "utf8");
334380
const modules = loadModules();
335381

382+
// Locales to emit static pages for, in addition to EN. Per project
383+
// strategy: DE is second-class supported, others stay experimental
384+
// (SPA navigates them but no SEO pages emitted yet).
385+
const SECONDARY_LOCALES = ["de"];
386+
const localeModules = {};
387+
for (const lang of SECONDARY_LOCALES) {
388+
const m = loadLocalizedModules(lang, modules);
389+
if (m) localeModules[lang] = m;
390+
}
391+
336392
let lessonPages = 0;
337393
let modulePages = 0;
338394
let sectionPages = 0;
@@ -354,11 +410,16 @@ for (const sectionId of Object.keys(SECTIONS)) {
354410
}
355411
return false;
356412
});
413+
const alternates = SECONDARY_LOCALES
414+
.filter((lang) => localeModules[lang])
415+
.map((lang) => ({ lang, href: `${ORIGIN}/${lang}/${sectionId}/` }))
416+
.concat([{ lang: "x-default", href: canonical }]);
357417
let html = rewriteHead(shellHtml, {
358418
title: `${meta.title} — Code Crispies`,
359419
description: meta.description,
360420
canonical,
361-
jsonLd: sectionJsonLd({ sectionId, canonical, modules: sectionModules })
421+
jsonLd: sectionJsonLd({ sectionId, canonical, modules: sectionModules }),
422+
alternates
362423
});
363424
html = fillPrerender(html, sectionPrerender({ sectionId, modules: sectionModules }));
364425
const dir = join(DIST, sectionId);
@@ -367,43 +428,81 @@ for (const sectionId of Object.keys(SECTIONS)) {
367428
sectionPages++;
368429
}
369430

370-
// Per-module + per-lesson pages
371-
for (const m of modules) {
372-
const moduleCanonical = `${ORIGIN}/${m.id}/`;
431+
/**
432+
* Emit a module's static pages (the module landing + every lesson page)
433+
* under a given output prefix. lang="en" writes to /<id>/, others write
434+
* to /<lang>/<id>/. Pages cross-link via hreflang alternates.
435+
*/
436+
function emitModulePages(m, lang, outPrefix) {
437+
const slugBase = outPrefix ? `${outPrefix}/${m.id}` : m.id;
438+
const moduleCanonical = `${ORIGIN}/${slugBase}/`;
373439
const moduleTitle = `${m.title || m.id} — Code Crispies`;
374440
const moduleDesc = stripHtml(m.description || `Interactive lessons covering ${m.title || m.id}.`).slice(0, 200);
375441

442+
const moduleAlternates = buildAlternates(m.id, lang, "");
376443
let moduleHtml = rewriteHead(shellHtml, {
377444
title: moduleTitle,
378445
description: moduleDesc,
379446
canonical: moduleCanonical,
380-
jsonLd: moduleJsonLd({ moduleObj: m, canonical: moduleCanonical })
447+
jsonLd: moduleJsonLd({ moduleObj: m, canonical: moduleCanonical }),
448+
lang,
449+
alternates: moduleAlternates
381450
});
382451
moduleHtml = fillPrerender(moduleHtml, modulePrerender({ moduleObj: m }));
383-
const moduleDir = join(DIST, m.id);
452+
const moduleDir = join(DIST, slugBase);
384453
mkdirSync(moduleDir, { recursive: true });
385454
writeFileSync(join(moduleDir, "index.html"), moduleHtml);
386455
modulePages++;
387456

388457
for (let i = 0; i < m.lessons.length; i++) {
389458
const lesson = m.lessons[i];
390459
if (!lesson) continue;
391-
const canonical = `${ORIGIN}/${m.id}/${i}/`;
460+
const canonical = `${ORIGIN}/${slugBase}/${i}/`;
392461
const title = `${lesson.title}${m.title || m.id} | Code Crispies`;
393462
const description = stripHtml(lesson.task || lesson.description || moduleDesc).slice(0, 200) || moduleDesc;
394463

464+
const lessonAlternates = buildAlternates(m.id, lang, `${i}/`);
395465
let html = rewriteHead(shellHtml, {
396466
title,
397467
description,
398468
canonical,
399-
jsonLd: lessonJsonLd({ moduleObj: m, lesson, lessonIndex: i, canonical })
469+
jsonLd: lessonJsonLd({ moduleObj: m, lesson, lessonIndex: i, canonical }),
470+
lang,
471+
alternates: lessonAlternates
400472
});
401473
html = fillPrerender(html, lessonPrerender({ moduleObj: m, lesson, lessonIndex: i }));
402-
const lessonDir = join(DIST, m.id, String(i));
474+
const lessonDir = join(DIST, slugBase, String(i));
403475
mkdirSync(lessonDir, { recursive: true });
404476
writeFileSync(join(lessonDir, "index.html"), html);
405477
lessonPages++;
406478
}
407479
}
408480

481+
/**
482+
* Build hreflang alternates list for a (module, lessonSuffix) pair across
483+
* EN + every secondary locale that has translations. Always includes
484+
* x-default → EN URL per Google guidance.
485+
*/
486+
function buildAlternates(moduleId, currentLang, lessonSuffix) {
487+
const alts = [{ lang: "en", href: `${ORIGIN}/${moduleId}/${lessonSuffix}` }];
488+
for (const lang of SECONDARY_LOCALES) {
489+
if (!localeModules[lang]) continue;
490+
alts.push({ lang, href: `${ORIGIN}/${lang}/${moduleId}/${lessonSuffix}` });
491+
}
492+
alts.push({ lang: "x-default", href: `${ORIGIN}/${moduleId}/${lessonSuffix}` });
493+
return alts;
494+
}
495+
496+
// Emit EN pages
497+
for (const m of modules) emitModulePages(m, "en", "");
498+
499+
// Emit per-locale pages — every locale we have translations for gets a
500+
// full page set. Modules without a localized translation use the EN
501+
// content (silent fallback) but the URL stays under /<lang>/.
502+
for (const lang of SECONDARY_LOCALES) {
503+
const localized = localeModules[lang];
504+
if (!localized) continue;
505+
for (const m of localized) emitModulePages(m, lang, lang);
506+
}
507+
409508
console.log(`✓ wrote ${sectionPages} section + ${modulePages} module + ${lessonPages} lesson pages`);

scripts/generate-sitemap.mjs

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,34 @@ const ORIGIN = "https://codecrispi.es";
2525

2626
const SECTIONS = ["css", "html", "markdown", "javascript"];
2727
const REFERENCE_IDS = ["css", "html", "flexbox", "grid", "selectors"];
28+
// Locales that get static page generation (mirror lesson-pages.mjs).
29+
// Each emits /<lang>/<module>/<idx>/ with EN content as silent fallback.
30+
const SECONDARY_LOCALES = ["de"];
2831

2932
/** Only modules wired into src/config/lessons.js — see lesson-pages.mjs */
3033
function getPublishedFileNames() {
3134
const src = readFileSync(join(ROOT, "src/config/lessons.js"), "utf8");
3235
const re = /import\s+\w+\s+from\s+["']\.\.\/\.\.\/lessons\/([0-9a-z][^"']+\.json)["']/g;
33-
const out = new Set();
34-
for (const m of src.matchAll(re)) out.add(m[1]);
35-
return out;
36+
const byLang = { en: new Set() };
37+
for (const m of src.matchAll(re)) {
38+
const f = m[1];
39+
if (f.includes("/")) {
40+
const lang = f.split("/")[0];
41+
if (!byLang[lang]) byLang[lang] = new Set();
42+
byLang[lang].add(f);
43+
} else {
44+
byLang.en.add(f);
45+
}
46+
}
47+
return byLang;
3648
}
3749

3850
function loadModules() {
39-
const published = getPublishedFileNames();
51+
const { en } = getPublishedFileNames();
4052
const out = [];
4153
for (const f of readdirSync(LESSONS_DIR)) {
4254
if (!f.endsWith(".json")) continue;
43-
if (!published.has(f)) continue;
55+
if (!en.has(f)) continue;
4456
try {
4557
const m = JSON.parse(readFileSync(join(LESSONS_DIR, f), "utf8"));
4658
if (!m.id || !Array.isArray(m.lessons)) continue;
@@ -52,6 +64,16 @@ function loadModules() {
5264
return out;
5365
}
5466

67+
/**
68+
* Returns a Set of present locales (only those with imports in lessons.js).
69+
* Sitemap emits /<lang>/<module>/<idx>/ entries for every EN module; the
70+
* generator falls back to EN content for modules without translations.
71+
*/
72+
function localesWithPages() {
73+
const published = getPublishedFileNames();
74+
return SECONDARY_LOCALES.filter((l) => published[l] && published[l].size > 0);
75+
}
76+
5577
function loadBlogSlugs() {
5678
const out = [];
5779
let files;
@@ -76,7 +98,7 @@ function urlEntry(loc, priority = "0.5", changefreq = "monthly") {
7698
return ` <url>\n <loc>${ORIGIN}${loc}</loc>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
7799
}
78100

79-
function buildSitemap(modules, blogPosts) {
101+
function buildSitemap(modules, blogPosts, locales) {
80102
const urls = [];
81103

82104
urls.push(urlEntry("/", "1.0", "weekly"));
@@ -97,6 +119,21 @@ function buildSitemap(modules, blogPosts) {
97119
}
98120
}
99121

122+
// Per-locale URLs — same module/lesson set under /<lang>/ prefix.
123+
// Slightly lower priority than EN so search engines treat EN as primary.
124+
for (const lang of locales) {
125+
urls.push(urlEntry(`/${lang}/`, "0.7", "weekly"));
126+
for (const sec of SECTIONS) {
127+
urls.push(urlEntry(`/${lang}/${sec}/`, "0.6", "weekly"));
128+
}
129+
for (const m of modules) {
130+
urls.push(urlEntry(`/${lang}/${m.id}/`, "0.55", "monthly"));
131+
for (let i = 0; i < m.lessonCount; i++) {
132+
urls.push(urlEntry(`/${lang}/${m.id}/${i}/`, "0.5", "monthly"));
133+
}
134+
}
135+
}
136+
100137
// Blog index + per-post (high priority — these are pre-rendered content)
101138
if (blogPosts.length > 0) {
102139
urls.push(urlEntry("/blog/", "0.9", "weekly"));
@@ -110,10 +147,11 @@ function buildSitemap(modules, blogPosts) {
110147

111148
const modules = loadModules();
112149
const blogPosts = loadBlogSlugs();
113-
const sitemap = buildSitemap(modules, blogPosts);
150+
const locales = localesWithPages();
151+
const sitemap = buildSitemap(modules, blogPosts, locales);
114152

115153
mkdirSync(DIST, { recursive: true });
116154
writeFileSync(join(DIST, "sitemap.xml"), sitemap);
117155

118156
const totalUrls = sitemap.match(/<loc>/g).length;
119-
console.log(`✓ wrote dist/sitemap.xml (${modules.length} modules, ${blogPosts.length} blog posts, ${totalUrls} URLs)`);
157+
console.log(`✓ wrote dist/sitemap.xml (${modules.length} modules · ${blogPosts.length} blog posts · ${locales.length} locales · ${totalUrls} URLs)`);

src/app.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2664,6 +2664,20 @@ function handleRoute(shouldUpdateUrl = true) {
26642664
return;
26652665
}
26662666

2667+
// Lang-prefixed deep link: /de/<module>/<idx> arrived without the SPA
2668+
// having loaded DE modules. Switch language + reload modules silently
2669+
// (loadModules falls back to EN per-module if a translation is missing).
2670+
if (route.lang && route.lang !== getLanguage()) {
2671+
track("language_url", { language: route.lang, deep: true });
2672+
setLanguage(route.lang);
2673+
applyTranslations();
2674+
if (elements.langSelect) elements.langSelect.value = route.lang;
2675+
const langModules = loadModules(route.lang);
2676+
lessonEngine.setModules(langModules);
2677+
renderModuleList(elements.moduleList, langModules, selectModule, selectLesson);
2678+
updateProgressDisplay();
2679+
}
2680+
26672681
switch (route.type) {
26682682
case RouteType.HOME:
26692683
showLandingPage();

src/config/lessons.js

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -418,14 +418,29 @@ const moduleStores = {
418418
* @returns {Array} Array of modules
419419
*/
420420
export function loadModules(language = "en") {
421-
const store = moduleStores[language] || moduleStoreEN;
422-
return store.map((module) => ({
423-
...module,
424-
lessons: module.lessons.map((lesson) => ({
425-
...lesson,
426-
mode: lesson.mode || module.mode || "css"
427-
}))
428-
}));
421+
// Strategy: EN is the canonical full set (32 modules). For non-EN
422+
// locales, replace each module with its translated version when
423+
// available; otherwise keep the EN module silently. Result: every
424+
// language returns 32 modules — no missing-translation gaps in the UI.
425+
if (language === "en" || !moduleStores[language]) {
426+
return moduleStoreEN.map(decorate);
427+
}
428+
const localized = moduleStores[language];
429+
const localizedById = new Map(localized.map((m) => [m.id, m]));
430+
return moduleStoreEN.map((enModule) => {
431+
const translated = localizedById.get(enModule.id);
432+
return decorate(translated || enModule);
433+
});
434+
435+
function decorate(module) {
436+
return {
437+
...module,
438+
lessons: module.lessons.map((lesson) => ({
439+
...lesson,
440+
mode: lesson.mode || module.mode || "css"
441+
}))
442+
};
443+
}
429444
}
430445

431446
/**

0 commit comments

Comments
 (0)