paraglide locales workspace
why
- per-app message tables drift: same string translated independently, product change lands in one app only, translators update one workspace while others go stale
- one owner makes messages a versioned, generated contract: every consumer rebuilds against the same source, missing message = type error not runtime lookup failure
rule
- use Paraglide for application i18n
- one dedicated workspace named
localesowns: inlang config, source locale files, generated typed message functions, locale metadata, validation, generation command - applications consume its public typed API only — no parallel message folders, translation helpers, or handwritten string-key types
- Paraglide compiles messages into tree-shakable, typed functions: missing message, wrong interpolation argument, or stale call fails at type/build time, not runtime
workspace contract
packages/locales/project.inlang/settings.jsonmessages/en.jsonvi.jsonsrc/generated/locale.tspackage.json
| concern | locales owns | application owns |
|---|---|---|
| message source | canonical locale files and message ids | no duplicate source tables. |
| generated API | Paraglide output and its package export | importing typed m.* functions. |
| locale policy | supported locales, base locale, fallback decision | selecting locale at the framework/request boundary. |
| message wording | canonical translation content | choosing a domain label mapper or rendering context. |
| validation | Paraglide/inlang check and generation task | making workspace validation part of its pipeline dependency. |
message ids:
- camel-case semantic ids describing rendered meaning:
processStatusActive,billingChargePending - never ids from source paths, english text, visual style, or case-conversion functions
- values with domain meaning route through a label file under
mappers/i18n/
generation boundary
- authored: source message files, inlang settings. generated, never hand-edited: Paraglide output
localesexposes one documented public entrypoint for messages and locale utilities; app workspaces depend on it and on thelocalesgeneration task through the task graph- adding a message: pick domain owner → add exact semantic key to base locale → supply required translations → run generation + validation → import the generated typed function. no temporary raw string while waiting for translation work
verification
- run the
localesvalidation and generation commands before application type checking. - typecheck every consuming workspace; missing message functions and invalid interpolation arguments must fail visibly.
- search for parallel
messages/,translations/,t()wrappers, and raw user-facing literals outside the workspace; remove unauthorized owners. - add a required interpolation to one message and confirm every typed caller is updated by the compiler.
- ensure every domain enum shown to users has a label mapper that imports from this workspace.
good and bad
bad
// apps/web/src/i18n/messages.tsexport const messages = {workflowStatusActive: "Active",};// apps/mobile/src/i18n/messages.tsexport const messages = {workflowStatusActive: "en cours", // translator updated mobile only};// apps/admin/src/i18n/messages.tsexport const messages = {workflowStatusActive: "Active", // admin is stale};
each app owns its messages: a product change to "Running" updates web only; a translator fixes French in mobile, admin is forgotten; users see inconsistent terminology.
good
// packages/locales/messages/en.json (canonical source){"workflowStatusActive": "Running"}// packages/locales/messages/fr.json{"workflowStatusActive": "En cours"}// apps/web/src/App.tsximport { m } from "@scope/locales";<span>{m.workflowStatusActive()}</span> // "Running"// apps/mobile/src/App.tsximport { m } from "@scope/locales";<Text>{m.workflowStatusActive()}</Text> // "Running"
generated function verifies message name and required interpolation at build time; one canonical source keeps every consumer on the current translation.