i18n-smell-detector 0.1.1 → 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 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. This can help catch entries that were copied as placeholders and never translated.
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',
@@ -67,7 +74,7 @@ export default {
67
74
 
68
75
  allowIdenticalKeys: [
69
76
  'brand.*',
70
- 'protocol.*',
77
+ /^protocol\./,
71
78
  'common.ok',
72
79
  'common.id'
73
80
  ],
@@ -75,12 +82,37 @@ export default {
75
82
  allowIdenticalValues: [
76
83
  'OK',
77
84
  'ID',
78
- 'HTTP',
85
+ /^HTTP\/\d(\.\d)?$/,
79
86
  'API',
80
87
  'GET',
81
88
  'POST'
82
89
  ],
83
90
 
91
+ placeholderPatterns: [
92
+ '\\{[^}]+\\}',
93
+ '\\{\\{[^}]+\\}\\}',
94
+ '%[sdif]',
95
+ '%\\([^)]+\\)[sdif]',
96
+ '\\$\\d+'
97
+ ],
98
+
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
+ },
84
116
  ignoreSameLanguageFamily: true,
85
117
  trimWhitespace: true,
86
118
  ignoreCase: false,
@@ -94,9 +126,15 @@ export default {
94
126
  | Option | Type | Default | Description |
95
127
  |---|---|---:|---|
96
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. |
97
130
  | `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 patterns that are allowed to match the base locale. Supports `*` wildcards, for example `brand.*`. |
99
- | `allowIdenticalValues` | `string[]` | `[]` | Exact values that are allowed to match the base locale, for example `OK`, `API`, or `GET`. |
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. |
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. |
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. |
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. |
100
138
  | `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
101
139
  | `trimWhitespace` | `boolean` | `true` | Trim leading and trailing whitespace before comparing values. |
102
140
  | `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
@@ -124,10 +162,16 @@ MEDIUM ja.form.search
124
162
 
125
163
  ## JSON output
126
164
 
127
- `--format json` prints an object with an `issues` array:
165
+ `--format json` prints an object with a `summary` and an `issues` array:
128
166
 
129
167
  ```json
130
168
  {
169
+ "summary": {
170
+ "high": 1,
171
+ "medium": 0,
172
+ "low": 0,
173
+ "ignored": 16
174
+ },
131
175
  "issues": [
132
176
  {
133
177
  "key": "home.welcome",
@@ -143,6 +187,8 @@ MEDIUM ja.form.search
143
187
 
144
188
  Fields:
145
189
 
190
+ Summary counts include all findings, including ignored findings that may be omitted from the visible `issues` array.
191
+
146
192
  | Field | Type | Description |
147
193
  |---|---|---|
148
194
  | `key` | `string` | Flattened locale key, such as `settings.profile.title`. |
@@ -154,6 +200,47 @@ Fields:
154
200
 
155
201
  Ignored issues are omitted by default. Pass `--include-ignored` to include them in JSON, console, or Markdown output.
156
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
+
157
244
  ## Exit codes
158
245
 
159
246
  | Code | Meaning |
@@ -169,12 +256,14 @@ Ignored issues are omitted by default. Pass `--include-ignored` to include them
169
256
  | Key matches `allowIdenticalKeys` | ignored |
170
257
  | Value matches `allowIdenticalValues` | ignored |
171
258
  | Same language family, such as `en` and `en-GB` | ignored |
172
- | URL, path, color, or code-like value | ignored |
173
259
  | Placeholder-only value, such as `{count}` | ignored |
260
+ | URL, path, color, or code-like value | ignored by default |
174
261
  | Short common label | low |
175
262
  | Single English word | medium |
176
263
  | English phrase | high |
177
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
+
178
267
  ## False positive tuning
179
268
 
180
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.
@@ -189,13 +278,19 @@ export default {
189
278
  allowIdenticalKeys: [
190
279
  'brand.*',
191
280
  'legal.name',
192
- 'protocol.*'
281
+ /^protocol\./
193
282
  ],
194
283
  allowIdenticalValues: [
195
284
  'OK',
196
285
  'ID',
197
286
  'API',
198
- 'HTTPS'
287
+ /^HTTP\/\d(\.\d)?$/
288
+ ],
289
+ placeholderPatterns: [
290
+ '\\{[^}]+\\}',
291
+ '\\{\\{[^}]+\\}\\}',
292
+ '%[sdif]',
293
+ '%\\([^)]+\\)[sdif]'
199
294
  ],
200
295
  failOn: 'high'
201
296
  };
@@ -204,10 +299,20 @@ export default {
204
299
  Useful tuning patterns:
205
300
 
206
301
  - Use `allowIdenticalKeys` for groups of expected identical values, such as names, legal labels, and protocol keys.
207
- - Use `allowIdenticalValues` for exact shared labels, acronyms, method names, and technical terms.
302
+ - Use string `allowIdenticalKeys` entries for exact or wildcard key matches, and `RegExp` entries for broader key families.
303
+ - Use string `allowIdenticalValues` entries for exact shared labels, and `RegExp` entries for structured values such as `HTTP/2`.
304
+ - Add `placeholderPatterns` for framework-specific placeholders such as `%s`, `%(name)s`, or `$1`. Mixed content such as `Total: {count}` is still reported.
305
+ - Set `ignoreCodeLike: false` if paths, colors, acronyms, or identifiers should be shown as findings.
208
306
  - Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
209
307
  - Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
210
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.
211
316
 
212
317
  ## Notes
213
318
 
@@ -225,4 +330,4 @@ npm run check
225
330
 
226
331
  ## License
227
332
 
228
- MIT
333
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.1.1",
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": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs",
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 v0.1.1
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 locales = await loadFlattenedLocales(effectiveConfig, path.dirname(configPath));
80
- const issues = checkIdenticalTranslations(locales, effectiveConfig);
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
@@ -12,6 +12,19 @@ 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
+
20
+ const DEFAULT_HARDCODED_ATTRIBUTES = [
21
+ 'placeholder',
22
+ 'title',
23
+ 'alt',
24
+ 'aria-label',
25
+ 'aria-description',
26
+ ];
27
+
15
28
  async function exists(filePath) {
16
29
  try {
17
30
  await access(filePath);
@@ -69,6 +82,28 @@ function validateConfig(config) {
69
82
  throw new Error(`Config locales.${locale} must be a file path string`);
70
83
  }
71
84
  }
85
+
86
+ validateRuleList(candidate.allowIdenticalKeys, 'allowIdenticalKeys');
87
+ validateRuleList(candidate.allowIdenticalValues, 'allowIdenticalValues');
88
+
89
+ if ('placeholderPatterns' in candidate && !Array.isArray(candidate.placeholderPatterns)) {
90
+ throw new Error('Config placeholderPatterns must be an array of strings or RegExp objects');
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');
72
107
  }
73
108
 
74
109
  /** @param {import('./types.js').DetectorConfig} config */
@@ -84,5 +119,68 @@ function withDefaults(config) {
84
119
  includeIgnored: false,
85
120
  failOn: 'high',
86
121
  ...config,
122
+ source: config.source || ['src/**/*.vue'],
123
+ hardcoded: normalizeHardcodedConfig(config.hardcoded || {}),
124
+ placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
125
+ ignoreCodeLike: config.ignoreCodeLike ?? true,
87
126
  };
88
127
  }
128
+
129
+ function isStringArray(value) {
130
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0);
131
+ }
132
+
133
+ function validateRuleList(rules, name) {
134
+ if (rules === undefined) return;
135
+ if (!Array.isArray(rules)) throw new Error(`Config ${name} must be an array`);
136
+
137
+ for (const rule of rules) {
138
+ if (typeof rule !== 'string' && !(rule instanceof RegExp)) {
139
+ throw new Error(`Config ${name} entries must be strings or RegExp objects`);
140
+ }
141
+ }
142
+ }
143
+
144
+ function normalizePlaceholderPatterns(patterns) {
145
+ return patterns.map((pattern) => {
146
+ if (pattern instanceof RegExp) return withGlobalFlag(pattern);
147
+
148
+ if (typeof pattern !== 'string') {
149
+ throw new Error('Config placeholderPatterns entries must be strings or RegExp objects');
150
+ }
151
+
152
+ try {
153
+ return new RegExp(pattern, 'g');
154
+ } catch (error) {
155
+ throw new Error(`Invalid placeholder pattern "${pattern}": ${error.message}`);
156
+ }
157
+ });
158
+ }
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
+
183
+ function withGlobalFlag(pattern) {
184
+ const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
185
+ return new RegExp(pattern.source, flags);
186
+ }
@@ -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,20 +19,20 @@ 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 = countBySeverity(issues);
22
+ const counts = summarizeIssues(issues);
21
23
  const lines = [
22
- color('1', 'i18n-smell-detector: identical translations'),
24
+ color('1', `i18n-smell-detector: ${options.title || 'identical translations'}`),
23
25
  `high=${counts.high} medium=${counts.medium} low=${counts.low} ignored=${counts.ignored}`,
24
26
  ];
25
27
 
26
28
  if (visible.length === 0) {
27
- lines.push(color('32', 'No copied base-locale values found.'));
29
+ lines.push(color('32', options.emptyMessage || 'No copied base-locale values found.'));
28
30
  return lines.join('\n');
29
31
  }
30
32
 
31
33
  lines.push('');
32
34
  for (const issue of visible) {
33
- lines.push(`${label(issue.severity)} ${issue.targetLocale}.${issue.key}`);
35
+ lines.push(`${label(issue.severity)} ${formatLocation(issue)}`);
34
36
  lines.push(` value: "${preview(issue.value)}"`);
35
37
  lines.push(` reason: ${issue.reason}`);
36
38
  }
@@ -38,12 +40,7 @@ export function renderConsoleReport(issues, options) {
38
40
  return lines.join('\n');
39
41
  }
40
42
 
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
- );
43
+ function formatLocation(issue) {
44
+ if (issue.file) return `${issue.file}:${issue.line}:${issue.column}`;
45
+ return `${issue.targetLocale}.${issue.key}`;
49
46
  }
@@ -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,23 +1,48 @@
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 lines = ['# Identical translations', ''];
9
+ const summary = summarizeIssues(issues);
10
+ const lines = [
11
+ `# ${options.heading || '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
- lines.push('No copied base-locale values found.');
18
+ lines.push(options.emptyMessage || 'No copied base-locale values found.');
11
19
  return `${lines.join('\n')}\n`;
12
20
  }
13
21
 
14
- lines.push('| Level | Locale | Key | Value | Reason |');
15
- lines.push('|---|---|---|---|---|');
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
+
16
30
  for (const issue of visible) {
17
- lines.push(
18
- `| ${escapeCell(issue.severity)} | ${escapeCell(issue.targetLocale)} | ${escapeCell(issue.key)} | ${escapeCell(issue.value)} | ${escapeCell(issue.reason)} |`
19
- );
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
+ }
20
40
  }
21
41
 
22
42
  return `${lines.join('\n')}\n`;
23
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,9 @@
1
+ export function summarizeIssues(issues) {
2
+ return issues.reduce(
3
+ (summary, issue) => {
4
+ summary[issue.severity] += 1;
5
+ return summary;
6
+ },
7
+ { high: 0, medium: 0, low: 0, ignored: 0 }
8
+ );
9
+ }
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { matchesAnyPattern } from './match-pattern.js';
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
- const text = value
23
- .replace(/\{\{\s*[^}]+\s*\}\}/g, '')
24
- .replace(/\{\s*[^}]+\s*\}/g, '')
25
- .replace(/[\s\p{P}\p{S}]/gu, '');
26
- return text.length === 0;
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.ignoreSameLanguageFamily ?? true) && languageFamily(baseLocale) === languageFamily(targetLocale)) {
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 || []).includes(value)) {
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 (isPlaceholderOnly(value)) return { severity: 'ignored', reason: 'placeholder only' };
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
- function patternToRegExp(pattern) {
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 patterns.some((pattern) => patternToRegExp(pattern).test(value));
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
+ }
@@ -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
@@ -5,8 +5,12 @@
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 {string[]=} source
12
+ * @property {{vueAttributes: string[], ignoreValues: (string | RegExp)[], ignorePatterns: RegExp[]}=} hardcoded
13
+ * @property {boolean=} ignoreCodeLike
10
14
  * @property {boolean=} ignoreSameLanguageFamily
11
15
  * @property {boolean=} trimWhitespace
12
16
  * @property {boolean=} ignoreCase
@@ -20,6 +24,15 @@
20
24
  * @property {string} value
21
25
  * @property {Severity} severity
22
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
23
36
  */
24
37
 
25
38
  export {};