Localization
Spillway publishes a site in several languages. Every entity is one translation; the translations of the same content form a translation group, and one of them is the source. Fields are either translatable (each language keeps its own value) or shared (copied from the source and kept in sync). URLs carry a locale prefix for every language but the default, and the public site gets <html lang>, hreflang alternates, per-locale sitemaps, per-locale menus, search and listings.
A site with one locale behaves exactly as it did before localization: no prefixes, no alternates, one sitemap.
Enabling languages
Under Site settings → Languages (or PUT /api/site):
{ "defaultLocale": "en", "locales": ["en", "es", "pt-BR"], "localePrefixDefault": false }
- Locales are BCP 47 tags (
en,es,pt-BR,zh-Hant) and are canonicalized (pt-brbecomespt-BR). Anything else is refused withvalidation_error. - The default locale is always part of
locales. localePrefixDefault: trueprefixes the default language as well:/en/aboutinstead of/about,/eninstead of/. Unprefixed URLs then redirect (301) to their prefixed form.- Removing a locale that still holds content answers
409 conflictwithdetails.locales: [{ locale, entities }]. Repeat the request with?force=1to remove it anyway; the content stays stored (and in the admin) but is no longer reachable on the public site until the locale comes back.
Translation groups
| Entity property | Meaning |
|---|---|
locale | The language of this entity. Must be one of the site's locales (POST/PUT /api/entities refuse others). |
translationGroupId | Shared by every translation of the same content. It is the source's id, and a new entity is the source of its own group. |
isSource | True on the source translation. When a source is deleted, the default-locale translation (else the oldest) becomes the source. |
translationStatus | current, or outdated when the source's translatable content changed after this translation was last saved. Saving the translation makes it current again. |
Every existing entity became its own source when migration 0019_localization ran.
Paths are unique per (site, locale, path), so the Spanish translation of /about keeps the path /about and is served at /es/about.
Translatable and shared fields
FieldDef.translatable decides per field. Defaults by type:
| Translatable (own value per language) | Shared (copied from the source, kept in sync) |
|---|---|
text, textarea, richtext, select, link, json, layout | number, boolean, date, daterange, reference, media, taxonomy |
Set translatable: false on a text field to share it (a product code, for example) or translatable: true on a media field to let each language pick its own image.
layout is translatable, with one nuance: it is copied when the translation is created and independent from then on. A layout is page content, so the Spanish page can drop or add blocks freely.
When the source is saved:
- Shared fields are copied into every translation. When something changed, the translation gets a revision noted "Shared fields synced from the
<locale>source"; its references, term tags and occurrences are refreshed. The translation's published snapshot is not touched: republish it to put the new value live. - If any translatable field or the title changed, every translation is marked
outdated.
Creating a translation
POST /api/entities/:id/translations with { "locale": "es", "mode": "empty" | "copy" | "ai" } creates a draft in the group of :id (the source's values are used whichever member you post to). It answers 409 when that locale already exists in the group and 400 when the locale is not enabled on the site.
| Mode | What the draft holds |
|---|---|
empty | The title, the shared fields and the layout. Translatable text fields start empty, even required ones; the editor fills them before publishing (checks refuse an empty required field). |
copy | Everything, as a starting point to edit. |
ai | Text fields and the copy inside the layout translated by the translate AI task, with the brand profile's terminology and banned words in the prompt. Requires ai.use and AI enabled. Each text field is one model call; layout strings go in batches of 60 as JSON. Usage is logged per call under the translate task, and the response carries aiTranslatedFields (which fields the model wrote) and usage. The revision note records the fields too. The AI never publishes. |
GET /api/entities/:id/translations returns { group: Entity[], missing: string[] }: the whole group, source first, and the site locales with no translation yet.
GET /api/translations/status (content.view_drafts) returns counts per locale over every source (total, current, outdated, missing) and up to 500 items that are outdated or missing, newest first: { sourceId, type, title, locale, status, entityId, updatedAt }.
GET /api/entities?locale=es lists one locale.
URLs and routing
| Request | What is served |
|---|---|
/about | The default-locale entity at /about. |
/es/about | The es entity at /about. The prefix match is case-insensitive (/ES/about). |
/es | The Spanish translation of the home page (homePageId's group), or 404 when there is none. |
/about when only a Spanish /about is published | 301 to /es/about (the query string is kept). |
/about with localePrefixDefault | 301 to /en/about. |
/es/search?q= | The site search page for es. |
/sitemap.xml | A sitemap index on multi-locale sites (see below). |
Entity.path stays locale-relative (/about). SearchResult.path and CollectionItem.path are the public paths (/es/about), because links are built from them; both also carry locale. GET /api/entities/by-path?path=/es/about understands the prefix, and ?locale=es works too.
Pages are cached by their public path, as before. Every page is tagged tgroup:<translationGroupId> in addition to the usual tags, so a change to any translation (which changes the alternates of the others) evicts the whole group.
Head, sitemaps and feeds
<html lang>is the entity's locale.- The canonical URL is the public (prefixed) path.
- On multi-locale sites every published translation in the group becomes
<link rel="alternate" hreflang="<locale>" href="...">, plushreflang="x-default"pointing at the default locale's version (or the current page when there is none). - Breadcrumb JSON-LD and the rich-text internal links use the prefixed paths.
/sitemap.xmlis a<sitemapindex>with one/sitemap-<locale>.xmlper locale; each entry carriesxhtml:linkalternates for its translations. A single-locale site keeps the plain<urlset>at/sitemap.xml.llms.txtlinks use the public paths.- Automatic redirects created when a published page's path changes use the public paths on both ends.
Menus
A menu item may carry labels: { "<locale>": "Label" }. When a page in that locale renders the menu, the locale's label wins over label. An item that links an entity links its translation in the page's locale when that translation is published, else the entity it was given. A plain internal href (/, /blog) follows the page's locale (/es, /es/blog) unless it already carries a prefix.
Search and collections
GET /api/search?locale=esrestricts results to one locale (every SQL provider and Meilisearch); without it every locale is searched. The public/es/searchpage always searcheses.- A Collection block on a page lists entities in the page's locale by default. A collection that filters on the
localesystem field keeps its own filter instead.GET /api/collections/:id/items?locale=esfilters the API; without?localethe API lists every locale.
Admin UI language
GET /api/i18n/:locale (public, cached for five minutes) returns { locale, available, messages }: the requested bundle laid over the English one, so every key resolves. es-MX resolves to es, an unknown locale to en. GET /api/i18n lists the available bundles and the signed-in user's preference.
Bundles live in packages/core/src/i18n/messages/<locale>.ts (en is the reference, es ships as a demo); a module or deployment can add or extend one with registerMessages(locale, messages) from packages/core/src/i18n/bundles.ts. Coverage today: the shell (navigation, account menu), the language strip, and the five most-used screens (Dashboard, Content list, Entity editor with its action bar, Media, Review queue) read their strings through t(key, english) and tv(key, english, vars) ({name} placeholders); the other screens are still English-only. Every call carries the English text, so a missing key never shows an identifier.
Each user keeps uiLocale (PUT /api/auth/me { "uiLocale": "es" }, null to follow the browser); the admin reads it from GET /api/auth/me.
Translation management systems
Pages can be sent to a translation management system (TMS) or exchanged by hand as XLIFF files. Whichever route, the translations come back as drafts in the right translation group and language: a missing translation is created (mode copy, so shared fields and the layout are in place) and then written, an existing one gets a new draft revision. The published revision is never touched, and nothing is published by the import.
What is sent
An entity becomes one XLIFF 2.0 document (<file id="<entityId>">) with one unit per piece of text:
| Unit id | What it is |
|---|---|
field:<key> | A translatable text, textarea or richtext field. The title is field:title when the type has no title field. |
field:<key>.<i> | Item i of a multiple text field. |
block:<blockId>:<prop> | A string prop of a layout block (block:Hero-1:title, block:Cards-1:cards.0.text). Blocks nested in slots (Columns) keep their own ids. root is the layout root. |
The same heuristic as the AI translation decides which layout strings are copy: ids, hrefs, image URLs, colors, option values (h2, left) and the Embed block's HTML are left out. Shared fields (translatable: false) are not sent. Rich text (richtext fields, and Text blocks in HTML mode) keeps its markup out of the translator's way: paired tags become <pc>, void tags (br, img) <ph>, with the original tags in <originalData>, so the translated unit comes back as the same HTML with the text replaced. Everything written back goes through the usual sanitizer.
Every job keeps the units it sent, so a file is matched against what was exported even if the source changed meanwhile. An import reports, per file, the units it applied, the ones it skipped (empty target, a block the target page no longer has, a shared field) and the ids it could not match (unmatched).
Providers
Under Site settings → Languages → Translation management system (PUT /api/i18n/tms/settings, site.settings). Credentials are write-only: they are encrypted with the same key as the AI provider keys (<DATA_DIR>/ai-secret), the API shows ******** where one is stored, and sending the mask back keeps it. Test connection does a project lookup with the stored credentials.
| Provider | Setup | How a job runs |
|---|---|---|
xliff (manual) | Nothing to configure. | Send to translation creates a job; XLIFF downloads a zip with one <entityId>.<locale>.xlf per page and language plus a manifest.json. Give the files to your translators; Upload takes the translated files back (a zip, or the files themselves, in any number of uploads). The job is applied once every page and language has a file. |
crowdin | Project id (the numeric id) and a personal access token. Crowdin Enterprise: the API host https://<org>.api.crowdin.com/api/v2. | One XLIFF file per page is added to the project (POST /storages, POST /projects/{id}/files). Progress is read per file and language; Pull builds and downloads each target (POST /translations/builds/files/{fileId}). Cancel deletes the files. |
phrase (Phrase TMS, formerly Memsource) | Project uid and an API token. Another region: the API host (https://cloud.memsource.com/web/api2/v1 is the default). | One job per page with every target language (POST /projects/{uid}/jobs); a part is done when its status is COMPLETED (or completed by the linguist / delivered). Pull downloads each part's target file. Cancel sets the parts to CANCELED. |
smartling | Project id, a user identifier and user secret (exchanged for a short-lived access token on every use). | One XLIFF file per page is uploaded and authorized for the target locales (Files API POST /file); status is the completed string count per locale; Pull downloads the published translation per locale. Cancel deletes the file. |
Locales are passed to the providers as the site stores them (es, pt-BR); make sure the project's target languages use the same tags.
Jobs
The Translations screen gains Send to translation: pick rows in the table (their sources and languages are pre-selected), search for content, choose the target languages and an optional due date. An editor can also open the screen with ?send=<entityId>. Sending needs content.translate (editor, manager, admin); reading jobs needs content.view_drafts.
| Status | Meaning |
|---|---|
requested | Created; the manual provider waits for an upload. |
in_progress | Handed to an API provider. The ticker polls every in-progress job every 10 minutes (report.progress per language). |
ready | The provider reported every target complete (poll or webhook). A translation.ready notification is queued; a rule on that event can email the reviewers or post a webhook. |
applied | Every page and language came back and was written as a draft (report.files has the per-file report). |
failed | The provider refused the hand-off or lost the files (error). |
cancelled | Canceled here (and at the provider where it allows it). Drafts already pulled are kept. |
Pull works on any open job of an API provider, ready or not: what the provider has is downloaded and applied. A job of the manual provider is pulled by uploading instead.
Webhooks
API providers can tell Spillway when a translation is done instead of waiting for the poll. Set a webhook secret and register the URL the settings show (/api/i18n/tms/webhook/<provider>, on the site's own domain on a multisite instance) with the provider. The request is accepted when it carries either X-Webhook-Secret: <secret> (a custom header, which Crowdin, Phrase and Smartling all let you add) or X-Spillway-Signature: sha256=<hmac-sha256 of the body with the secret>; anything else is refused with 403 and audited (translation.webhook.rejected). The payload's references (Crowdin file ids, Phrase job uids, Smartling fileUri) pick the jobs; each is confirmed with the provider before it is marked ready, because providers notify per file or per language. The route is public, rate limited and skips CSRF like the form submit.
Limits
- At most 100 entities per job; every entity of a job must share the source language. Translations are sent as their group's source.
- The manual bundle and uploads: up to 500 files, 16 MB each; a zip or bare XLIFF document,
application/zip,application/xliff+xmlor multipartfileparts. - XLIFF 2.0 only (2.1 is read); XLIFF 1.2 is refused. Segmented units are joined;
<mrk>annotations are flattened to their text. - Rich text comes back through the sanitizer, so tags a translator adds that are not on the allow-list are dropped.
- Crowdin's build endpoint and Phrase's target file are fetched synchronously; a very large page may hit the 30 s outbound timeout, in which case Pull again later.
Audit actions: translation.settings.update, translation.job.create, translation.job.pull, translation.job.upload, translation.job.cancel, translation.job.ready (actor null, from a webhook or the poll), translation.job.failed, translation.webhook.rejected.
Storage
Migration 0019_localization adds entity.translation_group_id (backfilled with the entity's own id, indexed), entity.is_source (default true), entity.translation_status (default current), site.locale_prefix_default and user.ui_locale. Per-locale menu labels live inside the menu's items JSON. Migration 0026_translation_jobs adds translation_setting (one row per site: provider, project, encrypted credentials and webhook secret) and translation_job (the entities and locales sent, the provider's id, the status, the import report and the units that were exported).
Spillway 0.1.0 · Edit this page on GitHub