From 38b33cbc73abf6de28e9163229b66a37cea1ca28 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 4 Jul 2026 01:48:27 +0800 Subject: [PATCH] feat(sdk): per-locale module tag labels (TagLabels), mirroring NameLabels The module card (info-dialog header + grid tile) renders the raw Config.Tags list, so a module's badges stay English regardless of locale. NameLabels already carries a per-locale display name (module.name catalog key), but Tags had no per-locale map at all. Add ManifestDefaults.TagLabels (map[string][]string, locale -> tag LIST, JSON "tagLabels", omitempty) ALONGSIDE the raw Tags fallback. It resolves from the module's i18n catalog key "module.tags" whose per-locale value packs the list into one comma-separated string (message catalogs are string-valued, so a JSON array leaf can't be used) -- new i18n.LookupList splits on the delimiter and trims each tag. A locale with no translation is omitted, so the web falls back to raw Tags exactly as it falls back to Name for NameLabels. No api-platform change: the map flows through as JSONB verbatim, the same path DescriptionLabels/NameLabels already use. Co-Authored-By: Claude Fable 5 --- i18n/i18n.go | 36 +++++++++++ i18n/i18n_test.go | 41 ++++++++++++ internal/core/tag_labels_i18n_test.go | 92 +++++++++++++++++++++++++++ system/manifest.go | 28 ++++++-- 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 internal/core/tag_labels_i18n_test.go diff --git a/i18n/i18n.go b/i18n/i18n.go index ee46ec6..9bec41c 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -199,6 +199,42 @@ func Lookup(key string) map[string]string { return out } +// LookupList resolves a catalog key whose per-locale value packs a LIST of +// items into one delimited string, returning locale → []item. Each locale's +// value is split on sep and every element is space-trimmed; empty elements are +// dropped, and a locale that yields no items is omitted. Like Lookup (and +// unlike Label.Resolve) it does NOT fall back to the raw key — an undeclared +// key yields an empty map, so a caller (e.g. manifest defaults) can omit the +// field and let a non-localized default win. +// +// It is the list counterpart of Lookup: Lookup(module.name)→NameLabels (one +// display string per locale), LookupList(module.tags, ",")→TagLabels (a tag +// LIST per locale). The delimited-string convention exists because message +// catalogs are string-valued — flatten() ignores JSON array leaves — so an +// author packs a tag list as e.g. en-US "Auth, Payments" / zh-TW "驗證, 付款". +// The returned map is owned by the caller. +func LookupList(key, sep string) map[string][]string { + mu.RLock() + defer mu.RUnlock() + out := map[string][]string{} + for locale, flat := range registry { + raw, ok := flat[key] + if !ok { + continue + } + var list []string + for _, part := range strings.Split(raw, sep) { + if part = strings.TrimSpace(part); part != "" { + list = append(list, part) + } + } + if len(list) > 0 { + out[locale] = list + } + } + return out +} + // Locales returns the sorted list of locales currently loaded. Test/debug aid. func Locales() []string { mu.RLock() diff --git a/i18n/i18n_test.go b/i18n/i18n_test.go index a45c2a4..615cfec 100644 --- a/i18n/i18n_test.go +++ b/i18n/i18n_test.go @@ -82,6 +82,47 @@ func TestLabel_IsZero(t *testing.T) { } } +func TestLookupList_SplitsTrimsAndDropsEmpties(t *testing.T) { + Reset() + t.Cleanup(Reset) + fsys := fstest.MapFS{ + // en-US: multi-tag list with irregular spacing + a trailing empty slot. + "i18n/en-US.json": &fstest.MapFile{Data: []byte(`{"module":{"tags":"Auth, Payments , "}}`)}, + // zh-TW: single localized tag (no delimiter). + "i18n/zh-TW.json": &fstest.MapFile{Data: []byte(`{"module":{"tags":"驗證"}}`)}, + } + if err := RegisterMessages(fsys, "i18n"); err != nil { + t.Fatalf("RegisterMessages: %v", err) + } + + got := LookupList("module.tags", ",") + if want := []string{"Auth", "Payments"}; !equalStrings(got["en-US"], want) { + t.Errorf("en-US = %v, want %v (split, trimmed, empties dropped)", got["en-US"], want) + } + if want := []string{"驗證"}; !equalStrings(got["zh-TW"], want) { + t.Errorf("zh-TW = %v, want %v", got["zh-TW"], want) + } +} + +func TestLookupList_MissingKeyYieldsEmptyMap(t *testing.T) { + loadFixture(t) // loads permissions.* only — no module.tags key anywhere. + if got := LookupList("module.tags", ","); len(got) != 0 { + t.Errorf("LookupList(missing) = %v, want empty map (no raw-key fallback)", got) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func TestRegisterMessages_MissingDirErrors(t *testing.T) { Reset() t.Cleanup(Reset) diff --git a/internal/core/tag_labels_i18n_test.go b/internal/core/tag_labels_i18n_test.go new file mode 100644 index 0000000..10e2289 --- /dev/null +++ b/internal/core/tag_labels_i18n_test.go @@ -0,0 +1,92 @@ +package core + +import ( + "testing" + "testing/fstest" + + "github.com/mirrorstack-ai/app-module-sdk/i18n" +) + +// TestTagLabels_ResolveCatalogPerLocale asserts that a module registering the +// "module.name" and "module.tags" catalog keys surfaces BOTH nameLabels and +// tagLabels per-locale in its manifest, while the raw Config.Name/Tags stay as +// the non-localized fallback. TagLabels is the list counterpart of NameLabels: +// the author packs each locale's tag list into a single comma-separated +// catalog value (en-US "Auth", zh-TW "驗證") and the manifest splits it. +func TestTagLabels_ResolveCatalogPerLocale(t *testing.T) { + i18n.Reset() + t.Cleanup(i18n.Reset) + + m := newModuleWithSecret(t, Config{ID: "oauth", Name: "OAuth Core", Tags: []string{"Auth"}}) + + // Catalog registered AFTER New — Lookup/LookupList read the global catalog + // lazily at manifest build, so catalog-load ordering is free (mirrors the + // description-label path). + fsys := fstest.MapFS{ + "i18n/en-US.json": &fstest.MapFile{Data: []byte(`{"module":{"name":"OAuth Core","tags":"Auth"}}`)}, + "i18n/zh-TW.json": &fstest.MapFile{Data: []byte(`{"module":{"name":"OAuth 核心","tags":"驗證"}}`)}, + } + if err := i18n.RegisterMessages(fsys, "i18n"); err != nil { + t.Fatalf("RegisterMessages: %v", err) + } + + got := manifestOf(t, m) + + // Raw defaults remain the fallback. + if got.Defaults.Name != "OAuth Core" { + t.Errorf("Defaults.Name = %q, want %q (raw stays the fallback)", got.Defaults.Name, "OAuth Core") + } + if len(got.Defaults.Tags) != 1 || got.Defaults.Tags[0] != "Auth" { + t.Errorf("Defaults.Tags = %v, want [Auth] (raw stays the fallback)", got.Defaults.Tags) + } + + // NameLabels per-locale. + if got.Defaults.NameLabels["en-US"] != "OAuth Core" { + t.Errorf("NameLabels[en-US] = %q, want %q", got.Defaults.NameLabels["en-US"], "OAuth Core") + } + if got.Defaults.NameLabels["zh-TW"] != "OAuth 核心" { + t.Errorf("NameLabels[zh-TW] = %q, want %q", got.Defaults.NameLabels["zh-TW"], "OAuth 核心") + } + + // TagLabels per-locale (list-valued). + if want := []string{"Auth"}; !equalTagList(got.Defaults.TagLabels["en-US"], want) { + t.Errorf("TagLabels[en-US] = %v, want %v", got.Defaults.TagLabels["en-US"], want) + } + if want := []string{"驗證"}; !equalTagList(got.Defaults.TagLabels["zh-TW"], want) { + t.Errorf("TagLabels[zh-TW] = %v, want %v", got.Defaults.TagLabels["zh-TW"], want) + } +} + +// TestTagLabels_AbsentWhenNoCatalog asserts a module with NO module.name / +// module.tags catalog keys ships neither nameLabels nor tagLabels (both omitted +// via omitempty), so the platform falls back to the raw Name/Tags. This is the +// back-compat path — a plain-string module keeps working unchanged. +func TestTagLabels_AbsentWhenNoCatalog(t *testing.T) { + i18n.Reset() + t.Cleanup(i18n.Reset) + + m := newModuleWithSecret(t, Config{ID: "oauth", Name: "OAuth Core", Tags: []string{"Auth"}}) + got := manifestOf(t, m) + + if got.Defaults.NameLabels != nil { + t.Errorf("NameLabels = %v, want nil (no catalog declared)", got.Defaults.NameLabels) + } + if got.Defaults.TagLabels != nil { + t.Errorf("TagLabels = %v, want nil (no catalog declared)", got.Defaults.TagLabels) + } + if len(got.Defaults.Tags) != 1 || got.Defaults.Tags[0] != "Auth" { + t.Errorf("Defaults.Tags = %v, want [Auth] (raw fallback)", got.Defaults.Tags) + } +} + +func equalTagList(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/system/manifest.go b/system/manifest.go index 4b40b84..497b71a 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -92,13 +92,31 @@ type MigrationVersions struct { // NameLabels carries per-locale display names (resolved from the module's // i18n catalog key "module.name"); empty when the module declared none, in // which case the platform falls back to Name. +// +// TagLabels carries per-locale localized tag LISTS (locale → []tag), the list +// counterpart of NameLabels. It is resolved from the module's i18n catalog key +// "module.tags", whose per-locale value packs the tag list into a single +// comma-separated string (e.g. en-US "Auth, Payments", zh-TW "驗證, 付款"); the +// manifest splits on "," and space-trims each tag. Empty when the module +// declared no "module.tags" key, in which case the platform falls back to the +// raw Tags list. Tags stays the default/fallback exactly as Name does. type ManifestDefaults struct { - Name string `json:"name"` - Icon string `json:"icon"` - Tags []string `json:"tags,omitempty"` - NameLabels map[string]string `json:"nameLabels,omitempty"` + Name string `json:"name"` + Icon string `json:"icon"` + Tags []string `json:"tags,omitempty"` + NameLabels map[string]string `json:"nameLabels,omitempty"` + TagLabels map[string][]string `json:"tagLabels,omitempty"` } +// tagCatalogKey / tagLabelSep define the "module.tags" authoring convention: +// one catalog key per locale holding a comma-separated tag list (see +// ManifestDefaults.TagLabels). Kept next to the handler that reads them so the +// wire convention lives in one place. +const ( + tagCatalogKey = "module.tags" + tagLabelSep = "," +) + // ManifestEvents declares which events the module emits and which it subscribes to. type ManifestEvents struct { Emits []string `json:"emits"` @@ -168,7 +186,7 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve httputil.JSON(w, http.StatusOK, ManifestPayload{ ID: id, Slug: slug, - Defaults: ManifestDefaults{Name: name, Icon: icon, Tags: tags, NameLabels: i18n.Lookup("module.name")}, + Defaults: ManifestDefaults{Name: name, Icon: icon, Tags: tags, NameLabels: i18n.Lookup("module.name"), TagLabels: i18n.LookupList(tagCatalogKey, tagLabelSep)}, Description: reg.Description(), DescriptionLabels: reg.DescriptionLabels(), Dependencies: reg.Dependencies(),