Internationalization
The app speaks two languages properly: English (the base locale) and
Serbian in Latin script (sr-Latn-RS), using Paraglide JS.
How it is wired
- Locales are declared in
project.inlang/settings.json(baseLocale: "en",locales: ["en", "sr-Latn-RS"]). - The message catalog is flat JSON:
messages/en.jsonandmessages/sr-Latn-RS.json, sorted ascending so an editor lists a component's strings together. paraglide.config.tsextends the shared root config with one deliberate difference: the strategy is['localStorage', 'baseLocale']. The locale lives inlocalStorage, never in the URL, so there is no locale routing and no link rewriting.scripts/i18n.tscompiles the catalog intosrc/paraglide/(gitignored);devandbuildrun it first, and it must have run once before lint/typecheck.- UI code imports
{ messages }from thelib/i18nseam; only that file touchessrc/paraglidedirectly.domain/never imports i18n: labels stay in the UI layer (blocks/conditionLabel.ts,features/skin/skinLabel.ts).
Switching languages
LocaleProvider sets <html lang> and keys the subtree on the locale, so
switching re-renders every messages.* call with no reload. The shell's
header, footer and <meta name="description"> live outside the app tree, so
ShellText writes their text on mount and on every locale change; the static
index.html/404.html keep brand fallbacks and a bilingual <noscript>
(the page must say something even with JavaScript off).
Serbian dialect
sr-Latn-RS uses standard Serbian ekavica (napred, levo, pomera,
ovde, neverovatno, vreme), not the ijekavica used in Bosnian/Croatian
(naprijed, lijevo, pomjeri, ovdje, nevjerovatno, vrijeme).
Key taxonomy
Keys are flat snake_case, prefixed with the feature that owns them:
palette_, controls_, level_picker_, block_for_, outcome_details_,
skin_chooser_, playground_cell_ and so on. Keys name the element, not
the string (palette_drop_to_delete, not drop_to_delete_text). A small
set of truly shared words is promoted to common_ (common_close,
common_collapse, common_expand); everything else stays in its owner's
prefix, because a translator could reasonably write a different sentence for
two places that happen to share an English word.
Plurals and variables
- Variables are
{name}and become typed inputs (messages.skin_pick({ color })). - Count-based strings are complex messages using
Intl.PluralRules, each locale listing its own categories: Englishone/other, Serbianone/few/other(for exampleblock_container_collapsed_count). - Cycle counts are
bigintindomain/; the UI converts withNumber(...)at the message boundary, never inside a message ordomain/. - Markup placeholders are not used: when a sentence must wrap a JSX element
(the
forloop letter), the message is split into..._prefix/..._suffixkeys and the element stays inline.
Level titles
Level names are content, not UI chrome, so they live in the level JSON as a
locale-keyed record ("title": { "en": "...", "sr-Latn-RS": "..." }),
resolved with localized(values, locale) (current locale, then base, then
the first entry). Levels that introduce a block or hazard name it in the
title in both locales.
What is not translated
The documentation in documentation/ is written in English only. It is
aimed at developers and reviewers, and every page is a single source file; a
translated guide would be a folder per locale with its own link-checker run.
The app itself is the bilingual part.
Adding a string
- Add the key to
messages/en.jsonin sorted position, with the right prefix. - Add the same key to
messages/sr-Latn-RS.json(every locale must have it). - Import
messagesfrom thelib/i18nseam and replace the literal. bun run i18n, thenbun validate.
Next: Code export.