Skip to content

feat(i18n): fail CI on translation keys that don't exist - #4233

Closed
sdornan wants to merge 2 commits into
rommapp:masterfrom
sdornan:claude/i18n-typed-messages
Closed

feat(i18n): fail CI on translation keys that don't exist#4233
sdornan wants to merge 2 commits into
rommapp:masterfrom
sdornan:claude/i18n-typed-messages

Conversation

@sdornan

@sdornan sdornan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Description

vue-i18n renders an unknown key as the key itself, so a typo or a rename that misses a call site ships as settings.scan-settings on screen instead of "Scan settings". Nothing in CI catches that today: check_i18n_locales.py and check_i18n_sorted.py compare the locale files against each other and never look at how the frontend uses them.

check_i18n_keys.py closes that gap. It resolves every literal t() / $t() / tm() key under frontend/src against en_US and fails with a file:line: key list, and it runs in the existing i18n workflow next to the other two checks. The workflow's path filter now also covers .ts / .vue, since a call site can break this without any locale file changing.

Deliberately out of scope: dynamic keys (t(m.labelKey), t(`settings.grant-${state}`)), which can't be resolved statically, and te(), whose whole purpose is asking whether a key is missing. Keys referenced only inside comments are skipped too.

What it found, all fixed here

  • platform.firmware-missing-from-fs, platform.firmware-verified-tooltip and platform.firmware-toggle-all-fs were called with an English string as the fallback argument, so they rendered correctly but were untranslatable in all 18 locales. They are real keys now, translated, and the three call sites drop the redundant fallback.
  • settings.group-platforms-by and settings.group-platforms-by-desc in the v1 platforms-drawer settings row never existed. Worth being precise: they feed a :key attribute rather than any rendered text, so nothing was visibly broken. The row's title now points at settings.platforms-drawer-group-by, the already-translated key the select beside it uses, and the unused description goes away. That keeps the v1 change to two lines, in line with v1 being frozen.

Then, auditing what the checker can't see, two more:

  • The regex refused a . before t( so that get( and format( wouldn't pass as calls, which also excluded all 52 i18n.global.t("...") sites in the stores and the router. Only a word character needs refusing. Coverage went from 2735 to 2790 literal keys; no new misses appeared.
  • settings.perm-entity.playlists was missing and rendered its raw key. PermEntity includes playlists, /permissions/catalog returns the whole enum, EditUserDialog passes it straight to the matrix, and the matrix labels each row with t(`settings.perm-entity.${e}`). Dynamic keys are exactly what this checker can't resolve, so this came out of hand-resolving all nine template-literal patterns against their value unions. It's the only miss among them.

Why this rather than TypeScript or eslint

This PR started as a vue-i18n DefineLocaleMessage schema, which turned out to buy editor autocompletion and nothing in CI: the call signature is <Key extends string>(key: Key | ResourceKeys | number): string, so Key widens to any string and t("common.nope") typechecks clean.

@intlify/eslint-plugin-vue-i18n's no-missing-keys is the usual answer and is ESLint 10 compatible, but it can't read this repo's layout. Keys here live in locales/<locale>/<namespace>.json and are referenced as namespace.key; the plugin builds its message tree from raw file contents with no filename prefix, so a trial run reported 2985 errors, essentially all false positives (settings.username "missing" because it looks for settings inside settings.json). Making it work would mean nesting all 304 locale JSONs under their namespace key, plus updating the loader and both existing checkers.

A ~90-line script in the style of the two checkers already in this directory covers the same ground with no dependency and no restructuring.

Also worth a separate pass

The whole settings.perm-entity / settings.perm-action block is untranslated English in every locale except pt_BR, so the permissions matrix renders English labels everywhere. playlists follows that existing style here rather than being the one translated row; translating the block properly is its own change.

Verification

The checker was confirmed to catch a planted typo (settings.theme-draksrc/v2/views/Settings/UserInterface.vue:73), to catch a bad nested path on a qualified i18n.global.t() call, and to pass on a clean tree. check_i18n_locales.py and check_i18n_sorted.py both pass with the new keys. npm run typecheck, npm run test (736), prettier and eslint pass on the touched files. The four vue/html-indent warnings eslint reports in FirmwareTab.vue are pre-existing on master, in a block this PR doesn't touch.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

The check itself is the test here; it's wired into CI rather than vitest, alongside the two existing i18n checkers.

AI assistance disclosure

This change was written with AI assistance (Claude Code). The AI wrote the checker, the fixes it surfaced and the translations, and ran the verification described above; the result was reviewed by me before submitting. The 51 new translated strings deserve a native-speaker eye in particular.

Copilot AI lite review requested due to automatic review settings August 18, 2026 22:38
@sdornan
sdornan marked this pull request as draft August 18, 2026 22:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds TypeScript typing for vue-i18n locale messages by declaring en_US as the global schema via DefineLocaleMessage, improving editor completion and safer refactors across the frontend i18n keyspace. This also introduces a drift test to ensure new namespace JSON files are reflected in the schema, and updates a v2 unit test to use a local, per-instance schema.

Changes:

  • Add vue-i18n module augmentation (DefineLocaleMessage) based on the en_US namespaces to drive typed key completion.
  • Cast the glob-built messages object at the i18n setup boundary to the global schema type.
  • Add/adjust tests: a schema drift test for namespace coverage, and a per-instance typed createI18n in PlatformsStatsSection.test.ts.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated no comments.

File Description
frontend/src/v2/components/Settings/PlatformsStatsSection.test.ts Types the test-local i18n instance schema for the single settings namespace.
frontend/src/locales/vue-i18n.d.ts Adds global DefineLocaleMessage augmentation using en_US namespaces as the schema source.
frontend/src/locales/schema.test.ts Adds a drift test to ensure every en_US namespace JSON is declared in the schema file.
frontend/src/locales/index.ts Imports DefineLocaleMessage and casts the glob-assembled messages object to the schema type at the boundary.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR defines the application-wide vue-i18n message schema from the en_US resources and types the dynamically loaded locale map at its assembly boundary.

  • Adds a DefineLocaleMessage module augmentation for all 16 locale namespaces.
  • Adds a drift test comparing schema declarations with the en_US namespace files.
  • Gives the single-namespace component test an explicit local composer schema.

Confidence Score: 4/5

The PR appears safe to merge, with one non-blocking limitation in the schema drift test's handling of hyphenated namespace names.

The runtime locale behavior remains unchanged, and the only accepted concern is that the new test recognizes a narrower namespace syntax than the existing loader.

Files Needing Attention: frontend/src/locales/schema.test.ts

Important Files Changed

Filename Overview
frontend/src/locales/vue-i18n.d.ts Adds the global locale-message augmentation using the existing en_US JSON resources as the schema.
frontend/src/locales/index.ts Types the dynamically assembled message map as DefineLocaleMessage without changing runtime loading behavior.
frontend/src/locales/schema.test.ts Adds namespace drift coverage, but its declaration regex does not support hyphenated names accepted by the runtime loader.
frontend/src/v2/components/Settings/PlatformsStatsSection.test.ts Supplies an explicit one-namespace schema for the test-local i18n composer.

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
frontend/src/locales/schema.test.ts:18
**Namespace parser excludes hyphens**

The loader accepts hyphenated namespace filenames, but `\w+` excludes their quoted interface properties from `declared`, so a valid runtime namespace and schema declaration fail this drift test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(i18n): type locale messages with a ..." | Re-trigger Greptile

Comment thread frontend/src/locales/schema.test.ts Outdated
.sort();

const schema = readFileSync(join(dir, "vue-i18n.d.ts"), "utf8");
const declared = [...schema.matchAll(/^ {4}(\w+): typeof \w+;$/gm)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Namespace parser excludes hyphens

The loader accepts hyphenated namespace filenames, but \w+ excludes their quoted interface properties from declared, so a valid runtime namespace and schema declaration fail this drift test.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/locales/schema.test.ts
Line: 18

Comment:
**Namespace parser excludes hyphens**

The loader accepts hyphenated namespace filenames, but `\w+` excludes their quoted interface properties from `declared`, so a valid runtime namespace and schema declaration fail this drift test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

vue-i18n renders an unknown key as the key itself, so a typo or a rename
that misses a call site ships as `settings.scan-settings` on screen. The
existing checks compare locales against each other and never look at how
the frontend uses them, and TypeScript can't help: `t()` accepts any
string by design, so dynamic keys keep working.

check_i18n_keys.py resolves every literal `t()` / `$t()` / `tm()` key in
src against en_US, and runs alongside the parity and sort checks. It
skips dynamic keys and `te()`, whose whole job is asking whether a key is
missing.

It found five, now fixed:

- The three firmware strings passed an English default, so they rendered
  but could never be translated. They are real keys now, in every locale,
  and the call sites drop the redundant fallback.
- The v1 platforms-drawer row named two keys that never existed. They
  feed a `:key` attribute rather than any visible text, so nothing was
  broken on screen; it now points at the translated key the control
  beside it already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sdornan
sdornan force-pushed the claude/i18n-typed-messages branch from 12b08a8 to 8e1af2d Compare August 18, 2026 23:01
@sdornan sdornan changed the title feat(i18n): type locale messages with a DefineLocaleMessage schema feat(i18n): fail CI on translation keys that don't exist Aug 18, 2026
Auditing what the checker cannot see turned up two things.

The regex refused a `.` before `t(`, to keep `get(` and `format(` from
passing as calls. That also excluded every `i18n.global.t("...")` site,
52 of them, in the stores and the router. Only a word character has to be
refused, so a qualified call now counts.

Hand-resolving the nine template-literal keys found a real gap the
checker structurally can't: `PermEntity` carries `playlists`, the
permission catalog endpoint returns the whole enum, and the matrix labels
each row with `t(`settings.perm-entity.${e}`)`. There was no
`perm-entity.playlists`, so that row rendered its key. It exists now in
every locale, following the block's existing English-except-pt_BR style.

Nested key paths resolve properly too, so a literal
`t("settings.perm-entity.playlists")` no longer reads as missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sdornan

sdornan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Closing this: it grew past what it should be for the value it delivers.

What it became was a new CI check plus 72 lines of locale strings I'd authored across 17 languages, bundled together. The translations in particular don't belong in a tooling PR, and the check itself would be a process change for every frontend PR, which is a maintainer call rather than something to slip in alongside a fix.

Recording what came out of it, in case any of it is useful later:

  • vue-i18n's TypeScript support (DefineLocaleMessage) gives editor autocompletion but no build-time rejection of unknown keys. The signature is <Key extends string>(key: Key | ResourceKeys | number): string, so Key widens to any string and t("common.nope") typechecks clean.
  • TypeScript can hold every locale to a schema so a missing translation fails vue-tsc, and it works (naming the offending key, both directions). It needs all 18 x 16 locale/namespace pairs statically enumerated, since TypeScript can't glob types, and check_i18n_locales.py already enforces the same thing in CI with clearer errors.
  • @intlify/eslint-plugin-vue-i18n's no-missing-keys is ESLint 10 compatible but can't read this repo's layout. It builds its message tree from raw file contents with no filename prefix, so with locales/<locale>/<namespace>.json and namespace.key references a trial run reported 2985 errors, essentially all false positives. Making it work means nesting all 304 locale JSONs under their namespace key.
  • A ~90-line script in the style of the two checkers already in src/locales/ does work, with no dependency and no restructuring, if the idea is ever wanted.

The one real bug it surfaced is split out into #4234: the permissions matrix renders settings.perm-entity.playlists as a raw key, because the catalog endpoint returns every PermEntity member and that label was missing.

Also noted along the way, unfixed: the three platform.firmware-* strings pass an English fallback and so are untranslatable in every locale, and the whole settings.perm-entity / settings.perm-action block is untranslated English outside pt_BR.

@sdornan sdornan closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants