i18n-smell-detector 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # i18n-smell-detector
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.
4
+
5
+ ## Features
6
+
7
+ - Smart Detection: Categorizes identical translations based on length and content (words vs. phrases).
8
+
9
+ - Flexible Whitelisting: Exclude brand names, protocol codes, or common UI labels via configuration.
10
+
11
+ - CI Integration: Configurable failure thresholds (`fail-on`) for build pipelines.
12
+
13
+ - Multiple Output Formats: Supports console, JSON, and Markdown.
14
+
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -D i18n-smell-detector
20
+ ```
21
+
22
+ For local development:
23
+
24
+ ```bash
25
+ npm install
26
+ npm test
27
+ npm run check
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Run the check:
33
+
34
+ ```bash
35
+ npx i18n-smell-detector check-identical --config i18n-smell.config.mjs
36
+ ```
37
+
38
+ Output Options:
39
+
40
+ ```bash
41
+ # Default output is console
42
+ npx i18n-smell-detector check-identical --format console
43
+ npx i18n-smell-detector check-identical --format json
44
+ npx i18n-smell-detector check-identical --format markdown
45
+ ```
46
+
47
+ ### CI/CD Integration
48
+
49
+ Set the severity level that causes the process to exit with an error:
50
+
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
55
+ ```
56
+
57
+ ## Configuration(`i18n-smell.config.mjs`)
58
+
59
+ ```js
60
+ export default {
61
+ baseLocale: 'en',
62
+ locales: {
63
+ en: './src/locales/en.json',
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
+ // ...
69
+ },
70
+
71
+ // Whitelist specific keys
72
+ allowIdenticalKeys: [
73
+ 'brand.*',
74
+ 'protocol.*',
75
+ 'common.ok',
76
+ 'common.id'
77
+ ],
78
+
79
+ // Whitelist specific values
80
+ allowIdenticalValues: [
81
+ 'OK',
82
+ 'ID',
83
+ 'HTTP',
84
+ 'API',
85
+ 'GET',
86
+ 'POST'
87
+ ],
88
+
89
+ ignoreSameLanguageFamily: true, // Ignore en -> en-GB
90
+ trimWhitespace: true, // Trim and compare
91
+ failOn: 'high'
92
+ };
93
+ ```
94
+
95
+ ## Example output
96
+
97
+ ```txt
98
+ i18n-smell-detector: identical translations
99
+ high=2 medium=1 low=0 ignored=16
100
+
101
+ HIGH zh.home.welcome
102
+ value: "Welcome back"
103
+ reason: copied English phrase
104
+
105
+ HIGH zh.settings.profile
106
+ value: "Profile settings"
107
+ reason: copied English phrase
108
+
109
+ MEDIUM ja.form.search
110
+ value: "Search"
111
+ reason: copied English word
112
+ ```
113
+
114
+ ## Detection Rules
115
+
116
+ | Case | Level |
117
+ |---|---|
118
+ | Key matches `allowIdenticalKeys` | ignored |
119
+ | 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 |
123
+ | Short common label | low |
124
+ | Single English word | medium |
125
+ | English phrase | high |
126
+
127
+ ## Notes
128
+
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.
130
+
131
+ - ***Conservative by design***: The rules are conservative. Tune `allowIdenticalKeys` and `allowIdenticalValues` before making this a mandatory step in your CI pipeline.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.js';
3
+
4
+ runCli(process.argv).catch((error) => {
5
+ const message = error instanceof Error ? error.message : String(error);
6
+ console.error(`i18n-smell-detector: ${message}`);
7
+ process.exit(2);
8
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "i18n-smell-detector",
3
+ "version": "0.1.0",
4
+ "description": "Find locale values copied from the base locale.",
5
+ "type": "module",
6
+ "bin": {
7
+ "i18n-smell-detector": "./bin/i18n-smell-detector.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "check": "node ./bin/i18n-smell-detector.js check-identical --config ./examples/vue-vite/i18n-smell.config.mjs",
17
+ "test": "node --test"
18
+ },
19
+ "keywords": [
20
+ "i18n",
21
+ "l10n",
22
+ "locale",
23
+ "translation",
24
+ "cli"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18.17"
29
+ }
30
+ }
@@ -0,0 +1,53 @@
1
+ import { classifyIdentical } from './rules/classify-identical.js';
2
+
3
+ function normalize(value, config) {
4
+ let text = String(value);
5
+ if (config.trimWhitespace ?? true) text = text.trim();
6
+ if (config.ignoreCase ?? false) text = text.toLowerCase();
7
+ return text;
8
+ }
9
+
10
+ /**
11
+ * @param {Record<string, Record<string, string>>} locales
12
+ * @param {import('./types.js').DetectorConfig} config
13
+ * @returns {import('./types.js').IdenticalIssue[]}
14
+ */
15
+ export function checkIdenticalTranslations(locales, config) {
16
+ const base = locales[config.baseLocale];
17
+ const issues = [];
18
+
19
+ for (const [targetLocale, target] of Object.entries(locales)) {
20
+ if (targetLocale === config.baseLocale) continue;
21
+
22
+ for (const [key, baseValue] of Object.entries(base)) {
23
+ if (!(key in target)) continue;
24
+ if (normalize(baseValue, config) !== normalize(target[key], config)) continue;
25
+
26
+ const { severity, reason } = classifyIdentical({
27
+ key,
28
+ value: target[key],
29
+ baseLocale: config.baseLocale,
30
+ targetLocale,
31
+ config,
32
+ });
33
+
34
+ issues.push({
35
+ key,
36
+ baseLocale: config.baseLocale,
37
+ targetLocale,
38
+ value: target[key],
39
+ severity,
40
+ reason,
41
+ });
42
+ }
43
+ }
44
+
45
+ return issues.sort(compareIssues);
46
+ }
47
+
48
+ function compareIssues(a, b) {
49
+ const rank = { high: 3, medium: 2, low: 1, ignored: 0 };
50
+ return rank[b.severity] - rank[a.severity]
51
+ || a.targetLocale.localeCompare(b.targetLocale)
52
+ || a.key.localeCompare(b.key);
53
+ }
package/src/cli.js ADDED
@@ -0,0 +1,90 @@
1
+ import path from 'node:path';
2
+ import { checkIdenticalTranslations } from './check-identical.js';
3
+ import { loadConfig, resolveConfigPath } from './config.js';
4
+ import { loadFlattenedLocales } from './locale/load-locales.js';
5
+ import { renderReport } from './reporters/index.js';
6
+ import { failRank, severityRank } from './severity.js';
7
+
8
+ const HELP = `
9
+ i18n-smell-detector v0.1
10
+
11
+ Usage:
12
+ i18n-smell-detector check-identical [options]
13
+ i18n-smell-detector check [options]
14
+
15
+ Options:
16
+ -c, --config <path> Config file path
17
+ --format <format> console | json | markdown (default: console)
18
+ --fail-on <level> high | medium | low | none (default: config.failOn or high)
19
+ --include-ignored Print ignored matches as well
20
+ -h, --help Show help
21
+
22
+ Example:
23
+ i18n-smell-detector check-identical -c i18n-smell.config.mjs --format markdown
24
+ `;
25
+
26
+ function parseArgs(argv) {
27
+ const args = argv.slice(2);
28
+ const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'check-identical';
29
+ const options = { command, format: 'console', includeIgnored: false, help: false };
30
+
31
+ for (let index = 0; index < args.length; index += 1) {
32
+ const arg = args[index];
33
+
34
+ if (arg === '-h' || arg === '--help') {
35
+ options.help = true;
36
+ } else if (arg === '-c' || arg === '--config') {
37
+ options.configPath = args[++index];
38
+ } else if (arg === '--format') {
39
+ options.format = readChoice(args[++index], ['console', 'json', 'markdown'], 'format');
40
+ } else if (arg === '--fail-on') {
41
+ options.failOn = readChoice(args[++index], ['none', 'low', 'medium', 'high'], 'fail-on level');
42
+ } else if (arg === '--include-ignored') {
43
+ options.includeIgnored = true;
44
+ } else {
45
+ throw new Error(`Unknown option: ${arg}`);
46
+ }
47
+ }
48
+
49
+ return options;
50
+ }
51
+
52
+ function readChoice(value, choices, name) {
53
+ if (!choices.includes(value)) {
54
+ throw new Error(`Unsupported ${name}: ${value}`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export async function runCli(argv) {
60
+ const options = parseArgs(argv);
61
+
62
+ if (options.help || options.command === 'help') {
63
+ console.log(HELP.trim());
64
+ return;
65
+ }
66
+
67
+ if (!['check-identical', 'check'].includes(options.command)) {
68
+ throw new Error(`Unknown command: ${options.command}\n${HELP}`);
69
+ }
70
+
71
+ const configPath = await resolveConfigPath(options.configPath, process.cwd());
72
+ const config = await loadConfig(configPath);
73
+ const effectiveConfig = {
74
+ ...config,
75
+ failOn: options.failOn || config.failOn || 'high',
76
+ includeIgnored: options.includeIgnored || config.includeIgnored || false,
77
+ };
78
+
79
+ const locales = await loadFlattenedLocales(effectiveConfig, path.dirname(configPath));
80
+ const issues = checkIdenticalTranslations(locales, effectiveConfig);
81
+
82
+ console.log(renderReport(issues, {
83
+ format: options.format,
84
+ includeIgnored: effectiveConfig.includeIgnored || false,
85
+ }));
86
+
87
+ const visible = effectiveConfig.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
88
+ const shouldFail = visible.some((issue) => severityRank[issue.severity] >= failRank(effectiveConfig.failOn || 'high'));
89
+ if (shouldFail) process.exit(1);
90
+ }
package/src/config.js ADDED
@@ -0,0 +1,86 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ const CONFIG_FILES = [
9
+ 'i18n-smell.config.mjs',
10
+ 'i18n-smell.config.cjs',
11
+ 'i18n-smell.config.js',
12
+ 'i18n-smell.config.json',
13
+ ];
14
+
15
+ async function exists(filePath) {
16
+ try {
17
+ await access(filePath);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function resolveConfigPath(explicitPath, cwd) {
25
+ if (explicitPath) {
26
+ const absolute = path.isAbsolute(explicitPath) ? explicitPath : path.resolve(cwd, explicitPath);
27
+ if (!(await exists(absolute))) throw new Error(`Config file not found: ${absolute}`);
28
+ return absolute;
29
+ }
30
+
31
+ for (const file of CONFIG_FILES) {
32
+ const absolute = path.resolve(cwd, file);
33
+ if (await exists(absolute)) return absolute;
34
+ }
35
+
36
+ throw new Error(`Config file not found. Expected one of: ${CONFIG_FILES.join(', ')}`);
37
+ }
38
+
39
+ export async function loadConfig(configPath) {
40
+ let loaded;
41
+
42
+ if (configPath.endsWith('.json') || configPath.endsWith('.cjs')) {
43
+ loaded = require(configPath);
44
+ } else {
45
+ loaded = await import(pathToFileURL(configPath).href);
46
+ }
47
+
48
+ const config = loaded.default || loaded;
49
+ validateConfig(config);
50
+ return withDefaults(config);
51
+ }
52
+
53
+ function validateConfig(config) {
54
+ if (!config || typeof config !== 'object') {
55
+ throw new Error('Config must export an object');
56
+ }
57
+
58
+ const candidate = /** @type {Record<string, unknown>} */ (config);
59
+ if (typeof candidate.baseLocale !== 'string' || !candidate.baseLocale) {
60
+ throw new Error('Config must include baseLocale');
61
+ }
62
+
63
+ if (!candidate.locales || typeof candidate.locales !== 'object' || Array.isArray(candidate.locales)) {
64
+ throw new Error('Config must include locales as an object');
65
+ }
66
+
67
+ for (const [locale, value] of Object.entries(candidate.locales)) {
68
+ if (typeof value !== 'string' || !value) {
69
+ throw new Error(`Config locales.${locale} must be a file path string`);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** @param {import('./types.js').DetectorConfig} config */
75
+ function withDefaults(config) {
76
+ return {
77
+ allowIdenticalKeys: [],
78
+ allowIdenticalValues: [],
79
+ ignoreSameLanguageFamily: true,
80
+ trimWhitespace: true,
81
+ ignoreCase: false,
82
+ includeIgnored: false,
83
+ failOn: 'high',
84
+ ...config,
85
+ };
86
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @param {unknown} input
3
+ * @param {string} [prefix]
4
+ * @returns {Record<string, string>}
5
+ */
6
+ export function flattenLocale(input, prefix = '') {
7
+ const result = {};
8
+
9
+ if (typeof input === 'string') {
10
+ if (prefix) result[prefix] = input;
11
+ return result;
12
+ }
13
+
14
+ if (Array.isArray(input)) {
15
+ input.forEach((value, index) => {
16
+ Object.assign(result, flattenLocale(value, prefix ? `${prefix}.${index}` : String(index)));
17
+ });
18
+ return result;
19
+ }
20
+
21
+ if (!input || typeof input !== 'object') return result;
22
+
23
+ for (const [key, value] of Object.entries(input)) {
24
+ Object.assign(result, flattenLocale(value, prefix ? `${prefix}.${key}` : key));
25
+ }
26
+
27
+ return result;
28
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { flattenLocale } from './flatten-locale.js';
4
+
5
+ async function readJson(filePath) {
6
+ const text = await readFile(filePath, 'utf8');
7
+ try {
8
+ return JSON.parse(text);
9
+ } catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ throw new Error(`Failed to parse ${filePath}: ${message}`);
12
+ }
13
+ }
14
+
15
+ /**
16
+ * @param {import('../types.js').DetectorConfig} config
17
+ * @param {string} cwd
18
+ */
19
+ export async function loadFlattenedLocales(config, cwd) {
20
+ const locales = {};
21
+
22
+ for (const [locale, localePath] of Object.entries(config.locales || {})) {
23
+ const absolutePath = path.isAbsolute(localePath) ? localePath : path.resolve(cwd, localePath);
24
+ locales[locale] = flattenLocale(await readJson(absolutePath));
25
+ }
26
+
27
+ if (!locales[config.baseLocale]) {
28
+ throw new Error(`baseLocale "${config.baseLocale}" is not listed in config.locales`);
29
+ }
30
+
31
+ return locales;
32
+ }
@@ -0,0 +1,49 @@
1
+ function color(code, text) {
2
+ if (process.env.NO_COLOR) return text;
3
+ return `\u001b[${code}m${text}\u001b[0m`;
4
+ }
5
+
6
+ function label(level) {
7
+ if (level === 'high') return color('31;1', 'HIGH');
8
+ if (level === 'medium') return color('33;1', 'MEDIUM');
9
+ if (level === 'low') return color('36;1', 'LOW');
10
+ return color('90', 'IGNORED');
11
+ }
12
+
13
+ function preview(value) {
14
+ const text = value.replace(/\s+/g, ' ').trim();
15
+ return text.length <= 90 ? text : `${text.slice(0, 87)}...`;
16
+ }
17
+
18
+ export function renderConsoleReport(issues, options) {
19
+ const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
20
+ const counts = countBySeverity(issues);
21
+ const lines = [
22
+ color('1', 'i18n-smell-detector: identical translations'),
23
+ `high=${counts.high} medium=${counts.medium} low=${counts.low} ignored=${counts.ignored}`,
24
+ ];
25
+
26
+ if (visible.length === 0) {
27
+ lines.push(color('32', 'No copied base-locale values found.'));
28
+ return lines.join('\n');
29
+ }
30
+
31
+ lines.push('');
32
+ for (const issue of visible) {
33
+ lines.push(`${label(issue.severity)} ${issue.targetLocale}.${issue.key}`);
34
+ lines.push(` value: "${preview(issue.value)}"`);
35
+ lines.push(` reason: ${issue.reason}`);
36
+ }
37
+
38
+ return lines.join('\n');
39
+ }
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
+ }
@@ -0,0 +1,9 @@
1
+ import { renderConsoleReport } from './console.js';
2
+ import { renderJsonReport } from './json.js';
3
+ import { renderMarkdownReport } from './markdown.js';
4
+
5
+ export function renderReport(issues, options) {
6
+ if (options.format === 'json') return renderJsonReport(issues, options);
7
+ if (options.format === 'markdown') return renderMarkdownReport(issues, options);
8
+ return renderConsoleReport(issues, options);
9
+ }
@@ -0,0 +1,4 @@
1
+ export function renderJsonReport(issues, options) {
2
+ const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
3
+ return `${JSON.stringify({ issues: visible }, null, 2)}\n`;
4
+ }
@@ -0,0 +1,23 @@
1
+ function escapeCell(value) {
2
+ return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
3
+ }
4
+
5
+ export function renderMarkdownReport(issues, options) {
6
+ const visible = options.includeIgnored ? issues : issues.filter((issue) => issue.severity !== 'ignored');
7
+ const lines = ['# Identical translations', ''];
8
+
9
+ if (visible.length === 0) {
10
+ lines.push('No copied base-locale values found.');
11
+ return `${lines.join('\n')}\n`;
12
+ }
13
+
14
+ lines.push('| Level | Locale | Key | Value | Reason |');
15
+ lines.push('|---|---|---|---|---|');
16
+ 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
+ );
20
+ }
21
+
22
+ return `${lines.join('\n')}\n`;
23
+ }
@@ -0,0 +1,82 @@
1
+ import { matchesAnyPattern } from './match-pattern.js';
2
+
3
+ function isBlank(value) {
4
+ return value.trim().length === 0;
5
+ }
6
+
7
+ function isExternalReference(value) {
8
+ const text = value.trim();
9
+ return /^(https?:\/\/|tel:)/i.test(text) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text);
10
+ }
11
+
12
+ function isCodeLike(value) {
13
+ const text = value.trim();
14
+ return /^#[0-9a-f]{3,8}$/i.test(text)
15
+ || /^\/[a-z0-9_./:-]*$/i.test(text)
16
+ || /^[A-Z]{2,5}$/.test(text)
17
+ || /^[A-Z]{2,5}\s?[0-9A-Z-]*$/.test(text)
18
+ || (/^[a-z0-9_.:-]+$/.test(text) && !/\s/.test(text) && !/[A-Z]/.test(text));
19
+ }
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;
27
+ }
28
+
29
+ function getLatinWords(value) {
30
+ return value.match(/[A-Za-z][A-Za-z']*/g) || [];
31
+ }
32
+
33
+ function languageFamily(locale) {
34
+ return locale.toLowerCase().split(/[-_]/)[0];
35
+ }
36
+
37
+ function isShortCommonLabel(value) {
38
+ const text = value.trim();
39
+ return text.length <= 3 || /^(ok|id|no|yes|on|off|5g|4g|lte)$/i.test(text);
40
+ }
41
+
42
+ /**
43
+ * @param {Object} input
44
+ * @param {string} input.key
45
+ * @param {string} input.value
46
+ * @param {string} input.baseLocale
47
+ * @param {string} input.targetLocale
48
+ * @param {import('../types.js').DetectorConfig} input.config
49
+ * @returns {{ severity: import('../types.js').Severity, reason: string }}
50
+ */
51
+ 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
+ return { severity: 'ignored', reason: 'allowed key' };
58
+ }
59
+
60
+ if ((config.allowIdenticalValues || []).includes(value)) {
61
+ return { severity: 'ignored', reason: 'allowed value' };
62
+ }
63
+
64
+ if (isBlank(value)) return { severity: 'ignored', reason: 'blank value' };
65
+ 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' };
68
+ if (isShortCommonLabel(value)) return { severity: 'low', reason: 'short common label' };
69
+
70
+ const words = getLatinWords(value);
71
+ const hasSentencePunctuation = /[.!?。!?]/.test(value);
72
+
73
+ if (words.length >= 2 || (words.length === 1 && hasSentencePunctuation)) {
74
+ return { severity: 'high', reason: 'copied English phrase' };
75
+ }
76
+
77
+ if (words.length === 1) {
78
+ return { severity: 'medium', reason: 'copied English word' };
79
+ }
80
+
81
+ return { severity: 'low', reason: 'same text as base locale' };
82
+ }
@@ -0,0 +1,8 @@
1
+ function patternToRegExp(pattern) {
2
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
3
+ return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
4
+ }
5
+
6
+ export function matchesAnyPattern(value, patterns = []) {
7
+ return patterns.some((pattern) => patternToRegExp(pattern).test(value));
8
+ }
@@ -0,0 +1,10 @@
1
+ export const severityRank = {
2
+ ignored: 0,
3
+ low: 1,
4
+ medium: 2,
5
+ high: 3,
6
+ };
7
+
8
+ export function failRank(failOn) {
9
+ return failOn === 'none' ? Number.POSITIVE_INFINITY : severityRank[failOn];
10
+ }
package/src/types.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @typedef {'ignored' | 'low' | 'medium' | 'high'} Severity
3
+ * @typedef {'console' | 'json' | 'markdown'} ReportFormat
4
+ *
5
+ * @typedef {Object} DetectorConfig
6
+ * @property {string} baseLocale
7
+ * @property {Record<string, string>} locales
8
+ * @property {string[]=} allowIdenticalKeys
9
+ * @property {string[]=} allowIdenticalValues
10
+ * @property {boolean=} ignoreSameLanguageFamily
11
+ * @property {boolean=} trimWhitespace
12
+ * @property {boolean=} ignoreCase
13
+ * @property {boolean=} includeIgnored
14
+ * @property {'none' | 'low' | 'medium' | 'high'=} failOn
15
+ *
16
+ * @typedef {Object} IdenticalIssue
17
+ * @property {string} key
18
+ * @property {string} baseLocale
19
+ * @property {string} targetLocale
20
+ * @property {string} value
21
+ * @property {Severity} severity
22
+ * @property {string} reason
23
+ */
24
+
25
+ export {};