From 3c0838489e80c3a43557397b12f3605adb1ca0a6 Mon Sep 17 00:00:00 2001 From: jari Date: Tue, 10 Feb 2026 14:41:59 +0100 Subject: [PATCH] feat: implement `useI18n({ messages })` (#694) --- lib/utils/index.ts | 235 +++++++++++++++++- lib/utils/locale-messages.ts | 32 ++- .../useI18n/messages-default.json | 4 + .../no-missing-keys/useI18n/messages-named.js | 1 + tests/lib/rules/no-missing-keys.ts | 204 ++++++++++++++- tests/lib/rules/no-unused-keys.ts | 12 +- 6 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/no-missing-keys/useI18n/messages-default.json create mode 100644 tests/fixtures/no-missing-keys/useI18n/messages-named.js diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 9ca585d2..e546997f 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -2,12 +2,13 @@ * @fileoverview Utilities for eslint plugin * @author kazuya kawaguchi (a.k.a. kazupon) */ -import type { AST as VAST } from 'vue-eslint-parser' +import { AST as VAST } from 'vue-eslint-parser' import { sync } from 'glob' import { resolve, dirname, extname } from 'path' import { FileLocaleMessage, BlockLocaleMessage, + UseI18nLocaleMessage, LocaleMessages } from './locale-messages' import { CacheLoader } from './cache-loader' @@ -20,8 +21,12 @@ import type { SettingsVueI18nLocaleDir, SettingsVueI18nLocaleDirObject, SettingsVueI18nLocaleDirGlob, - CustomBlockVisitorFactory + CustomBlockVisitorFactory, + I18nLocaleMessageDictionary, + I18nLocaleMessageValue, + VisitorKeys } from '../types' +import { existsSync } from 'fs' import * as jsoncESLintParser from 'jsonc-eslint-parser' import * as yamlESLintParser from 'yaml-eslint-parser' import { getCwd } from './get-cwd' @@ -174,14 +179,15 @@ export function getLocaleMessages( node.type === 'VElement' && node.name === 'i18n' )) || [] - if (!localeDir && !i18nBlocks.length) { + const useI18nMessages = getLocaleMessagesFromUseI18n(context) + if (!localeDir && !i18nBlocks.length && !useI18nMessages.length) { if ( !puttedSettingsError.has(context) && !options?.ignoreMissingSettingsError ) { context.report({ loc: UNEXPECTED_ERROR_LOCATION, - message: `You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation` + message: `You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation` }) puttedSettingsError.add(context) } @@ -195,7 +201,8 @@ export function getLocaleMessages( context, localeDir )) || - []) + []), + ...useI18nMessages ]) } @@ -325,6 +332,224 @@ function getLocaleMessagesFromI18nBlocks( return localeMessages } +// --- useI18n({ messages }) support --- + +type UseI18nResult = + | { type: 'inline'; dict: I18nLocaleMessageDictionary } + | { type: 'file'; fullpath: string; exportName: string | null } + +function objectExpressionToDict( + node: VAST.ESLintObjectExpression +): I18nLocaleMessageDictionary | null { + const dict: I18nLocaleMessageDictionary = {} + for (const prop of node.properties) { + if (prop.type !== 'Property') { + // SpreadElement or ESLintLegacySpreadProperty + return null + } + if (prop.computed) continue + const key = + prop.key.type === 'Identifier' + ? prop.key.name + : prop.key.type === 'Literal' + ? String(prop.key.value) + : null + if (key == null) continue + + const value = skipTSAsExpression(prop.value) + if (value.type === 'ObjectExpression') { + const nested = objectExpressionToDict(value) + if (nested == null) return null + dict[key] = nested + } else if (value.type === 'Literal') { + dict[key] = value.value as string | number | boolean | null + } else if ( + value.type === 'TemplateLiteral' && + value.expressions.length === 0 + ) { + dict[key] = value.quasis[0].value.cooked ?? value.quasis[0].value.raw + } + // skip other types (not statically analyzable) + } + return dict +} + +function collectVariableDeclarations( + ast: VAST.ESLintProgram +): Map { + const map = new Map() + for (const stmt of ast.body) { + if (stmt.type !== 'VariableDeclaration') continue + for (const decl of stmt.declarations) { + if (decl.id.type === 'Identifier' && decl.init) { + map.set(decl.id.name, skipTSAsExpression(decl.init)) + } + } + } + return map +} + +function collectImportSources( + ast: VAST.ESLintProgram +): Map { + const map = new Map< + string, + { source: string; importedName: string | null } + >() + for (const node of ast.body) { + if ( + node.type === 'ImportDeclaration' && + node.source.type === 'Literal' && + typeof node.source.value === 'string' + ) { + const source = node.source.value + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') { + map.set(specifier.local.name, { source, importedName: null }) + } else if (specifier.type === 'ImportSpecifier') { + const importedName = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : String(specifier.imported.value) + map.set(specifier.local.name, { source, importedName }) + } + // skip ImportNamespaceSpecifier + } + } + } + return map +} + +function resolveMessagesValue( + node: VAST.ESLintExpression | VAST.ESLintPattern, + variableMap: Map, + importMap: Map, + filename: string +): UseI18nResult | null { + const resolved = skipTSAsExpression(node) + if (resolved.type === 'ObjectExpression') { + const dict = objectExpressionToDict(resolved) + if (dict) { + return { type: 'inline', dict } + } + return null + } + if (resolved.type === 'Identifier') { + // Check variable declarations first + const varInit = variableMap.get(resolved.name) + if (varInit && varInit.type === 'ObjectExpression') { + const dict = objectExpressionToDict(varInit) + if (dict) { + return { type: 'inline', dict } + } + } + // Check imports + const importInfo = importMap.get(resolved.name) + if (importInfo) { + const dir = dirname(filename) + const fullpath = resolve(dir, importInfo.source) + if (existsSync(fullpath)) { + return { + type: 'file', + fullpath, + exportName: importInfo.importedName + } + } + } + } + return null +} + +function extractUseI18nMessages( + ast: VAST.ESLintProgram, + filename: string, + visitorKeys?: VisitorKeys +): (UseI18nLocaleMessage | FileLocaleMessage)[] { + if (!ast.body || !Array.isArray(ast.body)) return [] + const variableMap = collectVariableDeclarations(ast) + const importMap = collectImportSources(ast) + const results: (UseI18nLocaleMessage | FileLocaleMessage)[] = [] + + VAST.traverseNodes(ast as VAST.ESLintNode, { + visitorKeys, + enterNode(node) { + if (node.type !== 'CallExpression') return + const call = node as VAST.ESLintCallExpression + if ( + call.callee.type !== 'Identifier' || + call.callee.name !== 'useI18n' || + call.arguments.length === 0 + ) + return + + const arg = skipTSAsExpression(call.arguments[0]) + if (arg.type !== 'ObjectExpression') return + for (const prop of arg.properties) { + if (prop.type !== 'Property') continue + if (prop.computed) continue + const key = + prop.key.type === 'Identifier' + ? prop.key.name + : prop.key.type === 'Literal' + ? String(prop.key.value) + : null + if (key !== 'messages') continue + + const resolved = resolveMessagesValue( + prop.value, + variableMap, + importMap, + filename + ) + if (!resolved) break + if (resolved.type === 'inline') { + results.push( + new UseI18nLocaleMessage({ + fullpath: filename, + messages: resolved.dict + }) + ) + } else { + results.push( + new FileLocaleMessage({ + fullpath: resolved.fullpath, + localeKey: 'key', + exportName: resolved.exportName + }) + ) + } + break + } + }, + leaveNode() { + // noop + } + }) + + return results +} + +/** @type {WeakMap} */ +const useI18nLocaleMessagesCache = new WeakMap() + +function getLocaleMessagesFromUseI18n( + context: RuleContext +): (UseI18nLocaleMessage | FileLocaleMessage)[] { + const sourceCode = getSourceCode(context) + let results = useI18nLocaleMessagesCache.get(sourceCode.ast) as + | (UseI18nLocaleMessage | FileLocaleMessage)[] + | undefined + if (results) return results + const filename = getFilename(context) + results = extractUseI18nMessages( + sourceCode.ast as VAST.ESLintProgram, + filename, + sourceCode.visitorKeys + ) + useI18nLocaleMessagesCache.set(sourceCode.ast, results) + return results +} + export function defineCustomBlocksVisitor( context: RuleContext, jsonRule: CustomBlockVisitorFactory, diff --git a/lib/utils/locale-messages.ts b/lib/utils/locale-messages.ts index fec42931..7021d885 100644 --- a/lib/utils/locale-messages.ts +++ b/lib/utils/locale-messages.ts @@ -189,25 +189,46 @@ export class BlockLocaleMessage extends LocaleMessage { } } +export class UseI18nLocaleMessage extends LocaleMessage { + private _messages: I18nLocaleMessageDictionary + constructor({ + fullpath, + messages + }: { + fullpath: string + messages: I18nLocaleMessageDictionary + }) { + super({ fullpath, localeKey: 'key' }) + this._messages = messages + } + getMessagesInternal(): I18nLocaleMessageDictionary { + return this._messages + } +} + export class FileLocaleMessage extends LocaleMessage { private _resource: ResourceLoader + private _exportName: string | null /** * @param {object} arg * @param {string} arg.fullpath Absolute path. * @param {string[]} [arg.locales] The locales. * @param {LocaleKeyType} arg.localeKey Specifies how to determine the locale for localization messages. * @param {string | RegExp} args.localePattern Specifies how to determin the regular expression pattern for how to get the locale. + * @param {string} [arg.exportName] The named export to access from the loaded resource. */ constructor({ fullpath, locales, localeKey, - localePattern + localePattern, + exportName }: { fullpath: string locales?: string[] localeKey: LocaleKeyType localePattern?: string | RegExp + exportName?: string | null }) { super({ fullpath, @@ -215,6 +236,7 @@ export class FileLocaleMessage extends LocaleMessage { localeKey, localePattern }) + this._exportName = exportName ?? null this._resource = new ResourceLoader(fullpath, fileName => { const ext = extname(fileName).toLowerCase() if (ext === '.js') { @@ -230,7 +252,13 @@ export class FileLocaleMessage extends LocaleMessage { } getMessagesInternal(): I18nLocaleMessageDictionary { - return this._resource.getResource() + const raw = this._resource.getResource() + if (this._exportName) { + return ( + (raw[this._exportName] as I18nLocaleMessageDictionary | undefined) ?? {} + ) + } + return raw } } diff --git a/tests/fixtures/no-missing-keys/useI18n/messages-default.json b/tests/fixtures/no-missing-keys/useI18n/messages-default.json new file mode 100644 index 00000000..cc388a87 --- /dev/null +++ b/tests/fixtures/no-missing-keys/useI18n/messages-default.json @@ -0,0 +1,4 @@ +{ + "en": { "title": "Welcome" }, + "ja": { "title": "ようこそ" } +} diff --git a/tests/fixtures/no-missing-keys/useI18n/messages-named.js b/tests/fixtures/no-missing-keys/useI18n/messages-named.js new file mode 100644 index 00000000..c21fa076 --- /dev/null +++ b/tests/fixtures/no-missing-keys/useI18n/messages-named.js @@ -0,0 +1 @@ +module.exports = { messages: { en: { title: 'Welcome' }, ja: { title: 'ようこそ' } } } diff --git a/tests/lib/rules/no-missing-keys.ts b/tests/lib/rules/no-missing-keys.ts index 1b2e5d6e..8c6db9cc 100644 --- a/tests/lib/rules/no-missing-keys.ts +++ b/tests/lib/rules/no-missing-keys.ts @@ -235,6 +235,139 @@ tester.run('no-missing-keys', rule as never, { './tests/fixtures/no-missing-keys/complex-locales/locales/*.json' } } + }, + // useI18n({ messages }) -- inline basic key + { + filename: 'test.vue', + code: ` + + ` + }, + // useI18n({ messages }) -- inline nested key + { + filename: 'test.vue', + code: ` + + ` + }, + // useI18n({ messages }) -- v-t directive + { + filename: 'test.vue', + code: ` + + ` + }, + // Combined: block + useI18n({ messages }) + { + filename: 'test.vue', + code: `{"en": {"fromBlock": "yes"}, "ja": {"fromBlock": "はい"}} + + ` + }, + // useI18n({ messages }) -- local variable reference + { + filename: 'test.vue', + code: ` + + ` + }, + // useI18n({ messages }) -- default import from JSON + { + filename: join( + __dirname, + '../../fixtures/no-missing-keys/useI18n/Test.vue' + ), + code: ` + + ` + }, + // useI18n({ messages }) -- named import from JS + { + filename: join( + __dirname, + '../../fixtures/no-missing-keys/useI18n/Test.vue' + ), + code: ` + + ` + }, + // useI18n({ messages }) -- aliased named import + { + filename: join( + __dirname, + '../../fixtures/no-missing-keys/useI18n/Test.vue' + ), + code: ` + + ` } ] ), @@ -313,7 +446,7 @@ tester.run('no-missing-keys', rule as never, { // settings.vue-i18n.localeDir' error code: `$t('missing')`, errors: [ - `You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation` + `You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation` ] }, { @@ -413,6 +546,75 @@ tester.run('no-missing-keys', rule as never, { line: 14 } ] + }, + // useI18n({ messages }) -- inline missing key + { + filename: 'test.vue', + code: ` + + `, + errors: [`'missing' does not exist in localization message resources`] + }, + // useI18n({ messages }) -- local variable reference missing key + { + filename: 'test.vue', + code: ` + + `, + errors: [`'missing' does not exist in localization message resources`] + }, + // useI18n({ messages }) -- default import missing key + { + filename: join( + __dirname, + '../../fixtures/no-missing-keys/useI18n/Test.vue' + ), + code: ` + + `, + errors: [`'missing' does not exist in localization message resources`] + }, + // useI18n({ messages }) -- named import missing key + { + filename: join( + __dirname, + '../../fixtures/no-missing-keys/useI18n/Test.vue' + ), + code: ` + + `, + errors: [`'missing' does not exist in localization message resources`] } ] ) diff --git a/tests/lib/rules/no-unused-keys.ts b/tests/lib/rules/no-unused-keys.ts index 5e3c0a1a..2901a0b2 100644 --- a/tests/lib/rules/no-unused-keys.ts +++ b/tests/lib/rules/no-unused-keys.ts @@ -2072,7 +2072,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }, @@ -2082,7 +2082,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }, @@ -2092,7 +2092,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }, @@ -2102,7 +2102,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }, @@ -2112,7 +2112,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }, @@ -2122,7 +2122,7 @@ ${' '.repeat(6)} { line: 1, message: - "You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation" + "You need to set 'localeDir' at 'settings', or use '' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation" } ] }