# Architecture — i18n (the full contract) > The simple overview is in `library/i18n/README.md`. This is the complete contract for > **adding or overriding** a locale. The `i18n/` seam owns every user-facing string in the map. Translations are **folder-per-locale, one JSON file per area**, composed at build time into a Vue I18n instance — one instance per `` element so multiple embeds on a page don't bleed state. ## Layout ``` i18n/ index.js ← engine (don't edit to add a locale): glob-loads locales, builds createAppI18n() locales/ en/ ← one folder per locale (the fallback; fullest set — 10 areas) buttons.json legend.json detail.json options.json search.json messages.json aria.json prayerLaps.json tabs.json popup.json es/ ar/ zh/ … ← same key structure; some locales carry fewer area files (see the locale contract below) ``` | Export (`index.js`) | What it is | |---|---| | `createAppI18n()` | Builds a fresh Vue I18n instance. Called once per `` (`app.use(createAppI18n())`). | | `SUPPORTED_LOCALES` | `['ar','de','en','es','fr','hi','it','pt','ro','ru','zh']` — the 11 shipped locales. | | `RTL_LOCALES` | `Set(['ar'])` — locales that render right-to-left. | | `detectLocale()` | Reads `document.documentElement.lang` (set by Polylang on WordPress); falls back to `en`. | ## The locale contract - **8 core area files in every locale**, identical names across locales: `buttons`, `legend`, `detail`, `options`, `search`, `messages`, `aria`, `prayerLaps`. Two more areas are partially translated: `tabs` (in `ar`/`de`/`en`/`es`/`fr`/`pt`/`ru`; `hi`/`it`/`ro`/`zh` fall back to English) and `popup` (in `en`/`fr` only). So the on-disk matrix is: `en`/`fr` 10 files, `ar`/`de`/`es`/`pt`/`ru` 9, `hi`/`it`/`ro`/`zh` 8. The filename (minus `.json`) becomes the top-level message namespace — `buttons.json` → `$t('buttons.prayForThem')`. - **`en/` is the source of truth and the fallback.** Mirror its key structure exactly; keys may nest (`buttons.speedOption.slow`). Missing keys — and whole missing area files — fall back to English silently (`fallbackWarn`/`missingWarn` are off in the IIFE bundle). - All catalogs are **eager-globbed and inlined** (`import.meta.glob('./locales/*/*.json', { eager:true })`) — required because Vite can't code-split an IIFE bundle. Don't convert to lazy/dynamic import: the 2026-04-26 experiment GREW the bundle ~22 KB (see the note at the top of `index.js`). ## Add a custom locale (checklist) 1. **Copy `locales/en/` to `locales//`** (ISO-639-1, e.g. `sw`). Keep all 10 area files (`en/` carries the fullest set, including `tabs` + `popup`). 2. **Translate the values**, leaving every key in place. Don't rename or drop keys — a missing key reverts that one string to English; a renamed key is dead. 3. **Add `` to `SUPPORTED_LOCALES`** in `index.js` (and to `RTL_LOCALES` if it reads right-to-left). This is the only `index.js` edit a new locale needs. 4. **Verify** the glob picked it up and keys resolve: ```js import { createAppI18n, SUPPORTED_LOCALES } from '@map/i18n/index.js' const i18n = createAppI18n() console.log(SUPPORTED_LOCALES.includes('')) // true console.log(i18n.global.t('buttons.prayForThem', 1, { locale: '' })) // your translation ``` ## How it integrates with the component library The app-profile entry (`plugin-designs//index.js`) does `app.use(createAppI18n())` alongside `createPinia()`, so every component reaches strings through the standard `$t('area.key')` / `useI18n()`. Initial locale comes from `detectLocale()` (the host page's ``); the fallback chain is ` → en`. Because area files are namespaced by filename, a component only needs the namespace it uses — e.g. a button reads `buttons.*`, the legend reads `legend.*`.