@discord/eslint-plugin-discord-intl 0.4.2-canary.6e372bd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/index.js +15 -0
- package/lib/traverse.js +146 -0
- package/package.json +16 -0
- package/rules/trimmable-whitespace.js +51 -0
- package/rules/trimmable-whitespace.test.js +133 -0
- package/rules/use-static-access.js +100 -0
- package/rules/use-static-access.test.js +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Discord, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# eslint-plugin-discord-intl
|
|
2
|
+
|
|
3
|
+
An ESLint (v8) plugin for linting intl messages in the `@discord/intl` system. Currently this linter only checks best
|
|
4
|
+
practices for messages like whitespace and access notation in other source files, but will eventually be able to lint
|
|
5
|
+
the content of messages themselves using the database's `validateMessages` API.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
pnpm add -D @discord/eslint-plugin-discord-intl
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
module.exports = {
|
|
17
|
+
// Extend the recommended plugin configuration
|
|
18
|
+
extends: ['plugin:@discord/eslint-plugin-discord-intl/recommended'],
|
|
19
|
+
// Optionally add configuration for all rules
|
|
20
|
+
settings: {
|
|
21
|
+
'@discord/discord-intl': {
|
|
22
|
+
extraImports: {
|
|
23
|
+
'@app/intl': ['t'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
rules: {
|
|
3
|
+
'trimmed-whitespace': require('./rules/trimmable-whitespace'),
|
|
4
|
+
'use-static-access': require('./rules/use-static-access'),
|
|
5
|
+
},
|
|
6
|
+
configs: {
|
|
7
|
+
recommended: {
|
|
8
|
+
plugins: ['@discord/discord-intl'],
|
|
9
|
+
rules: {
|
|
10
|
+
'@discord/discord-intl/trimmed-whitespace': 'error',
|
|
11
|
+
'@discord/discord-intl/use-static-access': 'error',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
package/lib/traverse.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { isMessageDefinitionsFile } = require('@discord/intl-loader-core');
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('eslint').Rule.RuleListener} RuleListener */
|
|
4
|
+
/** @typedef {import('eslint').Rule.RuleContext} RuleContext */
|
|
5
|
+
/** @typedef {import('estree').MemberExpression} MemberExpression */
|
|
6
|
+
/** @typedef {import('estree').BaseModuleSpecifier} BaseModuleSpecifier */
|
|
7
|
+
/** @typedef {import('estree').ObjectExpression} ObjectExpression */
|
|
8
|
+
/** @typedef {import('estree').SimpleLiteral} SimpleLiteral */
|
|
9
|
+
/** @typedef {import('estree').TemplateLiteral} TemplateLiteral */
|
|
10
|
+
/** @typedef {import('estree').Property} Property */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {(node: MemberExpression, importer: BaseModuleSpecifier) => void} MessageAccessCallback
|
|
14
|
+
* @typedef {(
|
|
15
|
+
* property: Property,
|
|
16
|
+
* value: SimpleLiteral | TemplateLiteral,
|
|
17
|
+
* definition: ObjectExpression | undefined,
|
|
18
|
+
* ) => void} MessageDefinitionCallback
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Visit all `MemberExpression`s that act as accesses to intl messages, even if they are not
|
|
23
|
+
* statically analyzable. All accesses on all imported message objects will be traversed in a
|
|
24
|
+
* single pass, invoking `callback` for each one with the MemberExpression node as the first
|
|
25
|
+
* argument, and the specifier that imported the messages object as the second.
|
|
26
|
+
*
|
|
27
|
+
* @param {RuleContext} context The rule context from ESLint
|
|
28
|
+
* @param {MessageAccessCallback} callback Function to call for each instance of a message access
|
|
29
|
+
* @returns {RuleListener} A visitor object for the Babel transform.
|
|
30
|
+
*/
|
|
31
|
+
function traverseMessageAccesses(context, callback) {
|
|
32
|
+
const config = context.settings['@discord/discord-intl'];
|
|
33
|
+
const source = context.sourceCode;
|
|
34
|
+
|
|
35
|
+
return /** @type {RuleListener} */ ({
|
|
36
|
+
ImportDeclaration(path) {
|
|
37
|
+
const importSource = /** @type {string} */ (path.source.value);
|
|
38
|
+
// TODO: Make `isMessageDefinitionsFile` understand this properly.
|
|
39
|
+
const isDefinition =
|
|
40
|
+
isMessageDefinitionsFile(importSource) || importSource.endsWith('.messages');
|
|
41
|
+
const extraImportSpecifiers = config.extraImports?.[importSource] ?? [];
|
|
42
|
+
// This transformer only handles usages of intl messages, so only
|
|
43
|
+
// imports of definitions files and configured extra specifiers need to
|
|
44
|
+
// be handled.
|
|
45
|
+
if (!isDefinition && extraImportSpecifiers.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const specifiers = path.specifiers.filter(
|
|
50
|
+
(specifier) =>
|
|
51
|
+
(isDefinition && specifier.type === 'ImportDefaultSpecifier') ||
|
|
52
|
+
extraImportSpecifiers.includes(specifier.local.name),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
for (const specifier of specifiers) {
|
|
56
|
+
const bindingReferences = source
|
|
57
|
+
.getDeclaredVariables(specifier)
|
|
58
|
+
// This should only ever yield 1 variable, so flatMap is unnecessary, but it is safer to
|
|
59
|
+
// use in case this changes in the future.
|
|
60
|
+
.flatMap((variable) => variable.references);
|
|
61
|
+
|
|
62
|
+
for (const reference of bindingReferences) {
|
|
63
|
+
// @ts-expect-error `identifier` is actually `Identifier & NodeParentExtension`.
|
|
64
|
+
const parent = reference.identifier.parent;
|
|
65
|
+
|
|
66
|
+
// We only care about member expressions, since a direct reference to the
|
|
67
|
+
// message source doesn't necessarily make it a message access.
|
|
68
|
+
if (parent.type !== 'MemberExpression') continue;
|
|
69
|
+
|
|
70
|
+
callback(/** @type {MemberExpression} */ parent, specifier);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Visit all Message definitions in the file, invoking `callback` for each one with the string
|
|
79
|
+
* value node as the first argument and, if present, the full definition object as the second
|
|
80
|
+
* argument.
|
|
81
|
+
*
|
|
82
|
+
* @param {RuleContext} context The rule context from ESLint
|
|
83
|
+
* @param {MessageDefinitionCallback} callback Function to call for each message definition.
|
|
84
|
+
* @returns {RuleListener} A visitor object for the Babel transform.
|
|
85
|
+
*/
|
|
86
|
+
function traverseMessageDefinitions(context, callback) {
|
|
87
|
+
const source = context.sourceCode;
|
|
88
|
+
|
|
89
|
+
return /** @type {RuleListener} */ ({
|
|
90
|
+
CallExpression(path) {
|
|
91
|
+
// Only look at direct `defineMessages` function calls.
|
|
92
|
+
if (path.callee.type !== 'Identifier' || path.callee.name !== 'defineMessages') return;
|
|
93
|
+
|
|
94
|
+
// This is a very roundabout way of determining that the current CallExpression is calling
|
|
95
|
+
// the `defineMessages` function that was directly imported from `@discord/intl`.
|
|
96
|
+
const importingDefinitionSource = source
|
|
97
|
+
.getScope(path)
|
|
98
|
+
.variables[0].defs.find((definition) => definition.type === 'ImportBinding')?.parent
|
|
99
|
+
.source.value;
|
|
100
|
+
if (importingDefinitionSource !== '@discord/intl') return;
|
|
101
|
+
|
|
102
|
+
const definitionsObject = path.arguments[0];
|
|
103
|
+
// This early return assumes something else can/will assert that the call expression is valid.
|
|
104
|
+
// In almost every case, that's TypeScript, so we don't need to report on it twice.
|
|
105
|
+
if (definitionsObject.type !== 'ObjectExpression') return;
|
|
106
|
+
|
|
107
|
+
for (const property of definitionsObject.properties) {
|
|
108
|
+
// Spreads aren't analyzable here, and probably shouldn't be allowed.
|
|
109
|
+
// TODO: Apply a lint when a Spread is encountered? Or follow it to the definition.
|
|
110
|
+
if (property.type === 'SpreadElement') continue;
|
|
111
|
+
|
|
112
|
+
switch (property.value.type) {
|
|
113
|
+
case 'Literal':
|
|
114
|
+
case 'TemplateLiteral':
|
|
115
|
+
callback(
|
|
116
|
+
property,
|
|
117
|
+
/** @type {SimpleLiteral | TemplateLiteral} */ (property.value),
|
|
118
|
+
undefined,
|
|
119
|
+
);
|
|
120
|
+
break;
|
|
121
|
+
case 'ObjectExpression': {
|
|
122
|
+
console.log('got expression');
|
|
123
|
+
const messageProperty = property.value.properties.find((prop) => {
|
|
124
|
+
return prop.type === 'Property' && 'name' in prop.key && prop.key.name === 'message';
|
|
125
|
+
});
|
|
126
|
+
if (
|
|
127
|
+
messageProperty == null ||
|
|
128
|
+
messageProperty.type === 'SpreadElement' ||
|
|
129
|
+
messageProperty.value.type !== 'Literal'
|
|
130
|
+
) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
callback(
|
|
135
|
+
property,
|
|
136
|
+
/** @type {SimpleLiteral} */ (messageProperty.value),
|
|
137
|
+
property.value,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { traverseMessageAccesses, traverseMessageDefinitions };
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@discord/eslint-plugin-discord-intl",
|
|
3
|
+
"version": "0.4.2-canary.6e372bd",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"description": "ESLint plugin for validating and linting messages and translations using @discord/intl",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@discord/intl-loader-core": "0.4.2"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/eslint": "^8.56.12",
|
|
13
|
+
"@types/estree": "^1.0.6",
|
|
14
|
+
"eslint": "8.57.1"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const util = require('node:util');
|
|
2
|
+
const { traverseMessageDefinitions } = require('../lib/traverse');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} raw
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
function trimLeadingWhitespace(raw) {
|
|
9
|
+
return raw.replace(/^(\s|\\[rtn])+/, '');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} raw
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function trimTrailingWhitespace(raw) {
|
|
16
|
+
return raw.replace(/(\s|\\[rtn])+$/, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = /** @type {import('eslint').Rule.RuleModule} */ ({
|
|
20
|
+
meta: {
|
|
21
|
+
fixable: 'code',
|
|
22
|
+
docs: {
|
|
23
|
+
description: 'Disallow whitespace at the beginning and end of intl messages',
|
|
24
|
+
category: 'Best Practices',
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
noWhitespace:
|
|
28
|
+
'Do not add leading/trailing whitespace to messages. It can lead to inconsistency and ambiguity during translation',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
return traverseMessageDefinitions(context, (_property, value, _definition) => {
|
|
33
|
+
const sourceText = context.sourceCode.getText(value);
|
|
34
|
+
const quote = sourceText[0];
|
|
35
|
+
const rawNoQuotes = sourceText.slice(1, -1);
|
|
36
|
+
|
|
37
|
+
const trimmed = trimLeadingWhitespace(trimTrailingWhitespace(rawNoQuotes));
|
|
38
|
+
util.inspect(rawNoQuotes);
|
|
39
|
+
util.inspect(trimmed);
|
|
40
|
+
if (trimmed === rawNoQuotes) return;
|
|
41
|
+
|
|
42
|
+
context.report({
|
|
43
|
+
node: value,
|
|
44
|
+
messageId: 'noWhitespace',
|
|
45
|
+
fix(fixer) {
|
|
46
|
+
return [fixer.replaceText(value, `${quote}${trimmed}${quote}`)];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// enforce-foo-bar.test.js
|
|
2
|
+
const { RuleTester } = require('eslint');
|
|
3
|
+
const trimmableWhitespace = require('./trimmable-whitespace');
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester({
|
|
6
|
+
// Must use at least ecmaVersion 2015 because
|
|
7
|
+
// that's when `const` variables were introduced.
|
|
8
|
+
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} messages
|
|
13
|
+
*/
|
|
14
|
+
function defineMessages(messages) {
|
|
15
|
+
return `
|
|
16
|
+
import {defineMessages} from '@discord/intl';
|
|
17
|
+
|
|
18
|
+
export default defineMessages(${messages});
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ruleTester.run('trimmable-whitespace', trimmableWhitespace, {
|
|
23
|
+
valid: [
|
|
24
|
+
{
|
|
25
|
+
name: 'normal strings',
|
|
26
|
+
code: defineMessages("{ A: 'no trimmable whitespace' }"),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'templates',
|
|
30
|
+
code: defineMessages(
|
|
31
|
+
'{ A: `no trimmable whitespace`, QUASI: `${ space }`, MULTILINE: `hi\n yes` }',
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'multi-line',
|
|
36
|
+
code: defineMessages(
|
|
37
|
+
`{ A: \`no trimmable
|
|
38
|
+
whitespace\`}`,
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'object',
|
|
43
|
+
code: defineMessages(
|
|
44
|
+
'{ A: { message: "no whitespace", description: " does not matter here " }}',
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
invalid: [
|
|
49
|
+
{
|
|
50
|
+
code: defineMessages(`{
|
|
51
|
+
A: ' leading whitespace',
|
|
52
|
+
B: 'trailing whitespace ',
|
|
53
|
+
C: ' surrounding whitespace ',
|
|
54
|
+
}`),
|
|
55
|
+
output: defineMessages(`{
|
|
56
|
+
A: 'leading whitespace',
|
|
57
|
+
B: 'trailing whitespace',
|
|
58
|
+
C: 'surrounding whitespace',
|
|
59
|
+
}`),
|
|
60
|
+
errors: 3,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
code: defineMessages(`{
|
|
64
|
+
TABS: '\tleading whitespace',
|
|
65
|
+
B: 'trailing whitespace\t',
|
|
66
|
+
C: '\tsurrounding whitespace\t',
|
|
67
|
+
}`),
|
|
68
|
+
output: defineMessages(`{
|
|
69
|
+
TABS: 'leading whitespace',
|
|
70
|
+
B: 'trailing whitespace',
|
|
71
|
+
C: 'surrounding whitespace',
|
|
72
|
+
}`),
|
|
73
|
+
errors: 3,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
code: defineMessages(`{
|
|
77
|
+
NEWLINES: '\\nleading whitespace',
|
|
78
|
+
B: 'trailing whitespace\\n',
|
|
79
|
+
C: '\\nsurrounding whitespace\\n',
|
|
80
|
+
}`),
|
|
81
|
+
output: defineMessages(`{
|
|
82
|
+
NEWLINES: 'leading whitespace',
|
|
83
|
+
B: 'trailing whitespace',
|
|
84
|
+
C: 'surrounding whitespace',
|
|
85
|
+
}`),
|
|
86
|
+
errors: 3,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
code: defineMessages(`{
|
|
90
|
+
MIXED: '\\n \\t leading\\n \\twhitespace \\t\\n',
|
|
91
|
+
}`),
|
|
92
|
+
output: defineMessages(`{
|
|
93
|
+
MIXED: 'leading\\n \\twhitespace',
|
|
94
|
+
}`),
|
|
95
|
+
errors: 1,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
code: defineMessages(`{
|
|
99
|
+
ONLY_BROKEN: '\\n \\t leading\\n \\twhitespace \\t\\n',
|
|
100
|
+
VALID: 'valid string',
|
|
101
|
+
}`),
|
|
102
|
+
output: defineMessages(`{
|
|
103
|
+
ONLY_BROKEN: 'leading\\n \\twhitespace',
|
|
104
|
+
VALID: 'valid string',
|
|
105
|
+
}`),
|
|
106
|
+
errors: 1,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'template quasis',
|
|
110
|
+
code: defineMessages('{ A: `no trimmable whitespace`, QUASI: ` ${ space } ` }'),
|
|
111
|
+
output: defineMessages('{ A: `no trimmable whitespace`, QUASI: `${ space }` }'),
|
|
112
|
+
errors: 1,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'multiline templates',
|
|
116
|
+
code: defineMessages('{ A: `no trimmable whitespace`, QUASI: `\n\t${ space }\n ` }'),
|
|
117
|
+
output: defineMessages('{ A: `no trimmable whitespace`, QUASI: `${ space }` }'),
|
|
118
|
+
errors: 1,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'object',
|
|
122
|
+
code: defineMessages(
|
|
123
|
+
'{ A: { message: " no whitespace", description: " does not matter here " }}',
|
|
124
|
+
),
|
|
125
|
+
output: defineMessages(
|
|
126
|
+
'{ A: { message: "no whitespace", description: " does not matter here " }}',
|
|
127
|
+
),
|
|
128
|
+
errors: 1,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log('All tests passed!');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const { traverseMessageAccesses } = require('../lib/traverse');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output from import('identifier-regex').then((regex) => regex()). Used
|
|
5
|
+
* directly because ESLint 8 doesn't support ESM plugins nicely, so we
|
|
6
|
+
* have to stick in common JS.
|
|
7
|
+
*/
|
|
8
|
+
const IDENTIFIER_REGEX =
|
|
9
|
+
/^(?!await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|false|finally|for|function|if|import|in|instanceof|new|null|return|super|switch|this|throw|true|try|typeof|var|void|while|with|yield|implements|interface|package|private|protected|public|arguments|eval|globalThis|Infinity|NaN|undefined)[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns true if the given value is able to be written as an identifier rather than requiring a
|
|
13
|
+
* string container.
|
|
14
|
+
* @param {string} value
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
function isValidIdentifier(value) {
|
|
18
|
+
return IDENTIFIER_REGEX.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = /** @type {import('eslint').Rule.RuleModule} */ ({
|
|
22
|
+
meta: {
|
|
23
|
+
fixable: 'code',
|
|
24
|
+
docs: {
|
|
25
|
+
description:
|
|
26
|
+
'Ensure Intl Message accesses are statically analyzable by requiring dot notation, rather than computed accesses with `[]` brackets, using variables for message names, or interpolated template strings.',
|
|
27
|
+
category: 'Best Practices',
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
computedAccess:
|
|
31
|
+
'Messages should only be accessed with dot notation to be properly transformed during bundling',
|
|
32
|
+
nonStringKey:
|
|
33
|
+
'Message objects only contain names as keys. Any other type will always be incorrect',
|
|
34
|
+
invalidName: 'Message names must be valid identifiers for use with dot access notation',
|
|
35
|
+
interpolatedTemplate:
|
|
36
|
+
'Template strings should not be used to access messages. Use a static name with dot access instead',
|
|
37
|
+
staticTemplate:
|
|
38
|
+
'Avoid template strings for message accesses to ensure they are always static',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
create(context) {
|
|
43
|
+
return traverseMessageAccesses(context, (node) => {
|
|
44
|
+
// If it's already using dot access, then every condition must already be met.
|
|
45
|
+
if (!node.computed) return;
|
|
46
|
+
|
|
47
|
+
const receiver = context.sourceCode.getText(node.object);
|
|
48
|
+
const property = node.property;
|
|
49
|
+
|
|
50
|
+
if (property.type === 'TemplateLiteral') {
|
|
51
|
+
if (property.quasis.length === 1) {
|
|
52
|
+
const messageName = property.quasis[0].value.raw;
|
|
53
|
+
if (!isValidIdentifier(messageName)) {
|
|
54
|
+
context.report({ node: property, messageId: 'invalidName' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
context.report({
|
|
58
|
+
node,
|
|
59
|
+
messageId: 'computedAccess',
|
|
60
|
+
fix(fixer) {
|
|
61
|
+
return fixer.replaceText(node, `${receiver}.${messageName}`);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'interpolatedTemplate',
|
|
68
|
+
// Not fixable because the interpolation isn't knowable at compile time.
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (property.type === 'Literal') {
|
|
75
|
+
if (typeof property.value !== 'string') {
|
|
76
|
+
context.report({ node: property, messageId: 'nonStringKey' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const messageName = property.value;
|
|
80
|
+
if (!isValidIdentifier(messageName)) {
|
|
81
|
+
context.report({ node: property, messageId: 'invalidName' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
context.report({
|
|
85
|
+
node,
|
|
86
|
+
messageId: 'computedAccess',
|
|
87
|
+
fix(fixer) {
|
|
88
|
+
return fixer.replaceText(node, `${receiver}.${messageName}`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
context.report({
|
|
95
|
+
node,
|
|
96
|
+
messageId: 'nonStringKey',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// enforce-foo-bar.test.js
|
|
2
|
+
const { RuleTester } = require('eslint');
|
|
3
|
+
const useStaticAccess = require('./use-static-access');
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester({
|
|
6
|
+
// Must use at least ecmaVersion 2015 because
|
|
7
|
+
// that's when `const` variables were introduced.
|
|
8
|
+
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
ruleTester.run('use-static-access', useStaticAccess, {
|
|
12
|
+
valid: [
|
|
13
|
+
{
|
|
14
|
+
name: 'non-messages expressions',
|
|
15
|
+
code: `
|
|
16
|
+
// All valid because they aren't message identifiers
|
|
17
|
+
something['WHATEVER'];
|
|
18
|
+
foo['bar'];
|
|
19
|
+
t['correct'];
|
|
20
|
+
`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'dot-access message accesses',
|
|
24
|
+
code: `
|
|
25
|
+
import messages from 'Feature.messages';
|
|
26
|
+
messages.SOME_MESSAGE;
|
|
27
|
+
messages.SOME_MESSAGE['afterward'];
|
|
28
|
+
parent[messages.SOME_MESSAGE];
|
|
29
|
+
parent.call(messages.SOME_MESSAGE);
|
|
30
|
+
`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'extra imports',
|
|
34
|
+
settings: {
|
|
35
|
+
extraImports: {
|
|
36
|
+
'@app/intl': ['t'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
code: `
|
|
40
|
+
import {t} from '@app/intl';
|
|
41
|
+
t.SOME_MESSAGE;
|
|
42
|
+
t.SOME_MESSAGE['afterward'];
|
|
43
|
+
parent[t.SOME_MESSAGE];
|
|
44
|
+
parent.call(t.SOME_MESSAGE);
|
|
45
|
+
`,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
invalid: [
|
|
49
|
+
{
|
|
50
|
+
name: 'computed literal access',
|
|
51
|
+
code: `
|
|
52
|
+
import messages from 'Feature.messages';
|
|
53
|
+
messages['SOME_MESSAGE'];
|
|
54
|
+
`,
|
|
55
|
+
output: `
|
|
56
|
+
import messages from 'Feature.messages';
|
|
57
|
+
messages.SOME_MESSAGE;
|
|
58
|
+
`,
|
|
59
|
+
errors: 1,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'static template literal access',
|
|
63
|
+
code: `
|
|
64
|
+
import messages from 'Feature.messages';
|
|
65
|
+
messages[\`SOME_MESSAGE\`];
|
|
66
|
+
`,
|
|
67
|
+
output: `
|
|
68
|
+
import messages from 'Feature.messages';
|
|
69
|
+
messages.SOME_MESSAGE;
|
|
70
|
+
`,
|
|
71
|
+
errors: 1,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'dynamic template literal access',
|
|
75
|
+
code: `
|
|
76
|
+
import messages from 'Feature.messages';
|
|
77
|
+
messages[\`SCOPE_\${name}\`];
|
|
78
|
+
`,
|
|
79
|
+
errors: 1,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'variable access',
|
|
83
|
+
code: `
|
|
84
|
+
import messages from 'Feature.messages';
|
|
85
|
+
messages[someVariable];
|
|
86
|
+
`,
|
|
87
|
+
errors: 1,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'invalid identifier access',
|
|
91
|
+
code: `
|
|
92
|
+
import messages from 'Feature.messages';
|
|
93
|
+
messages['not an identifier'];
|
|
94
|
+
`,
|
|
95
|
+
errors: 1,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'keyword access',
|
|
99
|
+
code: `
|
|
100
|
+
import messages from 'Feature.messages';
|
|
101
|
+
messages['return'];
|
|
102
|
+
`,
|
|
103
|
+
errors: 1,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
console.log('All tests passed!');
|