Skip to content

Locale & i18n

pidgn ships two pieces for internationalization:

  • localeMiddleware resolves the best locale for each request (query param, cookie, Accept-Language header, default).
  • pidgn.i18n is a translation runtime — comptime-defined catalogs, placeholder interpolation, plural selection.

Resolution order (first match wins):

  1. Query param — default ?lang=...
  2. Cookie — default locale=...
  3. Accept-Language header (highest q value that matches an available locale)
  4. Configured default

The middleware writes the result to ctx.assigns["locale"]. Handlers and templates read it from there.

OptionTypeDefaultDescription
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.

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" },
} },
} },
},
};
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 fr

t and tn both fall back in this order:

  1. Requested locale.
  2. default_locale from the Translations struct.
  3. The raw key itself (so missing keys show up clearly in development).

Both {name} and numeric placeholders ({count}) are supported. Unknown placeholders are emitted verbatim — not replaced with empty strings — so missing args surface loudly.

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.

locales/fr.po
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.

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 by localeMiddleware), falling back to the catalog’s default_locale.
  • The returned slice is allocated from the request arena — no explicit free.
  • If you didn’t call setGlobal, ctx.t returns 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.

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"].

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.

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).

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>
  1. Author your translations in .po or 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"));
    }
  2. Register at startup:

    var loaded = try @import("translations.zig").load(gpa);
    pidgn.i18n.setGlobal(&loaded.translations);
  3. Register the locale middleware:

    pidgn.localeMiddleware(.{
    .available = &.{ "en", "fr", "de" },
    .default = "en",
    }),
  4. Translate inside handlers with the short form:

    const msg = try ctx.t("greet", .{ .name = user.name });

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.

  • Translations live in-memory. Editing a string requires a rebuild. For copy-heavy apps with frequent edits, wrap Translations with 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.