Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions i18n/i18n_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions internal/core/tag_labels_i18n_test.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 23 additions & 5 deletions system/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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(),
Expand Down
Loading