Translation Contract
This document owns the
@phis/ui side of translation: the server-only helpers, Label Sets, source
locales, structured documents, and what the package does with the resolved request locale.
Persistence, canonical storage envelopes, provider requests, and change markers are owned by
@phis/server (
TRANSLATIONS.md). The locale capability superset, each
Site's validated subset, and
GET /api/v1/site/locale are owned by
@phis/server
(
phis-server TRANSLATIONS.md, "Locale capability"). How translations are
cached in a Site process is described in
gateway/CACHES.md.
Helpers
The translation helpers are server-only and exported from @phis/ui/server-helpers
(server-helpers/translate.ts):
tr(msg, params?, ctx?, format?) and trForLocale(locale, msg, params?, ctx?, format?) translate one
Site-scoped message.
trBulk(msgs, ctx?, format?, sourceLocale?) and trBulkForLocale(locale, msgs, ctx?, format?, sourceLocale?)
translate several Site-scoped messages in one request and keep their order: one answer per message,
at its position. An empty message is answered with an empty text and not sent, so a caller may match
answers by index (gateway/tr.ts). Dropping it, as the batch once did, moved every later answer onto
the wrong message.
trGlobal(msg, params?, ctx?, format?), trGlobalForLocale(...), trGlobalBulk(msgs, ctx?, format?),
and trGlobalBulkForLocale(...) translate global Phi copy, whose source locale is always en
(PHI_CANONICAL_SOURCE_LOCALE). A request for en returns the source text without a request.
Rules:
The helpers without a locale read the request locale through resolvePhiRequestLocale(), and the
Site from the request-scoped runtime set by the CMS root rendering.
ctx is empty by default. Set it only when the same visible text must translate differently in
another semantic context. Shared UI copy uses the fixed registers PHI_TR_CTX_WEB_UI_LABEL
(captions on controls) and PHI_TR_CTX_WEB_UI_MESSAGE (sentences the UI says: empty states,
failures); Module descriptions use PHI_TR_CTX_MODULE_DESCRIPTION. Do not invent Site-specific
contexts for shared UI copy.
format defaults to text. Use html only for intentional translatable HTML markup.
A failed request returns the source text; the helpers never throw for a translation failure.
Dynamic values are never part of a translated string: no names, usernames, email addresses, company
names, customer numbers, Site names, URLs, or similar data. Source text uses the positional
placeholders %1, %2, ... (for example Login history for %1), and values are applied after
translation. The single-message helpers take them as params; bulk results and Label Sets are
formatted by the caller, and Client Components format already-translated templates locally instead of
concatenating labels with values.
Free-text page and Site copy prefers one trBulk(...) over many tr(...) calls.
Translation is separate from the data-source contract and from Form submit dispatch.
phiRuntime() (@phis/ui/server-helpers) is for runtime metadata itself -- apiBaseUrl,
internalToken, siteKey, locale, and fetchSiteLocaleConfig() -- not for translating.
Label Sets
Phi-owned UI copy is loaded through Label Sets (gateway/label-set.ts). Where the helpers come from
depends on who imports them:
A Module package outside
@phis/ui takes them from
@phis/ui/module/labels, never from
@phis/ui/server-helpers. The server barrel carries the same names, but with them
server-helpers/cms-root
and
next/headers; a Module definition is reachable from the browser graph too, because Client Widgets
render the Label Sets bound to it, so taking them from the barrel compiles and then answers
500 on
every page of the Site (see
THIRD_PARTY_MODULES.md).
A Site's own server code may take them from @phis/ui/server-helpers.
First-party Modules inside this package import gateway/label-set directly, which is as narrow as
@phis/ui/module/labels is.
The helpers:
definePhiLabelSet({ key, ctx, sourceLocale?, labels }) declares a set. labels maps semantic keys to
default text. An entry may name its own register as { text, ctx }; definePhiMessageLabel(text) marks
a sentence inside a set of captions.
definePhiRuntimeModuleLabelSet(moduleDefinition, { key, ctx, labels }) declares a Module-owned set.
Its key becomes module:<moduleId>:<key> and its source locale is the Module's sourceLocale.
getPhiLabelSet(options, definition) returns the texts under the same semantic keys. It sends one
bulk request per register; the positional batch is an internal transport detail. Sets are not cached
as sets; each message goes through the translation cache.
Widget code reads labels through semantic keys, never through free-text lookups or positions.
Label Sets are static UI copy. They may contain placeholder templates such as Edit user %1, but are
never built from resolved user, profile, company, or email data.
The database never stores Label Set keys, loader paths, or translation bundle identifiers.
First-party Widget loaders live in components/widgets/label-sets/* and are named semantically
(account.ts, inspector.ts), not by numeric ids. Their label types and default texts live in
components/widgets/label-types/*; a loader imports them rather than repeating them. A Widget's server
half imports its own loader; the CMS renderer and the gateway own no concrete Widget labels.
A block's labels prop carries Phi-owned UI and control copy; defaultLabels carries localized
default content shown only when CMS or config text is absent. Explicit config text such as
config.label, config.title, or config.tooltip wins over both. A block without copy uses the
PhiNoLabels type (types/widget-runtime.ts) instead of passing an empty labels object.
Semantic commands such as save, publish, review, restore, reset, reload, and upload are Phi copy from
the common-controls Label Set (components/widgets/label-sets/common-controls.ts).
Site copy placeholders
Copy written into a Widget's config -- by a Preset, or by whoever is authoring -- may name a few facts
about the Site instead of spelling them out (helpers/text-placeholders.ts):
The names are {site.name}, {site.wordmark}, {year} and {site.host}. That list is the whole
language; a placeholder is not a path into the runtime, and a name nothing answers is left standing so
that {jahr} renders as {jahr} and reads as the typo it is.
{site.wordmark} is what the Site calls itself in writing and {site.name} is what it is filed
under. Where no wordmark is set the wordmark resolves to the name, so it is never the emptier ask.
The order is mask, translate, fill, and never translate then fill. {year} is a word and a machine
translator translates words: it comes back as {Jahr}, which nothing answers. maskPhiTextPlaceholders
turns every name into a %n token first -- the same token this contract already uses for values -- and
resolvePhiTextPlaceholders fills them in afterwards, where the translator left them.
A text may carry values of its own beside it -- a list entry { text: "Site key: %1", values: [key] }.
They take %1 to %k, and the masked names count on from %k+1 (maskPhiTextPlaceholders(text, k)),
so one fill puts both back. A value is never translated, and a sentence with a value in it is one
catalogue row instead of one per Site.
Filling afterwards is also what makes a translation reusable. The stored translation is keyed on the
source text, so © %1 %2. All rights reserved. is one row per locale for every Site that adopts the
Preset, and the same row after New Year.
A Preset never resolves a placeholder while building its tree. That writes one Site's name and the
year of the adoption into everybody's copy, where they stay.
Widgets that carry short config copy resolve them; long-form Markdown and HTML bodies deliberately do
not, because a brace there is more likely to be a code sample than a placeholder.
Ant Design's own copy (pagination, empty states, picker text, validation templates, modal buttons) and
date/time formatting come from the Ant Design locale and the shared Day.js locale bridge
(components/root/phi-dayjs-locale.tsx), never from Label Sets. The Ant Design locale is applied once
in the root ConfigProvider (components/root/phi-root-layout.tsx). helpers/antd-locale.ts maps the
resolved Site locale to an Ant Design locale key and loads it through an explicit whitelist of
import("antd/locale/<key>") loaders, never through a computed import path.
Request locale
The resolved request locale comes from @phis/server (GET /api/v1/site/locale) and carries
locale, language, optional script and region, direction, intlLocale, optional
deeplTargetLang, labelFallbacks, and source (helpers/site-locale-config.ts). Shared UI does not
validate platform locale support and does not reimplement DeepL targets, text direction, or fallback
chains.
Root, CMS, metadata, translation, and redirect code all use the same request-scoped result
(server-helpers/request-locale.ts); none applies its own locale priority. That request is
no-store, because the answer depends on session, cookies, the explicit locale, and
Accept-Language. The Site locale configuration (fetchSiteLocaleConfig) may be cached per Site.
x-locale carries only the explicit locale of a locale-prefixed Public route. The Site proxy
(next/site-proxy.ts) removes any incoming x-locale and sets it only from the path prefix; a cookie,
browser preference, profile value, or already-resolved locale is never promoted into it.
Site-scoped translation calls carry no Site source locale: @phis/server derives it from the Site.
The Site defaultLocale is the last request-locale fallback and is never substituted for the en
source of global copy.
Source locale transport
Ordinary Site translation calls omit sourceLocale; @phis/server resolves the immutable Site source
locale.
Module text that reaches a Site translation call carries its Module's sourceLocale: a Navigation
label a Module ships (PhiCmsNavigationLabel.sourceLocale, set where the entry is resolved) and the
title of a Module route. Without it a Site whose source locale is not the Module's reads the Module's
English as its own and never translates it. A label the operator renames or adds loses or never has
the field and is read in the Site's source locale.
Standalone global labels may declare sourceLocale; omission means English. Module-owned third-party
presets inherit their owning Module locale instead of declaring another source language.
Every Runtime Module declares one canonical sourceLocale; omission means English. All package-authored
component copy inherits it through the Module Label-Set helper. Component-local source-language
overrides and mixed-language Module metadata are invalid.
Phi-owned Modules author canonical copy in English. Third-party Module metadata and component labels
may use another declared Module source locale.
External Markdown and HTML Widgets persist one plain sourceLocale in Widget config. It may differ
from the Site's available target locales.
sourceLocale is forwarded as its own request field. It must never be encoded into ctx.
If the active provider cannot translate the configured external source, the Widget renders the original
document and does not redetect on every render.
Context and format
Callers provide only optional natural-language provider context. They do not create internal context
flags. The gateway forwards format: "html" and explicit external source metadata; @phis/server owns the
canonical [html], [extern], and [html:extern] storage envelope.
Structured documents
Markdown and HTML are parsed into semantic translation units before the gateway is called.
One heading, paragraph, list-item paragraph, block-quote paragraph, or table cell is one unit.
Inline formatting and visible link text are preserved through translation-safe HTML markup.
Code, inline code, and link destinations are never translated. Image alt text is translatable.
All units from one document use the bulk gateway. The server resolves cache hits and provider request
chunking while preserving unit order.
HTML sanitization follows trust boundaries rather than render frequency. Canonical inline HTML is
sanitized by @phis/server before persistence. External documents and provider-produced HTML are
sanitized in the server render path because they are not trusted persisted Widget markup. Public and
App HTML Widget clients receive safe serialized markup and must not import the HTML sanitizer.
Typed Page and Asset destinations embedded in internally persisted Markdown/HTML, and the mandatory
rejection of those destinations in external documents, follow
REFERENCES.md. Reference
identities and destinations are structural metadata and never translation units.
For external sources, the Builder detects the source language once when a new attachment is configured
and persists it through the normal generic Widget config update. Subsequent renders use that value.
Builder may replace it explicitly or request a new detection after changing the attachment.
Removing a Widget or clearing its URL detaches the source only. It must not issue translation-unit purge
requests implicitly because units may be shared and Builder operations must remain undoable.
Runtime Module title, description, and other package-authored labels use global translations, never Site
translation units. Server-owned Authoring catalogs group metadata by Module source locale, translate it
in bulk, and serialize localized copies to Client providers. The immutable Runtime Module definition
retains its canonical source strings and locale; localization must not rewrite the active Module catalog.
A Module whose source locale is not the canonical one is translated by every installation that runs it,
on that installation's own provider, and never from the CDN. The canonical English its unit is keyed by
is generated rather than declared, so two installations key the same string under two different hashes
and no mirror can answer either of them. What would make those strings shareable is a package that
declares its canonical text beside its own language; a local pre-translation is not the same thing, and
looks like it is
(
phis-server TRANSLATIONS.md, "Translation modes").
Module category, id, Provider keys, capabilities, and other machine identities remain stable keys. A
separate global category Label Set may present a category key to users. Optional Site-owned presentation
overrides are Site content and do not replace the global package translation.