i18n-smell-detector 0.1.0 → 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
@@ -1,17 +1,20 @@
1
1
  # i18n-smell-detector
2
2
 
3
- `i18n-smell-detector` identifies potential "stale" translations in your localization files. It flags keys where the translated value is identical to the base locale, which often indicates that a string was never actually translated.
3
+ [![npm version](https://img.shields.io/npm/v/i18n-smell-detector.svg)](https://www.npmjs.com/package/i18n-smell-detector)
4
+ [![CI](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml/badge.svg)](https://github.com/xue-moe/i18n-smell-detector/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
4
6
 
5
- ## Features
6
-
7
- - Smart Detection: Categorizes identical translations based on length and content (words vs. phrases).
7
+ Find locale values copied from the base locale.
8
8
 
9
- - Flexible Whitelisting: Exclude brand names, protocol codes, or common UI labels via configuration.
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.
10
10
 
11
- - CI Integration: Configurable failure thresholds (`fail-on`) for build pipelines.
12
-
13
- - Multiple Output Formats: Supports console, JSON, and Markdown.
11
+ ## Features
14
12
 
13
+ - Detect values that are identical to the base locale
14
+ - Ignore expected matches by key or value
15
+ - Classify findings as `high`, `medium`, `low`, or `ignored`
16
+ - Output results as console text, JSON, or Markdown
17
+ - Fail CI based on a configurable severity level
15
18
 
16
19
  ## Install
17
20
 
@@ -19,14 +22,6 @@
19
22
  npm install -D i18n-smell-detector
20
23
  ```
21
24
 
22
- For local development:
23
-
24
- ```bash
25
- npm install
26
- npm test
27
- npm run check
28
- ```
29
-
30
25
  ## Usage
31
26
 
32
27
  Run the check:
@@ -35,26 +30,31 @@ Run the check:
35
30
  npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
36
31
  ```
37
32
 
38
- Output Options:
33
+ Output formats:
39
34
 
40
35
  ```bash
41
- # Default output is console
42
36
  npx i18n-smell-detector check-identical --format console
43
37
  npx i18n-smell-detector check-identical --format json
44
38
  npx i18n-smell-detector check-identical --format markdown
45
39
  ```
46
40
 
47
- ### CI/CD Integration
41
+ ## CI usage
42
+
43
+ Use `--fail-on` to choose the lowest severity that should make the command exit with code `1`.
48
44
 
49
- Set the severity level that causes the process to exit with an error:
45
+ ```bash
46
+ npx i18n-smell-detector check-identical --config i18n-smell.config.mjs --fail-on high
47
+ ```
48
+
49
+ For a new project, it is often easier to start in reporting mode while tuning allowlists:
50
50
 
51
51
  ```bash
52
- npx i18n-smell-detector check-identical --fail-on high
53
- npx i18n-smell-detector check-identical --fail-on medium
54
- npx i18n-smell-detector check-identical --fail-on none
52
+ npx i18n-smell-detector check-identical --config i18n-smell.config.mjs --fail-on none
55
53
  ```
56
54
 
57
- ## Configuration(`i18n-smell.config.mjs`)
55
+ ## Configuration
56
+
57
+ Create `i18n-smell.config.mjs`:
58
58
 
59
59
  ```js
60
60
  export default {
@@ -62,36 +62,58 @@ export default {
62
62
  locales: {
63
63
  en: './src/locales/en.json',
64
64
  zh: './src/locales/zh.json',
65
- ja: './src/locales/ja.json',
66
- ko: './src/locales/ko.json',
67
- fr: './src/locales/fr.json',
68
- // ...
65
+ ja: './src/locales/ja.json'
69
66
  },
70
67
 
71
- // Whitelist specific keys
72
68
  allowIdenticalKeys: [
73
69
  'brand.*',
74
- 'protocol.*',
70
+ /^protocol\./,
75
71
  'common.ok',
76
72
  'common.id'
77
73
  ],
78
-
79
- // Whitelist specific values
74
+
80
75
  allowIdenticalValues: [
81
76
  'OK',
82
77
  'ID',
83
- 'HTTP',
78
+ /^HTTP\/\d(\.\d)?$/,
84
79
  'API',
85
80
  'GET',
86
81
  'POST'
87
82
  ],
88
83
 
89
- ignoreSameLanguageFamily: true, // Ignore en -> en-GB
90
- trimWhitespace: true, // Trim and compare
84
+ placeholderPatterns: [
85
+ '\\{[^}]+\\}',
86
+ '\\{\\{[^}]+\\}\\}',
87
+ '%[sdif]',
88
+ '%\\([^)]+\\)[sdif]',
89
+ '\\$\\d+'
90
+ ],
91
+
92
+ ignoreCodeLike: true,
93
+ ignoreSameLanguageFamily: true,
94
+ trimWhitespace: true,
95
+ ignoreCase: false,
96
+ includeIgnored: false,
91
97
  failOn: 'high'
92
98
  };
93
99
  ```
94
100
 
101
+ ### Configuration reference
102
+
103
+ | Option | Type | Default | Description |
104
+ |---|---|---:|---|
105
+ | `baseLocale` | `string` | `'en'` | Locale used as the comparison source. It must also be listed in `locales`. |
106
+ | `locales` | `Record<string, string>` | `{}` | Map of locale codes to JSON locale file paths. Relative paths are resolved from the config file directory. |
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. |
111
+ | `failOn` | `'high' \| 'medium' \| 'low' \| 'none'` | `'high'` | Lowest severity that makes the CLI exit with code `1`. Use `none` to report without failing. |
112
+ | `trimWhitespace` | `boolean` | `true` | Trim leading and trailing whitespace before comparing values. |
113
+ | `ignoreCase` | `boolean` | `false` | Compare values case-insensitively when set to `true`. |
114
+ | `ignoreSameLanguageFamily` | `boolean` | `true` | Ignore regional variants that share the same language family, such as `en` and `en-GB`. Set to `false` to check regional variants too. |
115
+ | `includeIgnored` | `boolean` | `false` | Include ignored findings in reports. Can also be enabled with `--include-ignored`. |
116
+
95
117
  ## Example output
96
118
 
97
119
  ```txt
@@ -111,21 +133,124 @@ MEDIUM ja.form.search
111
133
  reason: copied English word
112
134
  ```
113
135
 
114
- ## Detection Rules
136
+ ## JSON output
137
+
138
+ `--format json` prints an object with a `summary` and an `issues` array:
139
+
140
+ ```json
141
+ {
142
+ "summary": {
143
+ "high": 1,
144
+ "medium": 0,
145
+ "low": 0,
146
+ "ignored": 16
147
+ },
148
+ "issues": [
149
+ {
150
+ "key": "home.welcome",
151
+ "baseLocale": "en",
152
+ "targetLocale": "zh",
153
+ "value": "Welcome back",
154
+ "severity": "high",
155
+ "reason": "copied English phrase"
156
+ }
157
+ ]
158
+ }
159
+ ```
160
+
161
+ Fields:
162
+
163
+ Summary counts include all findings, including ignored findings that may be omitted from the visible `issues` array.
164
+
165
+ | Field | Type | Description |
166
+ |---|---|---|
167
+ | `key` | `string` | Flattened locale key, such as `settings.profile.title`. |
168
+ | `baseLocale` | `string` | Locale being compared from. |
169
+ | `targetLocale` | `string` | Locale being checked. |
170
+ | `value` | `string` | Identical target value. |
171
+ | `severity` | `'high' \| 'medium' \| 'low' \| 'ignored'` | Classification assigned by the detection rules. |
172
+ | `reason` | `string` | Short explanation for the classification. |
173
+
174
+ Ignored issues are omitted by default. Pass `--include-ignored` to include them in JSON, console, or Markdown output.
175
+
176
+ ## Exit codes
177
+
178
+ | Code | Meaning |
179
+ |---:|---|
180
+ | `0` | Completed successfully and no visible issue reached the configured `failOn` threshold. |
181
+ | `1` | Completed successfully, but at least one visible issue reached the configured `failOn` threshold. |
182
+ | `2` | Runtime or configuration error, such as a missing config file, missing locale file, invalid JSON, or unsupported CLI option. |
183
+
184
+ ## Detection rules
115
185
 
116
186
  | Case | Level |
117
187
  |---|---|
118
188
  | Key matches `allowIdenticalKeys` | ignored |
119
189
  | Value matches `allowIdenticalValues` | ignored |
120
- | Same language family (e.g., en, en-GB) | ignored |
121
- | URLs, path, color, or code-like value | ignored |
122
- | Placeholder-only value (e.g., `{count}`) | ignored |
190
+ | Same language family, such as `en` and `en-GB` | ignored |
191
+ | Placeholder-only value, such as `{count}` | ignored |
192
+ | URL, path, color, or code-like value | ignored by default |
123
193
  | Short common label | low |
124
194
  | Single English word | medium |
125
195
  | English phrase | high |
126
196
 
197
+ ## False positive tuning
198
+
199
+ 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.
200
+
201
+ ```js
202
+ export default {
203
+ baseLocale: 'en',
204
+ locales: {
205
+ en: './src/locales/en.json',
206
+ zh: './src/locales/zh.json'
207
+ },
208
+ allowIdenticalKeys: [
209
+ 'brand.*',
210
+ 'legal.name',
211
+ /^protocol\./
212
+ ],
213
+ allowIdenticalValues: [
214
+ 'OK',
215
+ 'ID',
216
+ 'API',
217
+ /^HTTP\/\d(\.\d)?$/
218
+ ],
219
+ placeholderPatterns: [
220
+ '\\{[^}]+\\}',
221
+ '\\{\\{[^}]+\\}\\}',
222
+ '%[sdif]',
223
+ '%\\([^)]+\\)[sdif]'
224
+ ],
225
+ failOn: 'high'
226
+ };
227
+ ```
228
+
229
+ Useful tuning patterns:
230
+
231
+ - Use `allowIdenticalKeys` for groups of expected identical values, such as names, legal labels, and protocol keys.
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.
236
+ - Set `ignoreSameLanguageFamily: false` if you want to check regional variants such as `en-GB`, `en-AU`, or `pt-BR`.
237
+ - Set `failOn: 'none'` while tuning a new project, then raise it to `high` or `medium` once the allowlist is stable.
238
+ - Pass `--include-ignored` when reviewing why a value was ignored.
239
+
127
240
  ## Notes
128
241
 
129
- - ***Not a coverage checker***: This tool only reports keys that exist in both files but have identical values; it does not check for missing keys.
242
+ - This is not a coverage checker. It only reports keys that exist in both files and have identical values.
243
+ - The rules are conservative. Tune `allowIdenticalKeys` and `allowIdenticalValues` before making this a required CI step.
244
+ - Only string values are checked. Non-string locale values are ignored.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ npm install
250
+ npm test
251
+ npm run check
252
+ ```
253
+
254
+ ## License
130
255
 
131
- - ***Conservative by design***: The rules are conservative. Tune `allowIdenticalKeys` and `allowIdenticalValues` before making this a mandatory step in your CI pipeline.
256
+ MIT
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "i18n-smell-detector",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Find locale values copied from the base locale.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "i18n-smell-detector": "./bin/i18n-smell-detector.js"
8
8
  },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/xue-moe/i18n-smell-detector.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/xue-moe/i18n-smell-detector/issues"
15
+ },
16
+ "homepage": "https://github.com/xue-moe/i18n-smell-detector#readme",
9
17
  "files": [
10
18
  "bin",
11
19
  "src",
@@ -13,7 +21,7 @@
13
21
  "LICENSE"
14
22
  ],
15
23
  "scripts": {
16
- "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",
17
25
  "test": "node --test"
18
26
  },
19
27
  "keywords": [
package/src/cli.js CHANGED
@@ -6,7 +6,7 @@ import { renderReport } from './reporters/index.js';
6
6
  import { failRank, severityRank } from './severity.js';
7
7
 
8
8
  const HELP = `
9
- i18n-smell-detector v0.1
9
+ i18n-smell-detector v0.1.1
10
10
 
11
11
  Usage:
12
12
  i18n-smell-detector check-identical [options]
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);
@@ -56,24 +61,33 @@ function validateConfig(config) {
56
61
  }
57
62
 
58
63
  const candidate = /** @type {Record<string, unknown>} */ (config);
59
- if (typeof candidate.baseLocale !== 'string' || !candidate.baseLocale) {
60
- throw new Error('Config must include baseLocale');
64
+ if ('baseLocale' in candidate && (typeof candidate.baseLocale !== 'string' || !candidate.baseLocale)) {
65
+ throw new Error('Config baseLocale must be a non-empty string');
61
66
  }
62
67
 
63
- if (!candidate.locales || typeof candidate.locales !== 'object' || Array.isArray(candidate.locales)) {
64
- throw new Error('Config must include locales as an object');
68
+ if ('locales' in candidate && (!candidate.locales || typeof candidate.locales !== 'object' || Array.isArray(candidate.locales))) {
69
+ throw new Error('Config locales must be an object');
65
70
  }
66
71
 
67
- for (const [locale, value] of Object.entries(candidate.locales)) {
72
+ for (const [locale, value] of Object.entries(candidate.locales || {})) {
68
73
  if (typeof value !== 'string' || !value) {
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 */
75
87
  function withDefaults(config) {
76
88
  return {
89
+ baseLocale: 'en',
90
+ locales: {},
77
91
  allowIdenticalKeys: [],
78
92
  allowIdenticalValues: [],
79
93
  ignoreSameLanguageFamily: true,
@@ -82,5 +96,39 @@ function withDefaults(config) {
82
96
  includeIgnored: false,
83
97
  failOn: 'high',
84
98
  ...config,
99
+ placeholderPatterns: normalizePlaceholderPatterns(config.placeholderPatterns || DEFAULT_PLACEHOLDER_PATTERNS),
100
+ ignoreCodeLike: config.ignoreCodeLike ?? true,
85
101
  };
86
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
+ }
@@ -2,13 +2,28 @@ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { flattenLocale } from './flatten-locale.js';
4
4
 
5
- async function readJson(filePath) {
6
- const text = await readFile(filePath, 'utf8');
5
+ function readErrorReason(error) {
6
+ if (error && typeof error === 'object' && 'code' in error) {
7
+ if (error.code === 'ENOENT') return 'file not found';
8
+ if (error.code === 'EACCES') return 'permission denied';
9
+ }
10
+
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+
14
+ async function readJson(filePath, displayPath = filePath) {
15
+ let text;
16
+ try {
17
+ text = await readFile(filePath, 'utf8');
18
+ } catch (error) {
19
+ throw new Error(`Failed to read locale file: ${displayPath}\nReason: ${readErrorReason(error)}`);
20
+ }
21
+
7
22
  try {
8
23
  return JSON.parse(text);
9
24
  } catch (error) {
10
25
  const message = error instanceof Error ? error.message : String(error);
11
- throw new Error(`Failed to parse ${filePath}: ${message}`);
26
+ throw new Error(`Failed to parse locale file: ${displayPath}\nReason: ${message}`);
12
27
  }
13
28
  }
14
29
 
@@ -21,7 +36,7 @@ export async function loadFlattenedLocales(config, cwd) {
21
36
 
22
37
  for (const [locale, localePath] of Object.entries(config.locales || {})) {
23
38
  const absolutePath = path.isAbsolute(localePath) ? localePath : path.resolve(cwd, localePath);
24
- locales[locale] = flattenLocale(await readJson(absolutePath));
39
+ locales[locale] = flattenLocale(await readJson(absolutePath, localePath));
25
40
  }
26
41
 
27
42
  if (!locales[config.baseLocale]) {
@@ -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