Skip to content

Add no-unknown-locale rule #287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 11, 2022
Merged
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
1 change: 1 addition & 0 deletions docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
| [@intlify/vue-i18n/<wbr>no-duplicate-keys-in-locale](./no-duplicate-keys-in-locale.html) | disallow duplicate localization keys within the same locale | |
| [@intlify/vue-i18n/<wbr>no-dynamic-keys](./no-dynamic-keys.html) | disallow localization dynamic keys at localization methods | |
| [@intlify/vue-i18n/<wbr>no-missing-keys-in-other-locales](./no-missing-keys-in-other-locales.html) | disallow missing locale message keys in other locales | |
| [@intlify/vue-i18n/<wbr>no-unknown-locale](./no-unknown-locale.html) | disallow unknown locale name | |
| [@intlify/vue-i18n/<wbr>no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: |
| [@intlify/vue-i18n/<wbr>prefer-sfc-lang-attr](./prefer-sfc-lang-attr.html) | require lang attribute on `<i18n>` block | :black_nib: |

Expand Down
66 changes: 66 additions & 0 deletions docs/rules/no-unknown-locale.md
Original file line number Diff line number Diff line change
@@ -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

<eslint-code-block>

<!-- eslint-skip -->

```vue
<script>
/* eslint @intlify/vue-i18n/no-unknown-locale: "error" */
</script>

<!-- ✓ GOOD -->
<i18n locale="en">
{
"hello": "Hello!"
}
</i18n>

<!-- ✗ BAD -->
<i18n locale="foo">
{
"hello": "Foo!"
}
</i18n>
```

</eslint-code-block>

## :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)
2 changes: 2 additions & 0 deletions lib/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
266 changes: 266 additions & 0 deletions lib/rules/no-unknown-locale.ts
Original file line number Diff line number Diff line change
@@ -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<N extends JSONAST.JSONNode | YAMLAST.YAMLNode>(
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<YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta>()

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
})
4 changes: 3 additions & 1 deletion lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,5 +33,6 @@ export = {
'locale-messages': localeMessages,
parsers,
'path-utils': pathUtils,
'resource-loader': resourceLoader
'resource-loader': resourceLoader,
rule
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/file/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/file/en.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/file/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/file/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/key/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/no-unknown-locale/key/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
Loading