diff --git a/lib/index.js b/lib/index.js index 1bde6e3d..925113bb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,6 @@ /** * @fileoverview ESLint plugin for vue-i18n - * @author kazuya kawaguchi + * @author kazuya kawaguchi (a.k.a. kazupon) */ 'use strict' @@ -14,7 +14,7 @@ // import all rules in lib/rules module.exports.rules = { - // add your processors here + 'no-missing-key': require('./rules/no-missing-key') } // import processors diff --git a/lib/rules/no-missing-key.js b/lib/rules/no-missing-key.js new file mode 100644 index 00000000..c828f8ed --- /dev/null +++ b/lib/rules/no-missing-key.js @@ -0,0 +1,87 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + */ +'use strict' + +const { + defineTemplateBodyVisitor, + loadLocaleMessages, + findMissingsFromLocaleMessages +} = require('../utils/index') + +let localeMessages = null // cache + +function create (context) { + const { settings } = context + const localeDir = settings['vue-i18n'].localeDir + localeMessages = localeMessages || loadLocaleMessages(localeDir) + + return defineTemplateBodyVisitor(context, { + "VAttribute[directive=true][key.name='t']" (node) { + checkDirective(context, localeDir, localeMessages, node) + }, + + "VAttribute[directive=true][key.name.name='t']" (node) { + checkDirective(context, localeDir, localeMessages, node) + }, + + CallExpression (node) { + checkCallExpression(context, localeDir, localeMessages, node) + } + }, { + CallExpression (node) { + checkCallExpression(context, localeDir, localeMessages, node) + } + }) +} + +function checkDirective (context, localeDir, localeMessages, node) { + if ((node.value && node.value.type === 'VExpressionContainer') && + (node.value.expression && node.value.expression.type === 'Literal')) { + const key = node.value.expression.value + if (!key) { + // TODO: should be error + return + } + const missings = findMissingsFromLocaleMessages(localeMessages, key, localeDir) + if (missings.length) { + missings.forEach(missing => context.report({ node, ...missing })) + } + } +} + +function checkCallExpression (context, localeDir, localeMessages, node) { + const funcName = (node.callee.type === 'MemberExpression' && node.callee.property.name) || node.callee.name + + if (!/^(\$t|t|\$tc|tc)$/.test(funcName) || !node.arguments || !node.arguments.length) { + return + } + + const [keyNode] = node.arguments + if (keyNode.type !== 'Literal') { return } + + const key = keyNode.value + if (!key) { + // TODO: should be error + return + } + + const missings = findMissingsFromLocaleMessages(localeMessages, key, localeDir) + if (missings.length) { + missings.forEach(missing => context.report({ node, ...missing })) + } +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'disallow missing locale message key at localization methods', + category: 'Possible Errors', + recommended: true + }, + fixable: null, + schema: [] + }, + create +} diff --git a/lib/rules/no-missing.js b/lib/rules/no-missing.js deleted file mode 100644 index 90d960b6..00000000 --- a/lib/rules/no-missing.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict' - -function create (context) { - return { - } -} - -module.exports = { - meta: { - }, - create -} diff --git a/lib/utils/index.js b/lib/utils/index.js new file mode 100644 index 00000000..656d0833 --- /dev/null +++ b/lib/utils/index.js @@ -0,0 +1,55 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + */ +'use strict' + +const glob = require('glob') +const { resolve } = require('path') + +function defineTemplateBodyVisitor (context, templateBodyVisitor, scriptVisitor) { + if (context.parserServices.defineTemplateBodyVisitor === null) { + context.report({ + loc: { line: 1, column: 0 }, + message: 'Use the latest vue-eslint-parser. See also https://github.com/vuejs/eslint-plugin-vue#what-is-the-use-the-latest-vue-eslint-parser-error' + }) + return {} + } + return context.parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor) +} + +function loadLocaleMessages (pattern) { + const files = glob.sync(pattern) + return files.map(file => { + const path = resolve(process.cwd(), file) + const filename = file.replace(/^.*(\\|\/|:)/, '') + const messages = require(path) + return { path: file, filename, messages } + }) +} + +function findMissingsFromLocaleMessages (localeMessages, key) { + const missings = [] + const paths = key.split('.') + localeMessages.forEach(localeMessage => { + const length = paths.length + let last = localeMessage.messages + let i = 0 + while (i < length) { + const value = last[paths[i]] + if (value === undefined) { + missings.push({ + message: `'${key}' does not exist in '${localeMessage.path}'` + }) + } + last = value + i++ + } + }) + return missings +} + +module.exports = { + defineTemplateBodyVisitor, + loadLocaleMessages, + findMissingsFromLocaleMessages +} diff --git a/tests/fixtures/locales/en.json b/tests/fixtures/locales/en.json new file mode 100644 index 00000000..2714014b --- /dev/null +++ b/tests/fixtures/locales/en.json @@ -0,0 +1,14 @@ +{ + "hello": "hello world", + "messages": { + "hello": "hi DIO!", + "link": "@:message.hello", + "nested": { + "hello": "hi jojo!" + } + }, + "hello_dio": "hello underscore DIO!", + "hello {name}": "hello {name}!", + "hello-dio": "hello hyphen DIO!", + "foo.bar.buz": "hi flat key!" +} diff --git a/tests/fixtures/locales/ja.json b/tests/fixtures/locales/ja.json new file mode 100644 index 00000000..ee642ce2 --- /dev/null +++ b/tests/fixtures/locales/ja.json @@ -0,0 +1,14 @@ +{ + "hello": "ハローワールド", + "messages": { + "hello": "こんにちは、DIO!", + "link": "@:message.hello", + "nested": { + "hello": "こんにちは、ジョジョ!" + } + }, + "hello_dio": "こんにちは、アンダースコア DIO!", + "hello {name}": "こんにちは、{name}!", + "hello-dio": "こんにちは、ハイフン DIO!", + "foo.bar.buz": "こんにちは、フラットなキー!" +} \ No newline at end of file diff --git a/tests/lib/rules/no-missing-key.js b/tests/lib/rules/no-missing-key.js new file mode 100644 index 00000000..41f372d1 --- /dev/null +++ b/tests/lib/rules/no-missing-key.js @@ -0,0 +1,91 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/no-missing-key') + +const baseDir = './tests/fixtures/locales' +const resolve = file => `${baseDir}/${file}` + +const settings = { + 'vue-i18n': { + localeDir: `${baseDir}/*.json` + } +} + +const tester = new RuleTester({ + parser: 'vue-eslint-parser', + parserOptions: { ecmaVersion: 2015 } +}) + +tester.run('no-missing-key', rule, { + valid: [{ + // basic key + settings, + code: `$t('hello')` + }, { + // nested key + settings, + code: `t('messages.nested.hello')` + }, { + // linked key + settings, + code: `$tc('messages.hello.link')` + }, { + // hypened key + settings, + code: `tc('hello-dio')` + }, { + // key like the message + settings, + code: `$t('hello {name}')` + }, { + // Identifier + settings, + code: `$t(key)` + }, { + // using mustaches in template block + settings, + code: `` + }, { + // using custom directive in template block + settings, + code: `` + }], + + invalid: [{ + // basic + settings, + code: `$t('missing')`, + errors: [ + `'missing' does not exist in '${resolve('en.json')}'`, + `'missing' does not exist in '${resolve('ja.json')}'` + ] + }, { + // using mustaches in template block + settings, + code: ``, + errors: [ + `'missing' does not exist in '${resolve('en.json')}'`, + `'missing' does not exist in '${resolve('ja.json')}'` + ] + }, { + // using custom directive in template block + settings, + code: ``, + errors: [ + `'missing' does not exist in '${resolve('en.json')}'`, + `'missing' does not exist in '${resolve('ja.json')}'` + ] + }] +}) diff --git a/tests/lib/rules/no-missing.js b/tests/lib/rules/no-missing.js deleted file mode 100644 index 72e872e6..00000000 --- a/tests/lib/rules/no-missing.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict' - -const RuleTester = require('eslint').RuleTester - -const tester = new RuleTester() - -tester.run('no-missing', require('../../lib/rules/no-missing'), { - valid: [ - ], - invalid: [ - ] -})