Translations

Introduction

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 language 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 run sync:languages 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
langLanguage code (en, sl, ... )
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 language.

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 language 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-English language, 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-English languages are replaced with a visible placeholder.
  • route_name is never inherited from another language.
  • 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 run sync:languages to fill missing rows across languages.

The Translations Admin Module

Screenshot - 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/helpers picks the right one using Intl.PluralRules:

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

// 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 Languages

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

bun run sync:languages

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

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 (lang, 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 language, 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, call reload_all_translations() from $lib/i18n or restart the server.

import { reload_all_translations } from "$lib/i18n";

export async function get_dev_reload_translations(req: BunRequest): Promise<Response> {
    await reload_all_translations();
    return new Response("Reloaded", { status: 200 });
}

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