Files
felhom-controller/controller/internal/stacks/metadata_i18n.go
T
admin 60f0a86bd4
gates / gates (push) Successful in 25s
v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.

`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.

Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.

Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.

MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:31:52 +02:00

321 lines
13 KiB
Go

package stacks
import "strings"
// CATALOG COPY IN A SECOND LANGUAGE (localisation slice 5, R-560).
//
// A catalog template's customer-facing text is Hungarian in the fields the controller has always
// read (`description`, `app_info`, `deploy_fields[].label` …). English is a SIBLING BLOCK in the
// SAME file:
//
// description: "Titkosított jegyzet és szöveg megosztás"
// app_info:
// first_steps: [...]
// i18n:
// en:
// description: "Encrypted notes and text sharing"
// app_info:
// first_steps: [...]
//
// THREE PROPERTIES, EACH LOAD-BEARING.
//
// 1. **The Hungarian bytes never move.** A translation adds a block; it never edits a Hungarian
// string. `For("hu")` is the parsed struct with `I18n` cleared — pinned by
// TestMetaForHuIsIdentity over all 53 real catalog files.
//
// 2. **An older controller ignores the block.** `LoadMetadata` uses `yaml.Unmarshal`, which is
// NON-STRICT (no `KnownFields`; verified — this repo constructs no yaml.Decoder at all), so a
// controller built before this file silently drops `i18n:` and renders exactly as it did. That
// is what lets the catalog be pushed to the whole fleet ahead of the floor raise.
//
// 3. **Fallback is FIELD BY FIELD, never all-or-nothing.** A missing — or empty — English field
// shows the Hungarian one (10-localisation.md §4, operator ruling 3). A half-translated app is
// therefore a legal, shippable state, which is what makes the batch pushes safe.
//
// LISTS ARE REPLACED WHOLE, NEVER MERGED BY INDEX. `use_cases`, `first_steps` and `prerequisites`
// are prose in a running order; merging item 3 of one language with item 4 of another would produce
// a list nobody wrote. An English list that is present replaces the Hungarian list entirely; an
// absent or empty one leaves the Hungarian list alone.
//
// EVERY OTHER LIST IS MATCHED BY ITS OWN KEY, NEVER BY POSITION: `deploy_fields` by `env_var`,
// `options` by `value`, `optional_config` groups by `match_group` (the Hungarian `group` value they
// translate — a group has no other stable identity), `optional_config[].fields` by `env_var`,
// `integrations` by `target`, `data_paths` by `path`. Position matching would silently mistranslate
// the moment a Hungarian field is inserted above another, and nothing on the page would look wrong.
//
// WHAT AN OVERLAY MAY CARRY IS EXACTLY THE COPY FIELDS — no `env_var`, `type`, `default`,
// `generate`, `required`, `locked_after_deploy`, `data_key`, `path`, `role`, `docs_url`, no image,
// port or healthcheck. The struct shape below IS that rule: a translation cannot change what an app
// does, only what it says. The catalog's `check-copy-i18n.py` gate states the same rule on the file
// before it is ever parsed here.
// MetadataOverlay is one language's copy for one app. Every scalar is a POINTER so that "the
// translator did not write this field" is distinguishable from "the translator wrote an empty
// string" — both fall back to Hungarian, but only the first is silent.
type MetadataOverlay struct {
Description *string `yaml:"description,omitempty"`
AppInfo *AppInfoOverlay `yaml:"app_info,omitempty"`
DeployFields []DeployFieldOverlay `yaml:"deploy_fields,omitempty"`
OptionalConfig []OptionalConfigGroupOverlay `yaml:"optional_config,omitempty"`
Integrations []IntegrationOverlay `yaml:"integrations,omitempty"`
DataPaths []DataPathOverlay `yaml:"data_paths,omitempty"`
InitialCreds *InitialCredentialsOverlay `yaml:"initial_credentials,omitempty"`
}
// AppInfoOverlay carries the info page's prose. The three lists replace whole.
type AppInfoOverlay struct {
Tagline *string `yaml:"tagline,omitempty"`
UseCases []string `yaml:"use_cases,omitempty"`
FirstSteps []string `yaml:"first_steps,omitempty"`
Prerequisites []string `yaml:"prerequisites,omitempty"`
DefaultCreds *string `yaml:"default_creds,omitempty"`
}
// DeployFieldOverlay translates one deploy field, found by EnvVar.
type DeployFieldOverlay struct {
EnvVar string `yaml:"env_var"`
Label *string `yaml:"label,omitempty"`
Description *string `yaml:"description,omitempty"`
Placeholder *string `yaml:"placeholder,omitempty"`
Options []SelectOptionOverlay `yaml:"options,omitempty"`
}
// SelectOptionOverlay translates one select option's label, found by Value.
type SelectOptionOverlay struct {
Value string `yaml:"value"`
Label *string `yaml:"label,omitempty"`
}
// OptionalConfigGroupOverlay translates one optional-config group. MatchGroup is the HUNGARIAN
// `group:` value it belongs to — a group carries no id, and its own label is the thing being
// translated, so the match key has to be stated rather than derived.
type OptionalConfigGroupOverlay struct {
MatchGroup string `yaml:"match_group"`
Group *string `yaml:"group,omitempty"`
Description *string `yaml:"description,omitempty"`
Fields []OptionalConfigFieldOverlay `yaml:"fields,omitempty"`
}
// OptionalConfigFieldOverlay translates one optional-config field, found by EnvVar.
type OptionalConfigFieldOverlay struct {
EnvVar string `yaml:"env_var"`
Label *string `yaml:"label,omitempty"`
HelpText *string `yaml:"help_text,omitempty"`
}
// IntegrationOverlay translates one integration offer, found by Target.
type IntegrationOverlay struct {
Target string `yaml:"target"`
Label *string `yaml:"label,omitempty"`
Description *string `yaml:"description,omitempty"`
}
// DataPathOverlay translates one data-folder label, found by Path.
type DataPathOverlay struct {
Path string `yaml:"path"`
Label *string `yaml:"label,omitempty"`
}
// InitialCredentialsOverlay translates the note shown beside a first-login credential. Only the
// note: the file path, the format and the keys are configuration, and the credential VALUE never
// passes through here at all.
type InitialCredentialsOverlay struct {
Note *string `yaml:"note,omitempty"`
}
// BaseLanguage is the language the unmarshalled fields themselves are written in. It is not a
// choice a template can make — the catalog's Hungarian is the base by construction (the freeze
// gate in the catalog repo pins it byte for byte).
const BaseLanguage = "hu"
// overlayStr returns the translated string, or the base one when the translation is absent or
// blank. A blank translation is treated as ABSENT and never renders an empty box — operator
// ruling 3, 10-localisation.md §4.
func overlayStr(p *string, base string) string {
if p == nil || strings.TrimSpace(*p) == "" {
return base
}
return *p
}
// overlayList returns the translated list, or the base one when the translation is absent or
// empty. Whole-list replacement, never a per-index merge — see the header.
func overlayList(in []string, base []string) []string {
if len(in) == 0 {
return base
}
out := make([]string, len(in))
copy(out, in)
return out
}
// For returns this app's metadata as the given language renders it.
//
// For the base language — and for any language this template has no block for — it is the parsed
// struct with `I18n` cleared, so a Hungarian page cannot be changed by the presence of a
// translation. For a language with a block, copy fields are replaced one by one and EVERYTHING
// ELSE is carried through untouched.
//
// It NEVER mutates the receiver. Every slice it changes is copied first: `Metadata` values live
// inside the stack manager's cache and are handed to many requests at once, so an in-place edit
// would leak one household's language into another's page.
//
// VALUE receiver, like CanInstall and CatalogSinceAge above it — see the comment there: a pointer
// receiver on a type that reaches html/template inside an interface{} is a render-time 500.
func (m Metadata) For(lang string) Metadata {
out := m
out.I18n = nil
if lang == "" || lang == BaseLanguage {
return out
}
ov, ok := m.I18n[lang]
if !ok {
return out
}
out.Description = overlayStr(ov.Description, out.Description)
if ov.AppInfo != nil {
out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline)
out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds)
out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases)
out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps)
out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites)
}
if len(ov.DeployFields) > 0 && len(out.DeployFields) > 0 {
fields := make([]DeployField, len(out.DeployFields))
copy(fields, out.DeployFields)
for _, fo := range ov.DeployFields {
for i := range fields {
if fields[i].EnvVar != fo.EnvVar || fo.EnvVar == "" {
continue
}
fields[i].Label = overlayStr(fo.Label, fields[i].Label)
fields[i].Description = overlayStr(fo.Description, fields[i].Description)
fields[i].Placeholder = overlayStr(fo.Placeholder, fields[i].Placeholder)
if len(fo.Options) > 0 && len(fields[i].Options) > 0 {
opts := make([]SelectOption, len(fields[i].Options))
copy(opts, fields[i].Options)
for _, oo := range fo.Options {
for j := range opts {
if opts[j].Value == oo.Value && oo.Value != "" {
opts[j].Label = overlayStr(oo.Label, opts[j].Label)
}
}
}
fields[i].Options = opts
}
break
}
}
out.DeployFields = fields
}
if len(ov.OptionalConfig) > 0 && len(out.OptionalConfig) > 0 {
groups := make([]OptionalConfigGroup, len(out.OptionalConfig))
copy(groups, out.OptionalConfig)
for _, gov := range ov.OptionalConfig {
for i := range groups {
if groups[i].Group != gov.MatchGroup || gov.MatchGroup == "" {
continue
}
groups[i].Description = overlayStr(gov.Description, groups[i].Description)
if len(gov.Fields) > 0 && len(groups[i].Fields) > 0 {
flds := make([]OptionalConfigField, len(groups[i].Fields))
copy(flds, groups[i].Fields)
for _, fo := range gov.Fields {
for j := range flds {
if flds[j].EnvVar == fo.EnvVar && fo.EnvVar != "" {
flds[j].Label = overlayStr(fo.Label, flds[j].Label)
flds[j].HelpText = overlayStr(fo.HelpText, flds[j].HelpText)
}
}
}
groups[i].Fields = flds
}
// Group LAST: it is the match key, so translating it earlier would make the
// remaining overlay entries for this group unmatchable.
groups[i].Group = overlayStr(gov.Group, groups[i].Group)
break
}
}
out.OptionalConfig = groups
}
if len(ov.Integrations) > 0 && len(out.Integrations) > 0 {
ints := make([]IntegrationDef, len(out.Integrations))
copy(ints, out.Integrations)
for _, io := range ov.Integrations {
for i := range ints {
if ints[i].Target == io.Target && io.Target != "" {
ints[i].Label = overlayStr(io.Label, ints[i].Label)
ints[i].Description = overlayStr(io.Description, ints[i].Description)
}
}
}
out.Integrations = ints
}
if len(ov.DataPaths) > 0 && len(out.DataPaths) > 0 {
dps := make([]DataPath, len(out.DataPaths))
copy(dps, out.DataPaths)
for _, do := range ov.DataPaths {
for i := range dps {
if dps[i].Path == do.Path && do.Path != "" {
dps[i].Label = overlayStr(do.Label, dps[i].Label)
}
}
}
out.DataPaths = dps
}
if ov.InitialCreds != nil && out.InitialCreds != nil {
ic := *out.InitialCreds
ic.Note = overlayStr(ov.InitialCreds.Note, ic.Note)
out.InitialCreds = &ic
}
return out
}
// MetaFor is For on a stack, and it is the ONLY way a page should reach catalog copy: reading
// `stack.Meta.Description` straight out of the manager renders Hungarian to an English household.
// TestNoDirectMetaCopyReadOnPages pins that rule on the handler sources.
func (s Stack) MetaFor(lang string) Metadata { return s.Meta.For(lang) }
// LocalizeStacks returns the list as the given language renders it.
//
// For the base language it returns the INPUT SLICE ITSELF — not a copy. That is deliberate and it
// is the parity guarantee in code: the Hungarian page cannot differ from the pre-change one,
// because nothing on its path was touched. For another language every element is copied (Stack is
// a value type) and only its Meta replaced.
func LocalizeStacks(in []Stack, lang string) []Stack {
if lang == "" || lang == BaseLanguage {
return in
}
out := make([]Stack, len(in))
copy(out, in)
for i := range out {
out[i].Meta = out[i].Meta.For(lang)
}
return out
}
// LocalizeStack is LocalizeStacks for a single stack.
func LocalizeStack(in Stack, lang string) Stack {
in.Meta = in.Meta.For(lang)
return in
}
// LocalizeStackPtr is LocalizeStack for a stack POINTER, returning a NEW pointer so the value the
// caller holds — which may be the manager's own — is never written through. A nil stack stays nil.
func LocalizeStackPtr(in *Stack, lang string) *Stack {
if in == nil {
return nil
}
out := *in
out.Meta = out.Meta.For(lang)
return &out
}