diff --git a/docs/rules/README.md b/docs/rules/README.md index 2ecfb9f5..c36506e5 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -27,6 +27,7 @@ | [@intlify/vue-i18n/no-duplicate-keys-in-locale](./no-duplicate-keys-in-locale.html) | disallow duplicate localization keys within the same locale | | | [@intlify/vue-i18n/no-dynamic-keys](./no-dynamic-keys.html) | disallow localization dynamic keys at localization methods | | | [@intlify/vue-i18n/no-missing-keys-in-other-locales](./no-missing-keys-in-other-locales.html) | disallow missing locale message keys in other locales | | +| [@intlify/vue-i18n/no-unknown-locale](./no-unknown-locale.html) | disallow unknown locale name | | | [@intlify/vue-i18n/no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: | | [@intlify/vue-i18n/prefer-sfc-lang-attr](./prefer-sfc-lang-attr.html) | require lang attribute on `` block | :black_nib: | diff --git a/docs/rules/no-unknown-locale.md b/docs/rules/no-unknown-locale.md new file mode 100644 index 00000000..71e77142 --- /dev/null +++ b/docs/rules/no-unknown-locale.md @@ -0,0 +1,66 @@ +--- +title: '@intlify/vue-i18n/no-unknown-locale' +description: disallow unknown locale name +--- + +# @intlify/vue-i18n/no-unknown-locale + +> disallow unknown locale name + +## :book: Rule Details + +This rule reports the use of unknown locale names. + +By default, this rule only commonly known locale names specified in [RFC 5646] are allowed. +The rule uses the [is-language-code] package to check if the locale name is compatible with [RFC 5646]. + +[rfc 5646]: https://datatracker.ietf.org/doc/html/rfc5646 +[is-language-code]: https://www.npmjs.com/package/is-language-code + + + + + +```vue + + + + +{ + "hello": "Hello!" +} + + + + +{ + "hello": "Foo!" +} + +``` + + + +## :gear: Options + +```json +{ + "@intlify/vue-i18n/no-unknown-locale": [ + "error", + { + "locales": [], + "disableRFC5646": false + } + ] +} +``` + +- `locales` ... Specify the locale names you want to use specially in an array. The rule excludes the specified name from the check. +- `disableRFC5646` ... If `true`, only the locale names listed in `locales` are allowed. + +## :mag: Implementation + +- [Rule source](https://github.com/intlify/eslint-plugin-vue-i18n/blob/master/lib/rules/no-unknown-locale.ts) +- [Test source](https://github.com/intlify/eslint-plugin-vue-i18n/tree/master/tests/lib/rules/no-unknown-locale.ts) diff --git a/lib/rules.ts b/lib/rules.ts index b3906097..ebb9afa9 100644 --- a/lib/rules.ts +++ b/lib/rules.ts @@ -10,6 +10,7 @@ import noI18nTPathProp from './rules/no-i18n-t-path-prop' import noMissingKeysInOtherLocales from './rules/no-missing-keys-in-other-locales' import noMissingKeys from './rules/no-missing-keys' import noRawText from './rules/no-raw-text' +import noUnknownLocale from './rules/no-unknown-locale' import noUnusedKeys from './rules/no-unused-keys' import noVHtml from './rules/no-v-html' import preferLinkedKeyWithParen from './rules/prefer-linked-key-with-paren' @@ -28,6 +29,7 @@ export = { 'no-missing-keys-in-other-locales': noMissingKeysInOtherLocales, 'no-missing-keys': noMissingKeys, 'no-raw-text': noRawText, + 'no-unknown-locale': noUnknownLocale, 'no-unused-keys': noUnusedKeys, 'no-v-html': noVHtml, 'prefer-linked-key-with-paren': preferLinkedKeyWithParen, diff --git a/lib/rules/no-unknown-locale.ts b/lib/rules/no-unknown-locale.ts new file mode 100644 index 00000000..51169456 --- /dev/null +++ b/lib/rules/no-unknown-locale.ts @@ -0,0 +1,266 @@ +import type { AST as JSONAST } from 'jsonc-eslint-parser' +import type { AST as YAMLAST } from 'yaml-eslint-parser' +import type { AST as VAST } from 'vue-eslint-parser' +import { extname } from 'path' +import { isLangCode } from 'is-language-code' +import debugBuilder from 'debug' +import type { RuleContext, RuleListener } from '../types' +import { createRule } from '../utils/rule' +import { + getLocaleMessages, + defineCustomBlocksVisitor, + getAttribute +} from '../utils/index' +import type { LocaleMessage } from '../utils/locale-messages' +const debug = debugBuilder('eslint-plugin-vue-i18n:no-unknown-locale') + +function create(context: RuleContext): RuleListener { + const filename = context.getFilename() + const locales: string[] = context.options[0]?.locales || [] + const disableRFC5646 = context.options[0]?.disableRFC5646 || false + + function verifyLocaleCode( + locale: string, + reportNode: JSONAST.JSONNode | YAMLAST.YAMLNode | VAST.VAttribute | null + ) { + if (locales.includes(locale)) { + return + } + if (!disableRFC5646 && isLangCode(locale).res) { + return + } + context.report({ + message: "'{{locale}}' is unknown locale name", + data: { + locale + }, + loc: reportNode?.loc || { line: 1, column: 0 } + }) + } + + function createVerifyContext( + targetLocaleMessage: LocaleMessage, + block: VAST.VElement | null + ) { + type KeyStack = + | { + locale: null + node?: N + upper?: KeyStack + } + | { + locale: string + node?: N + upper?: KeyStack + } + let keyStack: KeyStack + if (targetLocaleMessage.isResolvedLocaleByFileName()) { + const locale = targetLocaleMessage.locales[0] + keyStack = { + locale + } + verifyLocaleCode(locale, block && getAttribute(block, 'locale')) + } else { + keyStack = { + locale: null + } + } + + // localeMessages.locales + return { + enterKey(key: string | number, node: N) { + if (keyStack.locale == null) { + const locale = String(key) + keyStack = { + node, + locale, + upper: keyStack + } + verifyLocaleCode(locale, node) + } else { + keyStack = { + node, + locale: keyStack.locale, + upper: keyStack + } + } + }, + leaveKey(node: N | null) { + if (keyStack.node === node) { + keyStack = keyStack.upper! + } + } + } + } + + /** + * Create node visitor for JSON + */ + function createVisitorForJson( + targetLocaleMessage: LocaleMessage, + block: VAST.VElement | null + ): RuleListener { + const ctx = createVerifyContext(targetLocaleMessage, block) + return { + JSONProperty(node: JSONAST.JSONProperty) { + const key = + node.key.type === 'JSONLiteral' ? `${node.key.value}` : node.key.name + + ctx.enterKey(key, node.key) + }, + 'JSONProperty:exit'(node: JSONAST.JSONProperty) { + ctx.leaveKey(node.key) + }, + 'JSONArrayExpression > *'( + node: JSONAST.JSONArrayExpression['elements'][number] & { + parent: JSONAST.JSONArrayExpression + } + ) { + const key = node.parent.elements.indexOf(node) + ctx.enterKey(key, node) + }, + 'JSONArrayExpression > *:exit'( + node: JSONAST.JSONArrayExpression['elements'][number] + ) { + ctx.leaveKey(node) + } + } + } + + /** + * Create node visitor for YAML + */ + function createVisitorForYaml( + targetLocaleMessage: LocaleMessage, + block: VAST.VElement | null + ): RuleListener { + const yamlKeyNodes = new Set() + + function withinKey(node: YAMLAST.YAMLNode) { + for (const keyNode of yamlKeyNodes) { + if ( + keyNode.range[0] <= node.range[0] && + node.range[0] < keyNode.range[1] + ) { + return true + } + } + return false + } + + const ctx = createVerifyContext(targetLocaleMessage, block) + + return { + YAMLPair(node: YAMLAST.YAMLPair) { + if (node.key != null) { + if (withinKey(node)) { + return + } + yamlKeyNodes.add(node.key) + } + + if (node.key != null && node.key.type === 'YAMLScalar') { + const keyValue = node.key.value + const key = typeof keyValue === 'string' ? keyValue : String(keyValue) + + ctx.enterKey(key, node.key) + } + }, + 'YAMLPair:exit'(node: YAMLAST.YAMLPair) { + if (node.key != null) { + ctx.leaveKey(node.key) + } + }, + 'YAMLSequence > *'( + node: YAMLAST.YAMLSequence['entries'][number] & { + parent: YAMLAST.YAMLSequence + } + ) { + if (withinKey(node)) { + return + } + const key = node.parent.entries.indexOf(node) + ctx.enterKey(key, node) + }, + 'YAMLSequence > *:exit'(node: YAMLAST.YAMLSequence['entries'][number]) { + ctx.leaveKey(node) + } + } + } + + if (extname(filename) === '.vue') { + return defineCustomBlocksVisitor( + context, + ctx => { + const localeMessages = getLocaleMessages(context) + const targetLocaleMessage = localeMessages.findBlockLocaleMessage( + ctx.parserServices.customBlock + ) + if (!targetLocaleMessage) { + return {} + } + return createVisitorForJson( + targetLocaleMessage, + ctx.parserServices.customBlock + ) + }, + ctx => { + const localeMessages = getLocaleMessages(context) + const targetLocaleMessage = localeMessages.findBlockLocaleMessage( + ctx.parserServices.customBlock + ) + if (!targetLocaleMessage) { + return {} + } + return createVisitorForYaml( + targetLocaleMessage, + ctx.parserServices.customBlock + ) + } + ) + } else if (context.parserServices.isJSON || context.parserServices.isYAML) { + const localeMessages = getLocaleMessages(context) + const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename) + if (!targetLocaleMessage) { + debug(`ignore ${filename} in no-unknown-locale`) + return {} + } + + if (context.parserServices.isJSON) { + return createVisitorForJson(targetLocaleMessage, null) + } else if (context.parserServices.isYAML) { + return createVisitorForYaml(targetLocaleMessage, null) + } + return {} + } else { + debug(`ignore ${filename} in no-unknown-locale`) + return {} + } +} + +export = createRule({ + meta: { + type: 'suggestion', + docs: { + description: 'disallow unknown locale name', + category: 'Best Practices', + url: 'https://eslint-plugin-vue-i18n.intlify.dev/rules/no-unknown-locale.html', + recommended: false + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + locales: { + type: 'array', + items: { type: 'string' } + }, + disableRFC5646: { type: 'boolean' } + }, + additionalProperties: false + } + ] + }, + create +}) diff --git a/lib/utils.ts b/lib/utils.ts index 408b2874..cb57dbcb 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -15,6 +15,7 @@ import * as localeMessages from './utils/locale-messages' import * as parsers from './utils/parsers' import * as pathUtils from './utils/path-utils' import * as resourceLoader from './utils/resource-loader' +import * as rule from './utils/rule' export = { 'cache-function': cacheFunction, @@ -32,5 +33,6 @@ export = { 'locale-messages': localeMessages, parsers, 'path-utils': pathUtils, - 'resource-loader': resourceLoader + 'resource-loader': resourceLoader, + rule } diff --git a/package.json b/package.json index 2bcdc677..afc362e6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "debug": "^4.3.1", "glob": "^7.1.3", "ignore": "^5.0.5", + "is-language-code": "^3.1.0", "js-yaml": "^4.0.0", "json5": "^2.1.3", "jsonc-eslint-parser": "^2.0.0", diff --git a/tests/fixtures/no-unknown-locale/file/en.json b/tests/fixtures/no-unknown-locale/file/en.json new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/file/en.json @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/no-unknown-locale/file/en.yaml b/tests/fixtures/no-unknown-locale/file/en.yaml new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/file/en.yaml @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/no-unknown-locale/file/test.json b/tests/fixtures/no-unknown-locale/file/test.json new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/file/test.json @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/no-unknown-locale/file/test.yaml b/tests/fixtures/no-unknown-locale/file/test.yaml new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/file/test.yaml @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/no-unknown-locale/key/test.json b/tests/fixtures/no-unknown-locale/key/test.json new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/key/test.json @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/no-unknown-locale/key/test.yaml b/tests/fixtures/no-unknown-locale/key/test.yaml new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/no-unknown-locale/key/test.yaml @@ -0,0 +1 @@ +null diff --git a/tests/lib/rules/no-unknown-locale.ts b/tests/lib/rules/no-unknown-locale.ts new file mode 100644 index 00000000..5a7fe367 --- /dev/null +++ b/tests/lib/rules/no-unknown-locale.ts @@ -0,0 +1,271 @@ +import { join } from 'path' +import { RuleTester } from 'eslint' +import rule = require('../../../lib/rules/no-unknown-locale') +import type { SettingsVueI18nLocaleDirObject } from '../../../lib/types' +const vueParser = require.resolve('vue-eslint-parser') +const jsonParser = require.resolve('jsonc-eslint-parser') +const yamlParser = require.resolve('yaml-eslint-parser') +const fileLocalesRoot = join(__dirname, '../../fixtures/no-unknown-locale/file') +const keyLocalesRoot = join(__dirname, '../../fixtures/no-unknown-locale/key') + +const options = { + json: { + fileTest: { + parser: jsonParser, + filename: join(fileLocalesRoot, 'test.json'), + settings: { + 'vue-i18n': { + localeDir: fileLocalesRoot + '/*.{json,yaml,yml}' + } + } + }, + fileEn: { + parser: jsonParser, + filename: join(fileLocalesRoot, 'en.json'), + settings: { + 'vue-i18n': { + localeDir: fileLocalesRoot + '/*.{json,yaml,yml}' + } + } + }, + key: { + parser: jsonParser, + filename: join(keyLocalesRoot, 'test.json'), + settings: { + 'vue-i18n': { + localeDir: { + pattern: keyLocalesRoot + '/*.{json,yaml,yml}', + localeKey: 'key' + } as SettingsVueI18nLocaleDirObject + } + } + } + }, + yaml: { + fileTest: { + parser: yamlParser, + filename: join(fileLocalesRoot, 'test.yaml'), + settings: { + 'vue-i18n': { + localeDir: fileLocalesRoot + '/*.{json,yaml,yml}' + } + } + }, + fileEn: { + parser: yamlParser, + filename: join(fileLocalesRoot, 'en.yaml'), + settings: { + 'vue-i18n': { + localeDir: fileLocalesRoot + '/*.{json,yaml,yml}' + } + } + }, + key: { + parser: yamlParser, + filename: join(keyLocalesRoot, 'test.yaml'), + settings: { + 'vue-i18n': { + localeDir: { + pattern: keyLocalesRoot + '/*.{json,yaml,yml}', + localeKey: 'key' + } as SettingsVueI18nLocaleDirObject + } + } + } + } +} + +const tester = new RuleTester({ + parser: vueParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('no-unknown-locale', rule as never, { + valid: [ + { + filename: 'test.vue', + code: ` + + {"msg_key":{}} + + + {"msg_key":{}} + + + {"msg_key":{}} + + + {"msg_key":{}} + + + {"msg_key":{}} + + + { + "sr-Latn-CS": {"msg_key":{}}, + "en-US": {"msg_key":{}} + } + + ` + }, + { + code: `{"msg_key":{}}`, + ...options.yaml.fileEn + }, + { + code: `{"msg_key":{}}`, + ...options.json.fileEn + }, + { + code: `{"en":{"msg_key":{}}}`, + ...options.yaml.key + }, + { + code: `{"en":{"msg_key":{}}}`, + ...options.json.key + }, + { + filename: 'test.vue', + code: ` + + + + { + "easy_ja": {} + } + + `, + options: [{ locales: ['easy_ja'] }] + }, + { + filename: 'test.vue', + code: ` + + + + { + "easy_ja": {} + } + + `, + options: [{ locales: ['easy_ja'], disableRFC5646: true }] + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + + + + + + + { + "foo": {}, + "en": {}, + "b-ar": {} + } + + `, + errors: [ + { + message: "'unknown' is unknown locale name", + line: 2, + column: 13 + }, + { + message: "'ja-foo' is unknown locale name", + line: 6, + column: 13 + }, + { + message: "'foo' is unknown locale name", + line: 10, + column: 9 + }, + { + message: "'b-ar' is unknown locale name", + line: 12, + column: 9 + } + ] + }, + { + code: `{"msg_key":{}}`, + ...options.yaml.fileTest, + errors: [ + { + message: "'test' is unknown locale name", + line: 1, + column: 1 + } + ] + }, + { + code: `{"msg_key":{}}`, + ...options.json.fileTest, + errors: [ + { + message: "'test' is unknown locale name", + line: 1, + column: 1 + } + ] + }, + { + code: `{"foo":{"msg_key":{}}}`, + ...options.yaml.key, + errors: [ + { + message: "'foo' is unknown locale name", + line: 1, + column: 2 + } + ] + }, + { + code: `{"foo":{"msg_key":{}}}`, + ...options.json.key, + errors: [ + { + message: "'foo' is unknown locale name", + line: 1, + column: 2 + } + ] + }, + { + filename: 'test.vue', + code: ` + + + + + + { + "easy_ja": {}, + "en": {}, + } + + `, + options: [{ locales: ['easy_ja'], disableRFC5646: true }], + errors: [ + { + message: "'en' is unknown locale name", + line: 4, + column: 13 + }, + { + message: "'en' is unknown locale name", + line: 9, + column: 9 + } + ] + } + ] +}) diff --git a/yarn.lock b/yarn.lock index c81c9421..c264b44c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -879,6 +879,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.14.0": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.2.tgz#66f68591605e59da47523c631416b18508779941" + integrity sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.0.0", "@babel/template@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6" @@ -6018,6 +6025,13 @@ is-lambda@^1.0.1: resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= +is-language-code@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-language-code/-/is-language-code-3.1.0.tgz#b2386b49227e7010636f16d0c2c681ca40136ab5" + integrity sha512-zJdQ3QTeLye+iphMeK3wks+vXSRFKh68/Pnlw7aOfApFSEIOhYa8P9vwwa6QrImNNBMJTiL1PpYF0f4BxDuEgA== + dependencies: + "@babel/runtime" "^7.14.0" + is-negative-zero@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"