ESLint Plugin

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.

eslint.config.mjs
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:

Rulerecommendedstrict
literal-key-prefixerrorerror
runtime-key-needs-declared-namespacewarnerror

Overriding a severity is plain flat-config precedence — a later entry wins:

eslint.config.mjs
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:

nuxt.config.ts
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:

  1. An i18n-namespace: comment on the call's line or the line above, naming the pattern the keys draw from.
  2. That exact pattern declared under declaredNamespaces in the kit config — the place the scanner reads.
components/ViewCard.vue
// i18n-namespace: views.defaults.**
const label = t(view.name_key)
i18n-kit.config.ts
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:

ShapeWhy 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.

The rules are Vue-only: the vue-i18n call family in plain Vue or Nuxt, no React call family. React projects keep the CLI's full scan coverage in CI. The plugin lints code, not catalogs — duplicate values, unused keys and completeness stay with the CLI.
Copyright © 2026