i18n-smell-detector 0.1.1 → 0.2.0
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/README.md +38 -10
- package/package.json +2 -2
- package/src/config.js +46 -0
- package/src/reporters/console.js +3 -11
- package/src/reporters/json.js +3 -1
- package/src/reporters/markdown.js +9 -1
- package/src/reporters/summary.js +9 -0
- package/src/rules/classify-identical.js +20 -15
- package/src/rules/match-pattern.js +2 -5
- package/src/rules/match-rule.js +21 -0
- package/src/types.js +4 -2
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ export default {
|
|
|
67
67
|
|
|
68
68
|
allowIdenticalKeys: [
|
|
69
69
|
'brand.*',
|
|
70
|
-
|
|
70
|
+
/^protocol\./,
|
|
71
71
|
'common.ok',
|
|
72
72
|
'common.id'
|
|
73
73
|
],
|
|
@@ -75,12 +75,21 @@ export default {
|
|
|
75
75
|
allowIdenticalValues: [
|
|
76
76
|
'OK',
|
|
77
77
|
'ID',
|
|
78
|
-
|
|
78
|
+
/^HTTP\/\d(\.\d)?$/,
|
|
79
79
|
'API',
|
|
80
80
|
'GET',
|
|
81
81
|
'POST'
|
|
82
82
|
],
|
|
83
83
|
|
|
84
|
+
placeholderPatterns: [
|
|
85
|
+
'\\{[^}]+\\}',
|
|
86
|
+
'\\{\\{[^}]+\\}\\}',
|
|
87
|
+
'%[sdif]',
|
|
88
|
+
'%\\([^)]+\\)[sdif]',
|
|
89
|
+
'\\$\\d+'
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
ignoreCodeLike: true,
|
|
84
93
|
ignoreSameLanguageFamily: true,
|
|
85
94
|
trimWhitespace: true,
|
|
86
95
|
ignoreCase: false,
|
|
@@ -95,8 +104,10 @@ export default {
|
|
|
95
104
|
|---|---|---:|---|
|
|
96
105
|
| `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
|
|
97
106
|
| `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
|
|
98
|
-
| `allowIdenticalKeys` | `string[]` | `[]` | Key
|
|
99
|
-
| `allowIdenticalValues` | `string[]` | `[]` |
|
|
107
|
+
| `allowIdenticalKeys` | `(string \| RegExp)[]` | `[]` | Key rules that are allowed to match the base locale. String rules support `*` wildcards, for example `brand.*`. `RegExp` rules are tested against the flattened key. |
|
|
108
|
+
| `allowIdenticalValues` | `(string \| RegExp)[]` | `[]` | Value rules that are allowed to match the base locale. String rules are exact matches, for example `OK`, `API`, or `GET`. `RegExp` rules are tested against the full value. |
|
|
109
|
+
| `placeholderPatterns` | `(string \| RegExp)[]` | `{...}` and `{{...}}` patterns | Placeholder patterns ignored when the whole value is only placeholders and whitespace. String entries must be valid regular expression sources. |
|
|
110
|
+
| `ignoreCodeLike` | `boolean` | `true` | Ignore built-in code-like values such as paths, colors, acronyms, and identifiers. Set to `false` to report them. |
|
|
100
111
|
| `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
|
|
101
112
|
| `trimWhitespace` | `boolean` | `true` | Trim leading and trailing whitespace before comparing values. |
|
|
102
113
|
| `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
|
|
@@ -124,10 +135,16 @@ MEDIUM ja.form.search
|
|
|
124
135
|
|
|
125
136
|
## JSON output
|
|
126
137
|
|
|
127
|
-
`--format json` prints an object with an `issues` array:
|
|
138
|
+
`--format json` prints an object with a `summary` and an `issues` array:
|
|
128
139
|
|
|
129
140
|
```json
|
|
130
141
|
{
|
|
142
|
+
"summary": {
|
|
143
|
+
"high": 1,
|
|
144
|
+
"medium": 0,
|
|
145
|
+
"low": 0,
|
|
146
|
+
"ignored": 16
|
|
147
|
+
},
|
|
131
148
|
"issues": [
|
|
132
149
|
{
|
|
133
150
|
"key": "home.welcome",
|
|
@@ -143,6 +160,8 @@ MEDIUM ja.form.search
|
|
|
143
160
|
|
|
144
161
|
Fields:
|
|
145
162
|
|
|
163
|
+
Summary counts include all findings, including ignored findings that may be omitted from the visible `issues` array.
|
|
164
|
+
|
|
146
165
|
| Field | Type | Description |
|
|
147
166
|
|---|---|---|
|
|
148
167
|
| `key` | `string` | Flattened locale key, such as `settings.profile.title`. |
|
|
@@ -169,8 +188,8 @@ Ignored issues are omitted by default. Pass `--include-ignored` to include them
|
|
|
169
188
|
| Key matches `allowIdenticalKeys` | ignored |
|
|
170
189
|
| Value matches `allowIdenticalValues` | ignored |
|
|
171
190
|
| Same language family, such as `en` and `en-GB` | ignored |
|
|
172
|
-
| URL, path, color, or code-like value | ignored |
|
|
173
191
|
| Placeholder-only value, such as `{count}` | ignored |
|
|
192
|
+
| URL, path, color, or code-like value | ignored by default |
|
|
174
193
|
| Short common label | low |
|
|
175
194
|
| Single English word | medium |
|
|
176
195
|
| English phrase | high |
|
|
@@ -189,13 +208,19 @@ export default {
|
|
|
189
208
|
allowIdenticalKeys: [
|
|
190
209
|
'brand.*',
|
|
191
210
|
'legal.name',
|
|
192
|
-
|
|
211
|
+
/^protocol\./
|
|
193
212
|
],
|
|
194
213
|
allowIdenticalValues: [
|
|
195
214
|
'OK',
|
|
196
215
|
'ID',
|
|
197
216
|
'API',
|
|
198
|
-
|
|
217
|
+
/^HTTP\/\d(\.\d)?$/
|
|
218
|
+
],
|
|
219
|
+
placeholderPatterns: [
|
|
220
|
+
'\\{[^}]+\\}',
|
|
221
|
+
'\\{\\{[^}]+\\}\\}',
|
|
222
|
+
'%[sdif]',
|
|
223
|
+
'%\\([^)]+\\)[sdif]'
|
|
199
224
|
],
|
|
200
225
|
failOn: 'high'
|
|
201
226
|
};
|
|
@@ -204,7 +229,10 @@ export default {
|
|
|
204
229
|
Useful tuning patterns:
|
|
205
230
|
|
|
206
231
|
- Use `allowIdenticalKeys` for groups of expected identical values, such as names, legal labels, and protocol keys.
|
|
207
|
-
- Use `
|
|
232
|
+
- Use string `allowIdenticalKeys` entries for exact or wildcard key matches, and `RegExp` entries for broader key families.
|
|
233
|
+
- Use string `allowIdenticalValues` entries for exact shared labels, and `RegExp` entries for structured values such as `HTTP/2`.
|
|
234
|
+
- Add `placeholderPatterns` for framework-specific placeholders such as `%s`, `%(name)s`, or `$1`. Mixed content such as `Total: {count}` is still reported.
|
|
235
|
+
- Set `ignoreCodeLike: false` if paths, colors, acronyms, or identifiers should be shown as findings.
|
|
208
236
|
- Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
|
|
209
237
|
- Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
|
|
210
238
|
- Pass `--include-ignored` when reviewing why a value was ignored.
|
|
@@ -225,4 +253,4 @@ npm run check
|
|
|
225
253
|
|
|
226
254
|
## License
|
|
227
255
|
|
|
228
|
-
MIT
|
|
256
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-smell-detector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Find locale values copied from the base locale.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"check": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs",
|
|
24
|
+
"check": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
25
25
|
"test": "node --test"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
package/src/config.js
CHANGED
|
@@ -12,6 +12,11 @@ const CONFIG_FILES = [
|
|
|
12
12
|
'i18n-smell.config.json',
|
|
13
13
|
];
|
|
14
14
|
|
|
15
|
+
const DEFAULT_PLACEHOLDER_PATTERNS = [
|
|
16
|
+
String.raw`\{\{[^}]+\}\}`,
|
|
17
|
+
String.raw`\{[^}]+\}`,
|
|
18
|
+
];
|
|
19
|
+
|
|
15
20
|
async function exists(filePath) {
|
|
16
21
|
try {
|
|
17
22
|
await access(filePath);
|
|
@@ -69,6 +74,13 @@ function validateConfig(config) {
|
|
|
69
74
|
throw new Error(`Config locales.${locale} must be a file path string`);
|
|
70
75
|
}
|
|
71
76
|
}
|
|
77
|
+
|
|
78
|
+
validateRuleList(candidate.allowIdenticalKeys, 'allowIdenticalKeys');
|
|
79
|
+
validateRuleList(candidate.allowIdenticalValues, 'allowIdenticalValues');
|
|
80
|
+
|
|
81
|
+
if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
|
|
82
|
+
throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
/** @param {import('./types.js').DetectorConfig} config */
|
|
@@ -84,5 +96,39 @@ function withDefaults(config) {
|
|
|
84
96
|
includeIgnored: false,
|
|
85
97
|
failOn: 'high',
|
|
86
98
|
...config,
|
|
99
|
+
placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
|
|
100
|
+
ignoreCodeLike: config.ignoreCodeLike ?? true,
|
|
87
101
|
};
|
|
88
102
|
}
|
|
103
|
+
|
|
104
|
+
function validateRuleList(rules, name) {
|
|
105
|
+
if (rules === undefined) return;
|
|
106
|
+
if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
|
|
107
|
+
|
|
108
|
+
for (const rule of rules) {
|
|
109
|
+
if (typeof rule !== 'string' && !(rule instanceof RegExp)) {
|
|
110
|
+
throw new Error(`Config ${name} entries must be strings or RegExp objects`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizePlaceholderPatterns(patterns) {
|
|
116
|
+
return patterns.map((pattern) => {
|
|
117
|
+
if (pattern instanceof RegExp) return withGlobalFlag(pattern);
|
|
118
|
+
|
|
119
|
+
if (typeof pattern !== 'string') {
|
|
120
|
+
throw new Error('Config placeholderPatterns entries must be strings or RegExp objects');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
return new RegExp(pattern, 'g');
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`Invalid placeholder pattern "${pattern}": ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function withGlobalFlag(pattern) {
|
|
132
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
133
|
+
return new RegExp(pattern.source, flags);
|
|
134
|
+
}
|
package/src/reporters/console.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { summarizeIssues } from './summary.js';
|
|
2
|
+
|
|
1
3
|
function color(code, text) {
|
|
2
4
|
if (process.env.NO_COLOR) return text;
|
|
3
5
|
return `\u001b[${code}m${text}\u001b[0m`;
|
|
@@ -17,7 +19,7 @@ function preview(value) {
|
|
|
17
19
|
|
|
18
20
|
export function renderConsoleReport(issues, options) {
|
|
19
21
|
const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
20
|
-
const counts =
|
|
22
|
+
const counts = summarizeIssues(issues);
|
|
21
23
|
const lines = [
|
|
22
24
|
color('1', 'i18n-smell-detector: identical translations'),
|
|
23
25
|
`high=${counts.high} medium=${counts.medium} low=${counts.low} ignored=${counts.ignored}`,
|
|
@@ -37,13 +39,3 @@ export function renderConsoleReport(issues, options) {
|
|
|
37
39
|
|
|
38
40
|
return lines.join('\n');
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
function countBySeverity(issues) {
|
|
42
|
-
return issues.reduce(
|
|
43
|
-
(acc, issue) => {
|
|
44
|
-
acc[issue.severity] += 1;
|
|
45
|
-
return acc;
|
|
46
|
-
},
|
|
47
|
-
{ high: 0, medium: 0, low: 0, ignored: 0 }
|
|
48
|
-
);
|
|
49
|
-
}
|
package/src/reporters/json.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { summarizeIssues } from './summary.js';
|
|
2
|
+
|
|
1
3
|
export function renderJsonReport(issues, options) {
|
|
2
4
|
const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
3
|
-
return `${JSON.stringify({ issues: visible }, null, 2)}\n`;
|
|
5
|
+
return `${JSON.stringify({ summary: summarizeIssues(issues), issues: visible }, null, 2)}\n`;
|
|
4
6
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { summarizeIssues } from './summary.js';
|
|
2
|
+
|
|
1
3
|
function escapeCell(value) {
|
|
2
4
|
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
3
5
|
}
|
|
4
6
|
|
|
5
7
|
export function renderMarkdownReport(issues, options) {
|
|
6
8
|
const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
7
|
-
const
|
|
9
|
+
const summary = summarizeIssues(issues);
|
|
10
|
+
const lines = [
|
|
11
|
+
'# Identical translations',
|
|
12
|
+
'',
|
|
13
|
+
`high=${summary.high} medium=${summary.medium} low=${summary.low} ignored=${summary.ignored}`,
|
|
14
|
+
'',
|
|
15
|
+
];
|
|
8
16
|
|
|
9
17
|
if (visible.length === 0) {
|
|
10
18
|
lines.push('No copied base-locale values found.');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { matchesAnyRule } from './match-rule.js';
|
|
2
2
|
|
|
3
3
|
function isBlank(value) {
|
|
4
4
|
return value.trim().length === 0;
|
|
@@ -18,12 +18,17 @@ function isCodeLike(value) {
|
|
|
18
18
|
|| (/^[a-z0-9_.:-]+$/.test(text) && !/\s/.test(text) && !/[A-Z]/.test(text));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function isPlaceholderOnly(value) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
function isPlaceholderOnly(value, patterns) {
|
|
22
|
+
let text = value.trim();
|
|
23
|
+
|
|
24
|
+
const orderedPatterns = [...(patterns || [])].sort((a, b) => b.source.length - a.source.length);
|
|
25
|
+
|
|
26
|
+
for (const pattern of orderedPatterns) {
|
|
27
|
+
pattern.lastIndex = 0;
|
|
28
|
+
text = text.replace(pattern, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return text.trim() === '';
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
function getLatinWords(value) {
|
|
@@ -49,22 +54,22 @@ function isShortCommonLabel(value) {
|
|
|
49
54
|
* @returns {{ severity: import('../types.js').Severity, reason: string }}
|
|
50
55
|
*/
|
|
51
56
|
export function classifyIdentical({ key, value, baseLocale, targetLocale, config }) {
|
|
52
|
-
if ((config.
|
|
53
|
-
return { severity: 'ignored', reason: 'same language family' };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (matchesAnyPattern(key, config.allowIdenticalKeys || [])) {
|
|
57
|
+
if (matchesAnyRule(key, config.allowIdenticalKeys || [], { wildcard: true })) {
|
|
57
58
|
return { severity: 'ignored', reason: 'allowed key' };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
if ((config.allowIdenticalValues || [])
|
|
61
|
+
if (matchesAnyRule(value, config.allowIdenticalValues || [])) {
|
|
61
62
|
return { severity: 'ignored', reason: 'allowed value' };
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
if ((config.ignoreSameLanguageFamily ?? true) && languageFamily(baseLocale) === languageFamily(targetLocale)) {
|
|
66
|
+
return { severity: 'ignored', reason: 'same language family' };
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
if (isBlank(value)) return { severity: 'ignored', reason: 'blank value' };
|
|
70
|
+
if (isPlaceholderOnly(value, config.placeholderPatterns || [])) return { severity: 'ignored', reason: 'placeholder only' };
|
|
65
71
|
if (isExternalReference(value)) return { severity: 'ignored', reason: 'external reference' };
|
|
66
|
-
if (
|
|
67
|
-
if (isCodeLike(value)) return { severity: 'ignored', reason: 'code-like value' };
|
|
72
|
+
if ((config.ignoreCodeLike ?? true) && isCodeLike(value)) return { severity: 'ignored', reason: 'code-like value' };
|
|
68
73
|
if (isShortCommonLabel(value)) return { severity: 'low', reason: 'short common label' };
|
|
69
74
|
|
|
70
75
|
const words = getLatinWords(value);
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
3
|
-
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
4
|
-
}
|
|
1
|
+
import { matchesAnyRule } from './match-rule.js';
|
|
5
2
|
|
|
6
3
|
export function matchesAnyPattern(value, patterns = []) {
|
|
7
|
-
return
|
|
4
|
+
return matchesAnyRule(value, patterns, { wildcard: true });
|
|
8
5
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function wildcardToRegExp(pattern) {
|
|
2
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
3
|
+
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function testRegExp(value, pattern) {
|
|
7
|
+
pattern.lastIndex = 0;
|
|
8
|
+
return pattern.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function matchesRule(value, rule, options = {}) {
|
|
12
|
+
if (rule instanceof RegExp) return testRegExp(value, rule);
|
|
13
|
+
if (typeof rule !== 'string') return false;
|
|
14
|
+
|
|
15
|
+
if (options.wildcard) return wildcardToRegExp(rule).test(value);
|
|
16
|
+
return value === rule;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function matchesAnyRule(value, rules = [], options = {}) {
|
|
20
|
+
return rules.some((rule) => matchesRule(value, rule, options));
|
|
21
|
+
}
|
package/src/types.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* @typedef {Object} DetectorConfig
|
|
6
6
|
* @property {string} baseLocale
|
|
7
7
|
* @property {Record<string, string>} locales
|
|
8
|
-
* @property {string[]=} allowIdenticalKeys
|
|
9
|
-
* @property {string[]=} allowIdenticalValues
|
|
8
|
+
* @property {(string | RegExp)[]=} allowIdenticalKeys
|
|
9
|
+
* @property {(string | RegExp)[]=} allowIdenticalValues
|
|
10
|
+
* @property {(string | RegExp)[]=} placeholderPatterns
|
|
11
|
+
* @property {boolean=} ignoreCodeLike
|
|
10
12
|
* @property {boolean=} ignoreSameLanguageFamily
|
|
11
13
|
* @property {boolean=} trimWhitespace
|
|
12
14
|
* @property {boolean=} ignoreCase
|