diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 40ecb6e9f..fa606c5b5 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -76,7 +76,7 @@ log "fetching envtest binaries for K8s 1.36.2" # ---------- Go module cache warmup ---------- # Go modules from go.work. Derived from 'use' entries in go.work. # Keep in sync with go.work when adding/removing modules. -for m in agent api audit-syslog-bridge capture-sidecar gameaction gameproto mcp-server netguard operator sentinel svcutil telemetry-receiver telemetryschema test/e2e tunnel; do +for m in agent api audit-syslog-bridge capture-sidecar gameaction gameproto i18n mcp-server netguard operator sentinel svcutil telemetry-receiver telemetryschema test/e2e tunnel; do log "go mod download ($m)" ( cd "$m" && go mod download ) done 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 c0f75cc41..4e4050014 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -123,6 +123,7 @@ jobs: - 'audit-syslog-bridge/**' - 'telemetry-receiver/**' - 'telemetryschema/**' + - 'i18n/**' - 'sentinel/**' - 'mcp-server/**' - 'capture-sidecar/**' @@ -136,7 +137,9 @@ jobs: - '**/go.sum' - '**/Dockerfile' web: + # Language packs feed the dashboard build and the i18n checks in the web job. - 'web/**' + - 'i18n/**' charts: - 'charts/**' # The chart embeds a copy of the root signing key; the drift @@ -604,7 +607,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 @@ -683,7 +686,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: @@ -796,6 +799,60 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: { node-version: "24", cache: "npm", cache-dependency-path: web/package-lock.json } - run: npm ci + - uses: ./.github/actions/go-cache + with: + key-suffix: web-i18n + - name: i18n language manifest + # The listing rule (80% complete, ltr, not en-XA) lives in the shared + # Go checker, so the manifest the dashboard build reads comes from it. + run: npm run i18n:manifest + - name: i18n checks + working-directory: . + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + set -euo pipefail + (cd web && npm run i18n:extract-only) + (cd api && go run ./cmd/messages-export > "$RUNNER_TEMP/registry.json") + base_flag=() + if [ -n "${BASE_SHA:-}" ] && [ "$BASE_SHA" != "0000000000000000000000000000000000000000" ]; then + if git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null \ + && git cat-file -e "$BASE_SHA:i18n/locales" 2>/dev/null; then + mkdir -p "$RUNNER_TEMP/base" + git archive "$BASE_SHA" i18n/locales | tar -x -C "$RUNNER_TEMP/base" + base_flag=(--base "$RUNNER_TEMP/base/i18n/locales") + fi + fi + cd i18n + go run ./cmd/i18n-check \ + --extracted ../web/src/generated/i18n/extracted.json \ + --registry "$RUNNER_TEMP/registry.json" --check-server \ + "${base_flag[@]}" \ + --summary "$GITHUB_STEP_SUMMARY" \ + locales + - name: i18n-check self-test on seeded defective packs + # SC-007: every fixture must make the checker fail with exactly its + # own finding code (needs-review is a notice, so it passes). + working-directory: i18n + run: | + set -euo pipefail + go build -o "$RUNNER_TEMP/i18n-check" ./cmd/i18n-check + for dir in testdata/defective/*/; do + code=$(basename "$dir") + args=() + if [ -f "$dir/args" ]; then + read -r -a args < <(sed "s#{dir}#${dir%/}#g" "$dir/args") + fi + set +e + out=$("$RUNNER_TEMP/i18n-check" "${args[@]}" "${dir%/}") + status=$? + set -e + want=1; [ "$code" = needs-review ] && want=0 + if [ "$status" -ne "$want" ] || ! grep -q "\[$code\]" <<<"$out"; then + echo "::error::fixture $code: exit $status (want $want), output:"; echo "$out"; exit 1 + fi + echo "ok $code" + done - name: eslint run: npm run lint - run: npm run build diff --git a/.github/workflows/publish-edge.yaml b/.github/workflows/publish-edge.yaml index 11ac55918..1c119a396 100644 --- a/.github/workflows/publish-edge.yaml +++ b/.github/workflows/publish-edge.yaml @@ -29,6 +29,7 @@ on: - "gameaction/**" - "gameproto/**" - "telemetryschema/**" + - "i18n/**" - "svcutil/**" - "gp-module/**" - "audit-syslog-bridge/**" diff --git a/.gitignore b/.gitignore index bbf734e0f..d41515d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,12 @@ web/coverage/ web/playwright-report/ web/test-results/ web/website-screenshots/ +# Generated i18n artifacts (compiled catalogs, extracted messages, language manifest) +web/src/generated/ +# Registry snapshot written by `make i18n-sync` and consumed by i18n-check +i18n/.registry.json +# Filtered language packs written by i18n-check --valid-out for the dashboard build +i18n/.valid/ web/e2e/.portforward.pid *.log .npmrc.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a5a20d1..bfd3709ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,36 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. - **Nuclear Option player lists are structured:** the agent's player output for JSON player lists now includes an entry per player (`steamId` and `faction`), which the API uses for display-name lookup. +- **Language selection and translations (spec 019):** the dashboard can be + shown in another language. A user's language choice is saved to the account (or + to the browser when signed out), and an admin sets the instance default. Languages are + shared packs under `i18n/locales/`; a language is listed once its pack is at + least 80% translated, and anything missing falls back to English. French is + the first pack and is listed once it reaches 80%. + Dates, numbers and relative times follow the active language. A pseudo + language (`en-XA`) exists for maintainers behind `VITE_I18N_PSEUDO=1` and is + never listed in a release. See [`docs/translating.md`](docs/translating.md). +- **Translated server messages (opt-in `problem+json`):** API errors are sent + as `application/problem+json` with a stable `messageId` and arguments only + when the request lists that type in `Accept`; every other client keeps the + unchanged English plain-text body. The dashboard opts in and shows the + message in the active language. Game server condition messages carry + `status.conditionArgs` so the dashboard can translate them too; the English + `message` is unchanged. +- **Notification language per destination:** each notification destination + has an optional `language`; notifications and test sends to Discord, email + and webhooks are rendered in it, and destinations with none use the + instance default. Webhook payloads gain additive `language`, `title` and + `text` fields; existing fields are unchanged. +- **Module translations:** a module can ship `locales/.json` next to + `module.yaml` (OCI layer type `application/vnd.gameplane.module.locale.v1+json` + and the same files in every other source). The operator validates them on + install, stores the valid entries in a `