Skip to content

Internationalisation

The contract for anyone writing a ddcore app.

English is the source language

A key is its English text. There is no en.csv: a key with no translation resolves to itself, which is already English.

ts
_("The end date cannot be earlier than the start date.")   // server
__("Save")                                                  // desk

A translation lives in <app>/translations/<lang>.csv, three columns:

key,translation,# where it came from
Save,Salvar,# desk/src/lib/components/FormView.svelte:132

The third column is a comment written by the extractor. Never maintain this file by hand — ddcore i18n extract derives it from the code, keeping every translation already there.

Catalogues merge core first, then each app in load order, so an app can override a core key. LoadI18n reads every translations/*.csv of every app, which is why an app gets translated metadata without a line of code.

label: and description: are keys

A DocType's label, a field's label and description, a Section Break's label, a workspace's and a report's label/title/description/category — all keys. Write them in English; the server translates them at /api/meta and /api/boot.

Identifiers are never translated: name, route, doctype, report, icon, fieldname, and a Link's or Table's options.

The site's name is the app's title

What a reader is told the site is called — the sidebar's heading, the browser tab, the name inside a recovery or invitation e-mail, /health — is defineApp's title. The first app that is not the core wins; with no app of its own, a site is called by the core's title.

So it is a key like any label, and the extractor already collects it from the app's metadata: write it in English, and ddcore i18n extract puts it in the catalogue with everything else. Nothing in ddcore.json names the site.

Nothing else in ddcore.json passes through the catalogue either — apps, lang, currency, timezone and the DSN are identity or behaviour, never interface text.

Select is canonical English

A Select's options are the values stored in the database and compared in code. They stay English. What moves is the display label, which is the value's own key:

ts
{ fieldname: "status", fieldtype: "Select",
  options: ["Open", "In progress", "Overdue", "Completed"] }

ddcore.db.getValue(...) === "Completed" is therefore correct in every language, and the desk shows "Concluída" to a Portuguese reader.

Colour by value, never by text

Declare optionColors on the field, keyed by the canonical value:

ts
optionColors: { Open: "blue", "In progress": "orange", Overdue: "red", Completed: "green" }

A colour matched against the text a reader happens to see is a colour that changes with the language. With optionColors declared, the desk colours and translates the status on its own — a list view needs no indicator at all.

Without it, the desk falls back to a built-in catalogue of canonical statuses (Draft, Open, Overdue, Completed, Paid, …) and then to a stable hash.

Interpolation, not concatenation

Placeholders are {0}, {1}, … and the arguments are values, never keys:

ts
_("Milestone {0} cannot be completed before the project starts.", [milestone.title])

An argument that is itself a label has to be translated where it is put in: c.T(f.Label) on the server, __(label) on the desk. A missing argument keeps its placeholder rather than vanishing, so a broken message looks broken.

There is no ICU. A plural is two keys:

ts
n === 1 ? __("1 version") : __("{0} versions", [n])

Dates, times and the site timezone

One timezone per site, in ddcore.json.

FieldtypeColumnMeaningShown
Date, Monthdatea civil date, no timezonenever converted
Datetimetimestamptzan instantin the site's timezone
Timetimea civil timenever converted

ddcore.utils.today() is the day in the site's timezone on both sides of the wire, which is what makes due_date < today() give one answer on the server and in the browser. So are now(), a Datetime's default: "Today", and the scheduler: daily fires at the site's midnight, not the server's.

A Datetime string carrying an offset keeps it. One without — "2026-01-15 10:00:00", which is exactly what now() returns — is read on the site's clock, never the process's.

Never format a date by hand. The desk derives the order, the separator, the month and weekday names, the decimal mark and the currency symbol from the locale plus ddcore.json:currencydd/mm/yyyy is right in one place and silently wrong everywhere else. The same goes for how many decimals money has: ddcore.json:currencyPrecision, defaulting to the currency's own minor unit, which is 2 for USD and 0 for JPY. See fieldtypes.

Before committing

ddcore i18n extract --all --lang pt-BR      # rewrite the catalogues
ddcore i18n extract --all --lang pt-BR --check   # what make check runs

--check exits non-zero listing untranslated keys and stale ones, with the file and the line. A forgotten key is otherwise silent: it renders as English on a translated screen and nothing breaks.

A computed key — _(status) — is reported as dynamic, not as an error. It is the normal shape for a Select value. The extractor collects a Select's options from the metadata, so those keys are covered even though no literal is written.

Over MCP

The same extractor is two tools, so an agent never leaves the loop to run the CLI or to compose CSV by hand:

  • i18n_extract {app?, lang?, check?, prune?} — what the CLI does, returning a structure: per app, the catalogue path, the key count and the missing, orphans and dynamic lists. With check it writes nothing.
  • set_translations {app, lang?, translations: {key: translation}} — applies the pairs, rewrites the catalogue in its canonical form and reloads it. A key the code does not have is refused by name, with nothing written: a typo would otherwise become an orphan nobody sees translated. The result is the same report, so iterate until missing is empty.

After adding a label:: i18n_extractset_translationsi18n_extract with check, which is what make check will run.

What is deliberately not translated

These become English literals and never pass through the catalogue, because their reader is not someone looking at an interface:

  • the CLI and its help
  • MCP tool descriptions — read by a model
  • Registry.Validate and the prelude.js throws — developer errors, raised by migrate or by writing an app wrong
  • seed data (services/demo.ts) — that is data, not interface

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)