Translations

Reepolee stores translations in the translations database table. There are no translation files in Reepolee - the database rows are the source of truth. create_ctx() resolves the active locale into ctx.translations, render() exposes that object to the template as props.translations, and templates read strings with the {_ } / {- } / {@ } lookup tags ({@ } renders the value through markdown).

Manage translations through the database, not through files. Use /system/translations, SQL INSERT / UPDATE, or bun reeman sync-translations when you need to add or change strings.

This page covers the database shape, how request handling reads it, and how to keep it in sync with templates.

Strings are namespaced by where they are consumed. Route-specific strings live under a dotted namespace like system.auth.login, while global strings live in the special root namespace. The login route therefore sees both its own strings and the global strings in the same ctx.translations object.

Database Model

The translation schema is flat in the database and nested in memory:

ColumnPurpose
localeFull BCP 47 locale code (en-us, sl-si, ... )
namespaceDotted namespace (system.auth.login, or empty / root for globals)
key_pathDotted key within the namespace (ui.title, actions.submit)
translationThe translated string

Fresh installs seed the table from the dialect-specific SQL files in sql/<dialect>/. Those files are bootstrap data only; the live application always reads from the database.

The loader maps a row into the in-memory tree like this:

  • namespace = "" or root becomes routes.<key_path> for global strings.
  • namespace = "system.auth.login" becomes system.auth.login.<key_path>.
  • key_path = "nav" is stored under routes.nav.<namespace> so navigation labels stay separate from page text.
  • key_path = "nav_prefix_title" is stored under routes.nav_prefix_title.<namespace> for grouped navigation labels.

The route_name key is special because it drives localized URL segments. It is stored in the same table, but it is never inherited from another locale.

Conventions for Keys

The DB rows still follow the same naming conventions the templates expect:

Key familyContainsCommon consumer
uiHeadings, body copy, page title, descriptive labels{_ ui.title }, {_ ui.* }
actionsButton text - submit, save, cancel, delete, back{_ actions.* }
errorsValidation messages by rule keyvalidate(data, ctx.translations.errors)
messagesToast and confirmation stringsToast payloads
fieldsPer-field metadata (field_name.label, etc.)Form templates
searchSearch-form copyList page templates
selectorsDropdown options (yes/no, per-page)List page templates
navNavigation menu entriesLayout templates, nav_label() helper

For your own keys, prefer snake_case values like record_updated and email_not_sent. Keep the key names lowercase and punctuation-free.

Reading Translations in Handlers

create_ctx(req, import.meta.dir) from $lib/request_context resolves the active locale and gives handlers the merged translation object on ctx.translations.

import { render } from "$lib/render";
import { create_ctx } from "$lib/request_context";

export async function get_auth_login(req: BunRequest): Promise<Response> {
    const ctx = await create_ctx(req, import.meta.dir);
    return render("form", { data: { action: "/login" }, ctx });
}

Passing ctx to render() is enough. render() exposes ctx.translations as props.translations and templates read it with {_ ui.title }, {_ fields.email.label }, and similar lookup tags.

If a handler needs a string for validation or a toast, read ctx.translations directly:

const [errors, valid_data] = validate(data, ctx.translations.errors);

The Database Is the Source of Truth

The translations table is authoritative. If a string exists in the DB, that value wins. If a key is missing in a non-default locale, the loader fills the gap with a braced placeholder such as {title} so the omission is obvious during testing.

The merge rules in lib/i18n.ts are intentionally simple:

  • Missing keys in non-default locales are replaced with a visible placeholder.
  • route_name is never inherited from another locale.
  • The loader is silent if the table does not exist yet, which keeps a fresh project from crashing during bootstrap.

To change a translation:

  • Run UPDATE or INSERT against the translations table.
  • Use the /system/translations admin UI.
  • Run bun reeman sync-translations to fill missing rows across locales.

The Translations Admin Module

Translations administration table with namespace, group, key, English and German values, filters, search, pagination, and inline editing

The /system/translations route is a CRUD UI over the translations table. It lets an operator browse namespaces, edit strings, add new rows, and delete obsolete ones. After each save, the app reloads translation state so the change is visible immediately without a restart.

This is the primary workflow for day-to-day translation edits. The seed SQL is only there to bootstrap a new database.

Pluralization

For counts, a single translation string can hold all plural forms separated by pipes, and the plural() helper in $lib/format picks the right one using Intl.PluralRules:

import { plural } from "$lib/format";

// translation value: "no items|one item|{count} items|{count} items|{count} items"
plural(props.messages.item_count, count, locale); // -> "3 items"

The helper selects the correct plural category and substitutes {count}. format_bulk_delete_message() builds on this for the generated bulk-delete handlers.

Synchronising Keys Across Locales

sync-translations scans the translations table for keys that exist in one locale but not another, translates the missing values with an LLM, and writes the results back to the database:

bun reeman sync-translations --translate

This is the non-interactive way to keep locales aligned. It does not edit files and it does not require a manual copy step.

Excluded Translations

sync-translations treats a key as untranslated when the locale's value is missing, ::missing::-prefixed, or identical to the English value - the last one being the heuristic for "copied from the default locale but never actually translated." Some keys are intentionally identical across locales, though: a language's own native name (English is "English" in every locale), short unit and abbreviation strings, and the cross-locale name data that add-locale populates with its own dedicated AI calls. config/excluded_translations.ts lists the key-path prefixes that skip the identical-value check:

export const excluded_translation_prefixes: string[] = [
    "ui.language_name",
    "ui.language_names",
    "ui.language_names_to",
    "ui.seconds",
    "ui.ttl",
];

extract_untranslated() in $lib/translation_merge is key-path aware: for an excluded prefix it flags a key only when it's missing or ::missing::-prefixed, never because its value matches English. Without the exclusion, these keys would be re-sent to the AI on every sync run even though nothing is actually untranslated.

Maintenance: Pruning and Filling Gaps

The reeman tools keep the translations table aligned with what the templates actually reference. They scan .ree templates for {_ ... } / {- ... } lookups, compare the references with the DB, and write a reviewable .sql file rather than touching the database directly.

reeman commandDirectionOutput
Prune unused translationsDB keys not referenced by any templateDELETE statements for orphaned rows
Sync missing translationsTemplate refs not present in the DBINSERT statements for the missing (locale, namespace, key_path) rows

Apply the generated SQL with your preferred DB client or through reeman's SQL runner.

Adding New Strings

The flow for adding a new translation key is:

  1. Reference the key in the template with {_ your_new_key } or {- your_new_key }.
  2. Add the row to the translations table for each locale, or use Sync missing translations to scaffold the missing rows.
  3. Fill the values through /system/translations or SQL updates.

For a global string that should appear on every page, use the root namespace. For a route-specific string, use that route's namespace.

Reloading Translations in Development

Translations are loaded from the database at startup and reloaded when the admin UI or sync tooling writes new values. If you change the database outside those flows, reload the translation repository and rebuild the route maps, or restart the server.

import { translations } from "$lib/i18n";
import { reload_route_maps } from "$lib/route_map";

export async function get_dev_reload_translations(req: BunRequest): Promise<Response> {
    await translations.reload();
    reload_route_maps(translations.all);
    return new Response("Reloaded");
}

The running server also exposes the hot-reload endpoint /__reload-translations, which the generators and queue worker use after writing new translation rows.

Editor Ghost Values (.reepolee/i18n/)

The database is the source of truth, but a {_ system.auth.login.ui.title } tag in a .ree file shows you a key path, not the string it resolves to. To close that gap in the editor, Reepolee generates a set of JSON files that the ree-templates VS Code extension reads to display the translated value as a ghost (inlay hint) right next to each {_ ... } /{- ... } tag.

A .ree template in VS Code showing the translated string rendered as a dimmed ghost value next to each {_ ... } translation tag

On bun dev startup - and again whenever translations reload via /__reload-translations - lib/emit_translations.ts dumps the loaded trees to:

.reepolee/
  i18n/
    en-us.json
    sl-si.json
    ...

One file per active locale. Each file is the full nested tree for that locale exactly as the loader holds it in memory (the global routes.* strings plus every namespace subtree), so the extension can resolve a template's per-route strings from it. This mirrors how ReeWeb ships its translations as co-located locale JSON, which is what the extension was originally built to read - the emit lets the same extension light up Reepolee's DB-backed strings too.

Three things worth knowing:

  • They are generated, not authored. These files are a working-folder aid for the editor, never a source of truth. Editing them changes nothing at runtime - the next bun dev or reload overwrites them from the database. To change a string, use the database (above).
  • Dev only. The dump runs on bun dev bootstrap and on the dev reload endpoint. It never runs in production or under test.
  • Not committed. .reepolee/ is in .gitignore. The files regenerate on the next dev run, so there is nothing to check in.

If the extension shows stale or missing ghosts, restart bun dev (or hit /__reload-translations) to re-emit from the current database state.