AnnouncementsIntroducing Arkoren

    Menu

    Localization

    Select a request language from client preferences and render validation messages with catalogs and explicit fallbacks.

    What's on this page
    1. Build language catalogs and select a request language from query, cookie, and header preferences.
    2. Render validation messages and application text with explicit regional and missing-message fallbacks.
    3. Load translations before middleware construction and check language-dependent responses.
    On this page

    A signup form can use the same validation rules for every visitor while showing messages in each visitor’s language. The locale middleware selects a language once per request and puts it, with a catalog of message templates, in the request context. Validation uses that context to render messages; your handlers can use the language for their own text.

    Arkoren does not provide a general translation engine, date/number formatting, or plural handling. Keep application text in your own catalogs or translation library. This page connects language selection to messages; the validation error guide owns error conversion and relocalization.

    Build Catalogs

    A validate.Catalog names a default language and maps supported language tags to validate.Messages. Each message maps a stable violation code, such as string.min, to plain text with optional {param} placeholders. Import arkoren.dev/foundation/validate and define a small Spanish translation:

    spanish := validate.Messages{
    	"required":     "es obligatorio",
    	"string.min":   "debe tener al menos {min} caracteres",
    	"string.email": "debe ser un correo electrónico válido",
    }

    For string.min, {min} comes from the violation’s min parameter. A missing parameter leaves its placeholder visible, so test spelling. Lists render with commas and times use RFC 3339. Templates do not include the field name; the validation error’s path identifies the input. Use number-independent phrasing when a language needs plural forms the template system cannot express.

    Add English as the default, using the independently mutable copy returned by validate.English():

    catalog := validate.Catalog{
    	Default: "en",
    	Locales: map[string]validate.Messages{
    		"en": validate.English(),
    		"es": spanish,
    	},
    }

    Catalog keys determine which languages clients can select. The Spanish catalog can be incomplete: missing messages fall back rather than disabling the language.

    Select Languages

    With an existing router routes, import arkoren.dev/core/http/middleware/locale and register middleware before routes that validate or use translated text:

    routes.Use(locale.Middleware(func(language *locale.Builder) {
    	language.Catalog(catalog)
    }))

    This uses Accept-Language; query and cookie selectors are disabled by default. For example, Accept-Language: es-MX,es;q=0.9,en;q=0.5 selects the catalog key es and sets Content-Language: es. Without locale middleware, validation uses built-in English and locale.FromContext returns an empty string.

    If links should force a language and your interface remembers a visitor’s choice, use this alternative registration:

    routes.Use(locale.Middleware(func(language *locale.Builder) {
    	language.Catalog(catalog)
    	language.QueryParam("lang")
    	language.Cookie("language")
    }))

    The middleware tries the first supported choice in this order:

    1. The named query parameter, such as ?lang=es.
    2. The named cookie, such as language=es.
    3. Accept-Language, ordered by client preference.
    4. The catalog default, or en if it is empty.

    An absent or unsupported query value falls through rather than blocking the cookie/header. Empty selector names disable those sources. Cookie detection only reads: your application writes the preference cookie and chooses its security attributes and lifetime. There is no locale cookie-lifetime API. Client language preferences are presentation input, not proof of identity or permission.

    Matching ignores case, surrounding whitespace, and _ versus -, then tries the base language. A request for es-MX can select es; a request for es does not select an es-MX-only entry. Add base-language entries when they should be selectable. If nothing matches, the default remains usable even without an entry in Locales.

    Header Weights

    Header candidates use descending q weights; omitted weights are 1 and ties retain header order. Thus fr;q=0.5, es;q=0.9, en;q=0.9 tries es, en, then fr.

    Weights accept HTTP decimal syntax: 0 or 1, optionally a decimal point and up to three digits; digits after 1 must be zero. 0.9 and 1.000 are valid; .9, +0.9, 1.001, 0.1234, exponent/hexadecimal forms, and nonfinite values are not. Missing or duplicate q parameters invalidate that entry. Malformed language tags, wildcards, invalid weights, and q=0 entries are skipped, not request errors. Skipping a candidate does not veto the configured fallback.

    Configuration Checks

    The completed catalog and nonempty cookie name are checked after every construction callback has run. Tags require one to eight ASCII letters followed by optional one-to-eight-character alphanumeric subtags, separated with - or _; surrounding ASCII whitespace is ignored for matching. This is a shape check, not full BCP 47 validation or a registry lookup.

    Invalid tags, duplicate normalized keys such as en-US and EN_us, and cookie names that fail net/http.Cookie.Valid panic during construction. An empty catalog/default is valid. Callbacks run once in order, so a later callback can replace an earlier invalid setting; nil callbacks panic. Finish loading messages before construction: middleware copies both catalog map layers, and later edits or retained builders do not reconfigure it.

    Read Languages

    Use the selected language for application-owned text. In a handler with incoming request r, w http.ResponseWriter, and imports net/http and arkoren.dev/core/http/response:

    switch locale.FromContext(r.Context()) {
    case "es":
    	return response.String(w, http.StatusOK, "Hola")
    default:
    	return response.String(w, http.StatusOK, "Hello")
    }

    The Spanish request above receives Hola. The default branch also covers tests or callers without middleware. For a larger catalog, use your translation library with this language rather than growing a switch for every message.

    A match preserves the original catalog spelling. A key ES_mx becomes context value ES_mx, but response header Content-Language: ES-mx: headers trim whitespace and replace underscores without changing case. The default uses its catalog spelling when an exact normalized key exists; otherwise it preserves Catalog.Default, or uses en. Your application text lookup must handle that fallback even when it has no validation catalog entry.

    The middleware adds Accept-Language to Vary, and Cookie when cookie selection is enabled. Error responses carry these headers too. Query selection is part of the URL, not an additional Vary header. Ensure any proxy cache also keys on the relevant query values; these headers do not make personalized responses safe for shared caching.

    Translate Messages

    Existing request validation renders violations with the request’s catalog and language. Pass r.Context() to schemas called directly rather than substituting a background context. An existing signup route rejecting a short password can produce this Spanish violation:

    {
      "code": "string.min",
      "params": {
        "min": 12
      },
      "message": "debe tener al menos 12 caracteres"
    }

    Only the message changes. Codes, params, paths, and the HTTP problem title stay stable for programmatic clients. See translated validation errors for the response boundary and Errors.Localize; this page does not replace your schema or diagnostic ownership policy.

    For an application rule with stable code username.reserved and public parameter name, add its message before middleware construction:

    catalog.Locales["es"]["username.reserved"] = "{name} está reservado"

    The parameter is inserted into the visible message. Never put credentials or other private values in message params. Use application-specific code prefixes to avoid future built-in collisions; custom rules owns producing the violation.

    Conditional messages can use specialized keys such as required_if:eq. A plain required_if translation is sufficient when every condition should use the same wording: it takes precedence over the built-in English variant. Add specialized translations when the condition needs a more precise sentence.

    Handle Fallbacks

    Message lookup differs from language negotiation. For an ordinary code it tries:

    1. The request language, then its base language.
    2. The catalog default, then its base language.
    3. Built-in English.
    4. The code itself if no template exists.

    A regional catalog can therefore override a few messages while its base catalog supplies the rest. Lookup uses stored spellings, not negotiation’s case folding: keep regional/base keys consistent, such as es-MX with es. A partly translated response can contain default-language or English messages even though its Content-Language reports the selected language.

    A missing custom translation can appear as "message": "username.reserved". The code remains valid for clients, but the reader sees an identifier. Test custom codes as well as built-in ones rather than assuming English knows every application message. Relocalizing existing errors preserves their original message when no template is known.

    Load Translations

    For larger catalogs, keep each language in a JSON object mapping codes to templates. For example, the application file locales/es.json can contain:

    {
      "required": "es obligatorio",
      "string.min": "debe tener al menos {min} caracteres"
    }

    Embed the files with the standard embed package at package scope:

    //go:embed locales/*.json
    var translations embed.FS

    At startup, decode each file with encoding/json/v2 before registering middleware:

    data, err := translations.ReadFile("locales/es.json")
    if err != nil {
    	return err
    }
    messages := validate.Messages{}
    if err := json.Unmarshal(data, &messages); err != nil {
    	return err
    }
    catalog.Locales["es"] = messages

    Fail startup on missing or malformed files instead of silently dropping a language. Middleware owns its copied catalog, not later mutations to these maps.

    Check Responses

    Send requests through your configured router, not only directly to a schema, to verify language selection and response headers together. With net/http/httptest and the /greeting route using the handler above:

    incoming := httptest.NewRequest(http.MethodGet, "/greeting?lang=es", nil)
    incoming.Header.Set("Accept-Language", "en")
    result := routes.Record(incoming)

    With query selection enabled, expect Content-Language: es and body Hola despite the English header. The caller owns result.Body and must close it after reading. Also test an unsupported query falling through to cookie/header, header weights, and a missing translation. Compare your supported rule codes with each language’s templates; validate.English() supplies built-in keys, not your custom code inventory.

    For selection settings and message APIs, see the locale reference and validate reference.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive