Skip to content

Commit 816a94e

Browse files
authored
Add no-unknown-locale rule (#287)
1 parent 731fc03 commit 816a94e

File tree

13 files changed

+627
-0
lines changed

13 files changed

+627
-0
lines changed

docs/rules/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
| [@intlify/vue-i18n/<wbr>no-duplicate-keys-in-locale](./no-duplicate-keys-in-locale.html) | disallow duplicate localization keys within the same locale | |
2828
| [@intlify/vue-i18n/<wbr>no-dynamic-keys](./no-dynamic-keys.html) | disallow localization dynamic keys at localization methods | |
2929
| [@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 | |
30+
| [@intlify/vue-i18n/<wbr>no-unknown-locale](./no-unknown-locale.html) | disallow unknown locale name | |
3031
| [@intlify/vue-i18n/<wbr>no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: |
3132
| [@intlify/vue-i18n/<wbr>prefer-sfc-lang-attr](./prefer-sfc-lang-attr.html) | require lang attribute on `<i18n>` block | :black_nib: |
3233

docs/rules/no-unknown-locale.md

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
title: '@intlify/vue-i18n/no-unknown-locale'
3+
description: disallow unknown locale name
4+
---
5+
6+
# @intlify/vue-i18n/no-unknown-locale
7+
8+
> disallow unknown locale name
9+
10+
## :book: Rule Details
11+
12+
This rule reports the use of unknown locale names.
13+
14+
By default, this rule only commonly known locale names specified in [RFC 5646] are allowed.
15+
The rule uses the [is-language-code] package to check if the locale name is compatible with [RFC 5646].
16+
17+
[rfc 5646]: https://datatracker.ietf.org/doc/html/rfc5646
18+
[is-language-code]: https://www.npmjs.com/package/is-language-code
19+
20+
<eslint-code-block>
21+
22+
<!-- eslint-skip -->
23+
24+
```vue
25+
<script>
26+
/* eslint @intlify/vue-i18n/no-unknown-locale: "error" */
27+
</script>
28+
29+
<!-- ✓ GOOD -->
30+
<i18n locale="en">
31+
{
32+
"hello": "Hello!"
33+
}
34+
</i18n>
35+
36+
<!-- ✗ BAD -->
37+
<i18n locale="foo">
38+
{
39+
"hello": "Foo!"
40+
}
41+
</i18n>
42+
```
43+
44+
</eslint-code-block>
45+
46+
## :gear: Options
47+
48+
```json
49+
{
50+
"@intlify/vue-i18n/no-unknown-locale": [
51+
"error",
52+
{
53+
"locales": [],
54+
"disableRFC5646": false
55+
}
56+
]
57+
}
58+
```
59+
60+
- `locales` ... Specify the locale names you want to use specially in an array. The rule excludes the specified name from the check.
61+
- `disableRFC5646` ... If `true`, only the locale names listed in `locales` are allowed.
62+
63+
## :mag: Implementation
64+
65+
- [Rule source](https://github.com/intlify/eslint-plugin-vue-i18n/blob/master/lib/rules/no-unknown-locale.ts)
66+
- [Test source](https://github.com/intlify/eslint-plugin-vue-i18n/tree/master/tests/lib/rules/no-unknown-locale.ts)

lib/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import noI18nTPathProp from './rules/no-i18n-t-path-prop'
1010
import noMissingKeysInOtherLocales from './rules/no-missing-keys-in-other-locales'
1111
import noMissingKeys from './rules/no-missing-keys'
1212
import noRawText from './rules/no-raw-text'
13+
import noUnknownLocale from './rules/no-unknown-locale'
1314
import noUnusedKeys from './rules/no-unused-keys'
1415
import noVHtml from './rules/no-v-html'
1516
import preferLinkedKeyWithParen from './rules/prefer-linked-key-with-paren'
@@ -29,6 +30,7 @@ export = {
2930
'no-missing-keys-in-other-locales': noMissingKeysInOtherLocales,
3031
'no-missing-keys': noMissingKeys,
3132
'no-raw-text': noRawText,
33+
'no-unknown-locale': noUnknownLocale,
3234
'no-unused-keys': noUnusedKeys,
3335
'no-v-html': noVHtml,
3436
'prefer-linked-key-with-paren': preferLinkedKeyWithParen,

lib/rules/no-unknown-locale.ts

+266
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { AST as JSONAST } from 'jsonc-eslint-parser'
2+
import type { AST as YAMLAST } from 'yaml-eslint-parser'
3+
import type { AST as VAST } from 'vue-eslint-parser'
4+
import { extname } from 'path'
5+
import { isLangCode } from 'is-language-code'
6+
import debugBuilder from 'debug'
7+
import type { RuleContext, RuleListener } from '../types'
8+
import { createRule } from '../utils/rule'
9+
import {
10+
getLocaleMessages,
11+
defineCustomBlocksVisitor,
12+
getAttribute
13+
} from '../utils/index'
14+
import type { LocaleMessage } from '../utils/locale-messages'
15+
const debug = debugBuilder('eslint-plugin-vue-i18n:no-unknown-locale')
16+
17+
function create(context: RuleContext): RuleListener {
18+
const filename = context.getFilename()
19+
const locales: string[] = context.options[0]?.locales || []
20+
const disableRFC5646 = context.options[0]?.disableRFC5646 || false
21+
22+
function verifyLocaleCode(
23+
locale: string,
24+
reportNode: JSONAST.JSONNode | YAMLAST.YAMLNode | VAST.VAttribute | null
25+
) {
26+
if (locales.includes(locale)) {
27+
return
28+
}
29+
if (!disableRFC5646 && isLangCode(locale).res) {
30+
return
31+
}
32+
context.report({
33+
message: "'{{locale}}' is unknown locale name",
34+
data: {
35+
locale
36+
},
37+
loc: reportNode?.loc || { line: 1, column: 0 }
38+
})
39+
}
40+
41+
function createVerifyContext<N extends JSONAST.JSONNode | YAMLAST.YAMLNode>(
42+
targetLocaleMessage: LocaleMessage,
43+
block: VAST.VElement | null
44+
) {
45+
type KeyStack =
46+
| {
47+
locale: null
48+
node?: N
49+
upper?: KeyStack
50+
}
51+
| {
52+
locale: string
53+
node?: N
54+
upper?: KeyStack
55+
}
56+
let keyStack: KeyStack
57+
if (targetLocaleMessage.isResolvedLocaleByFileName()) {
58+
const locale = targetLocaleMessage.locales[0]
59+
keyStack = {
60+
locale
61+
}
62+
verifyLocaleCode(locale, block && getAttribute(block, 'locale'))
63+
} else {
64+
keyStack = {
65+
locale: null
66+
}
67+
}
68+
69+
// localeMessages.locales
70+
return {
71+
enterKey(key: string | number, node: N) {
72+
if (keyStack.locale == null) {
73+
const locale = String(key)
74+
keyStack = {
75+
node,
76+
locale,
77+
upper: keyStack
78+
}
79+
verifyLocaleCode(locale, node)
80+
} else {
81+
keyStack = {
82+
node,
83+
locale: keyStack.locale,
84+
upper: keyStack
85+
}
86+
}
87+
},
88+
leaveKey(node: N | null) {
89+
if (keyStack.node === node) {
90+
keyStack = keyStack.upper!
91+
}
92+
}
93+
}
94+
}
95+
96+
/**
97+
* Create node visitor for JSON
98+
*/
99+
function createVisitorForJson(
100+
targetLocaleMessage: LocaleMessage,
101+
block: VAST.VElement | null
102+
): RuleListener {
103+
const ctx = createVerifyContext(targetLocaleMessage, block)
104+
return {
105+
JSONProperty(node: JSONAST.JSONProperty) {
106+
const key =
107+
node.key.type === 'JSONLiteral' ? `${node.key.value}` : node.key.name
108+
109+
ctx.enterKey(key, node.key)
110+
},
111+
'JSONProperty:exit'(node: JSONAST.JSONProperty) {
112+
ctx.leaveKey(node.key)
113+
},
114+
'JSONArrayExpression > *'(
115+
node: JSONAST.JSONArrayExpression['elements'][number] & {
116+
parent: JSONAST.JSONArrayExpression
117+
}
118+
) {
119+
const key = node.parent.elements.indexOf(node)
120+
ctx.enterKey(key, node)
121+
},
122+
'JSONArrayExpression > *:exit'(
123+
node: JSONAST.JSONArrayExpression['elements'][number]
124+
) {
125+
ctx.leaveKey(node)
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Create node visitor for YAML
132+
*/
133+
function createVisitorForYaml(
134+
targetLocaleMessage: LocaleMessage,
135+
block: VAST.VElement | null
136+
): RuleListener {
137+
const yamlKeyNodes = new Set<YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta>()
138+
139+
function withinKey(node: YAMLAST.YAMLNode) {
140+
for (const keyNode of yamlKeyNodes) {
141+
if (
142+
keyNode.range[0] <= node.range[0] &&
143+
node.range[0] < keyNode.range[1]
144+
) {
145+
return true
146+
}
147+
}
148+
return false
149+
}
150+
151+
const ctx = createVerifyContext(targetLocaleMessage, block)
152+
153+
return {
154+
YAMLPair(node: YAMLAST.YAMLPair) {
155+
if (node.key != null) {
156+
if (withinKey(node)) {
157+
return
158+
}
159+
yamlKeyNodes.add(node.key)
160+
}
161+
162+
if (node.key != null && node.key.type === 'YAMLScalar') {
163+
const keyValue = node.key.value
164+
const key = typeof keyValue === 'string' ? keyValue : String(keyValue)
165+
166+
ctx.enterKey(key, node.key)
167+
}
168+
},
169+
'YAMLPair:exit'(node: YAMLAST.YAMLPair) {
170+
if (node.key != null) {
171+
ctx.leaveKey(node.key)
172+
}
173+
},
174+
'YAMLSequence > *'(
175+
node: YAMLAST.YAMLSequence['entries'][number] & {
176+
parent: YAMLAST.YAMLSequence
177+
}
178+
) {
179+
if (withinKey(node)) {
180+
return
181+
}
182+
const key = node.parent.entries.indexOf(node)
183+
ctx.enterKey(key, node)
184+
},
185+
'YAMLSequence > *:exit'(node: YAMLAST.YAMLSequence['entries'][number]) {
186+
ctx.leaveKey(node)
187+
}
188+
}
189+
}
190+
191+
if (extname(filename) === '.vue') {
192+
return defineCustomBlocksVisitor(
193+
context,
194+
ctx => {
195+
const localeMessages = getLocaleMessages(context)
196+
const targetLocaleMessage = localeMessages.findBlockLocaleMessage(
197+
ctx.parserServices.customBlock
198+
)
199+
if (!targetLocaleMessage) {
200+
return {}
201+
}
202+
return createVisitorForJson(
203+
targetLocaleMessage,
204+
ctx.parserServices.customBlock
205+
)
206+
},
207+
ctx => {
208+
const localeMessages = getLocaleMessages(context)
209+
const targetLocaleMessage = localeMessages.findBlockLocaleMessage(
210+
ctx.parserServices.customBlock
211+
)
212+
if (!targetLocaleMessage) {
213+
return {}
214+
}
215+
return createVisitorForYaml(
216+
targetLocaleMessage,
217+
ctx.parserServices.customBlock
218+
)
219+
}
220+
)
221+
} else if (context.parserServices.isJSON || context.parserServices.isYAML) {
222+
const localeMessages = getLocaleMessages(context)
223+
const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename)
224+
if (!targetLocaleMessage) {
225+
debug(`ignore ${filename} in no-unknown-locale`)
226+
return {}
227+
}
228+
229+
if (context.parserServices.isJSON) {
230+
return createVisitorForJson(targetLocaleMessage, null)
231+
} else if (context.parserServices.isYAML) {
232+
return createVisitorForYaml(targetLocaleMessage, null)
233+
}
234+
return {}
235+
} else {
236+
debug(`ignore ${filename} in no-unknown-locale`)
237+
return {}
238+
}
239+
}
240+
241+
export = createRule({
242+
meta: {
243+
type: 'suggestion',
244+
docs: {
245+
description: 'disallow unknown locale name',
246+
category: 'Best Practices',
247+
url: 'https://eslint-plugin-vue-i18n.intlify.dev/rules/no-unknown-locale.html',
248+
recommended: false
249+
},
250+
fixable: null,
251+
schema: [
252+
{
253+
type: 'object',
254+
properties: {
255+
locales: {
256+
type: 'array',
257+
items: { type: 'string' }
258+
},
259+
disableRFC5646: { type: 'boolean' }
260+
},
261+
additionalProperties: false
262+
}
263+
]
264+
},
265+
create
266+
})

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"debug": "^4.3.1",
3131
"glob": "^7.1.3",
3232
"ignore": "^5.0.5",
33+
"is-language-code": "^3.1.0",
3334
"js-yaml": "^4.0.0",
3435
"json5": "^2.1.3",
3536
"jsonc-eslint-parser": "^2.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null

0 commit comments

Comments
 (0)