Conversation
There was a problem hiding this comment.
Pull request overview
Proof-of-concept integration of i18next-based translations into FMG, including runtime label replacement and some locale-specific grammar handling.
Changes:
- Added i18next initialization + custom formatters (gender/articles/prepositions) and wired it into module startup
- Updated state naming and name suffix generation to use i18next translations
- Added EN/FR locale JSONs and expanded
index.htmlwithdata-textattributes for extraction/translation
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/languageUtils.ts | Adds locale-specific adjective rules and introduces a shared gender map |
| src/utils/index.ts | Re-exports gender from utils barrel |
| src/modules/states-generator.ts | Uses i18next for localized state full names and sets FR gender metadata |
| src/modules/names-generator.ts | Localizes the default ia suffix via i18next |
| src/modules/locales.ts | Initializes i18next + registers custom formatters + updates DOM labels from data-* |
| src/modules/index.ts | Ensures locales initialization runs before other modules |
| src/index.html | Adds data-text keys across UI elements for translation |
| public/main.js | Attempts to await i18n initialization before continuing startup |
| public/locales/fr/lang.json | Adds French translations and grammar templates |
| public/locales/en/lang.json | Adds English baseline translations (identity mapping) |
| package.json | Adds i18next deps and an extraction script |
| i18next.config.ts | Adds i18next-cli extraction config plus HTML key extraction plugin |
| export const getAdjective = (nounToBeAdjective: string) => { | ||
| const adjectivizationRules = [ | ||
| { | ||
| name: "guo", | ||
| probability: 1, | ||
| condition: / Guo$/, | ||
| action: (noun: string) => noun.slice(0, -4), | ||
| }, | ||
| { | ||
| name: "orszag", | ||
| probability: 1, | ||
| condition: /orszag$/, | ||
| action: (noun: string) => | ||
| noun.length < 9 ? `${noun}ian` : noun.slice(0, -6), | ||
| }, | ||
| { | ||
| name: "stan", | ||
| probability: 1, | ||
| condition: /stan$/, | ||
| action: (noun: string) => | ||
| noun.length < 9 ? `${noun}i` : trimVowels(noun.slice(0, -4)), | ||
| }, | ||
| { | ||
| name: "land", | ||
| probability: 1, | ||
| condition: /land$/, | ||
| action: (noun: string) => { | ||
| if (noun.length > 9) return noun.slice(0, -4); | ||
| const root = trimVowels(noun.slice(0, -4), 0); | ||
| if (root.length < 3) return `${noun}ic`; | ||
| if (root.length < 4) return `${root}lish`; | ||
| return `${root}ish`; | ||
| }, | ||
| }, | ||
| { | ||
| name: "que", | ||
| probability: 1, | ||
| condition: /que$/, | ||
| action: (noun: string) => noun.replace(/que$/, "can"), | ||
| }, | ||
| { | ||
| name: "a", | ||
| probability: 1, | ||
| condition: /a$/, | ||
| action: (noun: string) => `${noun}n`, | ||
| }, | ||
| { | ||
| name: "o", | ||
| probability: 1, | ||
| condition: /o$/, | ||
| action: (noun: string) => noun.replace(/o$/, "an"), | ||
| }, | ||
| { | ||
| name: "u", | ||
| probability: 1, | ||
| condition: /u$/, | ||
| action: (noun: string) => `${noun}an`, | ||
| }, | ||
| { | ||
| name: "i", | ||
| probability: 1, | ||
| condition: /i$/, | ||
| action: (noun: string) => `${noun}an`, | ||
| }, | ||
| { | ||
| name: "e", | ||
| probability: 1, | ||
| condition: /e$/, | ||
| action: (noun: string) => `${noun}an`, | ||
| }, | ||
| { | ||
| name: "ay", | ||
| probability: 1, | ||
| condition: /ay$/, | ||
| action: (noun: string) => `${noun}an`, | ||
| }, | ||
| { | ||
| name: "os", | ||
| probability: 1, | ||
| condition: /os$/, | ||
| action: (noun: string) => { | ||
| const root = trimVowels(noun.slice(0, -2), 0); | ||
| if (root.length < 4) return noun.slice(0, -1); | ||
| return `${root}ian`; | ||
| }, | ||
| }, | ||
| { | ||
| name: "es", | ||
| probability: 1, | ||
| condition: /es$/, | ||
| action: (noun: string) => { | ||
| const root = trimVowels(noun.slice(0, -2), 0); | ||
| if (root.length > 7) return noun.slice(0, -1); | ||
| return `${root}ian`; | ||
| }, | ||
| }, | ||
| { | ||
| name: "l", | ||
| probability: 0.8, | ||
| condition: /l$/, | ||
| action: (noun: string) => `${noun}ese`, | ||
| }, | ||
| { | ||
| name: "n", | ||
| probability: 0.8, | ||
| condition: /n$/, | ||
| action: (noun: string) => `${noun}ese`, | ||
| }, | ||
| { | ||
| name: "ad", | ||
| probability: 0.8, | ||
| condition: /ad$/, | ||
| action: (noun: string) => `${noun}ian`, | ||
| }, | ||
| { | ||
| name: "an", | ||
| probability: 0.8, | ||
| condition: /an$/, | ||
| action: (noun: string) => `${noun}ian`, | ||
| }, | ||
| { | ||
| name: "ish", | ||
| probability: 0.25, | ||
| condition: /^[a-zA-Z]{6}$/, | ||
| action: (noun: string) => `${trimVowels(noun.slice(0, -1))}ish`, | ||
| }, | ||
| { | ||
| name: "an", | ||
| probability: 0.5, | ||
| condition: /^[a-zA-Z]{0,7}$/, | ||
| action: (noun: string) => `${trimVowels(noun)}an`, | ||
| }, | ||
| ]; | ||
| for (const rule of adjectivizationRules) { | ||
| const adjectivizationRules = { | ||
| en: [ |
✅ Deploy Preview for afmg ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Most of the work to make strings translatable in |
|
@Blipz, it looks pretty good. Yes, we would probably need Crowdin, but it has a significant lines count limit for free accounts. |
|
@Azgaar Do you know there is a form to request a license for open source projects? |
|
Yes, I saw it. Maybe we can apply. Would need to merge it to a separate branch. |
There was a problem hiding this comment.
Pull request overview
Adds an i18next-based proof-of-concept localization system to Fantasy Map Generator, replacing the old Google Translate UI flow and introducing French translations plus localized naming for some generated entities.
Changes:
- Introduces runtime locale initialization + DOM label/tooltip translation updates via i18next (HTTP backend).
- Localizes generated names for states/provinces (and a suffix in name generation) using translation keys + simple French grammatical formatters (gender/the/of).
- Adds French locale resource file and i18next extraction tooling/config.
Reviewed changes
Copilot reviewed 14 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/utils/languageUtils.ts |
Adds gender map and language-specific adjectivization rules used by state naming. |
src/utils/index.ts |
Re-exports gender from utils bundle. |
src/types/global.ts |
Declares initLocale / changeLocale globals. |
src/modules/states-generator.ts |
Uses i18next to build localized state full names; injects French gender metadata. |
src/modules/provinces-generator.ts |
Uses i18next to build localized province full names. |
src/modules/names-generator.ts |
Uses i18next for the default “ia” suffix (locale-dependent naming). |
src/modules/locales.ts |
New module: initializes i18next, adds formatters, updates DOM labels/tooltips. |
src/modules/index.ts |
Imports the new locales module during module bootstrap. |
public/modules/ui/options.js |
Wires language option changes to changeLocale; removes Google Translate controls; sets default language. |
public/main.js |
Initializes locale on DOMContentLoaded before continuing app startup. |
public/locales/fr/lang.json |
Adds French translation resource bundle (POC). |
public/icons.css |
Adjusts sort icon pseudo-element content (adds NBSP). |
package.json |
Adds i18next dependencies and an extraction script. |
package-lock.json |
Locks new i18next/i18next-cli dependency graph. |
i18next.config.ts |
Adds i18next-cli extraction config + HTML key extraction plugin. |
| for (const label of document.querySelectorAll("[data-html]")) { | ||
| let vars: TOptions = { | ||
| interpolation: { escapeValue: false }, | ||
| }; | ||
| for (const attr of label.attributes) { | ||
| if (attr.name.startsWith("data-var-")) { | ||
| vars[attr.name.slice(9)] = attr.value; | ||
| } | ||
| } | ||
| const translation = i18next.t( | ||
| label.getAttribute("data-html") as string, | ||
| vars, | ||
| ); | ||
| if (translation) label.innerHTML = translation; | ||
| } |
There was a problem hiding this comment.
updateLabels sets interpolation: { escapeValue: false } and then writes translations into innerHTML. If any data-var-* values (or translated strings) can include user-controlled content, this enables HTML/script injection. Prefer keeping escaping enabled for interpolated values and only using innerHTML for known-safe, static markup (or sanitize via a library like DOMPurify before assigning).
| noun: i18next.t(`stateForm::${state.formName}`), | ||
| complement: state.name, | ||
| gender: stateGender, | ||
| interpolation: { escapeValue: false }, |
There was a problem hiding this comment.
getFullName passes interpolation: { escapeValue: false } while interpolating state.name into the translated string. State names are user-editable in the UI, so disabling escaping here can allow HTML injection wherever fullName is rendered via innerHTML. Keep escaping enabled for user-controlled values (or sanitize) and only disable escaping for specific, trusted markup tokens.
| interpolation: { escapeValue: false }, |
| gender.fr = { | ||
| ...gender.fr, | ||
| Confederation: "feminine", | ||
| Diarchy: "feminine", | ||
| Horde: "feminine", | ||
| League: "feminine", | ||
| Marches: "feminine", | ||
| Oligarchy: "feminine", | ||
| Tetrarchy: "feminine", | ||
| Theocracy: "feminine", | ||
| "Trade Company": "feminine", | ||
| Union: "feminine", | ||
| }; | ||
|
|
||
| class StatesModule { |
There was a problem hiding this comment.
This module mutates gender.fr at import time (gender.fr = {...}), creating a hidden side effect and coupling translation metadata to the states generator. To keep this predictable and easier to extend, consider defining the gender map in a dedicated locale/translation module (or in languageUtils) and importing it, rather than mutating a shared object from within a generator module.
| gender.fr = { | |
| ...gender.fr, | |
| Confederation: "feminine", | |
| Diarchy: "feminine", | |
| Horde: "feminine", | |
| League: "feminine", | |
| Marches: "feminine", | |
| Oligarchy: "feminine", | |
| Tetrarchy: "feminine", | |
| Theocracy: "feminine", | |
| "Trade Company": "feminine", | |
| Union: "feminine", | |
| }; | |
| class StatesModule { | |
| class StatesModule { | |
| constructor() { | |
| this.extendFrenchGender(); | |
| } | |
| /** | |
| * Extend the French gender map with government types relevant for states. | |
| * This is done explicitly during StatesModule construction to avoid | |
| * mutating shared translation state at module import time. | |
| */ | |
| private extendFrenchGender() { | |
| gender.fr = { | |
| ...gender.fr, | |
| Confederation: "feminine", | |
| Diarchy: "feminine", | |
| Horde: "feminine", | |
| League: "feminine", | |
| Marches: "feminine", | |
| Oligarchy: "feminine", | |
| Tetrarchy: "feminine", | |
| Theocracy: "feminine", | |
| "Trade Company": "feminine", | |
| Union: "feminine", | |
| }; | |
| } |
|
@Blipz, Can you please check the comment and also I see a lot of missing translations (empty strings) in French file. It's better not to translate then to leave an empty string. |
I planned to complete them eventually, but in the meantime, it helped me to know what remains to be translated (i18next is configured to use the English fallback when the translation is empty). |
Description
Proof-of-concept for translating FMG using i18next.