Skip to main content

AD-016: Metadata i18n for Go Surfaces

Context

The frontend packages (AD-014) are localized through the KLF pipeline: extract translatable blocks from source, run them through kapi pseudo-translate / kapi ai-translate, and compile per-locale runtime catalogs. The Go backends serving those frontends emit a metadata surface (tool / format / plugin displayName, description, parameter title / description / enum labels / group labels) that also needs matching localization, so the backend-sourced half of every screen lines up with the frontend.

This AD describes a metadata Translator that localizes tool, format, and plugin metadata at API egress, fed by the same extraction pipeline as the frontend.

Decision

Metadata i18n uses four sequenced ideas, each chosen for its fit with the surrounding architecture:

1. English text is the lookup key (no artificial message IDs)

Registry structs keep their English literals (tool.DisplayName = "AI Translate"). Translation is a read-side projection keyed by (scope, source) where scope disambiguates collisions and source is the English text itself. Same convention the frontend LinguiJS setup uses — <Trans>AI Translate</Trans> — so translators see exactly one source artifact across the stack.

2. JSON is the extraction source

core/formats/json/ (AD-005) supports regex-matched key extraction (extractionRules), full-key-path block names (useKeyAsName), and round-trips through every SessionTool the framework ships. Plugins place their metadata on disk as manifest.json + schemas/*.json — the JSON filter reads them directly.

For builtin tools and formats (whose metadata lives in Go structs, not on disk), a //go:generate step emits an object-keyed core/i18n/builtins/metadata.json from the in-process registries, committed to the repo. CI gates freshness with git diff --exit-code. The generated document is object-keyed (tools.<id>, formats.<id>), not capability-array-keyed, so block names produced by the JSON filter stay stable when tools are added or removed.

3. KLF is the authoring format; gettext MO is the runtime format

KLF is the platform's authoring / exchange format — rich, placeholder- aware, and segment-oriented. It is the wrong shape for runtime lookup. MO's binary hash-indexed catalog with msgctxt for disambiguation maps directly onto the (scope, source) lookup, and github.com/leonelquinteros/gotext is a mature pure-Go loader. The core/formats/mo/ format writer consumes klf.Block streams and emits MO; DetectByExtension(".mo") picks it up when the output path's extension says .mo.

4. Localize at the API boundary, not per-call-site

One pass at metadata egress — i18n.LocalizeComponentSchema(s, t) — centralizes translation instead of scattering T(...) calls through tool constructors. Because tool and format metadata both serialize as a ComponentSchema, a single localizer over that type covers both. The surface is finite and centralized (CLI tools / formats / plugins listings and the Wails metadata readers), so one localization pass at egress covers it.

End state

Package layout

core/i18n/
├── doc.go # //go:generate directive
├── catalog.go # Scope, Translator, gotext-backed lookup
├── resolve.go # --lang / KAPI_LANG / config / LC_ALL / LANG chain
├── schema.go # LocalizeComponentSchema
├── embed.go # //go:embed all:catalogs → builtin MO files
├── catalogs/ # Compiled MO per locale (committed)
├── builtins/
│ └── metadata.json # Generated, committed; extraction input
├── gen/
│ ├── gen.go # Generator library
│ └── cmd/main.go # //go:generate entry point
└── i18n.kapi # Project file documenting the pipeline

core/formats/mo/ # Writer + stub reader (for DetectByExtension)

Runtime lookup

Every CLI / Wails / REST handler that emits tool, format, or plugin metadata passes the result through App.T() (CLI) or app.T() (kapi-desktop backend) before handing to the client. The Translator is built at startup from the locale precedence chain:

--lang flag > KAPI_LANG > config.language > LC_ALL / LC_MESSAGES / LANG > "en"

Builtin MO catalogs are embedded via //go:embed and merged in i18n.Resolve. The convention for plugin-provided catalogs (<pluginDir>/<name>/<version>/<i18nDir>/<locale>.mo) and the loader helpers i18n.PluginCatalogPath / i18n.LoadPluginCatalog exist on core/i18n, but with the legacy plugin loader retired (#438 phase 9) nothing currently populates ResolveOptions.PluginCatalogs — manifest- driven plugin discovery in cli/pluginhost does not yet feed catalogs into the Translator (see the TODO in cli/app.go). The "Plugin bundles" and "Plugins contribute their own localizations" material below describes the intended design rather than current behavior.

Pipeline

go generate ./core/i18n/... # Go registries → builtins/metadata.json
kapi pseudo-translate builtins/metadata.json \
--target-lang qps -f json \
-o core/i18n/catalogs/qps.mo # JSON reader → pseudo-translate → MO writer

One conversion, no KLF intermediate on disk — core/klf/ blocks flow through the in-process pipeline and the MO writer flattens them at the sink.

Plugin bundles

Plugin archives gain an optional i18n/ directory sibling to schemas/:

plugin-dir/
├── manifest.json
├── schemas/
└── i18n/
├── fr-FR.mo
└── ja-JP.mo

The conventional path is computed by i18n.PluginCatalogPath(pluginVersionDir, locale, i18nDir) (core/i18n/resolve.go), where the i18nDir argument defaults to "i18n" when empty; no manifest field currently overrides it. Plugins without an i18n/ directory work unchanged — absence of a translation is silent, not an error.

Scope format

Scope is the dot-separated full key path of the value in the canonical metadata document:

  • tools.ai-translate.displayName
  • formats.html.displayName
  • tools.ai-translate.properties.provider.title
  • tools.ai-translate.groups.provider.label

The MO file stores this string as msgctxt; the English source is msgid; the translation is msgstr. Homonyms ("Description" across many tools) stay isolated.

Consequences

  • Same authoring workflow for frontend and backend translators. Both sides ship .po-editable catalogs (via msgunfmt / Poedit) or the same English-source convention, so translators see one source artifact across the stack.
  • Adding a locale is one make kapi-i18n-translations run + commit. No tool registration changes, no schema edits.
  • Plugins contribute their own localizations. The platform does not need a centralized plugin-translation database — each plugin release ships its own i18n/ directory.
  • English source text in registry structs stays authoritative. Translation is strictly additive — missing translations fall back to the English source, never to a placeholder or error.
  • CLI surface is minimally extended. One new persistent flag (--lang); no new subcommands.

Scope

Cobra command Short / Long / Example, fmt.Fprintf table headers ("FORMAT", "SOURCE", …), and ad-hoc errors.New strings stay English — the metadata Translator targets the registry surface, not general CLI chrome.

The MO writer does not flatten placeholder runs — metadata strings are plain text, so placeholder handling would be dead code. Revisit if a metadata surface grows interpolation.

Schema deep-walk in kapi-desktop's GetToolSchema (which returns raw JSON to preserve x-* extensions) is not localized at that path; the tool palette uses ListTools, which is.