Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 230 additions & 5 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 '<i18n>' blocks. See the 'eslint-plugin-vue-i18n' documentation`
message: `You need to set 'localeDir' at 'settings', or use '<i18n>' blocks or 'useI18n({ messages })'. See the 'eslint-plugin-vue-i18n' documentation`
})
puttedSettingsError.add(context)
}
Expand All @@ -195,7 +201,8 @@ export function getLocaleMessages(
context,
localeDir
)) ||
[])
[]),
...useI18nMessages
])
}

Expand Down Expand Up @@ -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<string, VAST.ESLintExpression> {
const map = new Map<string, VAST.ESLintExpression>()
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<string, { source: string; importedName: string | null }> {
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<string, VAST.ESLintExpression>,
importMap: Map<string, { source: string; importedName: string | null }>,
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<Program, LocaleMessage[]>} */
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,
Expand Down
32 changes: 30 additions & 2 deletions lib/utils/locale-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,32 +189,54 @@ 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<I18nLocaleMessageDictionary>
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,
locales,
localeKey,
localePattern
})
this._exportName = exportName ?? null
this._resource = new ResourceLoader(fullpath, fileName => {
const ext = extname(fileName).toLowerCase()
if (ext === '.js') {
Expand All @@ -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
}
}

Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/no-missing-keys/useI18n/messages-default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"en": { "title": "Welcome" },
"ja": { "title": "ようこそ" }
}
1 change: 1 addition & 0 deletions tests/fixtures/no-missing-keys/useI18n/messages-named.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = { messages: { en: { title: 'Welcome' }, ja: { title: 'ようこそ' } } }
Loading