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 CHANGED
@@ -67,7 +67,7 @@ export default {
67
67
 
68
68
  allowIdenticalKeys: [
69
69
  'brand.*',
70
- 'protocol.*',
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
- 'HTTP',
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 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`. |
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
- 'protocol.*'
211
+ /^protocol\./
193
212
  ],
194
213
  allowIdenticalValues: [
195
214
  'OK',
196
215
  'ID',
197
216
  'API',
198
- 'HTTPS'
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 `allowIdenticalValues` for exact shared labels, acronyms, method names, and technical terms.
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.1.1",
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
+ }
@@ -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 = countBySeverity(issues);
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
- }
@@ -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 lines = ['# Identical translations', ''];
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.');
@@ -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
+ }
@@ -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
+ }
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