@@ -19,13 +19,15 @@ const PRACTICE_WORDS_DIR = path.join(REPO_ROOT, "server", "content", "practice_w
1919const PRACTICE_WORDS_TEMPLATE = path . join ( PRACTICE_WORDS_DIR , "_template.json" ) ;
2020const STORIES_DIR = path . join ( REPO_ROOT , "server" , "content" , "stories" ) ;
2121const STORIES_SOURCE = path . join ( STORIES_DIR , "english.json" ) ;
22+ // Lives beside the tooling (not in STORIES_DIR — the loader validates every file there).
23+ const STORIES_LOCALIZATION = path . join ( MODULE_DIR , "story-localization.json" ) ;
2224export const PRACTICE_WORDS_ID = "practice_words" ;
2325export const STORIES_ID = "stories" ;
2426const SOURCE_LANGUAGE_ID = "english" ;
2527
2628// Number of texts sent to LibreTranslate per /translate request. Batching keeps the
2729// request count (and the rate-limit budget) low — e.g. ~1000 words become ~20 calls.
28- export const TRANSLATE_BATCH_SIZE = 25 ;
30+ export const TRANSLATE_BATCH_SIZE = 50 ;
2931
3032export interface TargetLanguage extends Choice {
3133 id : string ;
@@ -593,6 +595,62 @@ function readStorySource(): StorySource[] {
593595 return parsed as StorySource [ ] ;
594596}
595597
598+ // --- Per-language localization map -------------------------------------------
599+ //
600+ // MT preserves proper nouns and the English source is set in the anglosphere
601+ // (London, pounds, UK museums). To give each language a native setting we read a
602+ // small map keyed by source story id: `terms` swap culturally-specific words and
603+ // `culturalNote` overrides the explanatory note per target culture.
604+
605+ type LocalizedTerm = string | { en : string ; target : string } ;
606+
607+ interface StoryLocalization {
608+ terms ?: Record < string , Record < string , LocalizedTerm > > ;
609+ culturalNote ?: Record < string , { term : string ; body : string } > ;
610+ }
611+
612+ type LocalizationMap = Record < string , StoryLocalization > ;
613+
614+ function readLocalizationMap ( ) : LocalizationMap {
615+ if ( ! fs . existsSync ( STORIES_LOCALIZATION ) ) return { } ;
616+ let parsed : unknown ;
617+ try {
618+ parsed = JSON . parse ( fs . readFileSync ( STORIES_LOCALIZATION , "utf8" ) ) ;
619+ } catch {
620+ throw new Error ( `Story localization map is invalid JSON: ${ relPath ( STORIES_LOCALIZATION ) } ` ) ;
621+ }
622+ return parsed && typeof parsed === "object" ? ( parsed as LocalizationMap ) : { } ;
623+ }
624+
625+ // `target` is the native spelling fed to MT (Roma); `en` is the exonym shown as the
626+ // English reference (Rome). Plain strings use the same value for both.
627+ const termEn = ( value : LocalizedTerm ) : string => ( typeof value === "string" ? value : value . en ) ;
628+ const termTarget = ( value : LocalizedTerm ) : string =>
629+ typeof value === "string" ? value : value . target ;
630+
631+ function escapeRegExp ( text : string ) : string {
632+ return text . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
633+ }
634+
635+ // Replace whole-word occurrences of each source term with its per-language value.
636+ // `useTarget` picks the native spelling (MT input) over the English exonym (reference).
637+ function applyStoryTerms (
638+ text : string ,
639+ terms : StoryLocalization [ "terms" ] ,
640+ libreCode : string ,
641+ useTarget : boolean
642+ ) : string {
643+ if ( ! terms ) return text ;
644+ let out = text ;
645+ for ( const [ source , perLang ] of Object . entries ( terms ) ) {
646+ const value = perLang [ libreCode ] ;
647+ if ( value === undefined ) continue ;
648+ const replacement = useTarget ? termTarget ( value ) : termEn ( value ) ;
649+ out = out . replace ( new RegExp ( `\\b${ escapeRegExp ( source ) } \\b` , "g" ) , replacement ) ;
650+ }
651+ return out ;
652+ }
653+
596654// Forward (English) text count, used for the wizard's pre-run estimate. The
597655// glossary pass adds more calls that can only be counted after translation.
598656export function countStorySourceTexts ( ) : number {
@@ -648,6 +706,108 @@ async function translateUnique(
648706 return map ;
649707}
650708
709+ interface WordMeta {
710+ pos : string ;
711+ note ?: string ;
712+ }
713+
714+ interface GeneratedStory {
715+ id : string ;
716+ level : string ;
717+ title : string ;
718+ titleEn : string ;
719+ theme : string ;
720+ category : string ;
721+ sentences : { target : string ; en : string ; break ?: boolean } [ ] ;
722+ glossary : Record < string , { g : string ; pos : string ; note ?: string } > ;
723+ culturalNote : { term : string ; body : string } ;
724+ }
725+
726+ // The hand-authored English glossary carries rich part-of-speech labels and grammar
727+ // notes. Since generated stories are translations of the *same* English sentences,
728+ // a reverse-translated word's English gloss usually matches an English glossary key —
729+ // so we borrow its pos/note rather than leaving pos blank.
730+ function buildEnglishLexicon ( stories : StorySource [ ] ) : Map < string , WordMeta > {
731+ const lexicon = new Map < string , WordMeta > ( ) ;
732+ for ( const story of stories ) {
733+ for ( const [ word , entry ] of Object . entries ( story . glossary || { } ) ) {
734+ const key = word . toLowerCase ( ) . trim ( ) ;
735+ if ( ! key || lexicon . has ( key ) ) continue ;
736+ lexicon . set ( key , { pos : String ( entry . pos || "" ) , note : entry . note } ) ;
737+ }
738+ }
739+ return lexicon ;
740+ }
741+
742+ const LEADING_ARTICLE_RE = / ^ (?: t o | a | a n | t h e ) \s + / ;
743+
744+ // Resolve an English gloss to a part-of-speech (and, on an exact match, a note).
745+ // Falls back through comma/slash alternatives and article-stripped forms for pos.
746+ function lookupEnglishMeta (
747+ lexicon : Map < string , WordMeta > ,
748+ gloss : string
749+ ) : { pos : string ; note ?: string } | null {
750+ const full = gloss . toLowerCase ( ) . trim ( ) ;
751+ const exact = lexicon . get ( full ) ;
752+ if ( exact ) return { pos : exact . pos , note : exact . note } ;
753+ const pieces = full . split ( / \s * (?: , | \/ | \b o r \b ) \s * / ) . map ( ( piece ) => piece . trim ( ) ) . filter ( Boolean ) ;
754+ for ( const piece of pieces ) {
755+ for ( const candidate of [ piece , piece . replace ( LEADING_ARTICLE_RE , "" ) . trim ( ) ] ) {
756+ const meta = lexicon . get ( candidate ) ;
757+ if ( meta ) return { pos : meta . pos } ; // pos only — a note for one sense may not fit a partial match
758+ }
759+ }
760+ return null ;
761+ }
762+
763+ // Serialise stories to match the hand-authored layout: each sentence and glossary
764+ // entry stays compact on a single line, while the surrounding structure is indented.
765+ export function formatStoriesJson ( stories : GeneratedStory [ ] ) : string {
766+ const jstr = ( value : unknown ) : string => JSON . stringify ( value ) ;
767+ const lines : string [ ] = [ "[" ] ;
768+ stories . forEach ( ( story , storyIndex ) => {
769+ const storyComma = storyIndex < stories . length - 1 ? "," : "" ;
770+ lines . push ( " {" ) ;
771+ lines . push ( ` "id": ${ jstr ( story . id ) } ,` ) ;
772+ lines . push ( ` "level": ${ jstr ( story . level ) } ,` ) ;
773+ lines . push ( ` "title": ${ jstr ( story . title ) } ,` ) ;
774+ lines . push ( ` "titleEn": ${ jstr ( story . titleEn ) } ,` ) ;
775+ lines . push ( ` "theme": ${ jstr ( story . theme ) } ,` ) ;
776+ lines . push ( ` "category": ${ jstr ( story . category ) } ,` ) ;
777+
778+ lines . push ( ` "sentences": [` ) ;
779+ story . sentences . forEach ( ( sentence , index ) => {
780+ const comma = index < story . sentences . length - 1 ? "," : "" ;
781+ const parts = [ `"target": ${ jstr ( sentence . target ) } ` , `"en": ${ jstr ( sentence . en ) } ` ] ;
782+ if ( sentence . break ) parts . push ( `"break": true` ) ;
783+ lines . push ( ` { ${ parts . join ( ", " ) } }${ comma } ` ) ;
784+ } ) ;
785+ lines . push ( ` ],` ) ;
786+
787+ const glossEntries = Object . entries ( story . glossary ) ;
788+ if ( glossEntries . length === 0 ) {
789+ lines . push ( ` "glossary": {},` ) ;
790+ } else {
791+ lines . push ( ` "glossary": {` ) ;
792+ glossEntries . forEach ( ( [ word , entry ] , index ) => {
793+ const comma = index < glossEntries . length - 1 ? "," : "" ;
794+ const parts = [ `"g": ${ jstr ( entry . g ) } ` , `"pos": ${ jstr ( entry . pos ) } ` ] ;
795+ if ( entry . note ) parts . push ( `"note": ${ jstr ( entry . note ) } ` ) ;
796+ lines . push ( ` ${ jstr ( word ) } : { ${ parts . join ( ", " ) } }${ comma } ` ) ;
797+ } ) ;
798+ lines . push ( ` },` ) ;
799+ }
800+
801+ lines . push ( ` "culturalNote": {` ) ;
802+ lines . push ( ` "term": ${ jstr ( story . culturalNote . term ) } ,` ) ;
803+ lines . push ( ` "body": ${ jstr ( story . culturalNote . body ) } ` ) ;
804+ lines . push ( ` }` ) ;
805+ lines . push ( ` }${ storyComma } ` ) ;
806+ } ) ;
807+ lines . push ( "]" ) ;
808+ return lines . join ( "\n" ) ;
809+ }
810+
651811export interface TranslateStoriesResult {
652812 outputPath : string ;
653813 elapsed : number ;
@@ -664,14 +824,43 @@ export async function translateStories(
664824 onProgress ?: ( progress : TranslateProgress ) => void
665825) : Promise < TranslateStoriesResult > {
666826 const stories = readStorySource ( ) ;
827+ const localization = readLocalizationMap ( ) ;
828+ const libreCode = config . target ;
667829 const stats : CategoryStats = { callCount : 0 , callDurations : [ ] , startedAt : Date . now ( ) } ;
668830 const unresolved : string [ ] = [ ] ;
669831
670- // Pass 1 — English → target for every sentence and title.
832+ // Pre-localize each story for this culture: `src` is the native-spelled English
833+ // fed to MT, `en` is the exonym shown as the reference, cultural notes are swapped,
834+ // and native place spellings are collected so they never become glossary entries.
835+ const prepared = stories . map ( ( story ) => {
836+ const loc = localization [ story . id ] || { } ;
837+ const toSource = ( text : string ) => applyStoryTerms ( text , loc . terms , libreCode , true ) ;
838+ const toRef = ( text : string ) => applyStoryTerms ( text , loc . terms , libreCode , false ) ;
839+ const sentences = story . sentences . map ( ( sentence ) => ( {
840+ src : toSource ( sentence . en ) ,
841+ en : toRef ( sentence . en ) ,
842+ break : sentence . break
843+ } ) ) ;
844+ const skip = new Set < string > ( ) ;
845+ for ( const perLang of Object . values ( loc . terms || { } ) ) {
846+ const value = perLang [ libreCode ] ;
847+ if ( value !== undefined ) for ( const token of storyTokens ( termTarget ( value ) ) ) skip . add ( token ) ;
848+ }
849+ return {
850+ story,
851+ titleSource : toSource ( story . titleEn ) ,
852+ titleRef : toRef ( story . titleEn ) ,
853+ sentences,
854+ culturalNote : loc . culturalNote ?. [ libreCode ] || story . culturalNote ,
855+ skip
856+ } ;
857+ } ) ;
858+
859+ // Pass 1 — English → target for every (localized) sentence and title.
671860 const forwardTexts = new Set < string > ( ) ;
672- for ( const story of stories ) {
673- forwardTexts . add ( story . titleEn ) ;
674- for ( const sentence of story . sentences ) forwardTexts . add ( sentence . en ) ;
861+ for ( const item of prepared ) {
862+ forwardTexts . add ( item . titleSource ) ;
863+ for ( const sentence of item . sentences ) forwardTexts . add ( sentence . src ) ;
675864 }
676865 const forwardList = [ ...forwardTexts ] ;
677866 const reportForward = ( done : number ) =>
@@ -689,15 +878,15 @@ export async function translateStories(
689878
690879 // Build target sentences and collect the unique target tokens to gloss.
691880 const targetTokens = new Set < string > ( ) ;
692- const builtStories = stories . map ( ( story ) => {
693- const sentences = story . sentences . map ( ( sentence ) => {
694- const target = forwardMap . get ( sentence . en ) || sentence . en ;
881+ const builtStories = prepared . map ( ( item ) => {
882+ const sentences = item . sentences . map ( ( sentence ) => {
883+ const target = forwardMap . get ( sentence . src ) || sentence . src ;
695884 for ( const token of storyTokens ( target ) ) targetTokens . add ( token ) ;
696885 const out : { target : string ; en : string ; break ?: boolean } = { target, en : sentence . en } ;
697886 if ( sentence . break ) out . break = true ;
698887 return out ;
699888 } ) ;
700- return { story , sentences } ;
889+ return { item , sentences } ;
701890 } ) ;
702891
703892 // Pass 2 — target → English for each glossary token.
@@ -715,33 +904,39 @@ export async function translateStories(
715904 tokenList , config , pacer , stats , config . target , "en" , unresolved , reportReverse
716905 ) ;
717906
718- const result = builtStories . map ( ( { story, sentences } ) => {
719- const glossary : Record < string , { g : string ; pos : string } > = { } ;
907+ const englishLexicon = buildEnglishLexicon ( stories ) ;
908+ const result : GeneratedStory [ ] = builtStories . map ( ( { item, sentences } ) => {
909+ const { story } = item ;
910+ const glossary : Record < string , { g : string ; pos : string ; note ?: string } > = { } ;
720911 for ( const sentence of sentences ) {
721912 for ( const token of storyTokens ( sentence . target ) ) {
722- if ( glossary [ token ] ) continue ;
913+ if ( glossary [ token ] || item . skip . has ( token ) ) continue ;
723914 const gloss = glossMap . get ( token ) || "" ;
724915 // Skip words that translated to themselves (proper nouns, unresolved).
725916 if ( ! gloss || gloss . toLowerCase ( ) === token ) continue ;
726- glossary [ token ] = { g : gloss , pos : "" } ;
917+ // Borrow pos (and an exact-match note) from the English source glossary.
918+ const meta = lookupEnglishMeta ( englishLexicon , gloss ) ;
919+ const entry : { g : string ; pos : string ; note ?: string } = { g : gloss , pos : meta ?. pos ?? "" } ;
920+ if ( meta ?. note ) entry . note = meta . note ;
921+ glossary [ token ] = entry ;
727922 }
728923 }
729924 return {
730925 id : rewriteId ( story . id , language ) ,
731926 level : story . level ,
732- title : forwardMap . get ( story . titleEn ) || story . titleEn ,
733- titleEn : story . titleEn ,
927+ title : forwardMap . get ( item . titleSource ) || item . titleSource ,
928+ titleEn : item . titleRef ,
734929 theme : story . theme ,
735930 category : story . category ,
736931 sentences,
737932 glossary,
738- culturalNote : story . culturalNote
933+ culturalNote : item . culturalNote
739934 } ;
740935 } ) ;
741936
742937 const outputPath = getOutputPath ( language , STORIES_ID ) ;
743938 fs . mkdirSync ( path . dirname ( outputPath ) , { recursive : true } ) ;
744- fs . writeFileSync ( outputPath , `${ JSON . stringify ( result , null , 2 ) } \n` , "utf8" ) ;
939+ fs . writeFileSync ( outputPath , `${ formatStoriesJson ( result ) } \n` , "utf8" ) ;
745940
746941 return {
747942 outputPath,
0 commit comments