i18n-smell-detector 0.2.0 → 0.3.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 +78 -1
- package/package.json +9 -2
- package/src/check-hardcoded.js +37 -0
- package/src/cli.js +13 -4
- package/src/config.js +52 -0
- package/src/reporters/console.js +8 -3
- package/src/reporters/markdown.js +24 -7
- package/src/rules/classify-hardcoded.js +54 -0
- package/src/source/scan-vue-sfc.js +26 -0
- package/src/source/scan-vue-template.js +105 -0
- package/src/types.js +11 -0
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
Find locale values copied from the base locale.
|
|
8
8
|
|
|
9
|
-
`i18n-smell-detector` checks localization files for keys whose translated value is identical to the base locale.
|
|
9
|
+
`i18n-smell-detector` checks localization files for keys whose translated value is identical to the base locale. It can also scan Vue templates for hardcoded user-visible strings.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- Detect values that are identical to the base locale
|
|
14
|
+
- Detect hardcoded strings in Vue template text and selected attributes
|
|
14
15
|
- Ignore expected matches by key or value
|
|
15
16
|
- Classify findings as `high`, `medium`, `low`, or `ignored`
|
|
16
17
|
- Output results as console text, JSON, or Markdown
|
|
@@ -28,6 +29,7 @@ Run the check:
|
|
|
28
29
|
|
|
29
30
|
```bash
|
|
30
31
|
npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
|
|
32
|
+
npx i18n-smell-detector check-hardcoded --config i18n-smell.config.mjs
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
Output formats:
|
|
@@ -36,6 +38,7 @@ Output formats:
|
|
|
36
38
|
npx i18n-smell-detector check-identical --format console
|
|
37
39
|
npx i18n-smell-detector check-identical --format json
|
|
38
40
|
npx i18n-smell-detector check-identical --format markdown
|
|
41
|
+
npx i18n-smell-detector check-hardcoded --format json
|
|
39
42
|
```
|
|
40
43
|
|
|
41
44
|
## CI usage
|
|
@@ -59,6 +62,10 @@ Create `i18n-smell.config.mjs`:
|
|
|
59
62
|
```js
|
|
60
63
|
export default {
|
|
61
64
|
baseLocale: 'en',
|
|
65
|
+
source: [
|
|
66
|
+
'src/**/*.vue'
|
|
67
|
+
],
|
|
68
|
+
|
|
62
69
|
locales: {
|
|
63
70
|
en: './src/locales/en.json',
|
|
64
71
|
zh: './src/locales/zh.json',
|
|
@@ -90,6 +97,22 @@ export default {
|
|
|
90
97
|
],
|
|
91
98
|
|
|
92
99
|
ignoreCodeLike: true,
|
|
100
|
+
hardcoded: {
|
|
101
|
+
vueAttributes: [
|
|
102
|
+
'placeholder',
|
|
103
|
+
'title',
|
|
104
|
+
'alt',
|
|
105
|
+
'aria-label',
|
|
106
|
+
'aria-description'
|
|
107
|
+
],
|
|
108
|
+
ignoreValues: [
|
|
109
|
+
'OK',
|
|
110
|
+
'ID'
|
|
111
|
+
],
|
|
112
|
+
ignorePatterns: [
|
|
113
|
+
'^v\\d+$'
|
|
114
|
+
]
|
|
115
|
+
},
|
|
93
116
|
ignoreSameLanguageFamily: true,
|
|
94
117
|
trimWhitespace: true,
|
|
95
118
|
ignoreCase: false,
|
|
@@ -103,11 +126,15 @@ export default {
|
|
|
103
126
|
| Option | Type | Default | Description |
|
|
104
127
|
|---|---|---:|---|
|
|
105
128
|
| `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
|
|
129
|
+
| `source` | `string[]` | `['src/**/*.vue']` | Source globs used by `check-hardcoded`. `node_modules` is ignored. |
|
|
106
130
|
| `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
|
|
107
131
|
| `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
132
|
| `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
133
|
| `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
134
|
| `ignoreCodeLike` | `boolean` | `true` | Ignore built-in code-like values such as paths, colors, acronyms, and identifiers. Set to `false` to report them. |
|
|
135
|
+
| `hardcoded.vueAttributes` | `string[]` | `placeholder`, `title`, `alt`, `aria-label`, `aria-description` | Static Vue attributes checked by `check-hardcoded`. Dynamic bindings such as `:placeholder` are ignored. |
|
|
136
|
+
| `hardcoded.ignoreValues` | `(string \| RegExp)[]` | `[]` | Exact values or regular expressions ignored by `check-hardcoded`. |
|
|
137
|
+
| `hardcoded.ignorePatterns` | `(string \| RegExp)[]` | `[]` | Regular expression patterns ignored by `check-hardcoded`. String entries are compiled as regular expressions. |
|
|
111
138
|
| `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
|
|
112
139
|
| `trimWhitespace` | `boolean` | `true` | Trim leading and trailing whitespace before comparing values. |
|
|
113
140
|
| `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
|
|
@@ -173,6 +200,47 @@ Summary counts include all findings, including ignored findings that may be omit
|
|
|
173
200
|
|
|
174
201
|
Ignored issues are omitted by default. Pass `--include-ignored` to include them in JSON, console, or Markdown output.
|
|
175
202
|
|
|
203
|
+
## Hardcoded string output
|
|
204
|
+
|
|
205
|
+
`check-hardcoded` reports Vue template text and static user-visible attributes:
|
|
206
|
+
|
|
207
|
+
```txt
|
|
208
|
+
i18n-smell-detector: hardcoded strings
|
|
209
|
+
high=1 medium=1 low=0 ignored=4
|
|
210
|
+
|
|
211
|
+
HIGH src/components/UserPanel.vue:12:15
|
|
212
|
+
value: "Profile settings"
|
|
213
|
+
reason: static template text
|
|
214
|
+
|
|
215
|
+
MEDIUM src/components/SearchInput.vue:8:23
|
|
216
|
+
value: "Search"
|
|
217
|
+
reason: static placeholder attribute
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
JSON output includes source locations:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"summary": {
|
|
225
|
+
"high": 1,
|
|
226
|
+
"medium": 1,
|
|
227
|
+
"low": 0,
|
|
228
|
+
"ignored": 4
|
|
229
|
+
},
|
|
230
|
+
"issues": [
|
|
231
|
+
{
|
|
232
|
+
"file": "src/components/UserPanel.vue",
|
|
233
|
+
"line": 12,
|
|
234
|
+
"column": 15,
|
|
235
|
+
"value": "Profile settings",
|
|
236
|
+
"severity": "high",
|
|
237
|
+
"reason": "static template text",
|
|
238
|
+
"kind": "vue-text"
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
176
244
|
## Exit codes
|
|
177
245
|
|
|
178
246
|
| Code | Meaning |
|
|
@@ -194,6 +262,8 @@ Ignored issues are omitted by default. Pass `--include-ignored` to include them
|
|
|
194
262
|
| Single English word | medium |
|
|
195
263
|
| English phrase | high |
|
|
196
264
|
|
|
265
|
+
For `check-hardcoded`, sentence-like template text and static attributes are `high`, single words are `medium`, short common labels are `low`, and configured or non-user-facing values are `ignored`.
|
|
266
|
+
|
|
197
267
|
## False positive tuning
|
|
198
268
|
|
|
199
269
|
Identical text is sometimes intentional. Start by running the detector in reporting mode, then allow known names, protocol terms, short labels, and other strings that should stay the same across languages.
|
|
@@ -236,6 +306,13 @@ Useful tuning patterns:
|
|
|
236
306
|
- Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
|
|
237
307
|
- Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
|
|
238
308
|
- Pass `--include-ignored` when reviewing why a value was ignored.
|
|
309
|
+
- Use `source` to choose Vue files for `check-hardcoded`.
|
|
310
|
+
- Tune `hardcoded.vueAttributes` if your project uses additional static attributes for user-visible text.
|
|
311
|
+
- Use `hardcoded.ignoreValues` and `hardcoded.ignorePatterns` for intentional literals such as `OK`, version labels, or product codes.
|
|
312
|
+
|
|
313
|
+
## Limitations
|
|
314
|
+
|
|
315
|
+
`check-hardcoded` currently scans Vue templates only. JavaScript, TypeScript, and JSX string detection are not included yet.
|
|
239
316
|
|
|
240
317
|
## Notes
|
|
241
318
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-smell-detector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find locale values copied from the base locale.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"check": "
|
|
24
|
+
"check": "npm run check:identical && npm run check:hardcoded",
|
|
25
|
+
"check:identical": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
26
|
+
"check:hardcoded": "node ./bin/i18n-smell-detector.js check-hardcoded --config ./examples/vue-vite/i18n-smell.config.mjs --fail-on none",
|
|
25
27
|
"test": "node --test"
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
|
@@ -34,5 +36,10 @@
|
|
|
34
36
|
"license": "MIT",
|
|
35
37
|
"engines": {
|
|
36
38
|
"node": ">=18.17"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@vue/compiler-dom": "^3.5.39",
|
|
42
|
+
"@vue/compiler-sfc": "^3.5.39",
|
|
43
|
+
"fast-glob": "^3.3.3"
|
|
37
44
|
}
|
|
38
45
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fg from 'fast-glob';
|
|
3
|
+
import { scanVueSfc } from './source/scan-vue-sfc.js';
|
|
4
|
+
|
|
5
|
+
function compareIssues(a, b) {
|
|
6
|
+
const rank = { high: 3, medium: 2, low: 1, ignored: 0 };
|
|
7
|
+
return rank[b.severity] - rank[a.severity]
|
|
8
|
+
|| a.file.localeCompare(b.file)
|
|
9
|
+
|| a.line - b.line
|
|
10
|
+
|| a.column - b.column;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function checkHardcodedStrings(config, baseDir) {
|
|
14
|
+
const files = await fg(config.source, {
|
|
15
|
+
cwd: baseDir,
|
|
16
|
+
absolute: true,
|
|
17
|
+
onlyFiles: true,
|
|
18
|
+
ignore: ['**/node_modules/**'],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (files.length === 0) {
|
|
22
|
+
throw new Error(`No source files matched: ${config.source.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const issues = [];
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const fileIssues = await scanVueSfc(file, config);
|
|
28
|
+
for (const issue of fileIssues) {
|
|
29
|
+
issues.push({
|
|
30
|
+
...issue,
|
|
31
|
+
file: path.relative(baseDir, issue.file),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return issues.sort(compareIssues);
|
|
37
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { checkHardcodedStrings } from './check-hardcoded.js';
|
|
2
3
|
import { checkIdenticalTranslations } from './check-identical.js';
|
|
3
4
|
import { loadConfig, resolveConfigPath } from './config.js';
|
|
4
5
|
import { loadFlattenedLocales } from './locale/load-locales.js';
|
|
@@ -6,10 +7,11 @@ import { renderReport } from './reporters/index.js';
|
|
|
6
7
|
import { failRank, severityRank } from './severity.js';
|
|
7
8
|
|
|
8
9
|
const HELP = `
|
|
9
|
-
i18n-smell-detector
|
|
10
|
+
i18n-smell-detector
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
12
13
|
i18n-smell-detector check-identical [options]
|
|
14
|
+
i18n-smell-detector check-hardcoded [options]
|
|
13
15
|
i18n-smell-detector check [options]
|
|
14
16
|
|
|
15
17
|
Options:
|
|
@@ -21,6 +23,7 @@ Options:
|
|
|
21
23
|
|
|
22
24
|
Example:
|
|
23
25
|
i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
|
|
26
|
+
i18n-smell-detector check-hardcoded -c i18n-smell.config.mjs --format json
|
|
24
27
|
`;
|
|
25
28
|
|
|
26
29
|
function parseArgs(argv) {
|
|
@@ -64,7 +67,7 @@ export async function runCli(argv) {
|
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
if (!['check-identical', 'check'].includes(options.command)) {
|
|
70
|
+
if (!['check-identical', 'check-hardcoded', 'check'].includes(options.command)) {
|
|
68
71
|
throw new Error(`Unknown command: ${options.command}\n${HELP}`);
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -76,12 +79,18 @@ export async function runCli(argv) {
|
|
|
76
79
|
includeIgnored: options.includeIgnored || config.includeIgnored || false,
|
|
77
80
|
};
|
|
78
81
|
|
|
79
|
-
const
|
|
80
|
-
const
|
|
82
|
+
const configDir = path.dirname(configPath);
|
|
83
|
+
const isHardcoded = options.command === 'check-hardcoded';
|
|
84
|
+
const issues = isHardcoded
|
|
85
|
+
? await checkHardcodedStrings(effectiveConfig, configDir)
|
|
86
|
+
: checkIdenticalTranslations(await loadFlattenedLocales(effectiveConfig, configDir), effectiveConfig);
|
|
81
87
|
|
|
82
88
|
console.log(renderReport(issues, {
|
|
83
89
|
format: options.format,
|
|
84
90
|
includeIgnored: effectiveConfig.includeIgnored || false,
|
|
91
|
+
title: isHardcoded ? 'hardcoded strings' : 'identical translations',
|
|
92
|
+
heading: isHardcoded ? 'Hardcoded strings' : 'Identical translations',
|
|
93
|
+
emptyMessage: isHardcoded ? 'No hardcoded strings found.' : 'No copied base-locale values found.',
|
|
85
94
|
}));
|
|
86
95
|
|
|
87
96
|
const visible = effectiveConfig.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
package/src/config.js
CHANGED
|
@@ -17,6 +17,14 @@ const DEFAULT_PLACEHOLDER_PATTERNS = [
|
|
|
17
17
|
String.raw`\{[^}]+\}`,
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
+
const DEFAULT_HARDCODED_ATTRIBUTES = [
|
|
21
|
+
'placeholder',
|
|
22
|
+
'title',
|
|
23
|
+
'alt',
|
|
24
|
+
'aria-label',
|
|
25
|
+
'aria-description',
|
|
26
|
+
];
|
|
27
|
+
|
|
20
28
|
async function exists(filePath) {
|
|
21
29
|
try {
|
|
22
30
|
await access(filePath);
|
|
@@ -81,6 +89,21 @@ function validateConfig(config) {
|
|
|
81
89
|
if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
|
|
82
90
|
throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
|
|
83
91
|
}
|
|
92
|
+
|
|
93
|
+
if ('source' in candidate && !isStringArray(candidate.source)) {
|
|
94
|
+
throw new Error('Config source must be an array of glob strings');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if ('hardcoded' in candidate && (!candidate.hardcoded || typeof candidate.hardcoded !== 'object' || Array.isArray(candidate.hardcoded))) {
|
|
98
|
+
throw new Error('Config hardcoded must be an object');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hardcoded = /** @type {Record<string, unknown>} */ (candidate.hardcoded || {});
|
|
102
|
+
if ('vueAttributes' in hardcoded && !isStringArray(hardcoded.vueAttributes)) {
|
|
103
|
+
throw new Error('Config hardcoded.vueAttributes must be an array of strings');
|
|
104
|
+
}
|
|
105
|
+
validateRuleList(hardcoded.ignoreValues, 'hardcoded.ignoreValues');
|
|
106
|
+
validateRuleList(hardcoded.ignorePatterns, 'hardcoded.ignorePatterns');
|
|
84
107
|
}
|
|
85
108
|
|
|
86
109
|
/** @param {import('./types.js').DetectorConfig} config */
|
|
@@ -96,11 +119,17 @@ function withDefaults(config) {
|
|
|
96
119
|
includeIgnored: false,
|
|
97
120
|
failOn: 'high',
|
|
98
121
|
...config,
|
|
122
|
+
source: config.source || ['src/**/*.vue'],
|
|
123
|
+
hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
|
|
99
124
|
placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
|
|
100
125
|
ignoreCodeLike: config.ignoreCodeLike ?? true,
|
|
101
126
|
};
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
function isStringArray(value) {
|
|
130
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0);
|
|
131
|
+
}
|
|
132
|
+
|
|
104
133
|
function validateRuleList(rules, name) {
|
|
105
134
|
if (rules === undefined) return;
|
|
106
135
|
if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
|
|
@@ -128,6 +157,29 @@ function normalizePlaceholderPatterns(patterns) {
|
|
|
128
157
|
});
|
|
129
158
|
}
|
|
130
159
|
|
|
160
|
+
function normalizeHardcodedConfig(config) {
|
|
161
|
+
return {
|
|
162
|
+
vueAttributes: config.vueAttributes || DEFAULT_HARDCODED_ATTRIBUTES,
|
|
163
|
+
ignoreValues: config.ignoreValues || [],
|
|
164
|
+
ignorePatterns: normalizeRegexRules(config.ignorePatterns || [], 'hardcoded.ignorePatterns'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeRegexRules(rules, name) {
|
|
169
|
+
return rules.map((rule) => {
|
|
170
|
+
if (rule instanceof RegExp) return rule;
|
|
171
|
+
if (typeof rule !== 'string') {
|
|
172
|
+
throw new Error(`Config ${name} entries must be strings or RegExp objects`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
return new RegExp(rule);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new Error(`Invalid ${name} pattern "${rule}": ${error.message}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
function withGlobalFlag(pattern) {
|
|
132
184
|
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
133
185
|
return new RegExp(pattern.source, flags);
|
package/src/reporters/console.js
CHANGED
|
@@ -21,21 +21,26 @@ export function renderConsoleReport(issues, options) {
|
|
|
21
21
|
const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
22
22
|
const counts = summarizeIssues(issues);
|
|
23
23
|
const lines = [
|
|
24
|
-
color('1',
|
|
24
|
+
color('1', `i18n-smell-detector: ${options.title || 'identical translations'}`),
|
|
25
25
|
`high=${counts.high} medium=${counts.medium} low=${counts.low} ignored=${counts.ignored}`,
|
|
26
26
|
];
|
|
27
27
|
|
|
28
28
|
if (visible.length === 0) {
|
|
29
|
-
lines.push(color('32', 'No copied base-locale values found.'));
|
|
29
|
+
lines.push(color('32', options.emptyMessage || 'No copied base-locale values found.'));
|
|
30
30
|
return lines.join('\n');
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
lines.push('');
|
|
34
34
|
for (const issue of visible) {
|
|
35
|
-
lines.push(`${label(issue.severity)} ${issue
|
|
35
|
+
lines.push(`${label(issue.severity)} ${formatLocation(issue)}`);
|
|
36
36
|
lines.push(` value: "${preview(issue.value)}"`);
|
|
37
37
|
lines.push(` reason: ${issue.reason}`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
return lines.join('\n');
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
function formatLocation(issue) {
|
|
44
|
+
if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
|
|
45
|
+
return `${issue.targetLocale}.${issue.key}`;
|
|
46
|
+
}
|
|
@@ -8,24 +8,41 @@ export function renderMarkdownReport(issues, options) {
|
|
|
8
8
|
const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
|
|
9
9
|
const summary = summarizeIssues(issues);
|
|
10
10
|
const lines = [
|
|
11
|
-
'
|
|
11
|
+
`# ${options.heading || 'Identical translations'}`,
|
|
12
12
|
'',
|
|
13
13
|
`high=${summary.high} medium=${summary.medium} low=${summary.low} ignored=${summary.ignored}`,
|
|
14
14
|
'',
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
if (visible.length === 0) {
|
|
18
|
-
lines.push('No copied base-locale values found.');
|
|
18
|
+
lines.push(options.emptyMessage || 'No copied base-locale values found.');
|
|
19
19
|
return `${lines.join('\n')}\n`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
if (visible.some((issue) => issue.file)) {
|
|
23
|
+
lines.push('| Level | Location | Value | Reason |');
|
|
24
|
+
lines.push('|---|---|---|---|');
|
|
25
|
+
} else {
|
|
26
|
+
lines.push('| Level | Locale | Key | Value | Reason |');
|
|
27
|
+
lines.push('|---|---|---|---|---|');
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
for (const issue of visible) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
if (issue.file) {
|
|
32
|
+
lines.push(
|
|
33
|
+
`| ${escapeCell(issue.severity)} | ${escapeCell(formatLocation(issue))} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
lines.push(
|
|
37
|
+
`| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
return `${lines.join('\n')}\n`;
|
|
31
43
|
}
|
|
44
|
+
|
|
45
|
+
function formatLocation(issue) {
|
|
46
|
+
if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
|
|
47
|
+
return `${issue.targetLocale}.${issue.key}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { matchesAnyRule } from './match-rule.js';
|
|
2
|
+
|
|
3
|
+
function isBlank(value) {
|
|
4
|
+
return value.trim().length === 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isMustacheOnly(value) {
|
|
8
|
+
return /^\{\{[\s\S]*\}\}$/.test(value.trim());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isNonUserFacing(value) {
|
|
12
|
+
const text = value.trim();
|
|
13
|
+
return /^[0-9.,:;+\-*/%()\s]+$/.test(text)
|
|
14
|
+
|| /^[\p{P}\p{S}]+$/u.test(text)
|
|
15
|
+
|| /^#[0-9a-f]{3,8}$/i.test(text)
|
|
16
|
+
|| /^(https?:\/\/|mailto:|tel:)/i.test(text)
|
|
17
|
+
|| /^\/[a-z0-9_./:-]*$/i.test(text)
|
|
18
|
+
|| /^[a-z0-9_.-]+\.(css|js|png|jpe?g|gif|svg|webp|json)$/i.test(text)
|
|
19
|
+
|| /^[A-Z]{2,6}$/.test(text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function latinWords(value) {
|
|
23
|
+
return value.match(/[A-Za-z][A-Za-z']*/g) || [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isShortCommonLabel(value) {
|
|
27
|
+
const text = value.trim();
|
|
28
|
+
return text.length <= 3 || /^(ok|id|no|yes|on|off)$/i.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function classifyHardcoded(value, config = {}) {
|
|
32
|
+
const hardcoded = config.hardcoded || {};
|
|
33
|
+
|
|
34
|
+
if (matchesAnyRule(value, hardcoded.ignoreValues || [])) {
|
|
35
|
+
return { severity: 'ignored', reason: 'ignored value' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (matchesAnyRule(value, hardcoded.ignorePatterns || [])) {
|
|
39
|
+
return { severity: 'ignored', reason: 'ignored pattern' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isBlank(value)) return { severity: 'ignored', reason: 'blank value' };
|
|
43
|
+
if (isMustacheOnly(value)) return { severity: 'ignored', reason: 'mustache expression' };
|
|
44
|
+
if (isNonUserFacing(value)) return { severity: 'ignored', reason: 'non-user-facing value' };
|
|
45
|
+
if (isShortCommonLabel(value)) return { severity: 'low', reason: 'short common label' };
|
|
46
|
+
|
|
47
|
+
const words = latinWords(value);
|
|
48
|
+
if (words.length >= 2 || /[.!?。!?]/.test(value)) {
|
|
49
|
+
return { severity: 'high', reason: 'sentence-like content' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (words.length === 1) return { severity: 'medium', reason: 'single word' };
|
|
53
|
+
return { severity: 'low', reason: 'possible user-facing text' };
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from '@vue/compiler-sfc';
|
|
3
|
+
import { scanVueTemplate } from './scan-vue-template.js';
|
|
4
|
+
|
|
5
|
+
function lineOffsetBefore(source, offset) {
|
|
6
|
+
return source.slice(0, offset).split(/\r?\n/).length - 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function scanVueSfc(file, config) {
|
|
10
|
+
const source = await readFile(file, 'utf8');
|
|
11
|
+
const result = parse(source, { filename: file });
|
|
12
|
+
|
|
13
|
+
if (result.errors.length > 0) {
|
|
14
|
+
const [error] = result.errors;
|
|
15
|
+
throw new Error(`Malformed Vue file ${file}: ${error.message || String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const template = result.descriptor.template;
|
|
19
|
+
if (!template) return [];
|
|
20
|
+
|
|
21
|
+
return scanVueTemplate(template.content, {
|
|
22
|
+
file,
|
|
23
|
+
lineOffset: lineOffsetBefore(source, template.loc.start.offset),
|
|
24
|
+
config,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { NodeTypes, parse } from '@vue/compiler-dom';
|
|
2
|
+
import { classifyHardcoded } from '../rules/classify-hardcoded.js';
|
|
3
|
+
|
|
4
|
+
function offsetToLocation(source, offset) {
|
|
5
|
+
const before = source.slice(0, offset);
|
|
6
|
+
const lines = before.split(/\r?\n/);
|
|
7
|
+
return {
|
|
8
|
+
line: lines.length,
|
|
9
|
+
column: lines[lines.length - 1].length + 1,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeText(value) {
|
|
14
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isI18nExpression(value) {
|
|
18
|
+
return /\b(?:t|te|\$t|i18n\.t)\s*\(/.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeIssue({ file, lineOffset, template, loc, value, kind, reason, severity }) {
|
|
22
|
+
const start = offsetToLocation(template, loc.start.offset);
|
|
23
|
+
return {
|
|
24
|
+
file,
|
|
25
|
+
line: lineOffset + start.line,
|
|
26
|
+
column: start.column,
|
|
27
|
+
value,
|
|
28
|
+
severity,
|
|
29
|
+
reason,
|
|
30
|
+
kind,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scanValue({ file, lineOffset, template, loc, value, kind, baseReason, config }) {
|
|
35
|
+
const text = normalizeText(value);
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
|
|
38
|
+
const classification = classifyHardcoded(text, config);
|
|
39
|
+
return makeIssue({
|
|
40
|
+
file,
|
|
41
|
+
lineOffset,
|
|
42
|
+
template,
|
|
43
|
+
loc,
|
|
44
|
+
value: text,
|
|
45
|
+
severity: classification.severity,
|
|
46
|
+
reason: classification.severity === 'ignored' ? classification.reason : baseReason,
|
|
47
|
+
kind,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function walk(node, visitor) {
|
|
52
|
+
visitor(node);
|
|
53
|
+
for (const child of node.children || []) walk(child, visitor);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanVueTemplate(template, { file, lineOffset = 0, config }) {
|
|
57
|
+
let ast;
|
|
58
|
+
try {
|
|
59
|
+
ast = parse(template, { comments: false });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new Error(`Malformed Vue template in ${file}: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const attributes = new Set(config.hardcoded.vueAttributes);
|
|
65
|
+
const issues = [];
|
|
66
|
+
|
|
67
|
+
walk(ast, (node) => {
|
|
68
|
+
if (node.type === NodeTypes.TEXT) {
|
|
69
|
+
const issue = scanValue({
|
|
70
|
+
file,
|
|
71
|
+
lineOffset,
|
|
72
|
+
template,
|
|
73
|
+
loc: node.loc,
|
|
74
|
+
value: node.content,
|
|
75
|
+
kind: 'vue-text',
|
|
76
|
+
baseReason: 'static template text',
|
|
77
|
+
config,
|
|
78
|
+
});
|
|
79
|
+
if (issue) issues.push(issue);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (node.type !== NodeTypes.ELEMENT) return;
|
|
84
|
+
|
|
85
|
+
for (const prop of node.props || []) {
|
|
86
|
+
if (prop.type !== NodeTypes.ATTRIBUTE) continue;
|
|
87
|
+
if (!attributes.has(prop.name) || !prop.value) continue;
|
|
88
|
+
if (isI18nExpression(prop.value.content)) continue;
|
|
89
|
+
|
|
90
|
+
const issue = scanValue({
|
|
91
|
+
file,
|
|
92
|
+
lineOffset,
|
|
93
|
+
template,
|
|
94
|
+
loc: prop.value.loc,
|
|
95
|
+
value: prop.value.content,
|
|
96
|
+
kind: `vue-attribute:${prop.name}`,
|
|
97
|
+
baseReason: `static ${prop.name} attribute`,
|
|
98
|
+
config,
|
|
99
|
+
});
|
|
100
|
+
if (issue) issues.push(issue);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return issues;
|
|
105
|
+
}
|
package/src/types.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @property {(string | RegExp)[]=} allowIdenticalKeys
|
|
9
9
|
* @property {(string | RegExp)[]=} allowIdenticalValues
|
|
10
10
|
* @property {(string | RegExp)[]=} placeholderPatterns
|
|
11
|
+
* @property {string[]=} source
|
|
12
|
+
* @property {{vueAttributes: string[], ignoreValues: (string | RegExp)[], ignorePatterns: RegExp[]}=} hardcoded
|
|
11
13
|
* @property {boolean=} ignoreCodeLike
|
|
12
14
|
* @property {boolean=} ignoreSameLanguageFamily
|
|
13
15
|
* @property {boolean=} trimWhitespace
|
|
@@ -22,6 +24,15 @@
|
|
|
22
24
|
* @property {string} value
|
|
23
25
|
* @property {Severity} severity
|
|
24
26
|
* @property {string} reason
|
|
27
|
+
*
|
|
28
|
+
* @typedef {Object} HardcodedIssue
|
|
29
|
+
* @property {string} file
|
|
30
|
+
* @property {number} line
|
|
31
|
+
* @property {number} column
|
|
32
|
+
* @property {string} value
|
|
33
|
+
* @property {Severity} severity
|
|
34
|
+
* @property {string} reason
|
|
35
|
+
* @property {string} kind
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
export {};
|