Install and Rules
Two rules, both about one contract: every i18n call must leave the orphan scanner enough to bound which keys it can produce. The scan keeps every safety net either way — the rules change how much work those nets do, not whether they exist.
Install
pnpm add -D @the-i18n-kit/eslint-plugin-vue
ESLint ^9.10.0 (flat config only) and Node 18 or newer. One optional peer,
@intlify/eslint-plugin-vue-i18n (^3.0.0 || ^4.0.0), is needed only for the
layer-aware factory.
import i18nKit from '@the-i18n-kit/eslint-plugin-vue'
export default [
...i18nKit.configs.recommended,
]
Despite the -vue in the name, both presets lint **/*.vue, **/*.ts,
**/*.js and **/*.mjs — and layerAware() adds .tsx and .jsx — because
vue-i18n calls live in composables and stores as much as in components. They
register the plugin under the @the-i18n-kit/vue prefix and differ in one
severity:
| Rule | recommended | strict |
|---|---|---|
literal-key-prefix | error | error |
runtime-key-needs-declared-namespace | warn | error |
Overriding a severity is plain flat-config precedence — a later entry wins:
export default [
...i18nKit.configs.recommended,
{ rules: { '@the-i18n-kit/vue/runtime-key-needs-declared-namespace': 'error' } },
]
On Nuxt with @nuxt/eslint, contribute the preset and a
layerAware() call to the generated config
instead of writing one:
import { i18nKitEslintAddon } from '@the-i18n-kit/eslint-plugin-vue/nuxt'
export default defineNuxtConfig({
modules: ['@nuxt/eslint'],
hooks: {
'eslint:config:addons': addons => addons.push(i18nKitEslintAddon()),
},
})
Run with recommended first, annotate the wire-driven calls as described below,
then promote to error once the remaining warnings are decisions rather than a
backlog. For a one-off exception, a standard eslint-disable comment is the
escape hatch.
Both rules recognize a call the way the scanner does: $t, $te and $tc
anywhere; a bare t, te or tc only where it resolves to a vue-i18n binding.
<template> expressions in .vue files are checked like script code.
literal-key-prefix
Error in both presets. A dynamic key — a template literal with expressions, or a
+ concatenation — must begin with a literal segment containing a dot.
t(`${section}.title`) // fires: nothing to anchor on
t(prefix + '.title') // fires
t(`bookings.status.${status}`) // passes
t('bookings.status.' + status) // passes
A key starting with a variable leaves the scanner two options: over-protect every
*.title key in the catalog, or delete live ones. Plain literals never fire.
Where a literal prefix is impossible, look full keys up from a local map of
literals instead.
runtime-key-needs-declared-namespace
Warn in recommended, error in strict. It fires when the key is entirely
runtime data — t(view.name_key), where the names arrive from a backend and no
frontend scan can see which keys the call keeps alive.
It demands two things, both checkable:
- An
i18n-namespace:comment on the call's line or the line above, naming the pattern the keys draw from. - That exact pattern declared under
declaredNamespacesin the kit config — the place the scanner reads.
// i18n-namespace: views.defaults.**
const label = t(view.name_key)
import { defineI18nKitConfig } from '@the-i18n-kit/cli/config'
export default defineI18nKitConfig({
declaredNamespaces: [
{ pattern: 'views.defaults.**', reason: 'sent by bookings-api as name_key' },
],
})
An annotation naming a pattern the config does not declare is reported as drift,
with the config path in the message; so is a runtime key with no kit config above
the file, since there is nowhere to declare the namespace. A pattern from any
layer's orphanScan.ignorePatterns is accepted too, and the config is re-read
when it changes, so a declaration you just added stops the report without an
editor restart. What a declaration protects is reported by
orphans.
What Never Fires
The scanner's own leniencies, restated — what the candidate net already protects, the rule does not question:
| Shape | Why it is safe |
|---|---|
const KEY = 'bookings.title' … t(KEY) | A same-file const holding a string literal |
const KEY = `bookings.${id}` … t(KEY) | A same-file const holding a dotted-prefix template |
const LABELS = { a: 'x.a' } … t(LABELS[state]) | A lookup into a same-file const map of literals |
te(x), $te(x) | An existence check commits to nothing |
Only const bindings count: a let or var can hold anything by the time the
call runs, and a conditional expression is runtime data if either branch is.
Refactoring a protected shape into an unprotected one is exactly when this rule
fires and should.