Localized Content

UI translations (the {locale}.json strings covered in Translations) are one axis. Localized content is the other: record-level field values that differ per locale. A blog post has an English title and body, and a Slovenian title and body - same record, different values per field per locale. This page covers the per-locale editor that sits inside every generated CRUD form, the copy-locale and generate-locale actions, and the small client script that powers the tab switcher.

This is a distinct system from Translations (file-backed UI strings) and from Locale Archives & Bundles (transport bundles for externally-translated locales). All three coexist: UI strings live in JSON files, record content lives in per-locale clone tables, and bundles are the import/export envelope for moving whole locales in and out.

How It Works

Every table with at least one localized: true column gets a clone table per non-default locale. The base table holds the default locale's values; <table>_<locale> holds the rest. All clones share the same integer primary key, so a record's id is identical across every locale's row - they are one logical record spread across N physical rows.

posts              (en-us: default locale, the source of truth)
posts_sl_si        (Slovenian clone - same id, translated title/body)
posts_de_de        (German clone - same id, translated title/body)

Clones are structurally identical to the base table, plus two provenance sidecar columns per localized field: <field>_src (which locale the value was copied from) and <field>_hash (a SHA-256 of the source value at copy time). These drive the stale-copy notice (see Copy Provenance below).

The clone tables are generated by bun reeman sync-locale-tables and the schema generator. The locale_table() resolver in lib/locale_tables.ts maps a logical table name plus a locale code to the right physical table at runtime.

Marking Fields as Localized

Localization is opt-in per column. In the generated schema/table.ts, set localized: true on any column entry:

const columns: Record<string, { width: string; class: string; /* ... */ localized?: boolean }> = {
    "checkbox": { width: "10ch", class: "text-center" },
    "id": { width: "10ch", class: "" },
    "title": { width: "30ch", class: "", localized: true },
    "body": { width: "auto", class: "", localized: true },
};

When LOCALIZE_CONTENT=true is set in the environment, the schema generator automatically marks every localizable string column (text, textarea, markdown) with localized: true during first-time scaffolding. Existing table.ts files are never rewritten, so you can add or remove the flag on any column at any time and run bun reeman refresh-crud to regenerate the form.

Two constraints apply:

  • The table must have an auto-increment integer primary key (clone rows reuse the base row's id verbatim).
  • System fields (id, display, search_text, created_at, updated_at, archived_at, archived_by_user_id, archived_by_user_display) are never localizable - they are identifiers and metadata, not translatable copy.

The Per-Locale Editor

When a route has at least one localized field, the generated edit form replaces the plain input for each localized field with a <localized-field-tabs> component. Each field owns its own tab bar - there is no record-wide locale switcher, because different fields can have different translated locales.

The component (components/localized-field-tabs.ree) renders:

  • A heading row with the field label and a tab per locale (default locale first, then every other configured locale).
  • A body where the default-locale input and one panel per non-default locale all occupy the same grid cell. Only one is visible at a time; the others are display: none.

Locale switching is pure CSS - no JavaScript runs to switch tabs. A radio input per locale (name="loc-tab-<field>") pairs with a generated <style> block that uses :has(#<radio>:checked) to show the matching panel and hide the others. The tab bar is accessible (radio inputs with visible labels, role="tablist" on the container).

Each non-default locale panel (components/localized-panel.ree) renders an input of the same type as the source field - a textarea for textarea/markdown, a <markdown-editor> for markdown, an <image-upload> for image fields, a <select> for booleans, and a plain <input> for everything else. The input name follows the pattern _lv[<field>][<locale>], which parse_localized_form() reads on save.

The localized-form.js Client

The tab switcher itself needs no JavaScript. The script (static/localized-form.js, loaded via <script src="/localized-form.js" defer>) adds two small behaviors on top:

  1. Remember the last-used locale tab. When you select a locale tab, a preferred_locale cookie is written (path=/;max-age=31536000;samesite=lax). On the next page load, build_localization_props() reads that cookie and pre-selects the same tab for every localized field. The cookie is only honored when its value is a configured locale - an unknown value falls back to the default locale.

  2. Double-click a tab to apply it everywhere. Double-clicking a locale's tab label selects that locale tab for every localized field on the form at once, not just the one field it belongs to. This is useful when you want to review or edit every field in the same locale.

// Remember the selected locale tab
document.addEventListener("change", (event) => {
    const radio = event.target.closest("[data-localized-tab]");
    if (!radio) return;
    document.cookie = `preferred_locale=${encodeURIComponent(radio.value)};path=/;max-age=31536000;samesite=lax`;
});

// Double-click a tab label to switch every field to that locale
document.addEventListener("dblclick", (event) => {
    const label = event.target.closest(".localized-tab-label");
    if (!label) return;
    // ... selects the same locale's radio in every field's tab group
});

The script is small (under 30 lines) and dependency-free. It is only injected into the form when the route has localized fields - the form.localization_script template slot is empty otherwise.

Saving Per-Locale Values

On a normal form save (POST .../edit), the handler:

  1. Validates and saves the default-locale fields (the record's own columns) as usual.
  2. Calls parse_localized_form(params, LOCALIZED_FIELDS) to extract every _lv[field][locale] value submitted by the editor.
  3. Calls save_locale_values() which writes each non-default locale's row in its clone table.

Editing a value by hand clears its provenance - the <field>_src and <field>_hash columns are set to NULL for every field the user touched. A hand-edited value is no longer a copy, so the stale-copy notice stops firing for it.

The write fan-out (fan_out_update in lib/locale_write.ts) also runs: the edited locale's table receives every column, while every other clone receives only the non-localized (shared) columns. This keeps shared data (foreign keys, dates, flags) identical across locales while protecting each locale's translated values from being overwritten by an edit in another locale. Every fan-out runs inside a single transaction on one connection, so a failure partway through cannot leave one locale holding a row the others do not.

Copy-Locale

Each non-default locale panel has a small "x" button next to its tab label. Clicking it copies the default locale's value for that one field into the target locale. This is a formaction submit to the generated .../copy-locale route - the whole form is submitted, the record is saved first (so the copy runs against freshly saved values, not unsaved edits), then the copy executes.

A full-row copy (every field at once) is also available via the _copy_locale parameter, which copies all localized fields from a source locale into a target locale.

Copying is one-time by design. Later edits to the source do not propagate. But the copy records provenance - see below.

Copy Provenance and Stale Notices

When a value is copied (or AI-generated) from one locale into another, three columns are written on the target locale's row:

ColumnHolds
<field>The copied value itself
<field>_srcThe source locale code (e.g. en-us)
<field>_hashSHA-256 of the source value at copy time

The stale_copy_notices() function compares each copied field's stored hash against the source's current value hash. If they differ, the editor shows a notice next to the field: "Source changed since copy (English)." This tells the editor the original moved on after the copy was made, so the translation may need review.

The hash is representation-insensitive (serialize_for_hash in lib/localized_hash.ts): the number 10.5 and the string "10.50" hash alike, because SQLite may return either form for the same column. Object values are JSON-stringified with sorted keys, so key order never changes the hash. Textareas normalize CRLF to LF, so an untouched multi-line copy does not read as edited on the first save.

Editing a copied value by hand clears its provenance (<field>_src = NULL, <field>_hash = NULL). It is then just a value, and the stale notice stops firing.

Generate-Locale (AI Translation)

Next to the copy button, each locale panel offers a "generate" action. This submits to the generated .../generate-locale route, which enqueues a translate_record queue job that calls the AI translator on the source locale's values and writes the translated text into the target locale's row.

The generation runs in the queue worker so the request returns immediately. If the queue is unavailable, the call runs inline as a fallback so the editor still fills in.

A generated value carries the same provenance as a manual copy - source locale plus a hash of the untranslated source value. So the stale-copy notice fires for free if the source is edited afterward. A generated value is just a copy whose text passed through a translator on the way in; it is fully editable afterward like any other.

Only text goes through translation. Numbers, flags, and dates are carried over verbatim - "translating" them means nothing.

The Write Fan-Out Model

Every write to a localized table touches every physical table for that record:

OperationBase table (default locale)Clone tables (other locales)
CreateInsert the rowInsert the same row (same id) into every clone
UpdateUpdate every columnUpdate only shared (non-localized) columns; the edited locale's clone also takes its own localized values
DeleteDelete the row (last)Delete from every clone first (FK-safe order)

Clones are deleted before the base row so foreign-key constraints that point at a locale-matched parent are not violated. After every write, invalidate_all_locales() clears the cache for every physical table - a write touches the shared columns of every clone, so every locale's cached results go stale, not just the edited locale's.

The Data Contract

The editor's data is assembled by build_localization_props() in lib/localized_form.ts. Generated CRUD handlers call it and pass the result to render() as localization. Keeping this in the library (rather than emitting it into every generated index.ts) means the editor's data contract can change without regenerating a single CRUD route.

The LocalizationProps object includes:

PropertyWhat it provides
active_localesEvery supported locale, default first
default_localeThe source-of-truth locale
locale_namesDisplay labels for the tab bar
fieldsThe localized field metadata (name, label, input type, folder)
valuesEvery locale's value for every field, keyed field|locale
errorsPer-field per-locale validation errors
staleFields whose source changed since copy (keyed like values)
copy_actionThe .../copy-locale route URL
generate_actionThe .../generate-locale route URL
preferred_localeThe last-used locale tab (from the cookie)

Submitted values are parsed back with parse_localized_form() and validated with validate_localized_inputs(), which runs each translation through the same per-field Zod rule the source field uses - so a translation can never bypass a constraint enforced on the original.

BREAD Resources

bun reeman create_localized_bread generates a BREAD resource (Browse/Read/Edit/Add/Delete over a non-DB source) whose store.ts stub is expected to hold content per locale. The generated form gets the same locale-tabs editor, copy-locale route, and locale_code-aware store signatures - but the storage layer is whatever the developer implements in store.ts, not clone tables. See BREAD Resources for the full synthetic schema format, the store contract, and implementation examples.

Keeping Clone Tables in Sync

When you add a new locale (via bun reeman add-locale or the reeman menu), or add a localized: true flag to an existing column, run bun reeman sync-locale-tables to create or reconcile the clone tables. The command reads every schema/table.ts, finds every localized: true column, and ensures each non-default locale has a clone table with matching structure. It also seeds any locale-localized foreign-key columns. See Locale Archives & Bundles for the full locale lifecycle.

CSS Reference

The editor's styles live in css/forms.css under the localized-form class. The key classes:

ClassRole
.localized-formAdded to the form element when localization is enabled
.localized-field-sourceWrapper for one localized field's tabs + body
.localized-field-headingLabel + tab bar row
.localized-field-tabbarThe tab bar container (role="tablist")
.localized-tab-labelA clickable tab label (pure CSS switching, no JS)
.localized-tab-radioThe hidden radio input that drives the CSS :has() switcher
.localized-field-bodyGrid cell holding the default input + one panel per locale
.localized-tab-panelA locale panel (display: none unless its radio is checked)
.localized-stale-noticeThe "source changed" notice (amber left border)

The :has() rules that pair each radio with its panel are generated per-field by localized-field-tabs.ree, because the locale set is config-driven, not fixed markup. No JavaScript is needed to switch locales.