[data-reveal]{opacity:1!important;transform:none!important}
Localization
Building

Localization

How the DMS resolves $-prefixed i18n keys, how your layer ships locales, and the runtime translation editor.

Every UI string in the DMS can be a $-prefixed i18n key instead of literal text. The mechanics below explain how resolution, namespaces, and locale files fit together.

The $-key convention

A label that starts with $ is an i18n key; anything else is literal text. The $ is a runtime sentinel only — the key in the JSON file does not include it.

page metadata
// backend page/permission/DataType metadata
displayName: "$page.tasks.title"; // resolves to t("page.tasks.title")
displayName: "Tasks"; // literal — passes through unchanged

The frontend resolves it through useTranslation().processI18n, which strips the $ and calls Vue I18n's t().

Namespaces

Every namespace maps to a top-level object in the locale JSON (without the $). Two rules keep layers from stepping on each other:

  • The base layers own the shared namespaces — $dms.*, $common.*, $page.*, $menu.*, $error.*, and so on. Because locales merge across layers, adding a key under one of these is possible — do it deliberately, never by accident.
  • Your module or project takes a namespace of its own: the maintained modules use dms_<name> (e.g. $dms_automation.title); a project does the same (e.g. $my_app.report.perm.regenerate).

Error keys live in the shared error.* namespace (see Error feedback for how backend messages reach it).

Ship your locales

Drop one JSON file per language under i18n/locales/ in your frontend module; the loader discovers them, so nothing declares them. The language code comes from the filename, which has to end in -<ll>-<CC>.json — my-app-en-GB.json contributes to en, my-app-fr-FR.json to fr. A file whose name does not match that shape is ignored, silently. Files sharing a language code merge across modules at build time — that is how every module contributes to the same language, and why a namespace can receive keys from several modules. The DMS base module ships en and fr; yours joins them by shipping files for the same codes.

frontend-vue/i18n/locales/my-app-en-GB.json
{
  "my_app": {
    "report": {
      "title": "Reports",
      "perm": { "regenerate": "Regenerate the report" }
    }
  }
}

Reference a key as "$my_app.report.title" in backend metadata, or t("my_app.report.title") in a Vue template. When a key must exist in several files (one per language), grep a neighboring key to find them all quickly.

Special characters

A locale value is a vue-i18n message, not plain text: { and } open and close a placeholder, @ starts a linked message (@:other.key), and | separates plural forms. A raw @ or an unmatched brace breaks the compilation of the message, and a raw | silently turns it into a plural. Write such a character as a literal interpolation, the character quoted inside braces:

CharacterWriteExample
@{'@'}"Write to support{'@'}example.com"
{{'{'}"Use {'{'}name{'}'} as a placeholder"
}{'}'}(as above)
|{'|'}"Monthly {'|'} Yearly"

A $ inside a value renders as written. The escaping applies to locale values only: the values a placeholder receives (params) are not compiled, so an email address passed as a param needs none. A message that does not compile fails where it is shown.

Composed texts

A text that names values — an amount, a date, a count — is better sent as a composed text than formatted on the server: { key, params }, the key of the message and the raw values, which the browser writes in the reader's language, time zone and clock. The display blocks, the layout banners and your own components accept it (Composed texts):

{ key: "saas.banner.retry", params: { date: { type: "date", value: invoice.retryAt, format: "day" } } }
// "saas.banner.retry": "Payment failed, retry {date}" → "Payment failed, retry Oct 10"

The message is an ordinary entry of your locale files, its {name} placeholders filled from params, and a count parameter picks the plural form ("no seat | {count} seat | {count} seats").

The language of a request

The interface language and the profile's language are one: for a signed-in user, the dashboard opens in the language stored on their profile (users.language), and every language switcher — the user menu, the command palette, the footer of the sign-in pages — stores the new choice on the profile as it switches the screen. A visitor's choice lives in a cookie until they sign in.

A route that words a text for the screen still reads the language of the request, not the profile: every request a block makes carries the interface language in the x-content-language header, the language the reader sees at that very moment.

src/pages/sales.ts
@Get("summary")
summary(@Parameter("x-content-language", "header") language: string) {
  return { title: language === "fr" ? "Ventes" : "Sales" };
}

Better still, send no worded text at all: a $ key or a composed text is written by the browser, in the language on screen, and the route answers the same JSON whatever the reader's language. Keep users.language for what reaches the user outside a page — the mails the server sends.

Dates and times

Each user picks, on Settings › Language & region, a time zone, a first day of the week, a clock (12- or 24-hour) and a date format (numeric 01/10/2026 or written 1 Oct 2026). Every one of them defaults to automatic: the browser's time zone, and the week and clock of the interface language. They are stored on the user row (timeZone, weekStart, timeFormat, dateFormat, all optional) and travel with the session user, so the server-rendered page already uses them.

In a component, format through useRegionalFormat() (Page context): it returns the shared formatters already bound to the interface language, so neither the language nor the preferences can be forgotten. The interface language is the bare tag (en), which Intl writes in US conventions, as the DMS's own blocks do; useRegionalFormat({ locale: "format" }) writes in the locale of the DMS's texts instead (formatLocale: en-GB, fr-FR).

a module's component
const { formatDateTime, formatPrice, dateTimeFormat } = useRegionalFormat();

const createdAt = computed(() => formatDateTime(props.row.createdAt));
const total = computed(() => formatPrice(props.row.total));
const day = computed(() =>
  dateTimeFormat({ weekday: "long", day: "numeric", month: "long" }).format(
    new Date(props.row.dueAt),
  ),
);

The shared formatters apply them for you — formatDate, formatTime, formatDateTime and formatRelativeTime from dms-core/app/utils/formatter, the date and relative_date table data types, notification and activity times, the members' "Last active", and the calendars of date pickers and the period selector. Outside a component (a plugin, a plain function), call them directly, passing the locale: called without one, they write en-US. Format through them rather than with a bare Intl.DateTimeFormat, or through regionalDateTimeFormat(locale, options) from dms-core/app/utils/regional, which takes the same options and adds the user's preferences. Explicit options win: a timeZone, hour12 or hourCycle you pass is kept, and the date format only rewrites full dates (day, month and year). For a value that is a calendar day computed in the browser, pass { keepLocalZone: true } so it does not move to another zone. A server render writes them in the browser's own zone, which the browser keeps in its dms-time-zone cookie (the user's chosen zone still comes first), so the HTML shows the dates the browser shows.

A screen that needs the values themselves — a calendar's weekStartsOn, the zone to show next to a time — reads them from useUserRegionalPreferences() (Page context).

Amounts keep the currency of their data: there is no display-currency preference, since showing an amount in another currency would need a conversion the DMS cannot make.