60f0a86bd4
gates / gates (push) Successful in 25s
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
321 lines
13 KiB
Go
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
|
|
}
|