Locale & i18n
pidgn ships two pieces for internationalization:
localeMiddlewareresolves the best locale for each request (query param, cookie,Accept-Languageheader, default).pidgn.i18nis a translation runtime — comptime-defined catalogs, placeholder interpolation, plural selection.
Locale detection middleware
Section titled “Locale detection middleware”Resolution order (first match wins):
- Query param — default
?lang=... - Cookie — default
locale=... Accept-Languageheader (highestqvalue that matches an available locale)- Configured
default
The middleware writes the result to ctx.assigns["locale"]. Handlers and templates read it from there.
Configuration
Section titled “Configuration”| Option | Type | Default | Description |
|---|---|---|---|
available | []const []const u8 | (required) | Locales you ship translations for. |
default | []const u8 | "en" | Fallback when nothing matches. |
cookie_name | []const u8 | "locale" | Empty disables cookie override. |
query_param | []const u8 | "lang" | Empty disables query override. |
Matching is prefix-based — an Accept-Language of en-US matches an available en.
const App = pidgn.Router.define(.{ .middleware = &.{ pidgn.localeMiddleware(.{ .available = &.{ "en", "fr", "de", "es" }, .default = "en", }), // ... }, .routes = routes,});
fn index(ctx: *pidgn.Context) !void { const locale = ctx.getAssign("locale") orelse "en"; // ... use it to pick translations}You can also use pidgn.detectLocale(ctx, config) or pidgn.negotiateLocale(header, available) directly if you don’t want the middleware.
Translation runtime
Section titled “Translation runtime”Catalogs are plain comptime data. No file loading at startup — the whole translation table is in the binary.
pub const translations = pidgn.Translations{ .default_locale = "en", .locales = &.{ .{ .code = "en", .entries = &.{ .{ .key = "greet", .value = "Hello, {name}!" }, .{ .key = "items", .plurals = &.{ .{ .count = 0, .value = "no items" }, .{ .count = 1, .value = "1 item" }, .{ .count = pidgn.Translations.other_count, .value = "{count} items" }, } }, } }, .{ .code = "fr", .entries = &.{ .{ .key = "greet", .value = "Bonjour, {name} !" }, .{ .key = "items", .plurals = &.{ .{ .count = 0, .value = "aucun article" }, .{ .count = 1, .value = "1 article" }, .{ .count = pidgn.Translations.other_count, .value = "{count} articles" }, } }, } }, },};Looking up a translation
Section titled “Looking up a translation”const msg = try pidgn.i18n.t( ctx.allocator, translations, ctx.getAssign("locale") orelse "en", "greet", .{ .name = @as([]const u8, "Ivan") },);defer ctx.allocator.free(msg);// "Hello, Ivan!" in en; "Bonjour, Ivan !" in frconst msg = try pidgn.i18n.tn( ctx.allocator, translations, locale, "items", n, .{ .count = n },);defer ctx.allocator.free(msg);n == 0→"no items"(or"aucun article"in fr).n == 1→"1 item".- Anything else → the
other_countform, interpolated withcount = n.
Fallback chain
Section titled “Fallback chain”t and tn both fall back in this order:
- Requested locale.
default_localefrom theTranslationsstruct.- The raw key itself (so missing keys show up clearly in development).
Placeholders
Section titled “Placeholders”Both {name} and numeric placeholders ({count}) are supported. Unknown placeholders are emitted verbatim — not replaced with empty strings — so missing args surface loudly.
Loading translations at runtime
Section titled “Loading translations at runtime”Hand-authoring Translations literals in Zig is fine for small apps but tedious for anything with real copy. Pidgn ships loaders for two external formats — .po (GNU gettext) and JSON — that return the same Translations value. Pair with @embedFile so the translations still live in the binary and startup is zero-I/O.
.po (gettext)
Section titled “.po (gettext)”msgid ""msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "greet"msgstr "Bonjour, {name} !"
msgid "items_singular"msgid_plural "items_plural"msgstr[0] "1 article"msgstr[1] "{count} articles"const fr_bytes = @embedFile("../locales/fr.po");var fr = try pidgn.i18n.loadPo(allocator, "fr", fr_bytes);// fr.translations is ready to use; call fr.deinit() at shutdown.Supported: msgid / msgstr, msgid_plural / msgstr[N], # comments, multi-line concatenated strings. The gettext header entry (empty msgid) is skipped.
{ "default_locale": "en", "locales": { "en": { "greet": "Hello, {name}!", "items": { "0": "no items", "1": "1 item", "other": "{count} items" } }, "fr": { "greet": "Bonjour, {name} !", "items": { "0": "aucun article", "1": "1 article", "other": "{count} articles" } } }}const bytes = @embedFile("../locales/all.json");var loaded = try pidgn.i18n.loadJson(allocator, bytes);String values are plain entries. Object values are plurals — numeric-string keys are exact counts, "other" is the fallback.
Both loaders return a Loaded value with an owning arena. Keep it for the life of the process (a web server) and drop it only at shutdown.
Simpler call-site: ctx.t and ctx.tn
Section titled “Simpler call-site: ctx.t and ctx.tn”Passing allocator, catalog, locale, key, and args to every translation call gets old. Register the catalog once, then handlers call ctx.t(key, args):
// Startup:var loaded = try pidgn.i18n.loadJson(allocator, @embedFile("locales.json"));pidgn.i18n.setGlobal(&loaded.translations);
// Handler:fn welcome(ctx: *pidgn.Context) !void { const heading = try ctx.t("greet", .{ .name = user.name }); try ctx.render(Template, .ok, .{ .heading = heading });}- Locale is read from
ctx.assigns["locale"](populated bylocaleMiddleware), falling back to the catalog’sdefault_locale. - The returned slice is allocated from the request arena — no explicit free.
- If you didn’t call
setGlobal,ctx.treturns a copy of the key rather than crashing.
Plural variant:
const count_msg = try ctx.tn("items", n, .{ .count = n });The explicit pidgn.i18n.t(...) form is still available as an escape hatch when you need a different catalog per request or per call.
Template integration
Section titled “Template integration”Templates can call translations directly via two pipes that the framework installs on every ctx.render*: t for lookups and tn for plurals.
<h1>{{"welcome_title" | t}}</h1><p>{{user_count | tn:"users_online"}}</p>The handler no longer needs to pre-render string fields just for labels — the pipes read the process-global catalog (set via setGlobal) and the request locale from ctx.assigns["locale"].
{{"key" | t}} — lookup
Section titled “{{"key" | t}} — lookup”The pipe input is the translation key. A quoted literal ("welcome_title") is the common form; a data field holding a key string works too:
{{"welcome_title" | t}} <!-- static key -->{{section.heading | t}} <!-- key computed in the handler -->The result is HTML-escaped by default; use triple braces to emit raw HTML when the translation legitimately contains markup:
{{{"rich_welcome" | t}}}If no catalog is installed, the pipe falls back to the key unchanged — safe in tests, scripts, and partial deployments.
{{count | tn:"key"}} — plural
Section titled “{{count | tn:"key"}} — plural”The pipe input is the count (any integer field in the data struct); the pipe arg is the key. If the selected plural form contains {count}, it’s substituted with the numeric input automatically:
{ "en": { "items": { "0": "no items", "1": "1 item", "other": "{count} items" } }}{{count | tn:"items"}}<!-- count = 0 → "no items" --><!-- count = 1 → "1 item" --><!-- count = 5 → "5 items" -->No other placeholder names are supported at template level. For translations with arbitrary {name}-style placeholders, pre-render in the handler (see below).
Still pre-render for complex placeholders
Section titled “Still pre-render for complex placeholders”The t/tn pipes cover the bulk of template strings — labels, headings, counts — but they don’t accept {name}-style args. For translations with other placeholders, use ctx.t in the handler and pass the result through the data struct:
fn welcome(ctx: *pidgn.Context) !void { try ctx.render(Template, .ok, .{ .heading = try ctx.t("greet", .{ .name = user.name }), .count = n, });}<h1>{{heading}}</h1><p>{{count | tn:"items"}}</p>Typical wiring
Section titled “Typical wiring”-
Author your translations in
.poor JSON and embed them:src/translations.zig const pidgn = @import("pidgn");const std = @import("std");pub fn load(allocator: std.mem.Allocator) !pidgn.i18n.Loaded {return pidgn.i18n.loadJson(allocator, @embedFile("../locales/all.json"));} -
Register at startup:
var loaded = try @import("translations.zig").load(gpa);pidgn.i18n.setGlobal(&loaded.translations); -
Register the locale middleware:
pidgn.localeMiddleware(.{.available = &.{ "en", "fr", "de" },.default = "en",}), -
Translate inside handlers with the short form:
const msg = try ctx.t("greet", .{ .name = user.name });
Choosing the locale cookie lifetime
Section titled “Choosing the locale cookie lifetime”If you expose a user-selectable locale, set it in a cookie with a reasonable max_age so repeat visits don’t redo Accept-Language negotiation:
ctx.setCookie("locale", "fr", .{ .max_age = 365 * 24 * 3600, .path = "/" });The middleware reads it automatically on subsequent requests.
Limitations
Section titled “Limitations”- Translations live in-memory. Editing a string requires a rebuild. For copy-heavy apps with frequent edits, wrap
Translationswith a hot-reloadable loader. - Plural rules are count-matching, not CLDR — “1”, “0”, and
other_count. Languages with more plural forms (Russian, Arabic, Polish) need more custom plural entries for each distinct form.