From d2396166a17042fd0c62515ed5b3f2b54e4c14db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:40:26 +0000 Subject: [PATCH 01/37] feat(i18n): add shared i18n module with ICU formatter, pack validator and i18n-check Spec 019 T001-T004, T007-T019: new Go module i18n/ (go.work, GO_MODULES, CI matrices, dependabot, coverage gate 90), ICU MessageFormat subset parser/formatter with CLDR plurals, pack load/validate/completeness, embedded catalog with regional -> base -> en fallback, the i18n-check CLI and one seeded defective fixture per finding code. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .github/dependabot.yml | 14 + .github/workflows/ci.yaml | 5 +- CLAUDE.md | 6 +- Makefile | 2 +- go.work | 1 + hack/auto_label.py | 1 + i18n/.testcoverage.yml | 13 + i18n/catalog/catalog.go | 244 +++++++ i18n/catalog/catalog_test.go | 184 +++++ i18n/cmd/i18n-check/fixtures_test.go | 96 +++ i18n/cmd/i18n-check/main.go | 675 ++++++++++++++++++ i18n/cmd/i18n-check/main_test.go | 334 +++++++++ i18n/doc.go | 8 + i18n/embed.go | 11 + i18n/go.mod | 5 + i18n/go.sum | 2 + i18n/icu/format.go | 236 ++++++ i18n/icu/format_test.go | 269 +++++++ i18n/icu/parse.go | 400 +++++++++++ i18n/icu/parse_test.go | 189 +++++ i18n/icu/placeholders.go | 58 ++ i18n/icu/placeholders_test.go | 65 ++ i18n/locales/en/app.json | 1 + i18n/locales/en/meta.json | 6 + i18n/pack/completeness.go | 102 +++ i18n/pack/completeness_test.go | 114 +++ i18n/pack/pack.go | 119 +++ i18n/pack/pack_test.go | 137 ++++ i18n/pack/validate.go | 240 +++++++ i18n/pack/validate_test.go | 186 +++++ i18n/specs.md | 66 ++ .../defective/extra-placeholder/en/app.json | 14 + .../defective/extra-placeholder/xx/app.json | 5 + .../defective/extra-placeholder/xx/meta.json | 1 + i18n/testdata/defective/extract-drift/args | 1 + .../defective/extract-drift/en/app.json | 14 + .../defective/extract-drift/extracted.json | 14 + .../defective/extract-drift/xx/app.json | 11 + .../defective/extract-drift/xx/meta.json | 1 + .../testdata/defective/icu-syntax/en/app.json | 14 + .../testdata/defective/icu-syntax/xx/app.json | 5 + .../defective/icu-syntax/xx/meta.json | 1 + .../defective/invalid-meta/en/app.json | 14 + .../defective/invalid-meta/xx/app.json | 11 + .../defective/invalid-meta/xx/meta.json | 1 + .../defective/malformed-pack/en/app.json | 14 + .../defective/malformed-pack/xx/app.json | 1 + .../defective/malformed-pack/xx/meta.json | 1 + i18n/testdata/defective/markup/en/app.json | 14 + i18n/testdata/defective/markup/xx/app.json | 5 + i18n/testdata/defective/markup/xx/meta.json | 1 + .../defective/missing-description/en/app.json | 11 + .../defective/missing-description/xx/app.json | 11 + .../missing-description/xx/meta.json | 1 + .../defective/missing-placeholder/en/app.json | 14 + .../defective/missing-placeholder/xx/app.json | 5 + .../missing-placeholder/xx/meta.json | 1 + .../defective/needs-review/en/app.json | 14 + .../defective/needs-review/xx/app.json | 5 + .../defective/needs-review/xx/meta.json | 1 + i18n/testdata/defective/server-drift/args | 1 + .../defective/server-drift/en/app.json | 18 + .../defective/server-drift/registry.json | 1 + .../defective/server-drift/xx/app.json | 11 + .../defective/server-drift/xx/meta.json | 1 + .../testdata/defective/unknown-id/en/app.json | 14 + .../testdata/defective/unknown-id/xx/app.json | 6 + .../defective/unknown-id/xx/meta.json | 1 + i18n/testdata/defective/unused-id/args | 1 + i18n/testdata/defective/unused-id/en/app.json | 14 + .../testdata/defective/unused-id/used-ids.txt | 2 + i18n/testdata/defective/unused-id/xx/app.json | 11 + .../testdata/defective/unused-id/xx/meta.json | 1 + .../contracts/check-tool.md | 1 + 74 files changed, 4073 insertions(+), 5 deletions(-) create mode 100644 i18n/.testcoverage.yml create mode 100644 i18n/catalog/catalog.go create mode 100644 i18n/catalog/catalog_test.go create mode 100644 i18n/cmd/i18n-check/fixtures_test.go create mode 100644 i18n/cmd/i18n-check/main.go create mode 100644 i18n/cmd/i18n-check/main_test.go create mode 100644 i18n/doc.go create mode 100644 i18n/embed.go create mode 100644 i18n/go.mod create mode 100644 i18n/go.sum create mode 100644 i18n/icu/format.go create mode 100644 i18n/icu/format_test.go create mode 100644 i18n/icu/parse.go create mode 100644 i18n/icu/parse_test.go create mode 100644 i18n/icu/placeholders.go create mode 100644 i18n/icu/placeholders_test.go create mode 100644 i18n/locales/en/app.json create mode 100644 i18n/locales/en/meta.json create mode 100644 i18n/pack/completeness.go create mode 100644 i18n/pack/completeness_test.go create mode 100644 i18n/pack/pack.go create mode 100644 i18n/pack/pack_test.go create mode 100644 i18n/pack/validate.go create mode 100644 i18n/pack/validate_test.go create mode 100644 i18n/specs.md create mode 100644 i18n/testdata/defective/extra-placeholder/en/app.json create mode 100644 i18n/testdata/defective/extra-placeholder/xx/app.json create mode 100644 i18n/testdata/defective/extra-placeholder/xx/meta.json create mode 100644 i18n/testdata/defective/extract-drift/args create mode 100644 i18n/testdata/defective/extract-drift/en/app.json create mode 100644 i18n/testdata/defective/extract-drift/extracted.json create mode 100644 i18n/testdata/defective/extract-drift/xx/app.json create mode 100644 i18n/testdata/defective/extract-drift/xx/meta.json create mode 100644 i18n/testdata/defective/icu-syntax/en/app.json create mode 100644 i18n/testdata/defective/icu-syntax/xx/app.json create mode 100644 i18n/testdata/defective/icu-syntax/xx/meta.json create mode 100644 i18n/testdata/defective/invalid-meta/en/app.json create mode 100644 i18n/testdata/defective/invalid-meta/xx/app.json create mode 100644 i18n/testdata/defective/invalid-meta/xx/meta.json create mode 100644 i18n/testdata/defective/malformed-pack/en/app.json create mode 100644 i18n/testdata/defective/malformed-pack/xx/app.json create mode 100644 i18n/testdata/defective/malformed-pack/xx/meta.json create mode 100644 i18n/testdata/defective/markup/en/app.json create mode 100644 i18n/testdata/defective/markup/xx/app.json create mode 100644 i18n/testdata/defective/markup/xx/meta.json create mode 100644 i18n/testdata/defective/missing-description/en/app.json create mode 100644 i18n/testdata/defective/missing-description/xx/app.json create mode 100644 i18n/testdata/defective/missing-description/xx/meta.json create mode 100644 i18n/testdata/defective/missing-placeholder/en/app.json create mode 100644 i18n/testdata/defective/missing-placeholder/xx/app.json create mode 100644 i18n/testdata/defective/missing-placeholder/xx/meta.json create mode 100644 i18n/testdata/defective/needs-review/en/app.json create mode 100644 i18n/testdata/defective/needs-review/xx/app.json create mode 100644 i18n/testdata/defective/needs-review/xx/meta.json create mode 100644 i18n/testdata/defective/server-drift/args create mode 100644 i18n/testdata/defective/server-drift/en/app.json create mode 100644 i18n/testdata/defective/server-drift/registry.json create mode 100644 i18n/testdata/defective/server-drift/xx/app.json create mode 100644 i18n/testdata/defective/server-drift/xx/meta.json create mode 100644 i18n/testdata/defective/unknown-id/en/app.json create mode 100644 i18n/testdata/defective/unknown-id/xx/app.json create mode 100644 i18n/testdata/defective/unknown-id/xx/meta.json create mode 100644 i18n/testdata/defective/unused-id/args create mode 100644 i18n/testdata/defective/unused-id/en/app.json create mode 100644 i18n/testdata/defective/unused-id/used-ids.txt create mode 100644 i18n/testdata/defective/unused-id/xx/app.json create mode 100644 i18n/testdata/defective/unused-id/xx/meta.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6eaee29fb..1b5a3736c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -186,6 +186,20 @@ updates: telemetryschema-minor-patch: update-types: ["minor", "patch"] + - package-ecosystem: "gomod" + directory: "/i18n" + schedule: + interval: "weekly" + day: "monday" + time: "03:00" + commit-message: + prefix: "chore(deps)" + include: "scope" + open-pull-requests-limit: 5 + groups: + i18n-minor-patch: + update-types: ["minor", "patch"] + - package-ecosystem: "gomod" directory: "/telemetry-receiver" schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4a6774952..08fa19389 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,6 +120,7 @@ jobs: - 'audit-syslog-bridge/**' - 'telemetry-receiver/**' - 'telemetryschema/**' + - 'i18n/**' - 'sentinel/**' - 'mcp-server/**' - 'capture-sidecar/**' @@ -499,7 +500,7 @@ jobs: strategy: fail-fast: false matrix: - module: [netguard, gameaction, gameproto, telemetryschema, operator, agent, api, sentinel, audit-syslog-bridge, telemetry-receiver, mcp-server, capture-sidecar, svcutil, tunnel, gp-module, test/e2e] + module: [netguard, gameaction, gameproto, telemetryschema, i18n, operator, agent, api, sentinel, audit-syslog-bridge, telemetry-receiver, mcp-server, capture-sidecar, svcutil, tunnel, gp-module, test/e2e] exclude: ${{ fromJSON(needs.changes.outputs.lint-exclude) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -578,7 +579,7 @@ jobs: strategy: fail-fast: false matrix: - module: [netguard, gameaction, gameproto, telemetryschema, operator, api, agent, audit-syslog-bridge, telemetry-receiver, sentinel, mcp-server, capture-sidecar, svcutil, tunnel, gp-module] + module: [netguard, gameaction, gameproto, telemetryschema, i18n, operator, api, agent, audit-syslog-bridge, telemetry-receiver, sentinel, mcp-server, capture-sidecar, svcutil, tunnel, gp-module] exclude: ${{ fromJSON(needs.changes.outputs.go-exclude) }} arch: [amd64, arm64] steps: diff --git a/CLAUDE.md b/CLAUDE.md index 4ad438ad5..21718e9bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,7 @@ netguard/ SSRF dial-guard: IsAllowed (operator module sources, API si gameaction/ console-injection guard + command renderer (api, agent) gameproto/ Minecraft/Terraria handshake parser (sentinel) telemetryschema/ shared telemetry report contract (api, telemetry-receiver) +i18n/ shared language packs, ICU formatter, pack validator, i18n-check (api, web, operator, gp-module) gp-module/ module authoring CLI: init/validate/preview/package operator/ controller-runtime operator api/v1alpha1/ CRD types (edit here, then `make generate manifests`); zz_generated.deepcopy.go GENERATED @@ -60,7 +61,7 @@ test/e2e/ Kind E2E suite (//go:build e2e) docs/ architecture, security, modules design.pen canonical Pencil dashboard design signing/ cosign public keys, dated legacy key + cross-signature (image + module) -go.work links all 16 Go modules (incl. test/e2e) +go.work links all 17 Go modules (incl. test/e2e) Makefile canonical task runner ``` @@ -75,7 +76,7 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev- - **E2E conventions:** register new tests in `test/e2e/buckets.sh`; `t.Parallel()` + unique names; guard shared resources (`ociPushMu` for module pushes, `ensureResticRepo(t)` for shared backup repos). Rate limits per cluster: IP burst 10 (5/min), user burst 6 (3/min) — cap an API bucket at ~7 admin logins. - **Lint:** `make lint` = gofmt, go vet, golangci-lint, ESLint, `check-specs`, `check-doc-versions`, `check-links`. -**Coverage minimums:** netguard 91, gameaction 91, gameproto 90, telemetryschema 90, gp-module 80, operator 72, api 80, agent 90, svcutil 90; audit-syslog-bridge, telemetry-receiver, sentinel, capture-sidecar, mcp-server, tunnel 70; web 92 lines / 76 functions / 82 branches / 92 statements. +**Coverage minimums:** netguard 91, gameaction 91, gameproto 90, telemetryschema 90, i18n 90, gp-module 80, operator 72, api 80, agent 90, svcutil 90; audit-syslog-bridge, telemetry-receiver, sentinel, capture-sidecar, mcp-server, tunnel 70; web 92 lines / 76 functions / 82 branches / 92 statements. ## Core rules @@ -116,6 +117,7 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev- | gameaction | Go | validates console input against schemas; escapes injection | | gameproto | Go | Minecraft/Terraria wire parser for connection filtering | | telemetryschema | Go | stdlib-only shared telemetry report contract: types, strict decode, official module catalog, report signing (api, telemetry-receiver) | +| i18n | Go | shared language packs (`go:embed`), ICU MessageFormat subset, pack validator, `i18n-check` CI tool (api, web, operator, gp-module) | | gp-module | Go | module CLI: scaffold, offline validate, dry-run preview, OCI package | | svcutil | Go | stdlib-only env + graceful shutdown (`RunHTTP`) | | operator | Go, controller-runtime | authoritative reconciler for 9 CRDs (`GameServer`, `GameTemplate`, …) | diff --git a/Makefile b/Makefile index 2fb3e2882..b51386e0b 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ KUBECONFIG_ENV := KUBE_CONTEXT := kind-$(KIND_CLUSTER) endif -GO_MODULES := netguard gameaction gameproto telemetryschema gp-module operator api agent audit-syslog-bridge telemetry-receiver sentinel mcp-server capture-sidecar svcutil tunnel +GO_MODULES := netguard gameaction gameproto telemetryschema i18n gp-module operator api agent audit-syslog-bridge telemetry-receiver sentinel mcp-server capture-sidecar svcutil tunnel GO_INTEGRATION_MODULES := operator api # test/e2e is a separate module (its own go.mod, excluded from the coverage diff --git a/go.work b/go.work index 69a166196..fa6b1bb44 100644 --- a/go.work +++ b/go.work @@ -8,6 +8,7 @@ use ( ./gameaction ./gameproto ./gp-module + ./i18n ./mcp-server ./netguard ./operator diff --git a/hack/auto_label.py b/hack/auto_label.py index edabfbbf6..b6a3fd0bd 100755 --- a/hack/auto_label.py +++ b/hack/auto_label.py @@ -62,6 +62,7 @@ "audit-syslog-bridge": "area: optional-components", "telemetry-receiver": "area: optional-components", "telemetryschema": "area: shared", + "i18n": "area: shared", } AREA_BY_FILE = { diff --git a/i18n/.testcoverage.yml b/i18n/.testcoverage.yml new file mode 100644 index 000000000..ce533f721 --- /dev/null +++ b/i18n/.testcoverage.yml @@ -0,0 +1,13 @@ +# Coverage threshold gate for the shared i18n module. +# Enforced by `make cover-go-check` via go-test-coverage/v2. +# +# i18n holds the shared language packs, the ICU message formatter, the pack +# validator and the i18n-check CLI. Almost all of it is pure and unit-testable, +# so the gate is 90%. +profile: coverage/unit.out +github-action-output: false + +threshold: + file: 0 + package: 0 + total: 90 diff --git a/i18n/catalog/catalog.go b/i18n/catalog/catalog.go new file mode 100644 index 000000000..c286ae549 --- /dev/null +++ b/i18n/catalog/catalog.go @@ -0,0 +1,244 @@ +// Package catalog loads the embedded language packs and resolves message ids +// to formatted text at runtime. +package catalog + +import ( + "errors" + "fmt" + "io/fs" + "path" + "slices" + "sync" + + "golang.org/x/text/language" + + "github.com/GameplanePanel/gameplane/i18n" + "github.com/GameplanePanel/gameplane/i18n/icu" + "github.com/GameplanePanel/gameplane/i18n/pack" +) + +// Catalog is an immutable set of validated language packs plus a cache of +// parsed messages. It is safe for concurrent use. +type Catalog struct { + reference string + packs map[string]map[string]pack.Entry // tag -> valid entries; reference included + meta map[string]pack.Meta + completeness map[string]float64 + + mu sync.RWMutex + cache map[cacheKey]icu.Message +} + +type cacheKey struct{ tag, id string } + +type languagePack struct { + tag string + entries map[string]pack.Entry + meta pack.Meta +} + +func newCatalog(reference string) *Catalog { + return &Catalog{ + reference: reference, + packs: map[string]map[string]pack.Entry{}, + meta: map[string]pack.Meta{}, + completeness: map[string]float64{}, + cache: map[cacheKey]icu.Message{}, + } +} + +// Load reads every language pack under the locales/ directory of fsys (pass +// i18n.Locales in production). Every directory with an app.json is a language. +// The reference pack must parse and may omit meta.json; other packs are skipped +// when their app.json or meta.json is missing or invalid. Translations are +// reduced to the entries pack.Valid keeps. +func Load(fsys fs.FS, reference string) (*Catalog, error) { + dirs, err := fs.ReadDir(fsys, "locales") + if err != nil { + return nil, fmt.Errorf("catalog: read locales: %w", err) + } + + var ( + refEntries map[string]pack.Entry + refMeta pack.Meta + others []languagePack + ) + for _, d := range dirs { + if !d.IsDir() { + continue + } + tag := d.Name() + entries, err := readEntries(fsys, tag) + meta, metaErr := readMeta(fsys, tag) + if tag == reference { + if err != nil { + return nil, fmt.Errorf("catalog: reference %q: %w", tag, err) + } + if metaErr != nil && !errors.Is(metaErr, fs.ErrNotExist) { + return nil, fmt.Errorf("catalog: reference %q meta: %w", tag, metaErr) + } + refEntries = entries + refMeta = pack.Meta{Tag: tag, Direction: "ltr"} + if metaErr == nil { + refMeta = meta + } + continue + } + if err != nil || metaErr != nil || meta.Tag != tag { + continue + } + others = append(others, languagePack{tag: tag, entries: entries, meta: meta}) + } + if refEntries == nil { + return nil, fmt.Errorf("catalog: reference %q not found under locales/", reference) + } + + c := newCatalog(reference) + c.packs[reference] = refEntries + c.meta[reference] = refMeta + c.completeness[reference] = 100 + for _, p := range others { + c.packs[p.tag] = pack.Valid(refEntries, p.entries) + c.meta[p.tag] = p.meta + c.completeness[p.tag] = pack.Completeness(refEntries, p.entries) + } + return c, nil +} + +func readEntries(fsys fs.FS, tag string) (map[string]pack.Entry, error) { + data, err := fs.ReadFile(fsys, path.Join("locales", tag, "app.json")) + if err != nil { + return nil, err + } + return pack.ParseEntries(data) +} + +func readMeta(fsys fs.FS, tag string) (pack.Meta, error) { + data, err := fs.ReadFile(fsys, path.Join("locales", tag, "meta.json")) + if err != nil { + return pack.Meta{}, err + } + return pack.ParseMeta(data) +} + +// defaultOnce guards the lazily loaded embedded catalog. +var ( + defaultOnce sync.Once + defaultCat *Catalog +) + +// Default returns the catalog built from the embedded packs in package i18n. +// If the embedded packs cannot be loaded it returns an empty catalog, so every +// Lookup reports false and callers fall back to their own text. +func Default() *Catalog { + defaultOnce.Do(func() { + c, err := Load(i18n.Locales, "en") + if err != nil { + c = newCatalog("en") + } + defaultCat = c + }) + return defaultCat +} + +// Tags returns every loaded language tag, the reference first and the rest +// sorted. +func (c *Catalog) Tags() []string { + others := make([]string, 0, len(c.packs)) + for tag := range c.packs { + if tag != c.reference { + others = append(others, tag) + } + } + slices.Sort(others) + if _, ok := c.packs[c.reference]; ok { + return append([]string{c.reference}, others...) + } + return others +} + +// Listed returns the tags offered to users, in the order of Tags. +func (c *Catalog) Listed() []string { + var out []string + for _, tag := range c.Tags() { + if c.IsListed(tag) { + out = append(out, tag) + } + } + return out +} + +// IsListed reports whether tag is offered to users. The reference is always +// listed. +func (c *Catalog) IsListed(tag string) bool { + comp, ok := c.completeness[tag] + if !ok { + return false + } + if tag == c.reference { + return true + } + return pack.Listed(tag, c.meta[tag], comp) +} + +// Completeness returns the percentage of reference messages that tag translates. +func (c *Catalog) Completeness(tag string) (float64, bool) { + comp, ok := c.completeness[tag] + return comp, ok +} + +// Lookup formats message id for tag. It tries the exact tag, then its base +// language, then the reference. Entries that fail to parse fall through to the +// next pack. ok is false only when the reference does not have id. +func (c *Catalog) Lookup(tag language.Tag, id string, args map[string]any) (string, bool) { + base, _ := tag.Base() + keys := []string{tag.String(), base.String(), c.reference} + for i, key := range keys { + if slices.Contains(keys[:i], key) { + continue + } + entry, ok := c.packs[key][id] + if !ok { + continue + } + msg, err := c.parse(key, id, entry.DefaultMessage) + if err != nil { + continue + } + return icu.Format(msg, language.Make(key), args), true + } + return "", false +} + +// parse returns the parsed message for key/id, caching successful parses. +func (c *Catalog) parse(key, id, text string) (icu.Message, error) { + ck := cacheKey{tag: key, id: id} + c.mu.RLock() + msg, ok := c.cache[ck] + c.mu.RUnlock() + if ok { + return msg, nil + } + msg, err := icu.Parse(text) + if err != nil { + return nil, fmt.Errorf("catalog: parse %s/%s: %w", key, id, err) + } + c.mu.Lock() + c.cache[ck] = msg + c.mu.Unlock() + return msg, nil +} + +// Tags returns the tags of the default catalog. +func Tags() []string { return Default().Tags() } + +// Listed returns the listed tags of the default catalog. +func Listed() []string { return Default().Listed() } + +// IsListed reports whether tag is listed in the default catalog. +func IsListed(tag string) bool { return Default().IsListed(tag) } + +// Lookup formats message id using the default catalog. +func Lookup(tag language.Tag, id string, args map[string]any) (string, bool) { + return Default().Lookup(tag, id, args) +} diff --git a/i18n/catalog/catalog_test.go b/i18n/catalog/catalog_test.go new file mode 100644 index 000000000..c89a84d04 --- /dev/null +++ b/i18n/catalog/catalog_test.go @@ -0,0 +1,184 @@ +package catalog + +import ( + "fmt" + "testing" + "testing/fstest" + + "golang.org/x/text/language" + + "github.com/GameplanePanel/gameplane/i18n" +) + +func testFS(files map[string]string) fstest.MapFS { + fsys := fstest.MapFS{} + for name, body := range files { + fsys["locales/"+name] = &fstest.MapFile{Data: []byte(body)} + } + return fsys +} + +func metaJSON(tag, direction string) string { + return fmt.Sprintf(`{"tag":%q,"nativeName":"N-%s","englishName":"E-%s","direction":%q}`, tag, tag, tag, direction) +} + +func TestLookupFallbackChain(t *testing.T) { + c, err := Load(testFS(map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A"},"b":{"defaultMessage":"B"},"x":{"defaultMessage":"X"}}`, + "en/meta.json": metaJSON("en", "ltr"), + "fr/app.json": `{"a":{"defaultMessage":"Afr"},"x":{"defaultMessage":"Xfr"}}`, + "fr/meta.json": metaJSON("fr", "ltr"), + "fr-CA/app.json": `{"a":{"defaultMessage":"Afr-CA"}}`, + "fr-CA/meta.json": metaJSON("fr-CA", "ltr"), + }), "en") + if err != nil { + t.Fatal(err) + } + tests := []struct { + tag, id, want string + }{ + {"fr-CA", "a", "Afr-CA"}, + {"fr-CA", "x", "Xfr"}, + {"fr-CA", "b", "B"}, + {"fr", "b", "B"}, + {"en-GB", "a", "A"}, + } + for _, tc := range tests { + got, ok := c.Lookup(language.MustParse(tc.tag), tc.id, nil) + if !ok || got != tc.want { + t.Errorf("Lookup(%s, %s) = %q, %v; want %q", tc.tag, tc.id, got, ok, tc.want) + } + } +} + +func TestInvalidTranslationDroppedForReference(t *testing.T) { + c, err := Load(testFS(map[string]string{ + "en/app.json": `{"n":{"defaultMessage":"{count} files"}}`, + "en/meta.json": metaJSON("en", "ltr"), + "fr/app.json": `{"n":{"defaultMessage":"{n} fichiers"}}`, + "fr/meta.json": metaJSON("fr", "ltr"), + }), "en") + if err != nil { + t.Fatal(err) + } + got, ok := c.Lookup(language.French, "n", map[string]any{"count": 2}) + if !ok || got != "2 files" { + t.Errorf("Lookup = %q, %v; want %q, true", got, ok, "2 files") + } +} + +func TestLookupMissingID(t *testing.T) { + c, err := Load(testFS(map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A"}}`, + }), "en") + if err != nil { + t.Fatal(err) + } + if _, ok := c.Lookup(language.English, "nope", nil); ok { + t.Error("Lookup of an id the reference lacks reported ok") + } +} + +func TestListedThreshold(t *testing.T) { + c, err := Load(testFS(map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A"},"b":{"defaultMessage":"B"}}`, + "en/meta.json": metaJSON("en", "ltr"), + "de/app.json": `{"a":{"defaultMessage":"Ade"}}`, + "de/meta.json": metaJSON("de", "ltr"), + "fr/app.json": `{"a":{"defaultMessage":"Afr"},"b":{"defaultMessage":"Bfr"}}`, + "fr/meta.json": metaJSON("fr", "ltr"), + "ar/app.json": `{"a":{"defaultMessage":"Aar"},"b":{"defaultMessage":"Bar"}}`, + "ar/meta.json": metaJSON("ar", "rtl"), + }), "en") + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprint(c.Tags()); got != "[en ar de fr]" { + t.Errorf("Tags() = %s; want [en ar de fr]", got) + } + if got := fmt.Sprint(c.Listed()); got != "[en fr]" { + t.Errorf("Listed() = %s; want [en fr]", got) + } + if c.IsListed("de") { + t.Error("50% pack reported as listed") + } + if !c.IsListed("en") { + t.Error("reference reported as not listed") + } + if comp, ok := c.Completeness("de"); !ok || comp != 50 { + t.Errorf("Completeness(de) = %v, %v; want 50, true", comp, ok) + } + if _, ok := c.Completeness("zz"); ok { + t.Error("Completeness of an unknown tag reported ok") + } +} + +func TestLoadErrors(t *testing.T) { + if _, err := Load(fstest.MapFS{}, "en"); err == nil { + t.Error("Load without a locales directory returned no error") + } + if _, err := Load(testFS(map[string]string{ + "fr/app.json": `{"a":{"defaultMessage":"Afr"}}`, + }), "en"); err == nil { + t.Error("Load without the reference returned no error") + } + if _, err := Load(testFS(map[string]string{ + "en/app.json": `{"a":`, + }), "en"); err == nil { + t.Error("Load with a malformed reference returned no error") + } +} + +func TestBrokenPacksSkipped(t *testing.T) { + c, err := Load(testFS(map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A"}}`, + "en/meta.json": metaJSON("en", "ltr"), + "de/app.json": `{"a":`, + "de/meta.json": metaJSON("de", "ltr"), + "fr/app.json": `{"a":{"defaultMessage":"Afr"}}`, + "fr/meta.json": `{"tag":"fr","nativeName":"x"`, + "es/app.json": `{"a":{"defaultMessage":"Aes"}}`, + "es/meta.json": metaJSON("zz", "ltr"), + "it/meta.json": metaJSON("it", "ltr"), + "pt/app.json": `{"a":{"defaultMessage":"Apt"}}`, + "pt/meta.json": metaJSON("pt", "ltr"), + }), "en") + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprint(c.Tags()); got != "[en pt]" { + t.Errorf("Tags() = %s; want [en pt]", got) + } + if !c.IsListed("pt") { + t.Error("valid pack pt not listed") + } +} + +func TestEmptyCatalogLookup(t *testing.T) { + c := newCatalog("en") + if _, ok := c.Lookup(language.English, "a", nil); ok { + t.Error("empty catalog returned a message") + } + if tags := c.Tags(); len(tags) != 0 { + t.Errorf("empty catalog Tags() = %v", tags) + } +} + +func TestDefaultLoadsEmbeddedPacks(t *testing.T) { + if _, err := Load(i18n.Locales, "en"); err != nil { + t.Fatalf("Load(i18n.Locales) error: %v", err) + } + tags := Tags() + if len(tags) == 0 || tags[0] != "en" { + t.Fatalf("Default().Tags() = %v; want reference first", tags) + } + if !IsListed("en") { + t.Error("reference not listed in the default catalog") + } + if got := Listed(); len(got) == 0 || got[0] != "en" { + t.Errorf("Listed() = %v; want reference first", got) + } + if _, ok := Lookup(language.English, "missing.id", nil); ok { + t.Error("Lookup of a missing id reported ok") + } +} diff --git a/i18n/cmd/i18n-check/fixtures_test.go b/i18n/cmd/i18n-check/fixtures_test.go new file mode 100644 index 000000000..1b2a238bf --- /dev/null +++ b/i18n/cmd/i18n-check/fixtures_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// allCodes lists every finding code; each has a fixture under testdata/defective. +var allCodes = []string{ + "malformed-pack", + "invalid-meta", + "icu-syntax", + "missing-placeholder", + "extra-placeholder", + "markup", + "unknown-id", + "missing-description", + "extract-drift", + "server-drift", + "unused-id", + "needs-review", +} + +var codeRE = regexp.MustCompile(`\[([a-z-]+)\]`) + +func TestDefectiveFixtures(t *testing.T) { + root := filepath.Join("..", "..", "testdata", "defective") + for _, code := range allCodes { + if _, err := os.Stat(filepath.Join(root, code)); err != nil { + t.Errorf("fixture for %s: %v", code, err) + } + } + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, d := range entries { + if !d.IsDir() { + continue + } + name := d.Name() + t.Run(name, func(t *testing.T) { + dir := filepath.Join(root, name) + var args []string + raw, err := os.ReadFile(filepath.Join(dir, "args")) + switch { + case err == nil: + for _, a := range strings.Fields(string(raw)) { + args = append(args, strings.ReplaceAll(a, "{dir}", dir)) + } + case !errors.Is(err, fs.ErrNotExist): + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run(append(args, dir), &stdout, &stderr) + + got := map[string]struct{}{} + for _, m := range codeRE.FindAllStringSubmatch(stdout.String(), -1) { + got[m[1]] = struct{}{} + } + if len(got) != 1 { + t.Fatalf("codes = %v; want exactly {%s}\nstdout:\n%s", keys(got), name, stdout.String()) + } + if _, ok := got[name]; !ok { + t.Errorf("codes = %v; want {%s}\nstdout:\n%s", keys(got), name, stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("unexpected stderr: %s", stderr.String()) + } + + want := 1 + if name == "needs-review" { + want = 0 + } + if code != want { + t.Errorf("exit = %d; want %d", code, want) + } + }) + } +} + +func keys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + return out +} diff --git a/i18n/cmd/i18n-check/main.go b/i18n/cmd/i18n-check/main.go new file mode 100644 index 000000000..99d4555af --- /dev/null +++ b/i18n/cmd/i18n-check/main.go @@ -0,0 +1,675 @@ +// Command i18n-check validates Gameplane's language packs. +// +// App mode (default) checks the reference pack and every translation under +// . It can sync the reference's server.* entries from a registry, +// check server.* and extracted messages for drift, report unused ids, stamp +// source hashes, and write the language manifest and a markdown summary. +// +// Module mode (--module) checks flat .json packs for a game module. +// +// Exit status: 0 when no error-severity finding was reported, 1 when one was, +// 2 for usage errors or unreadable required files. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "maps" + "math" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/GameplanePanel/gameplane/i18n/pack" +) + +const ( + exitOK = 0 + exitFinding = 1 + exitUsage = 2 +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// options is the parsed command line. +type options struct { + reference string + base string + extracted string + registry string + syncServer bool + checkServer bool + usedIDs string + manifest string + stamp bool + module string + summary string + dir string +} + +func run(args []string, stdout, stderr io.Writer) int { + fset := flag.NewFlagSet("i18n-check", flag.ContinueOnError) + fset.SetOutput(stderr) + var o options + fset.StringVar(&o.reference, "reference", "en", "reference language tag") + fset.StringVar(&o.base, "base", "", "previous language packs directory, for the summary's old completeness") + fset.StringVar(&o.extracted, "extracted", "", "formatjs-extracted messages file to compare with the reference") + fset.StringVar(&o.registry, "registry", "", "JSON registry of server.* messages (required by --sync-server and --check-server)") + fset.BoolVar(&o.syncServer, "sync-server", false, "replace the reference's server.* entries with the registry") + fset.BoolVar(&o.checkServer, "check-server", false, "report server.* entries that differ from the registry") + fset.StringVar(&o.usedIDs, "used-ids", "", "file listing the message ids code uses, one per line") + fset.StringVar(&o.manifest, "manifest", "", "write the language manifest JSON to this file") + fset.BoolVar(&o.stamp, "stamp", false, "record sourceHash on translated entries that lack one") + fset.StringVar(&o.module, "module", "", "module mode: check flat .json packs against this template") + fset.StringVar(&o.summary, "summary", "", "append a markdown summary to this file") + + if err := fset.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitOK + } + return exitUsage + } + if fset.NArg() != 1 { + say(stderr, "i18n-check: exactly one argument is required\n") + fset.Usage() + return exitUsage + } + o.dir = fset.Arg(0) + if err := o.checkUsage(); err != nil { + return fail(stderr, err) + } + if o.module != "" { + return runModule(o, stdout, stderr) + } + return runApp(o, stdout, stderr) +} + +// checkUsage rejects flag combinations that cannot work together. +func (o options) checkUsage() error { + if (o.syncServer || o.checkServer) && o.registry == "" { + return errors.New("--sync-server and --check-server need --registry") + } + if o.module == "" { + return nil + } + appOnly := []struct { + name string + set bool + }{ + {"--manifest", o.manifest != ""}, + {"--stamp", o.stamp}, + {"--sync-server", o.syncServer}, + {"--check-server", o.checkServer}, + {"--registry", o.registry != ""}, + {"--extracted", o.extracted != ""}, + {"--used-ids", o.usedIDs != ""}, + {"--base", o.base != ""}, + } + for _, f := range appOnly { + if f.set { + return fmt.Errorf("%s is not valid with --module", f.name) + } + } + return nil +} + +func runApp(o options, stdout, stderr io.Writer) int { + refDir := filepath.Join(o.dir, o.reference) + refFile := o.reference + "/app.json" + refPath := filepath.Join(refDir, "app.json") + refData, err := os.ReadFile(filepath.Clean(refPath)) + if err != nil { + return fail(stderr, fmt.Errorf("read reference: %w", err)) + } + + var registry map[string]pack.Entry + if o.syncServer || o.checkServer { + if registry, err = loadRegistry(o.registry); err != nil { + return fail(stderr, err) + } + } + var extracted []byte + if o.extracted != "" { + if extracted, err = os.ReadFile(filepath.Clean(o.extracted)); err != nil { + return fail(stderr, fmt.Errorf("read extracted messages: %w", err)) + } + } + var used map[string]struct{} + if o.usedIDs != "" { + if used, err = readIDs(o.usedIDs); err != nil { + return fail(stderr, err) + } + } + + ref, err := pack.ParseEntries(refData) + if err != nil { + return report(stdout, []pack.Finding{errFinding(pack.CodeMalformedPack, refFile, pack.IDPack, err.Error())}) + } + if o.syncServer { + if err := syncServer(refPath, ref, registry); err != nil { + return fail(stderr, err) + } + return exitOK + } + + findings := pack.ValidateReference(ref, refFile) + refMeta, _, metaFindings, err := readMeta(filepath.Join(refDir, "meta.json"), o.reference, o.reference+"/meta.json", false) + if err != nil { + return fail(stderr, err) + } + findings = append(findings, metaFindings...) + if o.checkServer { + findings = append(findings, checkServer(ref, registry, refFile)...) + } + if o.extracted != "" { + findings = append(findings, checkExtracted(ref, extracted, refFile)...) + } + if o.usedIDs != "" { + findings = append(findings, checkUsed(ref, used, refFile)...) + } + + trs, trFindings, err := loadTranslations(o.dir, o.reference, ref) + if err != nil { + return fail(stderr, err) + } + findings = append(findings, trFindings...) + if o.stamp { + if err := stampTranslations(ref, trs); err != nil { + return fail(stderr, err) + } + } + + rows := manifestRows(o.reference, refMeta, ref, trs) + if o.manifest != "" { + if err := writeManifest(o.manifest, rows); err != nil { + return fail(stderr, err) + } + } + pack.SortFindings(findings) + if o.summary != "" { + if err := writeSummary(o.summary, findings, summaryRows(o, rows)); err != nil { + return fail(stderr, err) + } + } + return report(stdout, findings) +} + +func runModule(o options, stdout, stderr io.Writer) int { + if _, err := os.ReadFile(filepath.Clean(o.module)); err != nil { + return fail(stderr, fmt.Errorf("read template: %w", err)) + } + // T084: template-derived id checking plugs in here. Every id a module's + // template references must exist in the reference pack. + + refFile := o.reference + ".json" + refData, err := os.ReadFile(filepath.Clean(filepath.Join(o.dir, refFile))) + if err != nil { + return fail(stderr, fmt.Errorf("read reference: %w", err)) + } + ref, err := pack.ParseEntries(refData) + if err != nil { + return report(stdout, []pack.Finding{errFinding(pack.CodeMalformedPack, refFile, pack.IDPack, err.Error())}) + } + findings := pack.ValidateReference(ref, refFile) + + dirEntries, err := os.ReadDir(o.dir) + if err != nil { + return fail(stderr, fmt.Errorf("read locales: %w", err)) + } + for _, d := range dirEntries { + name := d.Name() + if d.IsDir() || name == refFile || !strings.HasSuffix(name, ".json") { + continue + } + data, err := os.ReadFile(filepath.Clean(filepath.Join(o.dir, name))) + if err != nil { + return fail(stderr, fmt.Errorf("read %s: %w", name, err)) + } + tr, err := pack.ParseEntries(data) + if err != nil { + findings = append(findings, errFinding(pack.CodeMalformedPack, name, pack.IDPack, err.Error())) + continue + } + findings = append(findings, pack.ValidateTranslation(ref, tr, name)...) + } + + pack.SortFindings(findings) + if o.summary != "" { + if err := writeSummary(o.summary, findings, nil); err != nil { + return fail(stderr, err) + } + } + return report(stdout, findings) +} + +// translation is one non-reference app pack under the locales directory. +type translation struct { + tag string + dir string + entries map[string]pack.Entry + meta pack.Meta + metaOK bool +} + +// loadTranslations validates every other directory that has an app.json. +// Directories without one are skipped. Packs that fail to parse are reported +// and left out of the returned list. +func loadTranslations(dir, reference string, ref map[string]pack.Entry) ([]translation, []pack.Finding, error) { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil, nil, fmt.Errorf("read locales: %w", err) + } + var ( + out []translation + findings []pack.Finding + ) + for _, d := range dirEntries { + if !d.IsDir() || d.Name() == reference { + continue + } + tag := d.Name() + packDir := filepath.Join(dir, tag) + file := tag + "/app.json" + data, err := os.ReadFile(filepath.Clean(filepath.Join(packDir, "app.json"))) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", file, err) + } + meta, metaOK, metaFindings, err := readMeta(filepath.Join(packDir, "meta.json"), tag, tag+"/meta.json", true) + if err != nil { + return nil, nil, err + } + findings = append(findings, metaFindings...) + entries, err := pack.ParseEntries(data) + if err != nil { + findings = append(findings, errFinding(pack.CodeMalformedPack, file, pack.IDPack, err.Error())) + continue + } + findings = append(findings, pack.ValidateTranslation(ref, entries, file)...) + out = append(out, translation{tag: tag, dir: packDir, entries: entries, meta: meta, metaOK: metaOK}) + } + return out, findings, nil +} + +// readMeta reads and validates a pack's meta.json. A missing file is a finding +// when required is set and otherwise means the pack has no metadata. Only an +// unreadable file is returned as an error; ok reports whether the metadata is +// usable. +func readMeta(path, tag, file string, required bool) (meta pack.Meta, ok bool, findings []pack.Finding, err error) { + data, err := os.ReadFile(filepath.Clean(path)) + if errors.Is(err, fs.ErrNotExist) { + if required { + return pack.Meta{}, false, []pack.Finding{errFinding(pack.CodeInvalidMeta, file, pack.IDMeta, "meta.json is missing")}, nil + } + return pack.Meta{}, false, nil, nil + } + if err != nil { + return pack.Meta{}, false, nil, fmt.Errorf("read %s: %w", file, err) + } + findings = pack.ValidateMeta(data, tag, file) + meta, parseErr := pack.ParseMeta(data) + return meta, parseErr == nil && !hasErrors(findings), findings, nil +} + +// stampTranslations records the English source hash on every translated entry +// that lacks one, and rewrites the packs it changed. +func stampTranslations(ref map[string]pack.Entry, trs []translation) error { + ids := slices.Sorted(maps.Keys(ref)) + for i := range trs { + tr := &trs[i] + changed := false + for _, id := range ids { + entry, ok := tr.entries[id] + if !ok || entry.SourceHash != "" { + continue + } + if pack.EntryStatus(ref[id], entry, true) != pack.StatusTranslated { + continue + } + entry.SourceHash = pack.SourceHash(ref[id].DefaultMessage) + tr.entries[id] = entry + changed = true + } + if !changed { + continue + } + data, err := pack.Marshal(tr.entries) + if err != nil { + return fmt.Errorf("stamp %s: %w", tr.tag, err) + } + if err := os.WriteFile(filepath.Join(tr.dir, "app.json"), data, 0o600); err != nil { + return fmt.Errorf("write stamped %s/app.json: %w", tr.tag, err) + } + } + return nil +} + +// syncServer replaces the reference's server.* entries with the registry and +// writes the reference back. +func syncServer(path string, ref, registry map[string]pack.Entry) error { + for id := range ref { + if strings.HasPrefix(id, "server.") { + delete(ref, id) + } + } + maps.Copy(ref, registry) + data, err := pack.Marshal(ref) + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write reference: %w", err) + } + return nil +} + +// checkServer reports server.* ids that exist on only one side, or whose +// message or description differs between the reference and the registry. +func checkServer(ref, registry map[string]pack.Entry, file string) []pack.Finding { + ids := map[string]struct{}{} + for id := range ref { + if strings.HasPrefix(id, "server.") { + ids[id] = struct{}{} + } + } + for id := range registry { + ids[id] = struct{}{} + } + var out []pack.Finding + for _, id := range slices.Sorted(maps.Keys(ids)) { + r, inRef := ref[id] + g, inReg := registry[id] + switch { + case !inRef: + out = append(out, errFinding(pack.CodeServerDrift, file, id, "registry defines this server message but the reference does not; run --sync-server")) + case !inReg: + out = append(out, errFinding(pack.CodeServerDrift, file, id, "reference has this server message but the registry does not; run --sync-server")) + case r.DefaultMessage != g.DefaultMessage || r.Description != g.Description: + out = append(out, errFinding(pack.CodeServerDrift, file, id, "reference differs from the registry; run --sync-server")) + } + } + return out +} + +// isCodeID reports whether an id is extracted from source code. Server, +// condition and notification messages come from registries, not code. +func isCodeID(id string) bool { + for _, p := range []string{"server.", "condition.", "notify."} { + if strings.HasPrefix(id, p) { + return false + } + } + return true +} + +// checkExtracted compares the reference with the formatjs extraction for the +// ids that come from code. +func checkExtracted(ref map[string]pack.Entry, data []byte, file string) []pack.Finding { + ext, err := pack.ParseEntries(data) + if err != nil { + return []pack.Finding{errFinding(pack.CodeExtractDrift, file, pack.IDReference, "extracted messages do not parse: "+err.Error())} + } + ids := map[string]struct{}{} + for id := range ref { + if isCodeID(id) { + ids[id] = struct{}{} + } + } + for id := range ext { + if isCodeID(id) { + ids[id] = struct{}{} + } + } + var out []pack.Finding + for _, id := range slices.Sorted(maps.Keys(ids)) { + r, inRef := ref[id] + e, inExt := ext[id] + switch { + case !inExt: + out = append(out, errFinding(pack.CodeExtractDrift, file, id, "reference has this message but the code no longer extracts it")) + case !inRef: + out = append(out, errFinding(pack.CodeExtractDrift, file, id, "the code extracts this message but the reference does not have it")) + case r.DefaultMessage != e.DefaultMessage || r.Description != e.Description: + out = append(out, errFinding(pack.CodeExtractDrift, file, id, "reference text differs from the message extracted from the code")) + } + } + return out +} + +// checkUsed reports reference ids that no code uses. +func checkUsed(ref map[string]pack.Entry, used map[string]struct{}, file string) []pack.Finding { + var out []pack.Finding + for _, id := range slices.Sorted(maps.Keys(ref)) { + if _, ok := used[id]; !ok { + out = append(out, errFinding(pack.CodeUnusedID, file, id, "not used by any code; delete it from the reference")) + } + } + return out +} + +// registryItem is one entry of the --registry JSON array. +type registryItem struct { + ID string `json:"id"` + English string `json:"english"` + Description string `json:"description"` +} + +func loadRegistry(path string) (map[string]pack.Entry, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("read registry: %w", err) + } + var items []registryItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, fmt.Errorf("parse registry %s: %w", path, err) + } + out := make(map[string]pack.Entry, len(items)) + for _, it := range items { + out[it.ID] = pack.Entry{DefaultMessage: it.English, Description: it.Description} + } + return out, nil +} + +// readIDs reads one message id per line, ignoring blank lines. +func readIDs(path string) (map[string]struct{}, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("read used ids: %w", err) + } + set := map[string]struct{}{} + for _, line := range strings.Split(string(data), "\n") { + if id := strings.TrimSpace(line); id != "" { + set[id] = struct{}{} + } + } + return set, nil +} + +// manifestRow is one language in the --manifest JSON. +type manifestRow struct { + Tag string `json:"tag"` + NativeName string `json:"nativeName"` + EnglishName string `json:"englishName"` + Direction string `json:"direction"` + Completeness int `json:"completeness"` + Listed bool `json:"listed"` +} + +func newRow(tag string, m pack.Meta, completeness float64, listed bool) manifestRow { + return manifestRow{ + Tag: tag, + NativeName: m.NativeName, + EnglishName: m.EnglishName, + Direction: m.Direction, + Completeness: int(math.Floor(completeness)), + Listed: listed, + } +} + +// manifestRows lists the reference first, then the translations with valid +// metadata in tag order. +func manifestRows(reference string, refMeta pack.Meta, ref map[string]pack.Entry, trs []translation) []manifestRow { + rows := []manifestRow{newRow(reference, refMeta, 100, true)} + for _, tr := range trs { + if !tr.metaOK { + continue + } + c := pack.Completeness(ref, tr.entries) + rows = append(rows, newRow(tr.tag, tr.meta, c, pack.Listed(tr.tag, tr.meta, c))) + } + return rows +} + +func writeManifest(path string, rows []manifestRow) error { + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return fmt.Errorf("encode manifest: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write manifest: %w", err) + } + return nil +} + +// summaryRow is one translation row of the markdown summary. +type summaryRow struct { + tag string + completeness string + listed bool +} + +// summaryRows renders completeness as "old → new" when --base is given and the +// tag exists there, "—" → new when it does not, and plain "new" otherwise. +func summaryRows(o options, rows []manifestRow) []summaryRow { + var out []summaryRow + for _, r := range rows { + if r.Tag == o.reference { + continue + } + cur := fmt.Sprintf("%d%%", r.Completeness) + if o.base != "" { + prev := "—" + if c, ok := baseCompleteness(o.base, o.reference, r.Tag); ok { + prev = fmt.Sprintf("%d%%", int(math.Floor(c))) + } + cur = prev + " → " + cur + } + out = append(out, summaryRow{tag: r.Tag, completeness: cur, listed: r.Listed}) + } + return out +} + +// baseCompleteness returns the completeness of tag's pack in the base +// directory, measured against the base reference. +func baseCompleteness(base, reference, tag string) (float64, bool) { + ref, err := readEntriesFile(filepath.Join(base, reference, "app.json")) + if err != nil { + return 0, false + } + tr, err := readEntriesFile(filepath.Join(base, tag, "app.json")) + if err != nil { + return 0, false + } + return pack.Completeness(ref, tr), true +} + +func readEntriesFile(path string) (map[string]pack.Entry, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + entries, err := pack.ParseEntries(data) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return entries, nil +} + +// writeSummary appends a markdown section to path: a heading, the error and +// notice counts, an optional language table, and the findings in a code block. +func writeSummary(path string, findings []pack.Finding, rows []summaryRow) error { + errs, notices := countSeverities(findings) + var b strings.Builder + _, _ = fmt.Fprintf(&b, "### i18n-check\n\n%d error(s), %d notice(s)\n\n", errs, notices) + if len(rows) > 0 { + b.WriteString("| Language | Completeness | Listed |\n| --- | --- | --- |\n") + for _, r := range rows { + listed := "no" + if r.listed { + listed = "yes" + } + _, _ = fmt.Fprintf(&b, "| %s | %s | %s |\n", r.tag, r.completeness, listed) + } + b.WriteString("\n") + } + if len(findings) > 0 { + b.WriteString("```text\n") + for _, f := range findings { + b.WriteString(f.String() + "\n") + } + b.WriteString("```\n") + } + out, err := os.OpenFile(filepath.Clean(path), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open summary: %w", err) + } + if _, err := out.WriteString(b.String()); err != nil { + _ = out.Close() + return fmt.Errorf("write summary: %w", err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close summary: %w", err) + } + return nil +} + +// report prints the findings sorted and returns the exit status. +func report(stdout io.Writer, findings []pack.Finding) int { + pack.SortFindings(findings) + code := exitOK + for _, f := range findings { + say(stdout, "%s\n", f.String()) + if f.Severity == pack.SeverityError { + code = exitFinding + } + } + return code +} + +func errFinding(code, file, id, msg string) pack.Finding { + return pack.Finding{Code: code, Severity: pack.SeverityError, File: file, ID: id, Message: msg} +} + +func hasErrors(findings []pack.Finding) bool { + errs, _ := countSeverities(findings) + return errs > 0 +} + +func countSeverities(findings []pack.Finding) (errs, notices int) { + for _, f := range findings { + if f.Severity == pack.SeverityError { + errs++ + } else { + notices++ + } + } + return errs, notices +} + +func fail(stderr io.Writer, err error) int { + say(stderr, "i18n-check: %v\n", err) + return exitUsage +} + +func say(w io.Writer, format string, args ...any) { + _, _ = fmt.Fprintf(w, format, args...) +} diff --git a/i18n/cmd/i18n-check/main_test.go b/i18n/cmd/i18n-check/main_test.go new file mode 100644 index 000000000..6815973ee --- /dev/null +++ b/i18n/cmd/i18n-check/main_test.go @@ -0,0 +1,334 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/i18n/pack" +) + +func writeTree(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, body := range files { + p := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func metaJSON(tag, direction string) string { + return fmt.Sprintf(`{"tag":%q,"nativeName":"N-%s","englishName":"E-%s","direction":%q}`, tag, tag, tag, direction) +} + +// appJSON returns n reference-style entries k00..k(n-1), each with a note. +func appJSON(t *testing.T, n int) string { + t.Helper() + m := map[string]map[string]string{} + for i := range n { + m[fmt.Sprintf("k%02d", i)] = map[string]string{ + "defaultMessage": fmt.Sprintf("v%d", i), + "description": "note", + } + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestUsageErrors(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"}}`, + "template.yaml": "id: x\n", + }) + tpl := filepath.Join(dir, "template.yaml") + missing := filepath.Join(t.TempDir(), "missing") + out := filepath.Join(t.TempDir(), "manifest.json") + + tests := []struct { + name string + args []string + }{ + {"no locales dir", nil}, + {"two positional args", []string{dir, dir}}, + {"unknown flag", []string{"--bogus", dir}}, + {"sync without registry", []string{"--sync-server", dir}}, + {"check without registry", []string{"--check-server", dir}}, + {"unreadable reference", []string{missing}}, + {"unreadable registry", []string{"--check-server", "--registry", missing, dir}}, + {"unreadable used ids", []string{"--used-ids", missing, dir}}, + {"unreadable extracted", []string{"--extracted", missing, dir}}, + {"module with manifest", []string{"--module", tpl, "--manifest", out, dir}}, + {"module with stamp", []string{"--module", tpl, "--stamp", dir}}, + {"module with sync", []string{"--module", tpl, "--sync-server", "--registry", tpl, dir}}, + {"module missing template", []string{"--module", missing, dir}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if got := run(tc.args, &stdout, &stderr); got != 2 { + t.Errorf("exit = %d; want 2 (stderr: %s)", got, stderr.String()) + } + if stderr.Len() == 0 { + t.Error("usage error printed nothing to stderr") + } + }) + } +} + +func TestMalformedReferenceReported(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{"en/app.json": `{"a":`}) + var stdout, stderr bytes.Buffer + if got := run([]string{root}, &stdout, &stderr); got != 1 { + t.Fatalf("exit = %d; want 1", got) + } + if !strings.Contains(stdout.String(), "[malformed-pack]") { + t.Errorf("stdout = %q; want a malformed-pack finding", stdout.String()) + } +} + +func TestManifestShapeAndOrder(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"},"b":{"defaultMessage":"B","description":"d"}}`, + "en/meta.json": metaJSON("en", "ltr"), + "fr/app.json": `{"a":{"defaultMessage":"Afr"},"b":{"defaultMessage":"Bfr"}}`, + "fr/meta.json": metaJSON("fr", "ltr"), + "de/app.json": `{"a":{"defaultMessage":"Ade"}}`, + "de/meta.json": metaJSON("de", "ltr"), + "ar/app.json": `{"a":{"defaultMessage":"Aar"},"b":{"defaultMessage":"Bar"}}`, + "ar/meta.json": metaJSON("ar", "rtl"), + "xx/app.json": `{"a":{"defaultMessage":"Axx"}}`, + }) + manifest := filepath.Join(t.TempDir(), "manifest.json") + var stdout, stderr bytes.Buffer + // Exit 1 is expected: xx has no meta.json, which is an error-severity + // invalid-meta finding. xx is still skipped from the manifest. + if got := run([]string{"--manifest", manifest, root}, &stdout, &stderr); got != 1 { + t.Fatalf("exit = %d; want 1 for xx without meta.json (stdout: %s, stderr: %s)", got, stdout.String(), stderr.String()) + } + + data := readFile(t, manifest) + if !strings.HasSuffix(data, "]\n") { + t.Errorf("manifest does not end with a newline: %q", data) + } + var rows []manifestRow + if err := json.Unmarshal([]byte(data), &rows); err != nil { + t.Fatal(err) + } + want := []manifestRow{ + {Tag: "en", NativeName: "N-en", EnglishName: "E-en", Direction: "ltr", Completeness: 100, Listed: true}, + {Tag: "ar", NativeName: "N-ar", EnglishName: "E-ar", Direction: "rtl", Completeness: 100, Listed: false}, + {Tag: "de", NativeName: "N-de", EnglishName: "E-de", Direction: "ltr", Completeness: 50, Listed: false}, + {Tag: "fr", NativeName: "N-fr", EnglishName: "E-fr", Direction: "ltr", Completeness: 100, Listed: true}, + } + if fmt.Sprint(rows) != fmt.Sprint(want) { + t.Errorf("manifest rows =\n%+v\nwant\n%+v", rows, want) + } +} + +func TestSummaryShowsCompletenessChange(t *testing.T) { + base := t.TempDir() + writeTree(t, base, map[string]string{ + "en/app.json": appJSON(t, 20), + "fr/app.json": appJSON(t, 8), + }) + cur := t.TempDir() + writeTree(t, cur, map[string]string{ + "en/app.json": appJSON(t, 20), + "en/meta.json": metaJSON("en", "ltr"), + "fr/app.json": appJSON(t, 17), + "fr/meta.json": metaJSON("fr", "ltr"), + }) + summary := filepath.Join(t.TempDir(), "summary.md") + if err := os.WriteFile(summary, []byte("existing\n"), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if got := run([]string{"--base", base, "--summary", summary, cur}, &stdout, &stderr); got != 0 { + t.Fatalf("exit = %d; want 0 (stdout: %s)", got, stdout.String()) + } + got := readFile(t, summary) + for _, want := range []string{"existing\n", "### i18n-check", "0 error(s), 0 notice(s)", "| fr | 40% → 85% | yes |"} { + if !strings.Contains(got, want) { + t.Errorf("summary missing %q:\n%s", want, got) + } + } +} + +func TestStampWritesSourceHash(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"}}`, + "fr/app.json": `{"a":{"defaultMessage":"Afr"}}`, + "fr/meta.json": metaJSON("fr", "ltr"), + }) + var stdout, stderr bytes.Buffer + if got := run([]string{"--stamp", root}, &stdout, &stderr); got != 0 { + t.Fatalf("exit = %d; want 0 (stdout: %s)", got, stdout.String()) + } + entries, err := pack.ParseEntries([]byte(readFile(t, filepath.Join(root, "fr", "app.json")))) + if err != nil { + t.Fatal(err) + } + if want := pack.SourceHash("A"); entries["a"].SourceHash != want { + t.Errorf("sourceHash = %q; want %q", entries["a"].SourceHash, want) + } + if strings.Contains(readFile(t, filepath.Join(root, "en", "app.json")), "sourceHash") { + t.Error("reference was stamped") + } +} + +func TestSyncServerWritesRegistry(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"},"server.old":{"defaultMessage":"Old","description":"x"}}`, + }) + registry := filepath.Join(t.TempDir(), "registry.json") + if err := os.WriteFile(registry, []byte(`[{"id":"server.new","english":"New","description":"Shown"}]`), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if got := run([]string{"--sync-server", "--registry", registry, root}, &stdout, &stderr); got != 0 { + t.Fatalf("exit = %d; want 0 (stderr: %s)", got, stderr.String()) + } + entries, err := pack.ParseEntries([]byte(readFile(t, filepath.Join(root, "en", "app.json")))) + if err != nil { + t.Fatal(err) + } + if _, ok := entries["server.old"]; ok { + t.Error("server.old survived the sync") + } + if got := entries["server.new"]; got.DefaultMessage != "New" || got.Description != "Shown" { + t.Errorf("server.new = %+v; want registry values", got) + } + if _, ok := entries["a"]; !ok { + t.Error("non-server entry was dropped by the sync") + } +} + +func TestCheckServerRegistryAndReferenceOnlyIDs(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"},"server.only-ref":{"defaultMessage":"R","description":"d"}}`, + }) + registry := filepath.Join(t.TempDir(), "registry.json") + if err := os.WriteFile(registry, []byte(`[{"id":"server.only-reg","english":"G","description":"d"}]`), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if got := run([]string{"--check-server", "--registry", registry, root}, &stdout, &stderr); got != 1 { + t.Fatalf("exit = %d; want 1 (stdout: %s, stderr: %s)", got, stdout.String(), stderr.String()) + } + for _, want := range []string{"server.only-ref: [server-drift]", "server.only-reg: [server-drift]"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("stdout missing %q:\n%s", want, stdout.String()) + } + } +} + +func TestExtractedUnparseableReported(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"}}`, + }) + extracted := filepath.Join(t.TempDir(), "extracted.json") + if err := os.WriteFile(extracted, []byte(`{"a":`), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if got := run([]string{"--extracted", extracted, root}, &stdout, &stderr); got != 1 { + t.Fatalf("exit = %d; want 1 (stdout: %s, stderr: %s)", got, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "[extract-drift]") { + t.Errorf("stdout = %q; want an extract-drift finding", stdout.String()) + } +} + +func TestExtractedMissingAndExtraIDs(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"},"b":{"defaultMessage":"B","description":"d"}}`, + }) + extracted := filepath.Join(t.TempDir(), "extracted.json") + if err := os.WriteFile(extracted, []byte(`{"a":{"defaultMessage":"A","description":"d"},"c":{"defaultMessage":"C","description":"d"}}`), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if got := run([]string{"--extracted", extracted, root}, &stdout, &stderr); got != 1 { + t.Fatalf("exit = %d; want 1 (stdout: %s, stderr: %s)", got, stdout.String(), stderr.String()) + } + for _, want := range []string{": b: [extract-drift]", ": c: [extract-drift]"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("stdout missing %q:\n%s", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), ": a: [extract-drift]") { + t.Errorf("matching id a was reported:\n%s", stdout.String()) + } +} + +func TestSummaryUnwritablePathIsUsageError(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en/app.json": `{"a":{"defaultMessage":"A","description":"d"}}`, + }) + summary := filepath.Join(t.TempDir(), "missing-dir", "summary.md") + var stdout, stderr bytes.Buffer + if got := run([]string{"--summary", summary, root}, &stdout, &stderr); got != 2 { + t.Fatalf("exit = %d; want 2 (stdout: %s)", got, stdout.String()) + } + if stderr.Len() == 0 { + t.Error("unwritable summary printed nothing to stderr") + } +} + +func TestModulePacks(t *testing.T) { + tests := []struct { + name string + fr string + want int + }{ + {"clean", `{"a":{"defaultMessage":"Afr"}}`, 0}, + {"placeholder mismatch", `{"a":{"defaultMessage":"{x}"}}`, 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeTree(t, root, map[string]string{ + "en.json": `{"a":{"defaultMessage":"A","description":"d"}}`, + "fr.json": tc.fr, + }) + tpl := filepath.Join(t.TempDir(), "template.yaml") + if err := os.WriteFile(tpl, []byte("id: example\n"), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if got := run([]string{"--module", tpl, root}, &stdout, &stderr); got != tc.want { + t.Errorf("exit = %d; want %d (stdout: %s)", got, tc.want, stdout.String()) + } + }) + } +} diff --git a/i18n/doc.go b/i18n/doc.go new file mode 100644 index 000000000..010f4f04d --- /dev/null +++ b/i18n/doc.go @@ -0,0 +1,8 @@ +// Package i18n holds Gameplane's shared language packs: the English reference +// catalog, the translated packs under locales/, and the embedded file system +// that other modules import at build time. +// +// The ICU message formatter lives in package icu, pack validation in package +// pack, runtime lookup in package catalog, and the CI checker in +// cmd/i18n-check. +package i18n diff --git a/i18n/embed.go b/i18n/embed.go new file mode 100644 index 000000000..4db0d8ac5 --- /dev/null +++ b/i18n/embed.go @@ -0,0 +1,11 @@ +package i18n + +import "embed" + +// Locales holds every language pack under locales//, including the +// English reference (locales/en/app.json). go:embed paths cannot contain "..", +// so the embed directive must live in this root package while the consumers +// (package catalog) read the files from here. +// +//go:embed locales/*/*.json +var Locales embed.FS diff --git a/i18n/go.mod b/i18n/go.mod new file mode 100644 index 000000000..ee15207c2 --- /dev/null +++ b/i18n/go.mod @@ -0,0 +1,5 @@ +module github.com/GameplanePanel/gameplane/i18n + +go 1.26.0 + +require golang.org/x/text v0.42.0 diff --git a/i18n/go.sum b/i18n/go.sum new file mode 100644 index 000000000..57795ca58 --- /dev/null +++ b/i18n/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= diff --git a/i18n/icu/format.go b/i18n/icu/format.go new file mode 100644 index 000000000..7c0596dd7 --- /dev/null +++ b/i18n/icu/format.go @@ -0,0 +1,236 @@ +package icu + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "golang.org/x/text/feature/plural" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/number" +) + +// state carries the locale, the arguments and the current plural value (for #) +// through a recursive render. +type state struct { + tag language.Tag + args map[string]any + pound string + hasPound bool +} + +// Format renders m for tag using args. A missing argument renders as {name}. +// Format never panics on a malformed message; unknown shapes render as text. +func Format(m Message, tag language.Tag, args map[string]any) string { + var b strings.Builder + writeNodes(&b, m, state{tag: tag, args: args}) + return b.String() +} + +func writeNodes(b *strings.Builder, nodes []Node, st state) { + for _, n := range nodes { + switch n.Kind { + case KindLiteral: + b.WriteString(n.Text) + case KindPound: + if st.hasPound { + b.WriteString(st.pound) + } else { + b.WriteByte('#') + } + case KindTag: + b.WriteString("<" + n.Name + ">") + writeNodes(b, n.Children, st) + b.WriteString("") + case KindArgument: + v, ok := st.args[n.Name] + if !ok { + b.WriteString("{" + n.Name + "}") + continue + } + b.WriteString(formatArgument(v, n, st.tag)) + case KindPlural: + v, ok := st.args[n.Name] + if !ok { + b.WriteString("{" + n.Name + "}") + continue + } + writePlural(b, n, v, st) + case KindSelect: + v, ok := st.args[n.Name] + if !ok { + writeOption(b, findOption(n.Options, "other"), st, "", false) + continue + } + writeOption(b, findOption(n.Options, fmt.Sprint(v)), st, "", false) + } + } +} + +// writePlural renders the branch selected by an exact =N match, else by the +// CLDR plural category of the value, else the other branch. +func writePlural(b *strings.Builder, n Node, v any, st state) { + val, text, ok := toNumber(v) + if !ok { + writeOption(b, findOption(n.Options, "other"), st, fmt.Sprint(v), true) + return + } + x, _ := strconv.ParseFloat(text, 64) + opt := exactOption(n.Options, x) + if opt == nil { + opt = findOption(n.Options, pluralKey(st.tag, text)) + } + if opt == nil { + opt = findOption(n.Options, "other") + } + pound := message.NewPrinter(st.tag).Sprint(number.Decimal(val)) + writeOption(b, opt, st, pound, true) +} + +func writeOption(b *strings.Builder, opt *Option, st state, pound string, hasPound bool) { + if opt == nil { + return + } + inner := st + if hasPound { + inner.pound = pound + inner.hasPound = true + } + writeNodes(b, opt.Value, inner) +} + +func findOption(opts []Option, key string) *Option { + for i := range opts { + if opts[i].Key == key { + return &opts[i] + } + } + return nil +} + +func exactOption(opts []Option, x float64) *Option { + for i := range opts { + k := opts[i].Key + if !strings.HasPrefix(k, "=") { + continue + } + n, err := strconv.Atoi(k[1:]) + if err == nil && x == float64(n) { + return &opts[i] + } + } + return nil +} + +// pluralKey maps the CLDR category of a decimal string to its option key. +func pluralKey(tag language.Tag, text string) string { + i, v, w, f, t := operands(text) + switch plural.Cardinal.MatchPlural(tag, i, v, w, f, t) { + case plural.Zero: + return "zero" + case plural.One: + return "one" + case plural.Two: + return "two" + case plural.Few: + return "few" + case plural.Many: + return "many" + default: + return "other" + } +} + +// operands derives the CLDR plural operands i, v, w, f and t from a plain +// decimal string such as "-12.50". Operands too large for an int keep their +// last seven digits, as the CLDR rules permit. +func operands(text string) (i, v, w, f, t int) { + text = strings.TrimPrefix(text, "-") + intPart, frac, _ := strings.Cut(text, ".") + fracTrim := strings.TrimRight(frac, "0") + i = tailInt(intPart) + v = len(frac) + w = len(fracTrim) + f = tailInt(frac) + t = tailInt(fracTrim) + return i, v, w, f, t +} + +// tailInt parses the last seven digits of s; empty input yields 0. +func tailInt(s string) int { + const keep = 7 + if len(s) > keep { + s = s[len(s)-keep:] + } + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +// toNumber reports whether v is numeric (a Go numeric type or a numeric +// string). It returns the value for number.Decimal and its plain decimal text. +func toNumber(v any) (val any, text string, ok bool) { + switch t := v.(type) { + case int: + return t, strconv.FormatInt(int64(t), 10), true + case int8: + return t, strconv.FormatInt(int64(t), 10), true + case int16: + return t, strconv.FormatInt(int64(t), 10), true + case int32: + return t, strconv.FormatInt(int64(t), 10), true + case int64: + return t, strconv.FormatInt(t, 10), true + case uint: + return t, strconv.FormatUint(uint64(t), 10), true + case uint8: + return t, strconv.FormatUint(uint64(t), 10), true + case uint16: + return t, strconv.FormatUint(uint64(t), 10), true + case uint32: + return t, strconv.FormatUint(uint64(t), 10), true + case uint64: + return t, strconv.FormatUint(t, 10), true + case float32: + f := float64(t) + if math.IsNaN(f) || math.IsInf(f, 0) { + return nil, "", false + } + return f, strconv.FormatFloat(f, 'f', -1, 32), true + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return nil, "", false + } + return t, strconv.FormatFloat(t, 'f', -1, 64), true + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return nil, "", false + } + return f, strconv.FormatFloat(f, 'f', -1, 64), true + } + return nil, "", false +} + +func formatArgument(v any, n Node, tag language.Tag) string { + switch n.Format { + case "number": + if val, _, ok := toNumber(v); ok { + return message.NewPrinter(tag).Sprint(number.Decimal(val)) + } + case "date": + if t, ok := v.(time.Time); ok { + return t.Format("2006-01-02") + } + case "time": + if t, ok := v.(time.Time); ok { + return t.Format("15:04") + } + } + return fmt.Sprint(v) +} diff --git a/i18n/icu/format_test.go b/i18n/icu/format_test.go new file mode 100644 index 000000000..eb0466ea6 --- /dev/null +++ b/i18n/icu/format_test.go @@ -0,0 +1,269 @@ +package icu + +import ( + "fmt" + "math" + "testing" + "time" + + "golang.org/x/text/language" +) + +func TestFormatNumericTypes(t *testing.T) { + plural, err := Parse("{n, plural, one {# item} other {# items}}") + if err != nil { + t.Fatal(err) + } + number, err := Parse("{n, number}") + if err != nil { + t.Fatal(err) + } + values := []any{ + int(3), int8(3), int16(3), int32(3), int64(3), + uint(3), uint8(3), uint16(3), uint32(3), uint64(3), + float32(3), + } + for _, v := range values { + name := fmt.Sprintf("%T", v) + if got := Format(plural, language.English, map[string]any{"n": v}); got != "3 items" { + t.Errorf("plural with %s = %q; want %q", name, got, "3 items") + } + if got := Format(number, language.English, map[string]any{"n": v}); got != "3" { + t.Errorf("number with %s = %q; want %q", name, got, "3") + } + } + // NaN and +Inf have no meaningful count; they must still render without panicking. + for _, v := range []float64{math.NaN(), math.Inf(1)} { + if got := Format(plural, language.English, map[string]any{"n": v}); got == "" { + t.Errorf("plural with %v rendered empty", v) + } + if got := Format(number, language.English, map[string]any{"n": v}); got == "" { + t.Errorf("number with %v rendered empty", v) + } + } +} + +func TestFormat(t *testing.T) { + tests := []struct { + name string + tag language.Tag + in string + args map[string]any + want string + }{ + { + name: "french plural zero", + tag: language.French, + in: "{n, plural, one {# fichier} other {# fichiers}}", + args: map[string]any{"n": 0}, + want: "0 fichier", + }, + { + name: "french plural one", + tag: language.French, + in: "{n, plural, one {# fichier} other {# fichiers}}", + args: map[string]any{"n": 1}, + want: "1 fichier", + }, + { + name: "french plural other", + tag: language.French, + in: "{n, plural, one {# fichier} other {# fichiers}}", + args: map[string]any{"n": 2}, + want: "2 fichiers", + }, + { + name: "polish one", + tag: language.Polish, + in: "{n, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + args: map[string]any{"n": 1}, + want: "1 plik", + }, + { + name: "polish few", + tag: language.Polish, + in: "{n, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + args: map[string]any{"n": 2}, + want: "2 pliki", + }, + { + name: "polish many", + tag: language.Polish, + in: "{n, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + args: map[string]any{"n": 5}, + want: "5 plików", + }, + { + name: "polish few again at 22", + tag: language.Polish, + in: "{n, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + args: map[string]any{"n": 22}, + want: "22 pliki", + }, + { + name: "arabic zero", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 0}, + want: "z", + }, + { + name: "arabic one", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 1}, + want: "o", + }, + { + name: "arabic two", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 2}, + want: "t", + }, + { + name: "arabic few", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 3}, + want: "f", + }, + { + name: "arabic many", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 11}, + want: "m", + }, + { + name: "arabic other", + tag: language.Arabic, + in: "{n, plural, zero {z} one {o} two {t} few {f} many {m} other {x}}", + args: map[string]any{"n": 100}, + want: "x", + }, + { + name: "exact match wins over category", + tag: language.English, + in: "{n, plural, =0 {none} one {# item} other {# items}}", + args: map[string]any{"n": 0}, + want: "none", + }, + { + name: "english one with pound", + tag: language.English, + in: "{n, plural, =0 {none} one {# item} other {# items}}", + args: map[string]any{"n": 1}, + want: "1 item", + }, + { + name: "float plural uses visible fraction digits", + tag: language.English, + in: "{n, plural, one {# day} other {# days}}", + args: map[string]any{"n": 1.5}, + want: "1.5 days", + }, + { + name: "string numeric value", + tag: language.English, + in: "{n, plural, one {# day} other {# days}}", + args: map[string]any{"n": "1"}, + want: "1 day", + }, + { + name: "non numeric plural uses other", + tag: language.English, + in: "{n, plural, one {x} other {# y}}", + args: map[string]any{"n": "abc"}, + want: "abc y", + }, + { + name: "missing plural argument", + tag: language.English, + in: "{n, plural, one {x} other {y}}", + args: nil, + want: "{n}", + }, + { + name: "reordering", + tag: language.French, + in: "{b} avant {a}", + args: map[string]any{"a": "x", "b": "y"}, + want: "y avant x", + }, + { + name: "missing argument renders placeholder", + tag: language.English, + in: "Hello {a}", + args: nil, + want: "Hello {a}", + }, + { + name: "select match", + tag: language.English, + in: "{g, select, female {she} other {they}}", + args: map[string]any{"g": "female"}, + want: "she", + }, + { + name: "select fallback", + tag: language.English, + in: "{g, select, female {she} other {they}}", + args: map[string]any{"g": "x"}, + want: "they", + }, + { + name: "select missing argument uses other", + tag: language.English, + in: "{g, select, female {she} other {they}}", + args: nil, + want: "they", + }, + { + name: "tags kept", + tag: language.English, + in: "{a} and x", + args: map[string]any{"a": "y"}, + want: "y and x", + }, + { + name: "english number grouping", + tag: language.English, + in: "{n, number}", + args: map[string]any{"n": 1234567}, + want: "1,234,567", + }, + { + name: "date format", + tag: language.English, + in: "{d, date}", + args: map[string]any{"d": time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC)}, + want: "2026-10-11", + }, + { + name: "time format", + tag: language.English, + in: "{d, time}", + args: map[string]any{"d": time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC)}, + want: "09:30", + }, + { + name: "plain argument uses fmt", + tag: language.English, + in: "{a}", + args: map[string]any{"a": 42}, + want: "42", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := Parse(tt.in) + if err != nil { + t.Fatalf("Parse(%q): %v", tt.in, err) + } + if got := Format(m, tt.tag, tt.args); got != tt.want { + t.Fatalf("Format(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/i18n/icu/parse.go b/i18n/icu/parse.go new file mode 100644 index 000000000..a1e153dc9 --- /dev/null +++ b/i18n/icu/parse.go @@ -0,0 +1,400 @@ +// Package icu parses and formats the ICU MessageFormat subset used by the +// Gameplane language packs: simple arguments, number/date/time formats, +// plural and select, tags for inline markup, and apostrophe quoting. +package icu + +import ( + "fmt" + "strings" +) + +// Kind identifies the type of a parsed Node. +type Kind int + +// Node kinds produced by Parse. +const ( + KindLiteral Kind = iota // plain text + KindArgument // {name} or {name, number|date|time[, style]} + KindPlural // {name, plural, ...} + KindSelect // {name, select, ...} + KindPound // # inside a plural option + KindTag // children +) + +// Node is one element of a parsed message. +type Node struct { + Kind Kind + Text string // literal text (unescaped) + Name string // argument name or tag name + Format string // "", "number", "date", "time" (KindArgument only) + Style string // optional style after second comma, trimmed + Options []Option // plural/select branches, in source order + Children []Node // tag body +} + +// Option is one branch of a plural or select. +type Option struct { + Key string // "=3", "one", "other", or a select key + Value []Node +} + +// Message is a parsed ICU message. +type Message []Node + +// SyntaxError reports a malformed message at a byte offset. +type SyntaxError struct { + Offset int + Msg string +} + +// Error implements error. +func (e *SyntaxError) Error() string { + return fmt.Sprintf("offset %d: %s", e.Offset, e.Msg) +} + +// ctx describes the lexical context in which a sequence is parsed. +type ctx struct { + plural bool // '#' is a pound inside the nearest enclosing plural + option bool // inside a plural/select option; an unescaped '}' ends it + tag string // inside a tag body; a closing tag ends it +} + +type parser struct { + s string + pos int +} + +// Parse parses an ICU message. Errors are *SyntaxError values carrying the +// byte offset of the problem. +func Parse(s string) (Message, error) { + p := &parser{s: s} + msg, err := p.parseSeq(ctx{}) + if err != nil { + return nil, err + } + if p.pos < len(p.s) { + return nil, p.errAt(p.pos, "unmatched '}'") + } + return msg, nil +} + +func (p *parser) errAt(off int, msg string) error { + return &SyntaxError{Offset: off, Msg: msg} +} + +func (p *parser) parseSeq(c ctx) (Message, error) { + var out []Node + var lit strings.Builder + flush := func() { + if lit.Len() > 0 { + out = append(out, Node{Kind: KindLiteral, Text: lit.String()}) + lit.Reset() + } + } + for p.pos < len(p.s) { + ch := p.s[p.pos] + switch { + case ch == '\'': + p.apostrophe(c, &lit) + case ch == '{': + flush() + n, err := p.parseArgument(c) + if err != nil { + return nil, err + } + out = append(out, n) + case ch == '}': + if c.option { + flush() + return out, nil + } + return nil, p.errAt(p.pos, "unmatched '}'") + case ch == '#': + if c.plural { + flush() + out = append(out, Node{Kind: KindPound}) + } else { + lit.WriteByte('#') + } + p.pos++ + case ch == '<' && p.pos+1 < len(p.s) && p.s[p.pos+1] == '/': + if c.tag != "" { + flush() + return out, nil + } + return nil, p.errAt(p.pos, "closing tag has no matching opening tag") + case ch == '<' && p.pos+1 < len(p.s) && isLetter(p.s[p.pos+1]): + flush() + n, err := p.parseTag(c) + if err != nil { + return nil, err + } + out = append(out, n) + default: + lit.WriteByte(ch) + p.pos++ + } + } + flush() + return out, nil +} + +// apostrophe handles ICU apostrophe escaping. Two adjacent apostrophes are a +// literal apostrophe. A quote before '{', '}', '<' or '|' (or '#' inside a plural) starts quoted +// text that runs to the next lone apostrophe. Any other apostrophe is literal. +func (p *parser) apostrophe(c ctx, lit *strings.Builder) { + next := byte(0) + if p.pos+1 < len(p.s) { + next = p.s[p.pos+1] + } + switch { + case next == '\'': + lit.WriteByte('\'') + p.pos += 2 + case next == '{' || next == '}' || next == '<' || next == '|' || (next == '#' && c.plural): + p.pos++ // opening quote + for p.pos < len(p.s) { + b := p.s[p.pos] + if b == '\'' { + if p.pos+1 < len(p.s) && p.s[p.pos+1] == '\'' { + lit.WriteByte('\'') + p.pos += 2 + continue + } + p.pos++ // closing quote + return + } + lit.WriteByte(b) + p.pos++ + } + default: + lit.WriteByte('\'') + p.pos++ + } +} + +// parseArgument parses a brace expression starting at '{'. +func (p *parser) parseArgument(c ctx) (Node, error) { + open := p.pos + p.pos++ // '{' + p.skipSpace() + name, ok := p.parseName(false) + if !ok { + return Node{}, p.errAt(p.pos, "expected argument name") + } + p.skipSpace() + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, "unterminated '{'") + } + if p.s[p.pos] == '}' { + p.pos++ + return Node{Kind: KindArgument, Name: name}, nil + } + if p.s[p.pos] != ',' { + return Node{}, p.errAt(p.pos, "expected ',' or '}' after argument name") + } + p.pos++ + p.skipSpace() + typOff := p.pos + typ := p.parseWord() + p.skipSpace() + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, "unterminated '{'") + } + + switch typ { + case "number", "date", "time": + n := Node{Kind: KindArgument, Name: name, Format: typ} + if p.s[p.pos] == '}' { + p.pos++ + return n, nil + } + if p.s[p.pos] != ',' { + return Node{}, p.errAt(p.pos, "expected ',' or '}' after format") + } + p.pos++ + end := strings.IndexByte(p.s[p.pos:], '}') + if end < 0 { + return Node{}, p.errAt(open, "unterminated '{'") + } + n.Style = strings.TrimSpace(p.s[p.pos : p.pos+end]) + p.pos += end + 1 + return n, nil + case "plural", "select": + return p.parseChoice(c, name, typ, open) + case "": + return Node{}, p.errAt(typOff, "expected argument type") + default: + return Node{}, p.errAt(typOff, fmt.Sprintf("unknown argument type %q", typ)) + } +} + +// parseChoice parses the option list of a plural or select. p.pos is at the +// comma that follows the type keyword. +func (p *parser) parseChoice(c ctx, name, typ string, open int) (Node, error) { + if p.s[p.pos] != ',' { + return Node{}, p.errAt(p.pos, fmt.Sprintf("expected ',' after %s", typ)) + } + p.pos++ + n := Node{Kind: KindPlural, Name: name} + if typ == "select" { + n.Kind = KindSelect + } + seen := make(map[string]struct{}) + for { + p.skipSpace() + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, "unterminated '{'") + } + if p.s[p.pos] == '}' { + p.pos++ + break + } + keyOff := p.pos + key, err := p.parseKey(typ) + if err != nil { + return Node{}, err + } + if _, dup := seen[key]; dup { + return Node{}, p.errAt(keyOff, fmt.Sprintf("duplicate key %q", key)) + } + seen[key] = struct{}{} + p.skipSpace() + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, "unterminated '{'") + } + if p.s[p.pos] != '{' { + return Node{}, p.errAt(p.pos, fmt.Sprintf("expected '{' after %q", key)) + } + p.pos++ + optCtx := ctx{plural: c.plural, option: true} + if typ == "plural" { + optCtx.plural = true + } + value, err := p.parseSeq(optCtx) + if err != nil { + return Node{}, err + } + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, "unterminated '{'") + } + p.pos++ // closing '}' of the option + n.Options = append(n.Options, Option{Key: key, Value: value}) + } + if _, ok := seen["other"]; !ok { + return Node{}, p.errAt(open, fmt.Sprintf("%s %q is missing other branch", typ, name)) + } + return n, nil +} + +// parseKey reads one option key: "=N" or a category for plural, a name +// (letters, digits, '_' and '-') for select. +func (p *parser) parseKey(typ string) (string, error) { + if typ == "select" { + key, ok := p.parseName(true) + if !ok { + return "", p.errAt(p.pos, "expected select key") + } + return key, nil + } + if p.pos < len(p.s) && p.s[p.pos] == '=' { + start := p.pos + p.pos++ + digits := p.pos + for p.pos < len(p.s) && isDigit(p.s[p.pos]) { + p.pos++ + } + if p.pos == digits { + return "", p.errAt(start, "expected digits after '='") + } + return p.s[start:p.pos], nil + } + off := p.pos + word := p.parseWord() + if word == "offset" { + return "", p.errAt(off, "offset: is not supported") + } + switch word { + case "zero", "one", "two", "few", "many", "other": + return word, nil + case "": + return "", p.errAt(off, "expected plural key") + default: + return "", p.errAt(off, fmt.Sprintf("unknown plural key %q", word)) + } +} + +// parseTag parses body starting at '<'. +func (p *parser) parseTag(c ctx) (Node, error) { + open := p.pos + p.pos++ // '<' + name, _ := p.parseName(true) + if name == "" || !isLetter(name[0]) { + return Node{}, p.errAt(open, "invalid tag name") + } + if p.pos >= len(p.s) || p.s[p.pos] != '>' { + return Node{}, p.errAt(p.pos, fmt.Sprintf("tag <%s> must not have attributes", name)) + } + p.pos++ // '>' + body, err := p.parseSeq(ctx{plural: c.plural, tag: name}) + if err != nil { + return Node{}, err + } + if p.pos >= len(p.s) { + return Node{}, p.errAt(open, fmt.Sprintf("missing closing tag ", name)) + } + closeOff := p.pos + p.pos += 2 // "= len(p.s) || p.s[p.pos] != '>' { + return Node{}, p.errAt(closeOff, "invalid closing tag") + } + p.pos++ // '>' + if closeName != name { + return Node{}, p.errAt(closeOff, fmt.Sprintf("closing tag does not match <%s>", closeName, name)) + } + return Node{Kind: KindTag, Name: name, Children: body}, nil +} + +// parseName reads an identifier: a letter or '_' followed by letters, digits, +// '_' and, when dashes is true, '-'. Returns ok=false when none is present. +func (p *parser) parseName(dashes bool) (string, bool) { + start := p.pos + if p.pos >= len(p.s) || !(isLetter(p.s[p.pos]) || p.s[p.pos] == '_') { + return "", false + } + p.pos++ + for p.pos < len(p.s) { + b := p.s[p.pos] + if isLetter(b) || isDigit(b) || b == '_' || (dashes && b == '-') { + p.pos++ + continue + } + break + } + return p.s[start:p.pos], true +} + +// parseWord reads a run of ASCII letters (type keywords and plural categories). +func (p *parser) parseWord() string { + start := p.pos + for p.pos < len(p.s) && isLetter(p.s[p.pos]) { + p.pos++ + } + return p.s[start:p.pos] +} + +func (p *parser) skipSpace() { + for p.pos < len(p.s) { + switch p.s[p.pos] { + case ' ', '\t', '\n', '\r': + p.pos++ + default: + return + } + } +} + +func isLetter(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') } + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } diff --git a/i18n/icu/parse_test.go b/i18n/icu/parse_test.go new file mode 100644 index 000000000..8ab16ff1b --- /dev/null +++ b/i18n/icu/parse_test.go @@ -0,0 +1,189 @@ +package icu + +import ( + "errors" + "reflect" + "strconv" + "strings" + "testing" +) + +func TestParseValid(t *testing.T) { + tests := []struct { + name string + in string + want Message + }{ + { + name: "literal", + in: "Hello world", + want: Message{{Kind: KindLiteral, Text: "Hello world"}}, + }, + { + name: "simple argument", + in: "Hi {a}!", + want: Message{ + {Kind: KindLiteral, Text: "Hi "}, + {Kind: KindArgument, Name: "a"}, + {Kind: KindLiteral, Text: "!"}, + }, + }, + { + name: "number argument", + in: "{n, number}", + want: Message{{Kind: KindArgument, Name: "n", Format: "number"}}, + }, + { + name: "date argument with style", + in: "{d, date, short}", + want: Message{{Kind: KindArgument, Name: "d", Format: "date", Style: "short"}}, + }, + { + name: "plural with exact and category keys and pound", + in: "{n, plural, =0 {none} one {# item} other {# items}}", + want: Message{{ + Kind: KindPlural, + Name: "n", + Options: []Option{ + {Key: "=0", Value: Message{{Kind: KindLiteral, Text: "none"}}}, + {Key: "one", Value: Message{{Kind: KindPound}, {Kind: KindLiteral, Text: " item"}}}, + {Key: "other", Value: Message{{Kind: KindPound}, {Kind: KindLiteral, Text: " items"}}}, + }, + }}, + }, + { + name: "select", + in: "{g, select, male {he} other {they}}", + want: Message{{ + Kind: KindSelect, + Name: "g", + Options: []Option{ + {Key: "male", Value: Message{{Kind: KindLiteral, Text: "he"}}}, + {Key: "other", Value: Message{{Kind: KindLiteral, Text: "they"}}}, + }, + }}, + }, + { + name: "nested select inside plural keeps pound", + in: "{n, plural, other {{g, select, other {# x}}}}", + want: Message{{ + Kind: KindPlural, + Name: "n", + Options: []Option{{ + Key: "other", + Value: Message{{ + Kind: KindSelect, + Name: "g", + Options: []Option{{ + Key: "other", + Value: Message{{Kind: KindPound}, {Kind: KindLiteral, Text: " x"}}, + }}, + }}, + }}, + }}, + }, + { + name: "pound outside plural is literal", + in: "#1", + want: Message{{Kind: KindLiteral, Text: "#1"}}, + }, + { + name: "tag", + in: "x", + want: Message{{Kind: KindTag, Name: "b", Children: Message{{Kind: KindLiteral, Text: "x"}}}}, + }, + { + name: "tag around argument", + in: "{a}", + want: Message{{Kind: KindTag, Name: "link", Children: Message{{Kind: KindArgument, Name: "a"}}}}, + }, + { + name: "escaped apostrophe and quoted braces", + in: "It''s '{literal}'", + want: Message{{Kind: KindLiteral, Text: "It's {literal}"}}, + }, + { + name: "lone apostrophe is literal", + in: "don't", + want: Message{{Kind: KindLiteral, Text: "don't"}}, + }, + { + name: "unterminated quote runs to end", + in: "'{x", + want: Message{{Kind: KindLiteral, Text: "{x"}}, + }, + { + name: "less-than not followed by letter is literal", + in: "a < b", + want: Message{{Kind: KindLiteral, Text: "a < b"}}, + }, + { + name: "quoted tag-like text", + in: "''", + want: Message{{Kind: KindLiteral, Text: ""}}, + }, + { + name: "empty message", + in: "", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.in) + if err != nil { + t.Fatalf("Parse(%q) error: %v", tt.in, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Parse(%q)\n got %#v\nwant %#v", tt.in, got, tt.want) + } + }) + } +} + +func TestParseMalformed(t *testing.T) { + tests := []struct { + name string + in string + msg string + }{ + {name: "lone open brace", in: "{", msg: "expected argument name"}, + {name: "unterminated argument", in: "{a", msg: "unterminated '{'"}, + {name: "unmatched close brace", in: "a}", msg: "unmatched '}'"}, + {name: "plural without other", in: "{a, plural, one {x}}", msg: "missing other branch"}, + {name: "unknown type", in: "{a, foo}", msg: "unknown argument type"}, + {name: "unclosed tag", in: "x", msg: "missing closing tag "}, + {name: "closing tag without open", in: "x", msg: "closing tag has no matching opening tag"}, + {name: "mismatched closing tag", in: "x", msg: "does not match"}, + {name: "offset not supported", in: "{a, plural, offset:1 other {x}}", msg: "offset: is not supported"}, + {name: "tag with attributes", in: `y`, msg: "must not have attributes"}, + {name: "duplicate key", in: "{a, select, x {1} x {2} other {3}}", msg: "duplicate key"}, + {name: "unknown plural key", in: "{a, plural, mny {x} other {y}}", msg: "unknown plural key"}, + {name: "missing option brace", in: "{a, select, x y other {z}}", msg: "expected '{'"}, + {name: "unterminated style", in: "{d, date, short", msg: "unterminated '{'"}, + {name: "unterminated option", in: "{a, select, other {x", msg: "unterminated '{'"}, + {name: "empty equals key", in: "{a, plural, = {x} other {y}}", msg: "expected digits"}, + {name: "unbalanced brace inside tag", in: "x}", msg: "unmatched '}'"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.in) + if err == nil { + t.Fatalf("Parse(%q) succeeded, want error", tt.in) + } + var se *SyntaxError + if !errors.As(err, &se) { + t.Fatalf("Parse(%q) error %T is not *SyntaxError", tt.in, err) + } + if se.Offset < 0 || se.Offset > len(tt.in) { + t.Fatalf("Parse(%q) offset %d outside input", tt.in, se.Offset) + } + if !strings.Contains(se.Msg, tt.msg) { + t.Fatalf("Parse(%q) message %q does not contain %q", tt.in, se.Msg, tt.msg) + } + if got := se.Error(); got != "offset "+strconv.Itoa(se.Offset)+": "+se.Msg { + t.Fatalf("Error() = %q", got) + } + }) + } +} diff --git a/i18n/icu/placeholders.go b/i18n/icu/placeholders.go new file mode 100644 index 000000000..e9daf7bb2 --- /dev/null +++ b/i18n/icu/placeholders.go @@ -0,0 +1,58 @@ +package icu + +import "sort" + +// Arguments returns every argument name in m, including plural and select +// names and names used inside options and tag children. +func Arguments(m Message) map[string]struct{} { + set := make(map[string]struct{}) + collectArguments(m, set) + return set +} + +func collectArguments(nodes []Node, set map[string]struct{}) { + for _, n := range nodes { + switch n.Kind { + case KindArgument: + set[n.Name] = struct{}{} + case KindPlural, KindSelect: + set[n.Name] = struct{}{} + for _, o := range n.Options { + collectArguments(o.Value, set) + } + case KindTag: + collectArguments(n.Children, set) + } + } +} + +// Tags returns every tag name in m, recursively. +func Tags(m Message) map[string]struct{} { + set := make(map[string]struct{}) + collectTags(m, set) + return set +} + +func collectTags(nodes []Node, set map[string]struct{}) { + for _, n := range nodes { + switch n.Kind { + case KindTag: + set[n.Name] = struct{}{} + collectTags(n.Children, set) + case KindPlural, KindSelect: + for _, o := range n.Options { + collectTags(o.Value, set) + } + } + } +} + +// SortedKeys returns the members of set in ascending order. +func SortedKeys(set map[string]struct{}) []string { + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/i18n/icu/placeholders_test.go b/i18n/icu/placeholders_test.go new file mode 100644 index 000000000..65f64968a --- /dev/null +++ b/i18n/icu/placeholders_test.go @@ -0,0 +1,65 @@ +package icu + +import ( + "reflect" + "testing" +) + +func TestArguments(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {name: "simple", in: "{a} and {b}", want: []string{"a", "b"}}, + {name: "plural name and nested option args", in: "{n, plural, one {{x}} other {# {y}}}", want: []string{"n", "x", "y"}}, + {name: "select name and tag children", in: "{g, select, other {{a}}}", want: []string{"a", "g"}}, + {name: "deeply nested", in: "{n, plural, other {{g, select, other {{deep}}}}}", want: []string{"deep", "g", "n"}}, + {name: "none", in: "plain", want: []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := Parse(tt.in) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := SortedKeys(Arguments(m)) + if !reflect.DeepEqual(got, tt.want) && !(len(got) == 0 && len(tt.want) == 0) { + t.Fatalf("Arguments() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTags(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {name: "single", in: "x", want: []string{"b"}}, + {name: "nested", in: "{a}", want: []string{"b", "link"}}, + {name: "inside plural option", in: "{n, plural, other {#}}", want: []string{"b"}}, + {name: "none", in: "{a}", want: []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := Parse(tt.in) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := SortedKeys(Tags(m)) + if !reflect.DeepEqual(got, tt.want) && !(len(got) == 0 && len(tt.want) == 0) { + t.Fatalf("Tags() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSortedKeys(t *testing.T) { + got := SortedKeys(map[string]struct{}{"c": {}, "a": {}, "b": {}}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SortedKeys = %v, want %v", got, want) + } +} diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/i18n/locales/en/app.json @@ -0,0 +1 @@ +{} diff --git a/i18n/locales/en/meta.json b/i18n/locales/en/meta.json new file mode 100644 index 000000000..59c7dbbb9 --- /dev/null +++ b/i18n/locales/en/meta.json @@ -0,0 +1,6 @@ +{ + "tag": "en", + "nativeName": "English", + "englishName": "English", + "direction": "ltr" +} diff --git a/i18n/pack/completeness.go b/i18n/pack/completeness.go new file mode 100644 index 000000000..2016e62b2 --- /dev/null +++ b/i18n/pack/completeness.go @@ -0,0 +1,102 @@ +package pack + +import "github.com/GameplanePanel/gameplane/i18n/icu" + +// Status is the state of one translated message. +type Status string + +// Message statuses. +const ( + StatusTranslated Status = "translated" + StatusNeedsReview Status = "needs-review" + StatusInvalid Status = "invalid" + StatusMissing Status = "missing" +) + +// EntryStatus classifies a translation of ref. present reports whether the +// translation pack has an entry for the id at all. +func EntryStatus(ref Entry, tr Entry, present bool) Status { + if !present || tr.DefaultMessage == "" { + return StatusMissing + } + trM, err := icu.Parse(tr.DefaultMessage) + if err != nil { + return StatusInvalid + } + if refM, rerr := icu.Parse(ref.DefaultMessage); rerr == nil { + if !sameSet(icu.Arguments(refM), icu.Arguments(trM)) { + return StatusInvalid + } + } + if CheckMarkup(tr.DefaultMessage, trM) != "" { + return StatusInvalid + } + if tr.SourceHash != "" && tr.SourceHash != SourceHash(ref.DefaultMessage) { + return StatusNeedsReview + } + return StatusTranslated +} + +// Statuses returns the status of every reference id in tr. +func Statuses(ref, tr map[string]Entry) map[string]Status { + out := make(map[string]Status, len(ref)) + for id, refE := range ref { + e, ok := tr[id] + out[id] = EntryStatus(refE, e, ok) + } + return out +} + +// Completeness returns the percentage of reference ids that have a usable +// translation (translated or needs-review). An empty reference is 100. +func Completeness(ref, tr map[string]Entry) float64 { + if len(ref) == 0 { + return 100 + } + usable := 0 + for _, st := range Statuses(ref, tr) { + if st == StatusTranslated || st == StatusNeedsReview { + usable++ + } + } + return float64(usable) * 100 / float64(len(ref)) +} + +// Listed reports whether a language is offered to users. English is always +// listed; others need enough completeness, left-to-right text, and must not be +// the pseudo-locale. +func Listed(tag string, meta Meta, completeness float64) bool { + if tag == "en" { + return true + } + return completeness >= ListingThreshold && meta.Direction == "ltr" && tag != "en-XA" +} + +// Valid returns the entries of tr that may be shown to users: ids the +// reference still has, with status translated or needs-review. +func Valid(ref, tr map[string]Entry) map[string]Entry { + out := make(map[string]Entry, len(tr)) + for id, e := range tr { + refE, ok := ref[id] + if !ok { + continue + } + switch EntryStatus(refE, e, true) { + case StatusTranslated, StatusNeedsReview: + out[id] = e + } + } + return out +} + +func sameSet(a, b map[string]struct{}) bool { + if len(a) != len(b) { + return false + } + for k := range a { + if _, ok := b[k]; !ok { + return false + } + } + return true +} diff --git a/i18n/pack/completeness_test.go b/i18n/pack/completeness_test.go new file mode 100644 index 000000000..36f184183 --- /dev/null +++ b/i18n/pack/completeness_test.go @@ -0,0 +1,114 @@ +package pack + +import "testing" + +func TestEntryStatus(t *testing.T) { + ref := Entry{DefaultMessage: "Hi {name}", Description: "greet"} + tests := []struct { + name string + tr Entry + present bool + want Status + }{ + {name: "absent", present: false, want: StatusMissing}, + {name: "empty message", tr: Entry{}, present: true, want: StatusMissing}, + {name: "bad syntax", tr: Entry{DefaultMessage: "Salut {name"}, present: true, want: StatusInvalid}, + {name: "placeholder mismatch", tr: Entry{DefaultMessage: "Salut"}, present: true, want: StatusInvalid}, + {name: "markup problem", tr: Entry{DefaultMessage: "Salut {name}"}, present: true, want: StatusInvalid}, + {name: "stale", tr: Entry{DefaultMessage: "Salut {name}", SourceHash: "000000000000"}, present: true, want: StatusNeedsReview}, + {name: "current hash", tr: Entry{DefaultMessage: "Salut {name}", SourceHash: SourceHash(ref.DefaultMessage)}, present: true, want: StatusTranslated}, + {name: "translated", tr: Entry{DefaultMessage: "Salut {name}"}, present: true, want: StatusTranslated}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EntryStatus(ref, tt.tr, tt.present); got != tt.want { + t.Fatalf("EntryStatus = %s, want %s", got, tt.want) + } + }) + } +} + +func TestStatusesAndCompleteness(t *testing.T) { + ref := map[string]Entry{ + "a": {DefaultMessage: "A", Description: "d"}, + "b": {DefaultMessage: "B", Description: "d"}, + } + if got := Completeness(ref, nil); got != 0 { + t.Fatalf("Completeness empty translation = %v, want 0", got) + } + half := map[string]Entry{"a": {DefaultMessage: "AA"}} + if got := Completeness(ref, half); got != 50 { + t.Fatalf("Completeness half = %v, want 50", got) + } + stale := map[string]Entry{ + "a": {DefaultMessage: "AA"}, + "b": {DefaultMessage: "BB", SourceHash: "000000000000"}, + } + if got := Completeness(ref, stale); got != 100 { + t.Fatalf("Completeness with needs-review = %v, want 100", got) + } + st := Statuses(ref, half) + if st["a"] != StatusTranslated || st["b"] != StatusMissing { + t.Fatalf("Statuses = %v", st) + } + if got := Completeness(map[string]Entry{}, nil); got != 100 { + t.Fatalf("Completeness empty reference = %v, want 100", got) + } +} + +func TestListed(t *testing.T) { + ltr := Meta{Direction: "ltr"} + rtl := Meta{Direction: "rtl"} + tests := []struct { + name string + tag string + meta Meta + pct float64 + want bool + }{ + {name: "english always", tag: "en", meta: Meta{}, pct: 0, want: true}, + {name: "just below", tag: "fr", meta: ltr, pct: 79.9, want: false}, + {name: "at threshold", tag: "fr", meta: ltr, pct: 80, want: true}, + {name: "complete", tag: "fr", meta: ltr, pct: 100, want: true}, + {name: "rtl excluded", tag: "ar", meta: rtl, pct: 100, want: false}, + {name: "pseudo locale excluded", tag: "en-XA", meta: ltr, pct: 100, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Listed(tt.tag, tt.meta, tt.pct); got != tt.want { + t.Fatalf("Listed = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidDropsInvalidEntries(t *testing.T) { + ref := map[string]Entry{ + "ok": {DefaultMessage: "Hi {n}", Description: "d"}, + "bad": {DefaultMessage: "Hi", Description: "d"}, + "stale": {DefaultMessage: "Yo", Description: "d"}, + "missing": {DefaultMessage: "Miss", Description: "d"}, + } + tr := map[string]Entry{ + "ok": {DefaultMessage: "Salut {n}"}, + "bad": {DefaultMessage: "Salut {n}"}, + "stale": {DefaultMessage: "Salut", SourceHash: "000000000000"}, + "unknown": {DefaultMessage: "Nope"}, + } + got := Valid(ref, tr) + if _, ok := got["ok"]; !ok { + t.Error("ok dropped") + } + if _, ok := got["stale"]; !ok { + t.Error("needs-review entry dropped") + } + if _, ok := got["bad"]; ok { + t.Error("placeholder-mismatched entry kept") + } + if _, ok := got["unknown"]; ok { + t.Error("id not in reference kept") + } + if len(got) != 2 { + t.Fatalf("Valid returned %d entries, want 2", len(got)) + } +} diff --git a/i18n/pack/pack.go b/i18n/pack/pack.go new file mode 100644 index 000000000..19a0dd669 --- /dev/null +++ b/i18n/pack/pack.go @@ -0,0 +1,119 @@ +// Package pack reads, writes and validates language pack files: the per-language +// app.json entry maps, their meta.json descriptors, and the English reference +// that translators work from. +package pack + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" +) + +// ListingThreshold is the minimum completeness percentage at which a +// translation is offered to users. +const ListingThreshold = 80.0 + +// Entry is one message in an app.json pack. +type Entry struct { + DefaultMessage string `json:"defaultMessage"` + Description string `json:"description,omitempty"` + SourceHash string `json:"sourceHash,omitempty"` +} + +// Credit names a contributor of a translation. +type Credit struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` +} + +// Meta describes a language pack in meta.json. +type Meta struct { + Tag string `json:"tag"` + NativeName string `json:"nativeName"` + EnglishName string `json:"englishName"` + Direction string `json:"direction"` + Credits []Credit `json:"credits,omitempty"` +} + +var tagRE = regexp.MustCompile(`^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$`) + +// ValidTag reports whether tag is a well-formed BCP 47 language tag in the +// subset Gameplane accepts (language, optional script, optional region). +func ValidTag(tag string) bool { + return tagRE.MatchString(tag) +} + +// ParseEntries decodes an app.json file. The top level must be a JSON object +// whose values are entry objects; unknown entry keys are rejected. +func ParseEntries(data []byte) (map[string]Entry, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("pack: parse entries: %w", err) + } + if raw == nil { + return nil, errors.New("pack: parse entries: top level must be a JSON object") + } + out := make(map[string]Entry, len(raw)) + for id, msg := range raw { + dec := json.NewDecoder(bytes.NewReader(msg)) + dec.DisallowUnknownFields() + var e Entry + if err := dec.Decode(&e); err != nil { + return nil, fmt.Errorf("pack: entry %q: %w", id, err) + } + if err := expectEOF(dec); err != nil { + return nil, fmt.Errorf("pack: entry %q: %w", id, err) + } + if e.DefaultMessage == "" { + return nil, fmt.Errorf("pack: entry %q has no defaultMessage", id) + } + out[id] = e + } + return out, nil +} + +// ParseMeta decodes a meta.json file, rejecting unknown keys and trailing data. +func ParseMeta(data []byte) (Meta, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var m Meta + if err := dec.Decode(&m); err != nil { + return Meta{}, fmt.Errorf("pack: parse meta: %w", err) + } + if err := expectEOF(dec); err != nil { + return Meta{}, fmt.Errorf("pack: parse meta: %w", err) + } + return m, nil +} + +func expectEOF(dec *json.Decoder) error { + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return errors.New("unexpected data after JSON value") + } + return nil +} + +// Marshal encodes entries as app.json: keys sorted, two-space indentation, +// HTML characters left unescaped, and a trailing newline. +func Marshal(entries map[string]Entry) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(entries); err != nil { + return nil, fmt.Errorf("pack: marshal entries: %w", err) + } + return buf.Bytes(), nil +} + +// SourceHash returns the first 12 hex characters of the SHA-256 of the English +// message. It records which English text a translation was made from. +func SourceHash(english string) string { + sum := sha256.Sum256([]byte(english)) + return hex.EncodeToString(sum[:])[:12] +} diff --git a/i18n/pack/pack_test.go b/i18n/pack/pack_test.go new file mode 100644 index 000000000..eeb8c6d02 --- /dev/null +++ b/i18n/pack/pack_test.go @@ -0,0 +1,137 @@ +package pack + +import ( + "bytes" + "errors" + "testing" + + "github.com/GameplanePanel/gameplane/i18n/icu" +) + +func TestParseEntries(t *testing.T) { + tests := []struct { + name string + in string + wantErr bool + wantLen int + }{ + {name: "valid", in: `{"a":{"defaultMessage":"Hi","description":"greet"}}`, wantLen: 1}, + {name: "empty object", in: `{}`, wantLen: 0}, + {name: "null top level", in: `null`, wantErr: true}, + {name: "array top level", in: `[]`, wantErr: true}, + {name: "unknown key", in: `{"a":{"defaultMessage":"x","extra":1}}`, wantErr: true}, + {name: "missing defaultMessage", in: `{"a":{"description":"x"}}`, wantErr: true}, + {name: "null entry", in: `{"a":null}`, wantErr: true}, + {name: "trailing data", in: `{} {}`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseEntries([]byte(tt.in)) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseEntries err = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && len(got) != tt.wantLen { + t.Fatalf("len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +func TestParseMeta(t *testing.T) { + good := `{"tag":"fr","nativeName":"Français","englishName":"French","direction":"ltr","credits":[{"name":"A"}]}` + m, err := ParseMeta([]byte(good)) + if err != nil { + t.Fatalf("ParseMeta: %v", err) + } + if m.Tag != "fr" || len(m.Credits) != 1 { + t.Fatalf("unexpected meta %+v", m) + } + for name, in := range map[string]string{ + "unknown key": `{"tag":"fr","bogus":1}`, + "trailing data": good + ` {}`, + "not an object": `"fr"`, + } { + if _, err := ParseMeta([]byte(in)); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestMarshalRoundTripIsByteExact(t *testing.T) { + data := []byte("{\n \"a\": {\n \"defaultMessage\": \"Hi & more\",\n \"description\": \"greet\"\n },\n \"b\": {\n \"defaultMessage\": \"Bye\",\n \"description\": \"farewell\",\n \"sourceHash\": \"0123456789ab\"\n }\n}\n") + entries, err := ParseEntries(data) + if err != nil { + t.Fatalf("ParseEntries: %v", err) + } + out, err := Marshal(entries) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !bytes.Equal(out, data) { + t.Fatalf("round trip differs:\n%s\nwant:\n%s", out, data) + } +} + +func TestMarshalSortsKeysAndEmptyMap(t *testing.T) { + out, err := Marshal(map[string]Entry{ + "z": {DefaultMessage: "Z"}, + "a": {DefaultMessage: "A"}, + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if want := "{\n \"a\": {\n \"defaultMessage\": \"A\"\n },\n \"z\": {\n \"defaultMessage\": \"Z\"\n }\n}\n"; string(out) != want { + t.Fatalf("got %q, want %q", out, want) + } + empty, err := Marshal(map[string]Entry{}) + if err != nil { + t.Fatalf("Marshal empty: %v", err) + } + if string(empty) != "{}\n" { + t.Fatalf("empty = %q, want {}\\n", empty) + } +} + +func TestSourceHash(t *testing.T) { + h := SourceHash("Hello") + if len(h) != 12 { + t.Fatalf("len = %d, want 12", len(h)) + } + if h != SourceHash("Hello") { + t.Fatal("SourceHash is not stable") + } + if h == SourceHash("Hello!") { + t.Fatal("SourceHash does not depend on input") + } +} + +func TestValidTag(t *testing.T) { + for tag, want := range map[string]bool{ + "en": true, "fr": true, "fr-CA": true, "zh-Hant": true, "zh-Hant-TW": true, + "es-419": true, "en-XA": true, + "": false, "FR": false, "fr_CA": false, "english": false, "fr-ca": false, + } { + if got := ValidTag(tag); got != want { + t.Errorf("ValidTag(%q) = %v, want %v", tag, got, want) + } + } +} + +func TestParseErrorsWrapped(t *testing.T) { + _, err := ParseEntries([]byte(`{"a":{"defaultMessage":"x","nope":1}}`)) + if err == nil { + t.Fatal("expected error") + } + if errors.Unwrap(err) == nil { + t.Fatalf("error %v does not wrap a cause", err) + } +} + +func mustParse(t *testing.T, s string) icu.Message { + t.Helper() + m, err := icu.Parse(s) + if err != nil { + t.Fatalf("icu.Parse(%q): %v", s, err) + } + return m +} diff --git a/i18n/pack/validate.go b/i18n/pack/validate.go new file mode 100644 index 000000000..6ebcc75e5 --- /dev/null +++ b/i18n/pack/validate.go @@ -0,0 +1,240 @@ +package pack + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/GameplanePanel/gameplane/i18n/icu" +) + +// Severity classifies a Finding. Error findings fail the check; info findings +// are advisory. +type Severity string + +// Finding severities. +const ( + SeverityError Severity = "error" + SeverityInfo Severity = "info" +) + +// Finding codes reported by the validators. +const ( + CodeMalformedPack = "malformed-pack" + CodeInvalidMeta = "invalid-meta" + CodeICUSyntax = "icu-syntax" + CodeMissingPlaceholder = "missing-placeholder" + CodeExtraPlaceholder = "extra-placeholder" + CodeMarkup = "markup" + CodeUnknownID = "unknown-id" + CodeMissingDescription = "missing-description" + CodeExtractDrift = "extract-drift" + CodeServerDrift = "server-drift" + CodeUnusedID = "unused-id" + CodeNeedsReview = "needs-review" +) + +// Pseudo-identifiers used in findings that are not about a message id. +const ( + IDPack = "" + IDMeta = "" + IDReference = "" + IDRegistry = "" +) + +// Finding is one problem reported by a validator. +type Finding struct { + Code string `json:"code"` + Severity Severity `json:"severity"` + File string `json:"file"` + ID string `json:"id"` + Message string `json:"message"` +} + +// String renders the finding as ": : [] ". +func (f Finding) String() string { + return fmt.Sprintf("%s: %s: [%s] %s", f.File, f.ID, f.Code, f.Message) +} + +var ( + entityRE = regexp.MustCompile(`&[A-Za-z#][A-Za-z0-9]*;`) +) + +// CheckMarkup reports the first markup problem in a message, or "" when the +// message only uses the allowed inline tags and no HTML entities or scripts. +// raw is the message text as stored; m is its parsed form. +func CheckMarkup(raw string, m icu.Message) string { + tags := icu.SortedKeys(icu.Tags(m)) + for _, name := range tags { + if name != "b" && name != "link" { + return fmt.Sprintf("uses tag <%s>; only and are allowed", name) + } + } + if entityRE.MatchString(raw) { + return "contains an HTML entity" + } + lower := strings.ToLower(raw) + if strings.Contains(lower, "javascript:") { + return "contains a javascript: link" + } + if strings.Contains(lower, " 64 { + bad("nativeName is longer than 64 characters") + } + if meta.EnglishName == "" { + bad(`missing required field "englishName"`) + } else if utf8.RuneCountInString(meta.EnglishName) > 64 { + bad("englishName is longer than 64 characters") + } + switch meta.Direction { + case "": + bad(`missing required field "direction"`) + case "ltr", "rtl": + default: + bad(fmt.Sprintf("direction %q must be \"ltr\" or \"rtl\"", meta.Direction)) + } + return out +} + +// ValidateTranslation checks a translated pack against the English reference. +// Placeholder and markup problems are errors; a translation whose English +// source has changed since it was made is reported as needs-review (info). +func ValidateTranslation(ref, tr map[string]Entry, file string) []Finding { + var out []Finding + ids := sortedEntryIDs(tr) + for _, id := range ids { + e := tr[id] + refE, ok := ref[id] + if !ok { + out = append(out, errorFinding(CodeUnknownID, file, id, "translation for an id the English reference no longer has")) + continue + } + m, err := icu.Parse(e.DefaultMessage) + if err != nil { + out = append(out, errorFinding(CodeICUSyntax, file, id, fmt.Sprintf("does not parse: %v", err))) + continue + } + hasError := false + if refM, rerr := icu.Parse(refE.DefaultMessage); rerr == nil { + refArgs := icu.Arguments(refM) + trArgs := icu.Arguments(m) + if missing := setDifference(refArgs, trArgs); len(missing) > 0 { + out = append(out, errorFinding(CodeMissingPlaceholder, file, id, "missing placeholder(s): "+braceList(missing))) + hasError = true + } + if extra := setDifference(trArgs, refArgs); len(extra) > 0 { + out = append(out, errorFinding(CodeExtraPlaceholder, file, id, "placeholder(s) not in English: "+braceList(extra))) + hasError = true + } + } + if msg := CheckMarkup(e.DefaultMessage, m); msg != "" { + out = append(out, errorFinding(CodeMarkup, file, id, msg)) + hasError = true + } + if !hasError && e.SourceHash != "" && e.SourceHash != SourceHash(refE.DefaultMessage) { + out = append(out, Finding{ + Code: CodeNeedsReview, + Severity: SeverityInfo, + File: file, + ID: id, + Message: "English text changed since this was translated; please review", + }) + } + } + return out +} + +// SortFindings orders findings by file, then id, then code. +func SortFindings(fs []Finding) { + sort.SliceStable(fs, func(i, j int) bool { + if fs[i].File != fs[j].File { + return fs[i].File < fs[j].File + } + if fs[i].ID != fs[j].ID { + return fs[i].ID < fs[j].ID + } + return fs[i].Code < fs[j].Code + }) +} + +func errorFinding(code, file, id, msg string) Finding { + return Finding{Code: code, Severity: SeverityError, File: file, ID: id, Message: msg} +} + +func sortedEntryIDs(m map[string]Entry) []string { + ids := make([]string, 0, len(m)) + for id := range m { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// setDifference returns the sorted members of a that are not in b. +func setDifference(a, b map[string]struct{}) []string { + var out []string + for k := range a { + if _, ok := b[k]; !ok { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +func braceList(names []string) string { + parts := make([]string, len(names)) + for i, n := range names { + parts[i] = "{" + n + "}" + } + return strings.Join(parts, ", ") +} diff --git a/i18n/pack/validate_test.go b/i18n/pack/validate_test.go new file mode 100644 index 000000000..465abfd50 --- /dev/null +++ b/i18n/pack/validate_test.go @@ -0,0 +1,186 @@ +package pack + +import ( + "strings" + "testing" +) + +func codes(fs []Finding) []string { + out := make([]string, 0, len(fs)) + for _, f := range fs { + out = append(out, f.Code) + } + return out +} + +func hasCode(fs []Finding, code string) bool { + for _, f := range fs { + if f.Code == code { + return true + } + } + return false +} + +func TestFindingString(t *testing.T) { + f := Finding{Code: CodeUnusedID, Severity: SeverityError, File: "en/app.json", ID: "a.b", Message: "not used"} + want := "en/app.json: a.b: [unused-id] not used" + if got := f.String(); got != want { + t.Fatalf("String() = %q, want %q", got, want) + } +} + +func TestCheckMarkup(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "plain", raw: "Hello", want: ""}, + {name: "allowed tags", raw: "x y", want: ""}, + {name: "other tag", raw: "x", want: "uses tag ; only and are allowed"}, + {name: "entity", raw: "a & b", want: "contains an HTML entity"}, + {name: "numeric entity", raw: "'", want: "contains an HTML entity"}, + {name: "javascript link", raw: "JavaScript:alert(1)", want: "contains a javascript: link"}, + {name: "script tag", raw: "x ' + = 20.12.0" + }, + "optionalDependencies": { + "@formatjs/cli-native-darwin-arm64": "1.1.28", + "@formatjs/cli-native-linux-arm64": "1.2.28", + "@formatjs/cli-native-linux-arm64-musl": "1.0.26", + "@formatjs/cli-native-linux-x64": "1.1.28", + "@formatjs/cli-native-linux-x64-musl": "1.0.26", + "@formatjs/cli-native-win32-x64": "1.1.29" + }, + "peerDependencies": { + "@glimmer/syntax": "^0.84.3 || ^0.95.0", + "@vue/compiler-core": "3", + "content-tag": "4", + "vue": "3" + }, + "peerDependenciesMeta": { + "@glimmer/env": { + "optional": true + }, + "@glimmer/reference": { + "optional": true + }, + "@glimmer/syntax": { + "optional": true + }, + "@glimmer/validator": { + "optional": true + }, + "@vue/compiler-core": { + "optional": true + }, + "content-tag": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@formatjs/cli-native-darwin-arm64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-darwin-arm64/-/cli-native-darwin-arm64-1.1.28.tgz", + "integrity": "sha512-kFkM6peuJgZrzcuWZiCbnh3o9SHYWBYq3OW8vwL7Cd3IRnzaH1InnzverZpqhLfRKDs+JGa64ll/IuieW/u+4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@formatjs/cli-native-linux-arm64": { + "version": "1.2.28", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-linux-arm64/-/cli-native-linux-arm64-1.2.28.tgz", + "integrity": "sha512-yW8nfFoFJfWvDCbysEH9aU+zDbBPvoNcq1pYs8qoxwXSrvZS8irBdyfuX//Cqz6hQ2/fyGfbL/fxizotX0O4dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@formatjs/cli-native-linux-arm64-musl": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-linux-arm64-musl/-/cli-native-linux-arm64-musl-1.0.26.tgz", + "integrity": "sha512-nJu7scro7jqNJyrsUUD0ui+onpjog54Z8yf5h58Tqbr8kUJeUtmsz/cP4a42yU5WiJW5cXCFsT5nq2a8wM63Xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@formatjs/cli-native-linux-x64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-linux-x64/-/cli-native-linux-x64-1.1.28.tgz", + "integrity": "sha512-3alv3g1/5C91xM6gTqdWMoApMM3O1Xu3ezGbb3gAWiHwhWKl/MrzPsEB8wpmRhy+RB3i9MQvWpQOYBEy4zczHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@formatjs/cli-native-linux-x64-musl": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-linux-x64-musl/-/cli-native-linux-x64-musl-1.0.26.tgz", + "integrity": "sha512-XhOmfSVhvgYtquSRpuz4EWBIzR08RUpvBktfBuQDj/UMtpvTpedLFIsetBDVie1jzMtEVo/TKvhqYe0LkNJiFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@formatjs/cli-native-win32-x64": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/@formatjs/cli-native-win32-x64/-/cli-native-win32-x64-1.1.29.tgz", + "integrity": "sha512-YnZRcgrBT+IYtFnQUUCdukCPCYeJPsRUMzYtSFokGZY9lu3l6olJ1oPv4xzMNIocCUYyuCwG46aWqaTv1nz/gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -919,6 +1056,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@formatjs/intl": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-6.1.3.tgz", + "integrity": "sha512-fBxQVx6wi0f3iibauVFKr/uY+BJR0Z64Zuu6SB+p8hmxjqZoX2u4/RPYaKnFMut2+kNPIeGOaaKDHQ5odnXsZQ==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.21", + "intl-messageformat": "12.1.3" + } + }, "node_modules/@formatjs/intl-localematcher": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", @@ -928,6 +1076,96 @@ "tslib": "^2.8.0" } }, + "node_modules/@formatjs/intl/node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/@formatjs/intl/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl/node_modules/intl-messageformat": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-12.1.3.tgz", + "integrity": "sha512-JXi5wk8R/oSECKyxSIZ00VS8KG6MZkHM0qU4NS7RrvIWWXkiv3wVqGlCvEvyXiDvKlt2yqwZMiZGIv+I8rrnZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.21" + } + }, + "node_modules/@formatjs/ts-transformer": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@formatjs/ts-transformer/-/ts-transformer-4.5.0.tgz", + "integrity": "sha512-DFDQ4naGNzm7Sv5nsIUV8RqKLYMjQsljddwGN467Kx82fCfIJjiq3xzZdiNDoQwZGop5WcIfW9B42QZlU1ml7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "3.5.21", + "@types/babel__core": "7", + "@types/node": "22 || 24", + "json-stable-stringify": "1", + "typescript": "^5.6 || 6 || 7" + }, + "engines": { + "node": ">= 20.12.0" + }, + "peerDependencies": { + "ts-jest": "29" + }, + "peerDependenciesMeta": { + "ts-jest": { + "optional": true + } + } + }, + "node_modules/@formatjs/ts-transformer/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/@formatjs/ts-transformer/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@formatjs/ts-transformer/node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@formatjs/ts-transformer/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/@heroui/react": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@heroui/react/-/react-3.2.6.tgz", @@ -1150,9 +1388,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1170,9 +1405,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1190,9 +1422,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1210,9 +1439,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1230,9 +1456,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1250,9 +1473,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1270,9 +1490,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1290,9 +1507,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1310,9 +1524,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1336,9 +1547,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1362,9 +1570,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1388,9 +1593,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1414,9 +1616,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1440,9 +1639,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1466,9 +1662,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1492,9 +1685,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1848,6 +2038,28 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, "node_modules/@open-draft/until": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", @@ -2290,9 +2502,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2310,9 +2519,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2330,9 +2536,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2350,9 +2553,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2370,9 +2570,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2390,9 +2587,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3367,6 +3561,51 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3402,6 +3641,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3426,11 +3675,17 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3456,6 +3711,15 @@ "@types/node": "*" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3697,6 +3961,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unicode/unicode-17.0.0": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@unicode/unicode-17.0.0/-/unicode-17.0.0-2.0.7.tgz", + "integrity": "sha512-XdCUsw7exiSD8QICrzwb99xx9O/e7qMx/Oew1e8MBQGavCIIR0VO6vID7OSH/wQjUjogcfydqmsuRLkGM6LfLA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -4989,6 +5260,52 @@ } } }, + "node_modules/eslint-plugin-formatjs": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-formatjs/-/eslint-plugin-formatjs-8.1.1.tgz", + "integrity": "sha512-T0BzXq6J285HrXg0OfQioR4MjCV7dyWWjMdiQ4rSltbNywB2ptT2AB+hvjyhLdd6hkm10QRijjtLpAwJUFg0cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "3.5.21", + "@formatjs/ts-transformer": "4.5.0", + "@types/estree-jsx": "1", + "@types/picomatch": "4", + "@unicode/unicode-17.0.0": "2", + "magic-string": "1", + "picomatch": "2 || 3 || 4" + }, + "peerDependencies": { + "eslint": "9 || 10" + } + }, + "node_modules/eslint-plugin-formatjs/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/eslint-plugin-formatjs/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-formatjs/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -5638,6 +5955,18 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6018,6 +6347,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6410,6 +6751,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6430,6 +6791,16 @@ "node": ">=6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7277,6 +7648,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7521,6 +7901,52 @@ "react": "^19.3.0" } }, + "node_modules/react-intl": { + "version": "12.1.4", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-12.1.4.tgz", + "integrity": "sha512-huK6iXamrEJ4D011VCmO+/CyzFtSKeF7eqcHW1dVe3t2wpgB/Iscl6CoMso/YLItX3Qh/gcVQMXNbbhpGbCrUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/icu-messageformat-parser": "3.5.21", + "@formatjs/intl": "6.1.3", + "intl-messageformat": "12.1.3" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/react-intl/node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/react-intl/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/react-intl/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, + "node_modules/react-intl/node_modules/intl-messageformat": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-12.1.3.tgz", + "integrity": "sha512-JXi5wk8R/oSECKyxSIZ00VS8KG6MZkHM0qU4NS7RrvIWWXkiv3wVqGlCvEvyXiDvKlt2yqwZMiZGIv+I8rrnZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.21" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7619,6 +8045,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8046,6 +8484,18 @@ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -8067,6 +8517,15 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -8855,6 +9314,44 @@ "vitest": ">=5 <6" } }, + "node_modules/vitest/node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vitest/node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", @@ -8883,6 +9380,48 @@ } } }, + "node_modules/vitest/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/vitest/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/vitest/node_modules/magic-string": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", @@ -8893,6 +9432,138 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/vitest/node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/vitest/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/vitest/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/vitest/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/w3c-xmlserializer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", diff --git a/web/package.json b/web/package.json index 39b0fe695..68b772346 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "npm run i18n:compile && tsc -b && vite build", + "i18n:extract": "formatjs extract \"src/**/*.{ts,tsx}\" --ignore \"src/**/*.test.{ts,tsx}\" --ignore \"src/**/*.d.ts\" --ignore \"src/generated/**\" --out-file src/generated/i18n/extracted.json && node scripts/i18n-reference.mjs", + "i18n:compile": "node scripts/i18n-compile.mjs", + "i18n:manifest": "cd ../i18n && go run ./cmd/i18n-check --manifest ../web/src/generated/i18n/languages.json locales", "preview": "vite preview", "lint": "eslint .", "test": "vitest run", @@ -36,10 +39,12 @@ "react-aria": "^3.51.0", "react-aria-components": "^1.20.0", "react-dom": "^19.3.0", + "react-intl": "^12.1.4", "tailwind-merge": "^3.7.0" }, "devDependencies": { "@eslint/js": "^9.39.5", + "@formatjs/cli": "^6.16.34", "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4.3.3", "@tanstack/router-devtools": "^1.167.2", @@ -56,6 +61,7 @@ "@vitest/coverage-v8": "^5.0.3", "autoprefixer": "^10.6.1", "eslint": "^9.39.5", + "eslint-plugin-formatjs": "^8.1.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.13.0", diff --git a/web/public/mockServiceWorker.js b/web/public/mockServiceWorker.js index 14c9979d9..0c970efc9 100644 --- a/web/public/mockServiceWorker.js +++ b/web/public/mockServiceWorker.js @@ -7,15 +7,10 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '3.0.2' -const INTEGRITY_CHECKSUM = '5cd5cf8b54c3a90f82960cedcd637772' +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') - const activeClientIds = new Set() -/** - * @type {Map>>} - */ -const pendingRequests = new Map() addEventListener('install', function () { self.skipWaiting() @@ -25,101 +20,82 @@ addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()) }) -addEventListener('message', function (event) { +addEventListener('message', async function (event) { const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { return } - event.waitUntil( - (async () => { - if (event.data === 'CLIENT_CLOSE') { - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - activeClientIds.delete(clientId) - - // Await any pending requests from the closing client. - // This makes sure that those requests are handled and not passthrough. - const pending = pendingRequests.get(clientId) - if (pending != null && pending.size > 0) { - await Promise.allSettled(pending) - } - pendingRequests.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - await self.registration.unregister() - } - - const client = await self.clients.get(clientId) - - if (client != null) { - await sendToClient(client, { - type: 'CLIENT_CLOSED', - }) - } - - return - } + const client = await self.clients.get(clientId) - /** - * @note Check for the client AFTER handling "CLIENT_CLOSE". - * This prevents early return on "!client" in case the page has reloaded - * and disassociated itself from the worker. This ensures self-unregistration - * still fires for those pages. - */ - const client = await self.clients.get(clientId) + if (!client) { + return + } - if (!client) { - return - } + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - await sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - await sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - await sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() } - })(), - ) + + break + } + } }) addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. if ( @@ -137,54 +113,23 @@ addEventListener('fetch', function (event) { } const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) }) /** * @param {FetchEvent} event * @param {string} requestId + * @param {number} requestInterceptedAt */ -async function handleRequest(event, requestId) { +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event) const requestCloneForEvents = event.request.clone() - - const responsePromise = getResponse(event, client, requestId) - - if (client != null) { - let pending = pendingRequests.get(client.id) - - if (pending == null) { - pendingRequests.set(client.id, (pending = new Set())) - } - - pending.add(responsePromise) - responsePromise - .finally(() => pending.delete(responsePromise)) - .catch(() => {}) - } - - let response - - try { - response = await responsePromise - } catch (error) { - // The request has settled without a response (e.g. a passthrough - // request failed with a network error). Notify the client so it - // can release the resources associated with this request. - if (client && activeClientIds.has(client.id)) { - sendToClient(client, { - type: 'REQUEST_ERROR', - payload: { - request: { - id: requestId, - }, - error: serializeError(error), - }, - }) - } - - throw error - } + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise @@ -272,32 +217,18 @@ async function resolveMainClient(event) { * @param {FetchEvent} event * @param {Client | undefined} client * @param {string} requestId + * @param {number} requestInterceptedAt * @returns {Promise} */ -async function getResponse(event, client, requestId) { +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). const requestClone = event.request.clone() - /** - * @param {{ request?: { headers?: Array<[string, string]> } }} [data] - */ - function passthrough(data) { - const headers = new Headers() - const requestHeaders = data?.request?.headers - - if (Array.isArray(requestHeaders)) { - // Apply the request headers provided by the client. - // Those reflect any modifications made in the request handlers. - // Use ".append()" to support multiple headers with the same name. - for (const [name, value] of requestHeaders) { - headers.append(name, value) - } - } else { - for (const [name, value] of requestClone.headers) { - headers.append(name, value) - } - } + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the @@ -340,6 +271,7 @@ async function getResponse(event, client, requestId) { type: 'REQUEST', payload: { id: requestId, + interceptedAt: requestInterceptedAt, ...serializedRequest, }, }, @@ -348,35 +280,17 @@ async function getResponse(event, client, requestId) { switch (clientMessage.type) { case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data, event) + return respondWithMock(clientMessage.data) } case 'PASSTHROUGH': { - return passthrough(clientMessage.data) + return passthrough() } } return passthrough() } -/** - * @param {unknown} error - * @returns {{ name: string, message: string }} - */ -function serializeError(error) { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - } - } - - return { - name: 'Error', - message: String(error), - } -} - /** * @param {Client} client * @param {any} message @@ -404,10 +318,9 @@ function sendToClient(client, message, transferrables = []) { /** * @param {Response} response - * @param {FetchEvent} event - * @returns {Promise} + * @returns {Response} */ -async function respondWithMock(response, event) { +function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create @@ -416,19 +329,7 @@ async function respondWithMock(response, event) { return Response.error() } - let body = response.body - - // Buffer the streamed mocked response body for navigation requests. - // The stream is transferred from the client that is being navigated - // away from. Once the navigation commits, that client gets destroyed - // and the stream will never complete, resulting in an empty document. - // Buffering here keeps "event.respondWith()" pending (the navigation - // cannot commit) until the entire body arrives from the client. - if (event.request.mode === 'navigate' && body instanceof ReadableStream) { - body = await new Response(body).arrayBuffer() - } - - const mockedResponse = new Response(body, response) + const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, diff --git a/web/scripts/i18n-compile.mjs b/web/scripts/i18n-compile.mjs new file mode 100644 index 000000000..cfe2b28fa --- /dev/null +++ b/web/scripts/i18n-compile.mjs @@ -0,0 +1,32 @@ +// Compiles every shared locale catalog (../i18n/locales//app.json) into +// an AST-format JSON chunk under src/generated/i18n/.json. The dashboard +// loads these chunks at runtime via import.meta.glob (src/lib/i18n/languages.ts). +// +// Node ESM, no dependencies beyond the local @formatjs/cli binary. Skips +// silently when the shared locales directory is absent. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(here, ".."); +const localesDir = path.resolve(webRoot, "../i18n/locales"); +const outDir = path.join(webRoot, "src/generated/i18n"); +const formatjsBin = path.join(webRoot, "node_modules/.bin/formatjs"); + +if (!existsSync(localesDir)) { + process.exit(0); +} + +mkdirSync(outDir, { recursive: true }); + +for (const entry of readdirSync(localesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const tag = entry.name; + const source = path.join(localesDir, tag, "app.json"); + if (!existsSync(source)) continue; + const target = path.join(outDir, `${tag}.json`); + execFileSync(formatjsBin, ["compile", source, "--ast", "--out-file", target], { stdio: "inherit" }); + console.log(`i18n:compile ${tag} -> src/generated/i18n/${tag}.json`); +} diff --git a/web/scripts/i18n-reference.mjs b/web/scripts/i18n-reference.mjs new file mode 100644 index 000000000..eea16e875 --- /dev/null +++ b/web/scripts/i18n-reference.mjs @@ -0,0 +1,37 @@ +// Builds the extracted-message reference: every message the dashboard source +// uses (from `formatjs extract`) plus every server.*, condition.* and notify.* +// entry from the shared English catalog. Written back sorted by key so the +// output is stable across runs. +// +// Node ESM, no dependencies. Run via `npm run i18n:extract`. +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(here, ".."); +const extractedPath = path.join(webRoot, "src/generated/i18n/extracted.json"); +const referencePath = path.join(webRoot, "../i18n/locales/en/app.json"); + +const SERVER_PREFIXES = ["server.", "condition.", "notify."]; + +const extracted = JSON.parse(readFileSync(extractedPath, "utf8")); +const reference = JSON.parse(readFileSync(referencePath, "utf8")); + +const merged = {}; +for (const [id, entry] of Object.entries(reference)) { + if (SERVER_PREFIXES.some((prefix) => id.startsWith(prefix))) { + merged[id] = entry; + } +} +for (const [id, entry] of Object.entries(extracted)) { + merged[id] = { defaultMessage: entry.defaultMessage, description: entry.description }; +} + +const sorted = {}; +for (const key of Object.keys(merged).sort()) { + sorted[key] = merged[key]; +} + +writeFileSync(extractedPath, JSON.stringify(sorted, null, 2) + "\n"); +console.log(`i18n:extract wrote ${Object.keys(sorted).length} message(s) to src/generated/i18n/extracted.json`); diff --git a/web/specs.md b/web/specs.md index b840a44d6..8bd74646b 100644 --- a/web/specs.md +++ b/web/specs.md @@ -1067,6 +1067,19 @@ Each namespace is an object of typed functions building and fetching URLs: - **events.ts** — event severity / reason → display strings - **quantity.ts** — parse/format K8s quantities (500m → 0.5, 1Gi → 1073741824 bytes) +## Internationalization (spec 019) + +- **Provider.** `src/lib/i18n/I18nProvider.tsx` wraps the whole app inside `QueryClientProvider` in `src/main.tsx`, so `/login` and `/share/*` are covered. It renders react-intl `IntlProvider` and react-aria `I18nProvider` with the same locale, and exposes `useLanguage()` (`language`, `setLanguage`, `direction`). Outside a provider `useLanguage()` and `useFormat()` fall back to English. +- **Resolution order** (`src/lib/i18n/resolve.ts`): user choice, then browser choice (storage), then each `navigator.languages` entry (regional, then base language, so `fr-CA` resolves to `fr`), then the instance default, then `en`. Candidates that are not listed languages are skipped. Listing comes only from the generated manifest. +- **Storage key.** `localStorage["gameplane-language"]` holds `{choice, applied}`. `choice` is the viewer's pick on this browser; `applied` is the language last rendered. Every access is guarded, and failures mean "no preference". +- **Boot snippet.** `