i18n API

Piko's internationalisation surface has four parts. Configuration via piko.WithWebsiteConfig, accessors on *piko.RequestData, the *Translation fluent builder, and the DateTime wrapper for date/time variables. Piko derives the search-engine optimisation (SEO) head (canonical URL and hreflang alternates) automatically, as described below. Translation sources are JSON files in locales/<locale>.json and per-page <i18n> blocks. For task recipes see the basic setup how-to, the routing strategy how-to, and the pluralisation how-to. For the design rationale see about i18n.

Configuration

piko.I18nConfig

type I18nConfig struct {
    DefaultLocale string   `json:"defaultLocale"`
    Strategy      string   `json:"strategy"`
    Locales       []string `json:"locales"`
}
FieldTypePurpose
DefaultLocalestringLocale served when no other selection applies. Example "en".
StrategystringURL-encoding strategy; one of the values below.
Locales[]stringEvery supported locale code. Each code needs a matching locales/<code>.json file. An empty slice disables i18n.

Pass it via WithWebsiteConfig in your func main:

ssr := piko.New(
    piko.WithWebsiteConfig(piko.WebsiteConfig{
        I18n: piko.I18nConfig{
            DefaultLocale: "en",
            Strategy:      "prefix_except_default",
            Locales:       []string{"en", "fr", "de"},
        },
    }),
)

Strategy values

Strategy is a string. The runtime recognises exactly three values:

ValueBehaviour
"query-only"Default. Single path per page. The locale comes from a query parameter or detection (/about?lang=fr).
"prefix"Every locale carries a prefix, including the default (/en/about, /fr/about).
"prefix_except_default"The default locale is bare. Non-default locales carry a prefix (/about, /fr/about).

To turn i18n routing off entirely, leave Locales empty (or omit it).

Note: No piko.StrategyPrefix* constant exists. Pass the string literal directly in code ("prefix_except_default", "query-only", etc.).

Per-page opt-in

A page enables multi-locale routing by declaring a SupportedLocales function in its <script type="application/x-go"> block:

func SupportedLocales() []string {
    return []string{"en", "fr", "de"}
}

Without SupportedLocales, the page produces a single route under the default locale even when other pages opt in. The router reads the list at build time.

Request accessors

Methods on *piko.RequestData. Available in template expressions (the r. prefix is implicit) and in Render().

MethodReturnsPurpose
r.Locale()stringActive locale for the request (for example "en-GB").
r.DefaultLocale()stringConfigured default locale.
r.T(key, fallbacks...)*TranslationLook up key in the global store first, then the per-page <i18n> block. Variadic fallbacks tried in order.
r.LT(key, fallbacks...)*TranslationLook up key only in the per-page <i18n> block. Variadic fallbacks tried in order.
r.LF(value)*FormatBuilderWrap a value for locale-aware formatting outside a translation.

Inside templates, the T and LT shortcuts call r.T and r.LT:

<h1>{{ T("nav.home") }}</h1>
<p>{{ LT("page.heading") }}</p>

Fallback arguments

T and LT have signature T(keyAndFallbacks ...string) *Translation. The first argument is the key. Every subsequent argument is a fallback string the runtime tries in order if the lookup chain fails. You may supply multiple fallbacks:

r.T("button.save", "Save")
r.T("page.subtitle", "page.heading", "Welcome")

The first non-empty resolution wins. The literal key is the final fallback if every preceding fallback also resolves empty.

T versus LT

r.T(key) walks the global store first and falls back to the page's local store. Use it for translations that come from i18n/*.json and may also be locally overridden.

r.LT(key) only ever consults the local store. Use it when a string lives in this PK file's <i18n> block and you do not want a same-named global key to shadow it accidentally.

The translation builder

r.T(...) and r.LT(...) return a *Translation (alias piko.Translation). The builder is mutable, fluent, and pooled. Every setter returns the same *Translation so calls chain.

Terminate the chain with String() to render. The *Translation also implements fmt.Stringer, which means template expressions render it implicitly:

{{ T("greeting").StringVar("name", state.User) }}

Variable setters

MethodSignatureLocale-aware?Purpose
StringVar(name, value string)(string, string) *TranslationNoBind a string.
IntVar(name string, value int)(string, int) *TranslationNoBind a Go int.
FloatVar(name string, value float64)(string, float64) *TranslationYesBind a float64; locale picks the decimal separator and thousands grouping.
DecimalVar(name string, value maths.Decimal)(string, maths.Decimal) *TranslationYesBind an arbitrary-precision decimal from piko.sh/piko/wdk/maths.
MoneyVar(name string, value maths.Money)(string, maths.Money) *TranslationYesBind a money value; locale picks the currency-symbol position, separators, and digit grouping.
BigIntVar(name string, value maths.BigInt)(string, maths.BigInt) *TranslationYesBind an arbitrary-precision integer with locale grouping.
TimeVar(name string, value time.Time)(string, time.Time) *TranslationYesBind a time.Time with the medium date-time style for the locale.
DateTimeVar(name string, value DateTime)(string, DateTime) *TranslationYesBind a date/time with an explicit style; see The DateTime wrapper.
Var(name string, value any)(string, any) *TranslationNoGeneric fallback; uses fmt.Stringer if implemented, else %v.

Pluralisation

MethodSignaturePurpose
Count(n int)(int) *TranslationSet the count used for Common Locale Data Repository (CLDR) plural form selection. Auto-binds ${count}.
r.T("cart.items").Count(5).String()
// "5 items" with "one item|${count} items" in en

See How to pluralise translations for the pipe-separated form syntax and per-language rule sets.

Terminus

MethodSignaturePurpose
String()() stringResolve key, select plural form, render template parts, return the string. Releases the builder back to the pool.

The *Translation is also fmt.Stringer, so passing it directly into a template expression invokes String() implicitly.

Lifecycle

Piko pools the builder. Calling String() (or invoking it through fmt.Stringer) releases the builder. Do not retain a *Translation past the render that produced it.

Date and time formatting

Two builders cover locale-aware temporal formatting from user code: TimeVar on a *Translation and r.LF(value) for standalone formatting.

TimeVar

Embeds a time.Time inside a translation with the medium style for the active locale:

r.T("event.starts").TimeVar("at", record.StartsAt)

For a different style, format outside the translation with LF and bind the result with StringVar:

formatted := r.LF(record.StartsAt).Long().DateOnly().String()
r.T("event.starts").StringVar("at", formatted)

LF (format builder)

r.LF(value) returns a *FormatBuilder for inline locale-aware formatting. The bare LF and F helpers are also available in templates. The builder accepts time.Time, int*, uint*, float32/float64, string, bool, maths.Decimal, maths.BigInt, maths.Money, and time.Duration.

Style methods (each returns the same builder for chaining):

MethodStyleDate example (en-GB)Time example
Short()Compact02/01/200615:04
Medium()Default2 Jan 202615:04:05
Long()Explicit2 January 202615:04:05 GMT
Full()Most verboseMonday, 2 January 202615:04:05 GMT
DateOnly()Render only the date portion.
TimeOnly()Render only the time portion.
UTC()Convert to UTC before formatting.
Precision(n)Set decimal precision for numeric values.
Locale(code)Override the active locale for this builder only.

Terminate with String() (or rely on fmt.Stringer in templates).

Note: DateTimeVar exists on *Translation and accepts an internal DateTime value type. The type lives under piko.sh/piko/internal/..., which Go's internal-package rule prevents user code from importing. The same outcomes are reachable through TimeVar plus LF; use those from user code.

Translation sources

Global JSON files

Place one file per locale at locales/<locale>.json. The runtime ignores files outside this directory. The file's basename (so en.json becomes en) is the locale code.

{
  "nav": {
    "home": "Home",
    "about": "About"
  },
  "items": "no items|one item|${count} items"
}

Nested objects flatten to dot-separated keys. nav.home is the lookup key for "Home".

Per-page <i18n> blocks

Declare page-scoped translations inside a .pk file:

<i18n lang="json">
{
  "en": { "heading": "Welcome" },
  "fr": { "heading": "Bienvenue" }
}
</i18n>

A file may contain multiple <i18n> blocks. The compiler merges them.

Note: The compiler honours only lang="json". It silently skips blocks declared with any other lang attribute.

The block's top-level keys are locale codes. Each locale's value is the same nested-key shape as a global file.

Template-string syntax

Translation values support three constructs.

Variable interpolation

${expression} evaluates a Piko expression at render time. The expression sees variables bound through the *Var setters and the implicit count from Count(n).

{
  "greeting": "Hello, ${name}!",
  "summary": "${user} has ${count} items"
}

Expressions accept operators and property access (${user.firstName}, ${count + 1}).

Linked-message references

@key.path embeds another translation. The runtime resolves the reference against the same locale and store, passing through all bound variables:

{
  "common": {
    "appName": "MyApp"
  },
  "welcome": "Welcome to @common.appName, ${name}!"
}

The runtime caps recursion at depth 10 to break cycles.

Backslash escaping

\$ and \@ produce literal $ and @ characters:

{
  "email": "support\\@example.com",
  "price": "\\${amount}"
}

(The double backslash is JSON's escape. The parser sees \@ and \$.)

Pluralisation

Translation values may carry pipe-separated plural forms:

{
  "items": "no items|one item|${count} items"
}

Count(n) selects the form for the active locale. CLDR rules cover six categories (zero, one, two, few, many, other) and the form ordering follows the rule set per locale family. See How to pluralise translations for ordering tables.

To represent a literal pipe inside a form, escape it as ||.

Locale fallback chain

When r.T(key) or r.LT(key) resolves, the lookup walks:

  1. The exact requested locale (for example en-GB).
  2. The base language (for example en).
  3. DefaultLocale from I18nConfig.
  4. Each subsequent argument passed to T/LT (variadic fallbacks), tried in order.
  5. The literal key.

r.T(key) consults the global store first and the local store second. r.LT(key) only consults the local store.

SEO metadata

Piko derives the locale SEO head automatically. When a page declares SupportedLocales(), the framework fills Metadata.Language, Metadata.CanonicalURL, and Metadata.AlternateLinks from the page's registered per-locale route patterns, so it emits the <html lang>, <link rel="canonical">, and <link rel="alternate" hreflang="..."> tags correctly with no per-page code.

func SupportedLocales() []string { return []string{"en", "fr", "de"} }

func Render(r *piko.RequestData, props Props) (Response, piko.Metadata, error) {
    // Language, CanonicalURL and AlternateLinks are derived automatically from
    // the page's registered locale routes.
    return Response{}, piko.Metadata{Title: props.PageTitle}, nil
}

The canonical origin comes from the configured site URL (the SEO sitemap hostname) when set, otherwise the request host. A page may still set Metadata.CanonicalURL or Metadata.AlternateLinks explicitly to override the derived values.

The <piko:a> element rewrites its href for the active locale:

<piko:a href="/about">{{ T("nav.about") }}</piko:a>

A request on /fr/about clicking that link lands on /fr/about. With data-locale="en" the link points at the English variant regardless of the active locale, useful for language switchers.

See also

Integration tests: tests/integration/i18n/.