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, SQLINSERT/UPDATE, orbun reeman sync-translationswhen 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:
| Column | Purpose |
|---|---|
locale | Full BCP 47 locale code (en-us, sl-si, ... ) |
namespace | Dotted namespace (system.auth.login, or empty / root for globals) |
key_path | Dotted key within the namespace (ui.title, actions.submit) |
translation | The 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 = ""orrootbecomesroutes.<key_path>for global strings.namespace = "system.auth.login"becomessystem.auth.login.<key_path>.key_path = "nav"is stored underroutes.nav.<namespace>so navigation labels stay separate from page text.key_path = "nav_prefix_title"is stored underroutes.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 family | Contains | Common consumer |
|---|---|---|
ui | Headings, body copy, page title, descriptive labels | {_ ui.title }, {_ ui.* } |
actions | Button text - submit, save, cancel, delete, back | {_ actions.* } |
errors | Validation messages by rule key | validate(data, ctx.translations.errors) |
messages | Toast and confirmation strings | Toast payloads |
fields | Per-field metadata (field_name.label, etc.) | Form templates |
search | Search-form copy | List page templates |
selectors | Dropdown options (yes/no, per-page) | List page templates |
nav | Navigation menu entries | Layout 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_nameis 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
UPDATEorINSERTagainst thetranslationstable. - Use the
/system/translationsadmin UI. - Run
bun reeman sync-translationsto fill missing rows across locales.
The Translations Admin Module
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 command | Direction | Output |
|---|---|---|
| Prune unused translations | DB keys not referenced by any template | DELETE statements for orphaned rows |
| Sync missing translations | Template refs not present in the DB | INSERT 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:
- Reference the key in the template with
{_ your_new_key }or{- your_new_key }. - Add the row to the
translationstable for each locale, or use Sync missing translations to scaffold the missing rows. - Fill the values through
/system/translationsor 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.
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 devor reload overwrites them from the database. To change a string, use the database (above). - Dev only. The dump runs on
bun devbootstrap 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.

