Translation
Sondely adopts TourCockpit's translation system for everything the product itself says: screens, emails and the public site (Chip, 5 November 2026). This page describes that system from its code, says how Sondely's existing catalogs, review gate and email strings move into it, and keeps the separate feature of survey content translation apart, since what an organization writes in its surveys is customer content and never catalog material.
Related: Migration overview, Screens into Next.js, Testing and Catching Bugs, Business Logic, Agent Layer, Survey translation (current implementation).
Two things called translation
| Interface translation | Survey content translation | |
|---|---|---|
| What | The words Sondely says: buttons, labels, errors, emails, the landing page | The title, groups, questions, options and rating labels an organization wrote |
| Who writes the English | Sondely's developers, in code | The customer, in the builder |
| Languages | A fixed list per release, every unit complete before production | Any of 33 target languages, per survey, chosen by the customer |
| Where it lives | Catalog files in the repo, committed | The database, per survey (SurveyTranslation, see Data Model) |
| Who approves | The checks at the door, then the release gate | The customer, with draft and approve |
| System | TourCockpit's catalogs | A Sondely feature, ported from Python |
TourCockpit's translation system, as built
Read from ideaplaces-tour-platform at f952d0eea; the copy was made at 74e9a2a53 on 7 November 2026 (Framework Copy). Spec: docs.ideaplaces.com/tourcockpit/implementation/translation-store.
The store
i18n/source.json every English unit: { source, context, kind, ref }
i18n/paths.json where each unit is said (a ref path) -> its unit id
i18n/ui/<locale>.json what each language says on a screen
i18n/help/<locale>.json the help centre
i18n/pages/<locale>.json the public site
i18n/changelog/<locale>.json What's new
i18n/narration/<locale>.json film scripts (video-as-code)
| Property | How it works | Where |
|---|---|---|
| A unit is its English sentence | The id is Lingui's generateMessageId(source, context), a hash. A changed English sentence is a new unit by construction, so a stale translation can never sit under new words. No invented keys |
scripts/i18n/catalog.ts (unitId), src/lib/i18n/catalog/index.ts (the same hash at runtime) |
| Context | Only where one English sentence must come out two ways: t.x("login", "Email"). The import found 45 such places in TourCockpit |
src/lib/i18n/catalog/index.ts |
| An entry | { text, status, translatedAt, model }, with status one of imported, translated, reviewed (blessed by a person, never overwritten by the agent), stale (decided wrong: the old words stay on screen, the unit counts as outstanding) |
scripts/i18n/catalog.ts (CatalogEntry) |
| Slices | A unit lands in the slice its ref names (help:, changelog:, pages:, narration:, else ui). A screen bundles only ui; adding help to the screen bundle had taken the sign-in page from 231 kB to 430 kB |
SLICES and slicesOf in scripts/i18n/catalog.ts |
| Fallback | A unit with no entry renders English. That is the design while work is pending, never the state in production | src/lib/i18n/catalog/index.ts |
| Plurals | ICU inside the sentence. Plural categories come from CLDR through Intl.PluralRules, never written by hand (pt is asked for as pt-PT) |
src/lib/i18n/checks/plural.ts |
| Reading | t() from useT() in a client component, from src/lib/i18n/server.ts on the server. The reader's locale comes from their profile, then a cookie, then Accept-Language |
src/lib/i18n/catalog/useT.ts, src/lib/i18n/server.ts |
| Outbound text | An email is composed for someone absent, so its locale comes from stored data (User.locale, else the company's language) |
src/lib/i18n/recipient.ts |
| Size today | 5,993 units: 2,914 ui, 1,748 help, 728 pages, 325 changelog, 278 narration |
i18n/source.json |
| Locales | English source plus es, de, fr, it, pt, ro, listed in TARGET_LOCALES (scripts/i18n/catalog.ts) and LOCALES (src/lib/i18n/dictionaries/types.ts) |
The scripts
| Command | Does |
|---|---|
npm run i18n:import (scripts/i18n/import-catalogs.ts, with collect.ts) |
Gathers every English sentence the app says into source.json, carrying any translation that already exists as imported. Idempotent. Where the same English has several translations that agree, one unit; where they disagree, one unit per context, nothing silently picked. Also how a new t() string becomes a unit |
npm run i18n:translate -- --apply <file> (scripts/i18n/translate.ts) |
The only door into a catalog. Takes { locale, model, translations: { <unit id>: text } }, checks every sentence, writes the good ones, lists the refused ones with the reason. No catalog is ever edited by hand |
npm run i18n:status (scripts/i18n/status.ts) |
What each language owes, and what is wrong in what it has. --pending prints the count for a precheck. Exits non-zero while a unit is half translated |
npm run i18n:promotable |
Exits 1 if anything is pending. Called by the production deploy before the image is tagged (.github/workflows/deploy.yml, "Every language complete, or no deploy"): English is allowed on dev, never in production |
npm run i18n:glossary-check (scripts/i18n/glossary-check.ts) |
For every unit whose English names a glossary concept, asks whether the translation uses the agreed word. A finding is a question, printed with the sentence, not a verdict |
npm run i18n:retranslate (scripts/i18n/retranslate.ts) |
Selects units by concept, term, ref or failing check, and with --write marks them stale and writes the agent a brief. How "French calls this X now" is applied everywhere |
npm run i18n:baseline (scripts/i18n/baseline.ts, baseline.json) |
The ratchet for hard-coded strings: today's count per file, which may only go down |
The checks
One definition, problemsWith in src/lib/i18n/checks/checks.ts, asked by both the door and the status report, so the two cannot disagree.
| Check | Refuses | Where |
|---|---|---|
| Empty | A blank translation | checks.ts |
| Placeholders | A dropped or invented {name} |
checks.ts |
| Plurals | A plural missing the one or other form the language has |
checks.ts, plural.ts |
| Cue markers | Moved or dropped [scene:x] and [action:x] markers (film narration) |
checks.ts |
| Variety traps | Forms that are wrong for this locale: Brazilian in European Portuguese, Latin American in Castilian, the wrong register | src/lib/i18n/checks/traps.ts (LOCALE_TRAPS for pt, es, de, fr, it, ro) |
| Diacritics | Word shapes never correct without their accent ("-cion", "electronico", "l acces") | src/lib/i18n/checks/diacritics.ts |
| Dashes | An em or en dash the English does not have; the English itself may carry none | checks.ts, tests/unit/i18n-no-dashes.test.ts |
| Glossary terms | A concept said other than with the agreed word, for terms marked verified (41 concepts) | src/lib/i18n/checks/glossary-terms.ts |
| Half translated | A unit answered in some languages and not others | halfTranslated in scripts/i18n/catalog.ts, tests/unit/i18n-half-translated.test.ts |
| Hard-coded strings | A user-visible string in JSX, placeholder, aria-label or ariaLabel that never reaches t(), parsed with the TypeScript compiler, under all of src/ |
scripts/i18n/scan.ts, allowlist.ts, tests/unit/i18n-hardcoded-guard.test.ts |
| Outbound | Prose in an email subject or body written outside the catalogs | scripts/i18n/outbound.ts, tests/unit/i18n-outbound-guard.test.ts |
Eighteen tests/unit/i18n-*.test.ts files run these on every push.
The agents
| Agent | Role |
|---|---|
tour-platform-translator (.claude/agents/tour-platform-translator.md, 729 lines) |
The reference on words: who reads them, the register per locale with its evidence, the glossary in prose, the vocabulary kept verbatim, how to add a locale. Writes nothing |
tour-platform-translate (.claude/agents/tour-platform-translate.md) |
The only writer. Runs i18n:status, works one locale at a time in batches of at most 30 units grouped by ref, applies each batch through i18n:translate, fixes refusals, and commits only when nothing is half translated. In TourCockpit it runs every fifteen minutes on develop from the C3 trigger tour-translate |
Sondely's catalogs today
Read from sondely at 06debbf.
| Piece | Where | Detail |
|---|---|---|
| Catalogs | frontend/lang/en.json, fr.json, es.json |
Flat dot-notation keys (compliance.searchPlaceholder), 897 keys each, read by react-intl. 33 key prefixes; the largest are marketing (133), builder (99), take (88), auth (64) |
| ICU | 74 messages carry a placeholder, 6 a plural, none a select | |
| Duplicates | 166 keys repeat an English value another key already has | |
| Review gate | frontend/lang/_status.json |
{ "fr": "APPROVED", "es": "APPROVED" }. A locale is enabled only when its catalog is approved |
| Enabled locales | frontend/src/i18n/config.ts |
ENABLED_LOCALES is the source (en) plus every APPROVED entry, so today English, French and Spanish. DEFAULT_LOCALE is fr for anonymous visitors (Luca, 21 September 2026); SOURCE_LOCALE stays en. The backend reads the same file (backend/app/services/languages.py) |
| Pseudo-locale | scripts/generate-pseudo-locale.js, qps |
Used to sweep for hard-coded strings in the browser |
| Checks | frontend/src/i18n/catalog.test.ts |
Every catalog a subset of en.json, every enabled locale with the exact key set, every message valid ICU, placeholders preserved |
| Translation engine | backend/app/services/ui_translation.py, run by scripts/translate_ui.py through scripts/translate-ui.sh |
Batches of 50 key and value pairs per model call, a strict JSON schema forcing the same keys, placeholder and HTML tag preservation, product names untranslated (Sondely, CatalyzeUp, WeHappers), formal register (vous, usted), a per-language glossary (GLOSSARIES, French in Quebec conventions: courriel, organisme, OBNL, pointage), up to three retries per batch, output marked PENDING for human review. --offline writes a [de] pseudo translation for the add-a-language end to end test |
| Email strings | backend/app/services/email_i18n.py |
50 keys, each with en, fr and es in one Python dictionary, read by the template functions in backend/app/services/email.py. Locale per recipient: the user's saved locale, then the organization's, then English |
Measured in the existing translations: French uses vous in 37 messages and tu in none, "courriel" in 32 and "e-mail" or "email" in 7; Spanish uses usted in 5 and tú in none. Three messages in each catalog carry an em or en dash.
How Sondely's catalogs move in
The move happens screen by screen, with the screens (Screens into Next.js). A moved screen says its English inline through t(); the Vite app keeps reading frontend/lang/*.json for the screens it still serves.
| Step | What happens |
|---|---|
| 1. Copy the system | scripts/i18n/*, src/lib/i18n/catalog/, src/lib/i18n/checks/, the i18n:* scripts in package.json and the i18n-* unit tests are copied into the root of CatalyzeUp/sondely-platform with source headers (planned as server/; copied on 7 November 2026). The help, changelog and narration slices are kept in the code but start empty |
| 2. Set the locales | TARGET_LOCALES and LOCALES set to Sondely's list (the open decision below). The source stays English |
| 3. Rewrite the per-locale data | The traps, the glossary and the register are product data, not engine, and TourCockpit's are wrong for Sondely. TourCockpit's Spanish traps refuse usted ("the Spanish register here is informal"), while Sondely's Spanish is formal; its glossary is tour vocabulary (booking, excursion, pickup). glossary-terms.ts is rewritten from GLOSSARIES in ui_translation.py; traps.ts keeps the engine and gets Sondely's patterns (vous and usted required, tu and tú refused, and for French the Quebec choices such as courriel over e-mail, inferred and to be confirmed); the translator agent's file is rewritten for Sondely's readers (nonprofit staff and their respondents, not a tour office) |
| 4. Import a screen's strings | When a screen moves, its intl.formatMessage({ id }) calls become t("English sentence"). i18n:import is adapted to read the old flat catalogs as one more source, so the French and Spanish already reviewed for each key arrive as imported entries and nothing is re-translated. The 166 repeated English values collapse into one unit where their translations agree and keep a context where they differ. The three dashed messages are reworded first, or the no-dash test refuses them |
| 5. Retire the old key | The key is removed from frontend/lang/*.json only after i18n:import has run, never before (the order that cost TourCockpit an evening on 12 September 2026, per the header of import-catalogs.ts) |
| 6. Translate new English | New sentences go through the copied translate agent and i18n:translate. ui_translation.py and translate_ui.py are retired with the Python backend; their batch contract (identical keys, placeholders kept, brand names kept, retries on refusal) is what TourCockpit's door already enforces, per sentence instead of per batch |
| 7. Gate production | i18n:promotable in the production deploy. A locale on the list ships complete or the deploy stops |
| 8. Drop the old gate | When the Vite app is deleted, frontend/lang/, _status.json, react-intl and the pseudo-locale go with it. The hard-coded string guard replaces the qps sweep |
The review gate. Sondely today ships a locale only after a person approves the whole catalog. TourCockpit ships every unit that passes the checks and lets a person mark a unit reviewed afterwards. Kept as is, Sondely's approved French and Spanish arrive as imported and new sentences arrive as translated. If Chip wants a person's sign-off before a new locale ships, the list of enabled locales stays data (one file) and the deploy gate checks it; that is a decision below.
Email templates
| Today | After |
|---|---|
EMAIL_STRINGS in email_i18n.py: 50 keys (verification, invitation, password reset, one-time code, the three reminder families, the shared fallback line), each with English, French and Spanish written in Python |
Each template is a TSX email, as TourCockpit's src/lib/auth/email.tsx, saying its English through t(). The existing French and Spanish are imported as imported entries the same way the screens' are |
Placeholders {org}, {survey} in Python format strings |
ICU placeholders, checked at the door |
| Locale per recipient: user, then organization, then English | The same rule through src/lib/i18n/recipient.ts, rewritten for Sondely's two readers: an owner or member (their User.locale) and a respondent (the language they took the survey in, else the organization's) |
| Nothing checks that a sender's prose is translated | The outbound guard lists every Sondely file that sends text (OUTBOUND_FILES in scripts/i18n/outbound.ts) |
Survey content translation: a separate feature
This is ported as a Sondely feature in Business Logic and API Routes, not put into the catalogs: the words belong to the customer, change at the customer's pace, and are stored per survey. It may reuse TourCockpit's checks underneath where they fit (placeholders, diacritics), never its door or its catalog files.
| Piece | Today (Python) | After |
|---|---|---|
| Languages | SUPPORTED_LANGUAGES in backend/app/services/translation.py: 33 targets (es, fr, pt, ar, sw, lg, hu, hi, ht, am, so, my, zh, bn, de, ja, ko, ru, it, nl, pl, tr, vi, th, id, ms, tl, uk, ro, el, he, yo, ha), English as source |
The same list, in TypeScript |
| Extraction | extract_translatable_content and build_translation_schema build a strict JSON schema keyed by survey, group and question id |
Same shape, a Zod schema |
| Model | call_azure_openai, key from AZURE_OPENAI_KEY or, failing that, the openai-ideaplaces resource in rg-ideaplaces through the az CLI |
The model client set in Infrastructure. The fallback to an IdeaPlaces resource crosses businesses and is not ported (inferred decision) |
| Validation | validate_translation: survey title present and changed, the same sections and questions in the same order with the same ids, question types unchanged, option counts and rating label keys unchanged. A failed validation is reported per language |
Ported with its tests by behaviour (Testing and Catching Bugs). A natural candidate for the validator an agent calls before writing (phase 5) |
| Routes | POST /surveys/{id}/translate (stores each language as draft), PATCH /surveys/{id}/translations/{lang} (edit, and status: approved records approved_at and approved_by), DELETE /surveys/{id}/translations/{lang} |
Same contract, pinned by the contract suite |
| Storage | translations[lang] and translation_metadata[lang] on the survey document, translations[lang] on each question |
SurveyTranslation, one row per language, content keyed by survey, group and question id (Data Model) |
| Respondent | GET /surveys/public/{publicId}?lang= applies a language when it is in the survey's available_languages. The take page resolves the language from the URL, the respondent's picker, the account locale, the browser, then the survey default (frontend/src/i18n/surveyLanguage.ts) |
Same ladder, server side, so the first render is already in the right language |
Two things observed in the code, to confirm before porting:
- A draft is served to respondents.
POST /translateadds the language toavailable_languagesat once with statusdraft, and the public route checksavailable_languages, not the status (backend/app/api/surveys/routes.py, lines 1023 to 1031 and 248 to 253). If approval is meant to gate what respondents see, the port is where to fix it, with a failing contract test first. - The chrome around a translated survey falls back to English. A survey in Swahili shows its questions in Swahili and the take page's buttons in English, because only English, French and Spanish have interface catalogs. That is what the open decision below is about.
Open decision: which interface locales ship
| Option | Cost | Note |
|---|---|---|
| English, French, Spanish, as today | None beyond the move | Matches the catalogs that exist and are approved |
| Add TourCockpit's other four (de, it, pt, ro) | Each locale's register, glossary and traps written for Sondely's readers, then every unit translated and kept complete at every deploy | The engine takes any list; the per-locale data is the work |
| A respondent set wider than the owner set | A slice of its own for the take flow (the take, takeLanding, score, results and session strings, about 130 keys today) translated into the languages surveys are most often taken in |
The engine has slices already; a second locale list per slice is new code (inferred). Closes the gap above for respondents without translating the builder into 33 languages |
Every locale on a list is committed and checked at every push, and blocks a production deploy while incomplete, so a longer list is a standing cost, not a one-off. A second question rides with it: whether a person signs off a new locale before it ships, as Sondely does today, or units ship once they pass the checks, as TourCockpit does.