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 1808ef91c..22931b4ee 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 2c256e20e..50352cd78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -127,6 +127,7 @@ jobs: - 'audit-syslog-bridge/**' - 'telemetry-receiver/**' - 'telemetryschema/**' + - 'i18n/**' - 'sentinel/**' - 'mcp-server/**' - 'capture-sidecar/**' @@ -143,7 +144,9 @@ jobs: - 'hack/check-govulncheck_test.sh' - 'hack/testdata/govulncheck/**' 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 @@ -698,7 +701,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 @@ -777,7 +780,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: @@ -890,6 +893,67 @@ 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") + (cd operator && go run ./hack/condition-ids -check -app ../i18n/locales/en/app.json -ids "$RUNNER_TEMP/condition-ids.txt") + { + jq -r 'keys[]' web/src/generated/i18n/extracted.json + jq -r '.[].id' "$RUNNER_TEMP/registry.json" + cat "$RUNNER_TEMP/condition-ids.txt" + } | sort -u > "$RUNNER_TEMP/used-ids.txt" + 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 \ + --used-ids "$RUNNER_TEMP/used-ids.txt" \ + "${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 ca014ed8c..f91e9d12d 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/.github/workflows/visual-diff.yaml b/.github/workflows/visual-diff.yaml index 1e8a8f160..18f2f93f2 100644 --- a/.github/workflows/visual-diff.yaml +++ b/.github/workflows/visual-diff.yaml @@ -5,11 +5,13 @@ on: branches: [master] paths: - 'web/**' + - 'i18n/locales/**' - 'assets/design-export/**' - '.github/workflows/visual-diff.yaml' pull_request: paths: - 'web/**' + - 'i18n/locales/**' - 'assets/design-export/**' - '.github/workflows/visual-diff.yaml' workflow_dispatch: @@ -80,3 +82,51 @@ jobs: path: web/e2e/screenshots/*.png retention-days: 14 if-no-files-found: warn + + pseudo-locale: + name: en-XA screenshots (spec 019) + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 30 + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: web/package-lock.json + + - name: install dependencies + run: npm ci + + - name: playwright browser cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('web/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}-${{ runner.arch }}- + + - name: install chromium + run: npx playwright install --with-deps chromium + + - name: capture en-XA screenshots and check clipping + run: npm run screenshots:pseudo + + - name: upload en-XA captures and clipping report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: en-XA-screenshots + path: | + web/test-results/en-XA + web/playwright-report + retention-days: 14 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index bbf734e0f..0e4f25360 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,15 @@ 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 +# Condition ids and used-id list written by `make check-i18n` (spec 019) +i18n/.condition-ids.txt +i18n/.used-ids.txt +# 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 558a7e034..0a72e788a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,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 `