i18n

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package i18n is wowapi's cross-cutting message-catalog and locale-negotiation kernel. It gives every product built on the framework one consistent way to localize synchronous API response strings — problem-detail titles/details and validation field messages — without each product re-implementing risky translation plumbing (GAP-001).

The design has four pieces:

  • Catalog: an in-process (locale, key) -> message map with a deterministic fallback to a single default locale, and a final fallback to the key itself so a missing translation can never break a response. Lookups are read-only and allocation-free, safe to call on the request path.
  • Registry: the module/product registration surface. It ships the framework's own English catalog as the first bundle (problem titles + validation tag messages, under the reserved "kernel." namespace) and lets modules add their own bundles under their "<module>." prefix — mirroring how kernel/notify and kernel/seeds accumulate module contributions and surface ownership errors at boot via Err().
  • Negotiate: RFC 9110 §12.5.4 Accept-Language q-value content negotiation, ported from the battle-tested wowsociety implementation.
  • Well-known keys (KeyProblemTitle / KeyValidationMessage): the stable keys the framework's own English messages are stored under, so kernel/httpx and kernel/validation can localize their output while keeping machine Codes byte-stable.

English is the default locale and the ultimate fallback. Internal logs are unaffected — they stay technical English by never routing through this package.

Sources, precedence, and loading (B1 / GAP-001B)

Catalogs are built from first-class Sources (Source/Loader) in a fixed precedence: embedded framework YAML defaults -> product framework-override files -> product/module catalog files -> compiled Go bundles -> (reserved) DB overlay. See loader.go (LoadCatalog/Layer/Policy), the fs and Go sources, and config.go (BuildLayers). The framework's own English strings live in embedded per-locale YAML (locales/<locale>/kernel.yaml), not hardcoded maps. After boot merges everything, the catalog is Frozen: request-time reads never race a write, and Add becomes a no-op (Decision 3). Products supply kernel.* translations through the sanctioned override files or Registry.RegisterFrameworkLocale, never raw post-boot Add.

Scope: static strings only (v1)

This package stores and returns STATIC strings. It has no message-template engine, named placeholders, or plural selection. The only parameter mechanism is the framework's %s-style validation messages, whose argument kernel/validation fills at render time — not the catalog; a translation must keep the same %-verb count (wowapi i18n validate enforces this). Products needing rich interpolation/pluralization format the final string in the handler and store only static fragments here. This is a deliberate, documented v1 limit.

Import boundary: stdlib + kernel/errors only. Never module, app, adapters, or testkit.

Index

Constants

View Source
const DefaultLocale = "en"

DefaultLocale is the framework's default locale and ultimate fallback. English is always present in the framework catalog.

Variables

This section is empty.

Functions

func KeyDetail

func KeyDetail(code string) string

KeyDetail is the well-known catalog key under which a localized problem-details Detail is stored for the given machine code (an *errors.Error's Code, or kind.DefaultCode() when Code is unset — exactly the code kernel/httpx.WriteError computes). Keyed by the stable machine code, never the English text, so a translation can never drift the code on the wire. Unlike KeyProblemTitle/KeyValidationMessage, there is no guarantee a given code has an entry: Detail only localizes where the framework (or a product) ships a stable, user-facing message for that code; otherwise the producer's Msg is used verbatim (see httpx.WriteError).

func KeyProblemTitle

func KeyProblemTitle(kind errors.Kind) string

KeyProblemTitle is the well-known catalog key under which the framework's English problem-detail title for kind is stored (and translations are keyed). It is derived from the kind's STABLE machine code (kind.DefaultCode()), never from the English text, so a translation can never drift the key and the machine Code on the wire stays byte-stable regardless of locale.

func KeyValidationMessage

func KeyValidationMessage(tag string) string

KeyValidationMessage is the well-known catalog key for the framework's English message for a validator tag (e.g. "required", "email", "min"). Keyed by the stable tag name, independent of the translated text, so the FieldError.Code stays stable.

func LocaleFrom

func LocaleFrom(ctx context.Context) string

LocaleFrom returns the bound locale tag, or "" if none.

func Negotiate

func Negotiate(acceptLanguage string, supported []string, def string) string

Negotiate picks the best locale from an HTTP Accept-Language header value against the supported list, falling back to def when the header is empty, unparseable, or names nothing supported.

It implements RFC 9110 §12.5.4 content negotiation, ported from the battle-tested wowsociety implementation. It is deliberately narrow: it matches on the primary language subtag only (a supported "mr" matches an offered "mr-IN"), which covers the common case without a full BCP 47 tag matcher. A "*" wildcard is intentionally NOT treated as a match for any specific supported locale — it expresses no preference, so we fall back to def. An offer with q=0 is an explicit refusal and is skipped.

func WithContext

func WithContext(ctx context.Context, locale string, cat *Catalog) context.Context

WithContext binds the negotiated locale tag and the catalog to resolve messages against. A nil cat is allowed (Catalog.Lookup on nil echoes keys).

Types

type Bundle

type Bundle struct {
	// Locale is the BCP 47 locale tag these messages are written in (e.g. "en",
	// "mr"). Required.
	Locale string
	// Messages maps stable message keys to their translated text for Locale.
	Messages map[string]string
}

Bundle is a set of messages a module (or the product) registers for one locale. Every key must be prefixed with the registering module's name ("<module>."); the framework owns the reserved "kernel." namespace.

type Catalog

type Catalog struct {
	// contains filtered or unexported fields
}

Catalog is an in-process message catalog keyed by locale then message key. The zero value is not usable; construct with NewCatalog. A nil *Catalog is a valid empty catalog (the zero-config path): every Lookup echoes the key.

func CatalogFrom

func CatalogFrom(ctx context.Context) *Catalog

CatalogFrom returns the bound catalog, or nil if none.

func LoadCatalog

func LoadCatalog(def string, layers ...Layer) (*Catalog, error)

LoadCatalog builds a frozen Catalog by merging layers in precedence order: earlier layers first, later layers overriding. def is the default/fallback locale (always "en" for the framework). It enforces, per the documented precedence rules:

  • intra-layer duplicate keys (same locale+key from two sources in ONE layer) fail — a hard authoring error;
  • namespace ownership: only a layer whose Policy.OwnsFramework is set may write kernel.* keys, and a FrameworkOverrideOnly layer may only override kernel.* keys a lower layer already defined (never introduce new ones);
  • a later layer overriding an earlier layer's key is allowed (precedence).

All violations across all layers are accumulated and returned as one error so a single run surfaces every problem (mirrors the seeds/registry boot pattern). On success the returned Catalog is ready for request-time reads; callers seal it with Freeze once boot completes.

func NewCatalog

func NewCatalog(def string) *Catalog

NewCatalog returns an empty Catalog whose fallback locale is def. def should be one of the locales later populated via Add so Lookup's fallback resolves to a real translation rather than echoing the key.

func (*Catalog) Add

func (c *Catalog) Add(locale, key, message string)

Add registers message for (locale, key), overwriting any prior value. Must be called on a Catalog built with NewCatalog (the zero value is not usable).

Add is a no-op after Freeze: catalogs are sealed at boot (Decision 3) and are read-only on the request path, so a post-freeze mutation attempt is silently ignored rather than racing concurrent Lookups. Boot-time construction (the Loader, Registry) writes before Freeze; if you need a post-boot overlay, that is the separate opt-in B13 concern, not raw Add.

func (*Catalog) Default

func (c *Catalog) Default() string

Default returns the catalog's fallback locale.

func (*Catalog) Freeze

func (c *Catalog) Freeze()

Freeze seals the catalog for request-time reads. After Freeze, Add is a no-op, so the messages map is never mutated concurrently with Lookup. Boot calls this once, after every source and module bundle has been merged. Freeze is idempotent. A nil *Catalog Freeze is a no-op.

func (*Catalog) Frozen

func (c *Catalog) Frozen() bool

Frozen reports whether the catalog has been sealed.

func (*Catalog) Locales

func (c *Catalog) Locales() []string

Locales returns the sorted set of locales with at least one registered message.

func (*Catalog) Lookup

func (c *Catalog) Lookup(locale, key string) (message, resolvedLocale string)

Lookup resolves key for locale. It returns the resolved message and the locale it was actually served from. Resolution is deterministic:

  1. exact (locale, key) if present;
  2. otherwise (default-locale, key) if present;
  3. otherwise the key itself, with the default locale — never an error, so a missing translation cannot break a response.

A nil *Catalog echoes the key with an empty resolved locale.

func (*Catalog) Supports

func (c *Catalog) Supports(locale string) bool

Supports reports whether locale has at least one registered message. A nil *Catalog supports nothing.

type GoSource

type GoSource struct {
	// contains filtered or unexported fields
}

GoSource is a compiled Go catalog bundle: an in-process set of RawBundles a product assembles in Go (generated code or hand-written) and passes to the loader. It is the Go-native, compile-time-owned equivalent of the YAML/JSON catalog files — a product package exports these bundles and the composition root feeds them into the Go layer of LoadCatalog.

Idiomatic shape: a product's internal/i18n/catalogs package exposes a function returning []i18n.RawBundle (or an i18n.Source built from them via NewGoSource), which the generated cmd mains pass through config-driven wiring. Because it is plain Go, the compiler enforces that the bundles exist and typecheck.

func NewGoSource

func NewGoSource(bundles ...RawBundle) *GoSource

NewGoSource wraps compiled RawBundles as a Source. Each bundle should set a stable Origin (e.g. "internal/i18n/catalogs/en.go") for clear validation errors. The loader applies the configured layer's ownership policy to these keys exactly as it does for fs sources.

func (*GoSource) Kind

func (g *GoSource) Kind() SourceKind

Kind reports KindGo.

func (*GoSource) Load

func (g *GoSource) Load() ([]RawBundle, error)

Load returns the compiled bundles. It never errors — the bundles are already in memory and typechecked; ownership/duplicate validation is the loader's job.

type Layer

type Layer struct {
	// Name is a short label used in error messages ("framework defaults",
	// "product overrides", "catalogs", "go bundles").
	Name string
	// Policy is the ownership rule applied to every key in this layer.
	Policy Policy
	// Sources are the sources that make up this layer, loaded in order. Their
	// contributions are pooled and intra-layer duplicates are rejected.
	Sources []Source
}

Layer is one precedence tier: an ordered set of sources plus the ownership policy applied to every key they contribute. Layers are merged in the order passed to LoadCatalog; a later layer overriding an earlier layer's key is allowed (that is the whole point of precedence), but two sources WITHIN one layer defining the same (locale, key) is a conflict and fails validation.

func BuildLayers

func BuildLayers(root fs.FS, specs []SourceSpec) ([]Layer, error)

BuildLayers turns a product's ordered source specs into loader Layers in the canonical precedence: framework defaults (implicit, always first) → product framework-override fs/go sources → product/module catalog fs sources → Go bundle sources. It groups specs into the right layer by kind and OverridesFramework so the generated api/worker/migrate binaries can hand the result straight to app.Boot(app.WithI18nLayers(...)).

root is the product filesystem the KindFS paths resolve against (os.DirFS at the product root). The framework-defaults source is NOT emitted here — the registry already installs it; these layers stack on top. A db_overlay spec is rejected today (no built-in overlay ships; B13), so a product that enables one fails loudly rather than silently getting nothing.

type Policy

type Policy struct {
	// OwnsFramework lets this layer write keys in the reserved kernel.* namespace.
	// Only the framework-defaults layer and a sanctioned product framework-override
	// layer set this true.
	OwnsFramework bool
	// FrameworkOverrideOnly, when set together with OwnsFramework, means the layer
	// may only OVERRIDE kernel.* keys that a lower layer already defined — it may
	// not introduce brand-new kernel.* keys. This is the product override contract:
	// a product may retranslate a framework string but may not invent framework
	// strings. Ignored unless OwnsFramework is true.
	FrameworkOverrideOnly bool
}

Policy declares what a precedence layer's sources are permitted to write. It makes the precedence rules explicit and testable rather than implied by load order alone.

type RawBundle

type RawBundle struct {
	// Locale is the BCP 47 tag these messages are written in (e.g. "en", "mr").
	Locale string
	// Messages maps fully-qualified message keys to translated text.
	Messages map[string]string
	// Origin is a human-readable provenance label used only in error messages
	// (e.g. "locales/mr/kernel.yaml", "<embedded framework defaults>").
	Origin string
}

RawBundle is one locale's worth of messages a Source yields, tagged with the origin (file path, "<embedded>", "<go>") so validation and merge errors name exactly where a bad key came from. It is deliberately close to Bundle but carries provenance and is never namespace-checked by the producer — the Loader applies the layer's ownership policy centrally.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry collects i18n bundles from the framework and from modules, mirroring kernel/notify's Registry and kernel/seeds' merge: contributions accumulate, ownership is enforced per module, and errors are surfaced at boot via Err() rather than panicking mid-registration. The framework's own English catalog is installed at construction, so a Registry is never empty.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns a Registry pre-loaded with the framework's English catalog (problem titles + validation messages under the reserved kernel.* namespace). English is the default locale and ultimate fallback.

func (*Registry) ApplyLayers

func (r *Registry) ApplyLayers(layers ...Layer)

ApplyLayers merges configured source layers (framework overrides, product/ module catalog files, Go bundles) into the registry's live catalog in precedence order, ON TOP of the framework defaults and any module bundles already registered. It applies the same ownership and intra-layer duplicate rules as LoadCatalog. Boot calls this once after modules have registered and before Freeze; a violation is recorded via Err() so boot fails closed with every other registration error. Pass the framework-defaults layer FIRST only when constructing a standalone catalog — here the registry already carries the framework defaults, so callers pass just the product layers.

func (*Registry) Catalog

func (r *Registry) Catalog() *Catalog

Catalog returns the merged catalog. Safe to call at any point; the returned pointer reflects later Register calls (it is the live catalog).

func (*Registry) Err

func (r *Registry) Err() error

Err returns the accumulated registration errors joined, or nil. Callers (app boot) must check this before serving, consistent with the other registries.

func (*Registry) Freeze

func (r *Registry) Freeze()

Freeze seals the catalog after boot so request-time reads never race a write.

func (*Registry) Register

func (r *Registry) Register(module string, b Bundle)

Register merges a module's bundle into the catalog. Every key must be prefixed with module + "." and must not fall in the reserved kernel.* namespace. A bad locale, ownership violation, or reserved-namespace write records an error retrievable via Err() (the whole bundle is validated, not short-circuited).

func (*Registry) RegisterFrameworkLocale

func (r *Registry) RegisterFrameworkLocale(b Bundle)

RegisterFrameworkLocale is the SANCTIONED path for a product to supply translations of the framework's own kernel.* strings (e.g. a Marathi translation of the framework's problem titles). It is the guarded replacement for raw Catalog.Add: unlike Add it validates that every key is a kernel.* key AND already exists in the framework defaults (so a product may retranslate a framework string but not invent a new kernel.* key), recording violations via Err() like every other registration path. The product composition root calls this; modules must not (they have no reason to touch kernel.*, and Register rejects kernel.* from them).

In practice the scaffold wires product kernel.* overrides through the config- driven fs override layer (ApplyLayers), which is the file-based equivalent; RegisterFrameworkLocale is the in-code equivalent for products that prefer a Go bundle for their framework overrides.

type Source

type Source interface {
	// Kind reports which first-class source kind this is (for diagnostics and
	// config round-tripping).
	Kind() SourceKind
	// Load reads and parses the source into per-locale bundles. It returns an
	// error only for I/O or parse failures (unreadable file, malformed YAML/JSON,
	// duplicate key within a single file); ownership and cross-source duplicate
	// checks are the Loader's job.
	Load() ([]RawBundle, error)
}

Source loads one or more RawBundles for a single precedence layer. It is the extension point of the subsystem: framework defaults, product fs files, and compiled Go bundles all implement it, and a future DB overlay (B13) can add another implementation without changing the Loader or the Catalog. A Source performs I/O and parsing only; it does not enforce namespace ownership — the Loader does that centrally against the layer's Policy, so every source kind gets identical, tested ownership rules.

func FrameworkDefaultsSource

func FrameworkDefaultsSource() Source

FrameworkDefaultsSource returns the always-present, lowest-precedence source: the framework's embedded per-locale YAML defaults under the reserved kernel.* namespace. It is the canonical implementation of KindFrameworkDefaults and the first Layer every product loads.

func NewFSSource

func NewFSSource(fsys fs.FS, root string, formats ...string) Source

NewFSSource returns a Source that loads YAML and JSON catalog files from fsys rooted at root (e.g. os.DirFS(productRoot) with root "locales"). formats picks which extensions are read; pass both "yaml" and "json" for the canonical mixed layout. It is used for product framework-override files and for product/module catalogs — the Loader's Layer.Policy decides which namespaces the loaded keys may occupy.

type SourceKind

type SourceKind string

SourceKind names a first-class catalog source. It is part of the stable loader contract: config files, `wowapi i18n validate`, and the scaffold all refer to these by string, so values must not change once shipped.

const (
	// KindFrameworkDefaults is the framework's own embedded per-locale YAML
	// (kernel/i18n/locales/<locale>/kernel.yaml). Always the first, lowest
	// precedence layer; owns the reserved kernel.* namespace.
	KindFrameworkDefaults SourceKind = "framework_defaults"
	// KindFS is product-local catalog files on an fs.FS (YAML and/or JSON).
	// Used for both product framework-override files and product/module catalogs.
	KindFS SourceKind = "fs"
	// KindGo is a compiled Go catalog bundle: an in-process []RawBundle a
	// product assembles in Go (generated or hand-written) for compile-time
	// ownership. Highest static precedence layer.
	KindGo SourceKind = "go"
	// KindDBOverlay is reserved for a future opt-in database overlay (B13). No
	// built-in implementation ships today; the contract reserves the kind and
	// the final precedence slot so an overlay can be added without a breaking
	// change. Decision 3: catalogs freeze at boot by default; the overlay is a
	// separate opt-in concern.
	KindDBOverlay SourceKind = "db_overlay"
)

type SourceSpec

type SourceSpec struct {
	// Kind selects the source: "framework_defaults", "fs", "go", or "db_overlay".
	Kind SourceKind
	// Path is the fs root for a KindFS source (e.g. "locales"), relative to Root.
	Path string
	// Formats limits which file extensions a KindFS source reads ("yaml","json").
	// Empty means both.
	Formats []string
	// OverridesFramework marks a KindFS (or KindGo) source as a sanctioned
	// framework-override layer: it may retranslate kernel.* keys (but not invent
	// them). Product/module catalog sources leave this false.
	OverridesFramework bool
	// Enabled gates the source; a disabled source contributes nothing (used for
	// the scaffolded-but-off go/db_overlay stubs).
	Enabled bool
	// Go supplies compiled bundles for a KindGo source.
	Go []RawBundle
}

SourceSpec is a config-neutral description of one configured catalog source, the bridge between a product's i18n config section (parsed in the product's appcfg package) and the framework loader. It mirrors the scaffolded config shape (kind/path/formats/overrides_framework/enabled) without the framework depending on any product config type, keeping kernel/i18n a leaf package.

type ValidateOptions

type ValidateOptions struct {
	// DefaultLocale is the fallback locale; every key must exist here (a key that
	// exists only in a non-default locale has no fallback and is a coverage hole).
	DefaultLocale string
	// SupportedLocales is the set of locales the product declares it supports.
	// Every key present in any locale must also be present in each supported
	// locale, OR resolvable via the default-locale fallback. Because Lookup always
	// falls back to DefaultLocale, the coverage rule is: a key missing from a
	// supported locale is a WARNING-level gap reported as a problem only when it is
	// also missing from the default locale (a total miss). We report BOTH: a hard
	// error for a key absent from the default locale, and a per-locale coverage
	// gap for a key present in the default locale but missing from a supported one.
	SupportedLocales []string
	// StrictCoverage, when true, promotes per-locale coverage gaps (key present in
	// default but missing from a supported locale) to hard problems. Default false:
	// the fallback makes them non-fatal, but `wowapi i18n validate --strict` (or
	// product CI) can require full coverage.
	StrictCoverage bool
}

ValidateOptions configures Validate.

type ValidationReport

type ValidationReport struct {
	// Locales is the sorted set of locales any source contributed to.
	Locales []string
	// Keys is the total number of distinct message keys across all locales.
	Keys int
	// Problems is the sorted list of human-readable defects. Empty == valid.
	Problems []string
}

ValidationReport is the outcome of Validate: the set of authoring defects found across a product's configured catalog sources, plus the coverage stats a CI check reports on success. A zero-length Problems slice means the catalog is valid.

func Validate

func Validate(opts ValidateOptions, layers ...Layer) (ValidationReport, error)

Validate loads the given layers WITHOUT building a servable catalog and checks the four defect classes the benchmark requires:

  • namespace ownership + intra-layer duplicates (delegated to the loader, so validate and boot agree exactly);
  • locale coverage: every key present in the default locale; optionally every key present in every supported locale (StrictCoverage);
  • placeholder compatibility: a translation's %-verb count must match the default-locale template's, so a localized min/max message can't drop or add a parameter and render wrong.

It never mutates global state and returns a ValidationReport; the CLI turns a non-OK report into a non-zero exit. layers must include the framework defaults layer first (the CLI supplies it) so kernel.* coverage is checked too.

func (ValidationReport) OK

func (r ValidationReport) OK() bool

OK reports whether the catalog passed (no problems).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL