@@ -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 */
5656function getPublishedFileNames ( ) {
5757 const src = readFileSync ( join ( ROOT , "src/config/lessons.js" ) , "utf8" ) ;
5858 const re = / i m p o r t \s + \w + \s + f r o m \s + [ " ' ] \. \. \/ \. \. \/ l e s s o n s \/ ( [ 0 - 9 a - z ] [ ^ " ' ] + \. j s o n ) [ " ' ] / 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
6473function 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 ( / < h t m l \s + l a n g = " [ ^ " ] * " / i, `<html lang="${ lang } "` ) ;
142+
107143 // Title
108144 html = html . replace ( / < t i t l e > [ ^ < ] * < \/ t i t l e > / , `<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 = / < l i n k \s + r e l = " a l t e r n a t e " \s + h r e f l a n g = " [ ^ " ] * " \s + h r e f = " [ ^ " ] * " \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 ( / < m e t a \s + p r o p e r t y = " o g : u r l " \s + c o n t e n t = " [ ^ " ] * " \s * \/ ? > / i,
125171 `<meta property="og:url" content="${ canonical } " />` ) ;
@@ -333,6 +379,16 @@ function sectionJsonLd({ sectionId, canonical, modules }) {
333379const shellHtml = readFileSync ( join ( DIST , "index.html" ) , "utf8" ) ;
334380const 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+
336392let lessonPages = 0 ;
337393let modulePages = 0 ;
338394let 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+
409508console . log ( `✓ wrote ${ sectionPages } section + ${ modulePages } module + ${ lessonPages } lesson pages` ) ;
0 commit comments