@mxml3gend/gloss 0.1.0 → 0.1.2
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 +52 -0
- package/dist/check.js +382 -0
- package/dist/config.js +139 -10
- package/dist/gitDiff.js +113 -0
- package/dist/index.js +178 -9
- package/dist/renameKeyUsage.js +4 -12
- package/dist/server.js +25 -1
- package/dist/translationKeys.js +20 -0
- package/dist/translationTree.js +22 -0
- package/dist/typegen.js +30 -0
- package/dist/ui/Gloss_logo.png +0 -0
- package/dist/ui/assets/index-CgyZVU2h.css +1 -0
- package/dist/ui/assets/index-DfgO64nU.js +12 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/logo_full.png +0 -0
- package/dist/usage.js +5 -23
- package/dist/usageExtractor.js +151 -0
- package/dist/usageScanner.js +12 -23
- package/package.json +13 -3
- package/dist/ui/assets/index-CREq9Gop.css +0 -1
- package/dist/ui/assets/index-Dhb2pVPI.js +0 -10
package/README.md
CHANGED
|
@@ -35,6 +35,11 @@ export default {
|
|
|
35
35
|
defaultLocale: "en",
|
|
36
36
|
path: "src/i18n",
|
|
37
37
|
format: "json",
|
|
38
|
+
scan: {
|
|
39
|
+
include: ["src/**/*.{ts,tsx,js,jsx}"],
|
|
40
|
+
exclude: ["**/*.test.tsx"],
|
|
41
|
+
mode: "regex", // or "ast" for strict parsing
|
|
42
|
+
},
|
|
38
43
|
};
|
|
39
44
|
```
|
|
40
45
|
|
|
@@ -46,6 +51,9 @@ module.exports = {
|
|
|
46
51
|
defaultLocale: "en",
|
|
47
52
|
path: "src/i18n",
|
|
48
53
|
format: "json",
|
|
54
|
+
scan: {
|
|
55
|
+
mode: "ast",
|
|
56
|
+
},
|
|
49
57
|
};
|
|
50
58
|
```
|
|
51
59
|
|
|
@@ -56,4 +64,48 @@ gloss --help
|
|
|
56
64
|
gloss --version
|
|
57
65
|
gloss --no-open
|
|
58
66
|
gloss --port 5179
|
|
67
|
+
gloss open key auth.login.title
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## CI Guardrails
|
|
71
|
+
|
|
72
|
+
Run project checks for missing/orphan/invalid keys, placeholder mismatches, and potential hardcoded UI text:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
gloss check
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Machine-readable output:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
gloss check --format json
|
|
82
|
+
gloss check --format both
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`gloss check` exits with code `1` when issues are found, so it is CI-friendly.
|
|
86
|
+
|
|
87
|
+
The local UI also consumes this data through `/api/check` and shows a hardcoded-text status chip.
|
|
88
|
+
|
|
89
|
+
## Typed Key Generation
|
|
90
|
+
|
|
91
|
+
Generate `i18n-keys.d.ts` from current translation keys:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
gloss gen-types
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Use the generated `I18nKey` type in your app's `t(...)` signature to get key autocomplete while typing.
|
|
98
|
+
|
|
99
|
+
Custom output path:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gloss gen-types --out src/types/i18n-keys.d.ts
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Deep-Link Open
|
|
106
|
+
|
|
107
|
+
Open Gloss directly focused on a key:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
gloss open key auth.login.title
|
|
59
111
|
```
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readAllTranslations } from "./fs.js";
|
|
4
|
+
import { createScanMatcher } from "./scanFilters.js";
|
|
5
|
+
import { flattenObject } from "./translationTree.js";
|
|
6
|
+
import { getInvalidTranslationKeyReason, isLikelyTranslationKey, } from "./translationKeys.js";
|
|
7
|
+
import { inferUsageRoot, scanUsage } from "./usageScanner.js";
|
|
8
|
+
const HARDCODED_IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
".git",
|
|
11
|
+
".next",
|
|
12
|
+
".nuxt",
|
|
13
|
+
".turbo",
|
|
14
|
+
"dist",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
"out",
|
|
18
|
+
"storybook-static",
|
|
19
|
+
]);
|
|
20
|
+
const HARDCODED_EXTENSIONS = new Set([".tsx", ".jsx"]);
|
|
21
|
+
const JSX_TEXT_REGEX = />\s*([A-Za-z][A-Za-z0-9 .,!?'’"’-]{1,})\s*</g;
|
|
22
|
+
const JSX_ATTRIBUTE_REGEX = /\b(?:title|label|placeholder|alt|aria-label|helperText|tooltip|description)\s*=\s*["'`]([^"'`]+)["'`]/g;
|
|
23
|
+
const SIMPLE_PLACEHOLDER_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
24
|
+
const ICU_PLACEHOLDER_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\s*,\s*(plural|select|selectordinal)\s*,/g;
|
|
25
|
+
const ICU_PLURAL_START_REGEX = /\{([A-Za-z_][A-Za-z0-9_]*)\s*,\s*plural\s*,/g;
|
|
26
|
+
const ICU_CATEGORY_REGEX = /(?:^|[\s,])(=?\d+|zero|one|two|few|many|other)\s*\{/g;
|
|
27
|
+
const projectRoot = () => process.env.INIT_CWD || process.cwd();
|
|
28
|
+
const normalizePath = (filePath) => filePath.split(path.sep).join("/");
|
|
29
|
+
const withCollapsedWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
30
|
+
const lineNumberAtIndex = (source, index) => {
|
|
31
|
+
let line = 1;
|
|
32
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
33
|
+
if (source.charCodeAt(cursor) === 10) {
|
|
34
|
+
line += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return line;
|
|
38
|
+
};
|
|
39
|
+
const hasIgnoredPathSegment = (relativePath) => normalizePath(relativePath)
|
|
40
|
+
.split("/")
|
|
41
|
+
.some((segment) => HARDCODED_IGNORED_DIRECTORIES.has(segment));
|
|
42
|
+
const isLikelyHardcodedText = (value) => {
|
|
43
|
+
const text = withCollapsedWhitespace(value);
|
|
44
|
+
if (text.length < 3) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (!/[A-Za-z]/.test(text)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
// Ignore values that clearly look like i18n keys, but keep plain words
|
|
51
|
+
// like "test" or "Save" so hardcoded UI text is still detected.
|
|
52
|
+
if (isLikelyTranslationKey(text) && /[.:/]/.test(text)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (/^(true|false|null|undefined)$/i.test(text)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (/^(https?:|\/|#)/i.test(text)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (/[=;(){}|]|=>/.test(text)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (/\b(?:return|const|let|var|function|import|export)\b/.test(text)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (/\b(?:void|promise|string|number|boolean|record|unknown|any|extends|infer)\b/i.test(text)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const flattenByLocale = (cfg, data) => {
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const locale of cfg.locales) {
|
|
75
|
+
const tree = data[locale] ?? {};
|
|
76
|
+
result[locale] = flattenObject(tree);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
const uniqueSorted = (items) => Array.from(new Set(items)).sort((left, right) => left.localeCompare(right));
|
|
81
|
+
const asComparable = (items) => items.join("\u0000");
|
|
82
|
+
const findMatchingBraceEnd = (value, startIndex) => {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
for (let index = startIndex; index < value.length; index += 1) {
|
|
85
|
+
const char = value[index];
|
|
86
|
+
if (char === "{") {
|
|
87
|
+
depth += 1;
|
|
88
|
+
}
|
|
89
|
+
else if (char === "}") {
|
|
90
|
+
depth -= 1;
|
|
91
|
+
if (depth === 0) {
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return -1;
|
|
97
|
+
};
|
|
98
|
+
const extractPlaceholders = (value) => {
|
|
99
|
+
const placeholders = new Set();
|
|
100
|
+
let match = SIMPLE_PLACEHOLDER_REGEX.exec(value);
|
|
101
|
+
while (match) {
|
|
102
|
+
placeholders.add(match[1]);
|
|
103
|
+
match = SIMPLE_PLACEHOLDER_REGEX.exec(value);
|
|
104
|
+
}
|
|
105
|
+
SIMPLE_PLACEHOLDER_REGEX.lastIndex = 0;
|
|
106
|
+
match = ICU_PLACEHOLDER_REGEX.exec(value);
|
|
107
|
+
while (match) {
|
|
108
|
+
placeholders.add(match[1]);
|
|
109
|
+
match = ICU_PLACEHOLDER_REGEX.exec(value);
|
|
110
|
+
}
|
|
111
|
+
ICU_PLACEHOLDER_REGEX.lastIndex = 0;
|
|
112
|
+
return uniqueSorted(placeholders);
|
|
113
|
+
};
|
|
114
|
+
const extractPluralCategories = (value) => {
|
|
115
|
+
const categoriesByVariable = new Map();
|
|
116
|
+
let match = ICU_PLURAL_START_REGEX.exec(value);
|
|
117
|
+
while (match) {
|
|
118
|
+
const variable = match[1];
|
|
119
|
+
const startIndex = match.index;
|
|
120
|
+
const endIndex = findMatchingBraceEnd(value, startIndex);
|
|
121
|
+
if (endIndex === -1) {
|
|
122
|
+
match = ICU_PLURAL_START_REGEX.exec(value);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const block = value.slice(startIndex, endIndex + 1);
|
|
126
|
+
const categories = categoriesByVariable.get(variable) ?? new Set();
|
|
127
|
+
let categoryMatch = ICU_CATEGORY_REGEX.exec(block);
|
|
128
|
+
while (categoryMatch) {
|
|
129
|
+
categories.add(categoryMatch[1]);
|
|
130
|
+
categoryMatch = ICU_CATEGORY_REGEX.exec(block);
|
|
131
|
+
}
|
|
132
|
+
ICU_CATEGORY_REGEX.lastIndex = 0;
|
|
133
|
+
categoriesByVariable.set(variable, categories);
|
|
134
|
+
ICU_PLURAL_START_REGEX.lastIndex = endIndex + 1;
|
|
135
|
+
match = ICU_PLURAL_START_REGEX.exec(value);
|
|
136
|
+
}
|
|
137
|
+
ICU_PLURAL_START_REGEX.lastIndex = 0;
|
|
138
|
+
return new Map(Array.from(categoriesByVariable.entries()).map(([variable, categories]) => [
|
|
139
|
+
variable,
|
|
140
|
+
uniqueSorted(categories),
|
|
141
|
+
]));
|
|
142
|
+
};
|
|
143
|
+
const scanHardcodedText = async (rootDir, cfg) => {
|
|
144
|
+
const issues = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const shouldScanFile = createScanMatcher(cfg.scan);
|
|
147
|
+
const visitDirectory = async (directory) => {
|
|
148
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const fullPath = path.join(directory, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
if (entry.name.startsWith(".") ||
|
|
153
|
+
HARDCODED_IGNORED_DIRECTORIES.has(entry.name)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
await visitDirectory(fullPath);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!entry.isFile() || !HARDCODED_EXTENSIONS.has(path.extname(entry.name))) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const relativePath = normalizePath(path.relative(rootDir, fullPath));
|
|
163
|
+
if (hasIgnoredPathSegment(relativePath) || !shouldScanFile(relativePath)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const source = await fs.readFile(fullPath, "utf8");
|
|
167
|
+
let textMatch = JSX_TEXT_REGEX.exec(source);
|
|
168
|
+
while (textMatch) {
|
|
169
|
+
const text = withCollapsedWhitespace(textMatch[1]);
|
|
170
|
+
if (isLikelyHardcodedText(text)) {
|
|
171
|
+
const line = lineNumberAtIndex(source, textMatch.index);
|
|
172
|
+
const dedupeKey = `${relativePath}:${line}:jsx_text:${text}`;
|
|
173
|
+
if (!seen.has(dedupeKey)) {
|
|
174
|
+
seen.add(dedupeKey);
|
|
175
|
+
issues.push({ file: relativePath, line, kind: "jsx_text", text });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
textMatch = JSX_TEXT_REGEX.exec(source);
|
|
179
|
+
}
|
|
180
|
+
JSX_TEXT_REGEX.lastIndex = 0;
|
|
181
|
+
let attrMatch = JSX_ATTRIBUTE_REGEX.exec(source);
|
|
182
|
+
while (attrMatch) {
|
|
183
|
+
const text = withCollapsedWhitespace(attrMatch[1]);
|
|
184
|
+
if (isLikelyHardcodedText(text)) {
|
|
185
|
+
const line = lineNumberAtIndex(source, attrMatch.index);
|
|
186
|
+
const dedupeKey = `${relativePath}:${line}:jsx_attribute:${text}`;
|
|
187
|
+
if (!seen.has(dedupeKey)) {
|
|
188
|
+
seen.add(dedupeKey);
|
|
189
|
+
issues.push({ file: relativePath, line, kind: "jsx_attribute", text });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
attrMatch = JSX_ATTRIBUTE_REGEX.exec(source);
|
|
193
|
+
}
|
|
194
|
+
JSX_ATTRIBUTE_REGEX.lastIndex = 0;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
await visitDirectory(rootDir);
|
|
198
|
+
issues.sort((left, right) => {
|
|
199
|
+
if (left.file !== right.file) {
|
|
200
|
+
return left.file.localeCompare(right.file);
|
|
201
|
+
}
|
|
202
|
+
if (left.line !== right.line) {
|
|
203
|
+
return left.line - right.line;
|
|
204
|
+
}
|
|
205
|
+
return left.text.localeCompare(right.text);
|
|
206
|
+
});
|
|
207
|
+
return issues;
|
|
208
|
+
};
|
|
209
|
+
export async function runGlossCheck(cfg) {
|
|
210
|
+
const rootDir = projectRoot();
|
|
211
|
+
const data = (await readAllTranslations(cfg));
|
|
212
|
+
const flatByLocale = flattenByLocale(cfg, data);
|
|
213
|
+
const usageRoot = inferUsageRoot(cfg);
|
|
214
|
+
const usage = await scanUsage(usageRoot, cfg.scan);
|
|
215
|
+
const usageKeys = new Set(Object.keys(usage));
|
|
216
|
+
const translationKeys = new Set(cfg.locales.flatMap((locale) => Object.keys(flatByLocale[locale] ?? {})));
|
|
217
|
+
const allKeys = uniqueSorted([...translationKeys, ...usageKeys]);
|
|
218
|
+
const missingTranslations = [];
|
|
219
|
+
for (const key of allKeys) {
|
|
220
|
+
const missingLocales = cfg.locales.filter((locale) => {
|
|
221
|
+
const value = flatByLocale[locale]?.[key];
|
|
222
|
+
return value === undefined || value.trim() === "";
|
|
223
|
+
});
|
|
224
|
+
if (missingLocales.length > 0) {
|
|
225
|
+
missingTranslations.push({
|
|
226
|
+
key,
|
|
227
|
+
missingLocales,
|
|
228
|
+
usedInCode: usageKeys.has(key),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const orphanKeys = [];
|
|
233
|
+
for (const key of translationKeys) {
|
|
234
|
+
if (usageKeys.has(key)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const localesWithValue = cfg.locales.filter((locale) => {
|
|
238
|
+
const value = flatByLocale[locale]?.[key];
|
|
239
|
+
return value !== undefined && value.trim() !== "";
|
|
240
|
+
});
|
|
241
|
+
orphanKeys.push({ key, localesWithValue });
|
|
242
|
+
}
|
|
243
|
+
const invalidKeys = [];
|
|
244
|
+
for (const key of translationKeys) {
|
|
245
|
+
const reason = getInvalidTranslationKeyReason(key);
|
|
246
|
+
if (reason) {
|
|
247
|
+
invalidKeys.push({ key, reason });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const placeholderMismatches = [];
|
|
251
|
+
for (const key of translationKeys) {
|
|
252
|
+
const localesWithValue = cfg.locales.filter((locale) => {
|
|
253
|
+
const value = flatByLocale[locale]?.[key];
|
|
254
|
+
return value !== undefined && value.trim() !== "";
|
|
255
|
+
});
|
|
256
|
+
if (localesWithValue.length <= 1) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const referenceLocale = localesWithValue.includes(cfg.defaultLocale)
|
|
260
|
+
? cfg.defaultLocale
|
|
261
|
+
: localesWithValue[0];
|
|
262
|
+
const referenceValue = flatByLocale[referenceLocale][key];
|
|
263
|
+
const expectedPlaceholders = extractPlaceholders(referenceValue);
|
|
264
|
+
const expectedPluralByVariable = extractPluralCategories(referenceValue);
|
|
265
|
+
const expectedPlaceholdersComparable = asComparable(expectedPlaceholders);
|
|
266
|
+
const mismatchedLocales = [];
|
|
267
|
+
const byLocale = {};
|
|
268
|
+
const pluralMismatches = [];
|
|
269
|
+
for (const locale of localesWithValue) {
|
|
270
|
+
const value = flatByLocale[locale][key];
|
|
271
|
+
const placeholders = extractPlaceholders(value);
|
|
272
|
+
byLocale[locale] = placeholders;
|
|
273
|
+
if (asComparable(placeholders) !== expectedPlaceholdersComparable) {
|
|
274
|
+
mismatchedLocales.push(locale);
|
|
275
|
+
}
|
|
276
|
+
const actualPluralByVariable = extractPluralCategories(value);
|
|
277
|
+
const pluralVariables = uniqueSorted([
|
|
278
|
+
...expectedPluralByVariable.keys(),
|
|
279
|
+
...actualPluralByVariable.keys(),
|
|
280
|
+
]);
|
|
281
|
+
for (const variable of pluralVariables) {
|
|
282
|
+
const expectedCategories = expectedPluralByVariable.get(variable) ?? [];
|
|
283
|
+
const actualCategories = actualPluralByVariable.get(variable) ?? [];
|
|
284
|
+
if (asComparable(expectedCategories) !== asComparable(actualCategories)) {
|
|
285
|
+
pluralMismatches.push({
|
|
286
|
+
locale,
|
|
287
|
+
variable,
|
|
288
|
+
expectedCategories,
|
|
289
|
+
actualCategories,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (mismatchedLocales.length > 0 || pluralMismatches.length > 0) {
|
|
295
|
+
placeholderMismatches.push({
|
|
296
|
+
key,
|
|
297
|
+
referenceLocale,
|
|
298
|
+
expectedPlaceholders,
|
|
299
|
+
byLocale,
|
|
300
|
+
mismatchedLocales: uniqueSorted(mismatchedLocales),
|
|
301
|
+
pluralMismatches,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const hardcodedTexts = await scanHardcodedText(usageRoot, cfg);
|
|
306
|
+
const summary = {
|
|
307
|
+
missingTranslations: missingTranslations.length,
|
|
308
|
+
orphanKeys: orphanKeys.length,
|
|
309
|
+
invalidKeys: invalidKeys.length,
|
|
310
|
+
placeholderMismatches: placeholderMismatches.length,
|
|
311
|
+
hardcodedTexts: hardcodedTexts.length,
|
|
312
|
+
totalIssues: missingTranslations.length +
|
|
313
|
+
orphanKeys.length +
|
|
314
|
+
invalidKeys.length +
|
|
315
|
+
placeholderMismatches.length +
|
|
316
|
+
hardcodedTexts.length,
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
ok: summary.totalIssues === 0,
|
|
320
|
+
generatedAt: new Date().toISOString(),
|
|
321
|
+
rootDir: rootDir,
|
|
322
|
+
locales: cfg.locales,
|
|
323
|
+
summary,
|
|
324
|
+
missingTranslations,
|
|
325
|
+
orphanKeys,
|
|
326
|
+
invalidKeys,
|
|
327
|
+
placeholderMismatches,
|
|
328
|
+
hardcodedTexts,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const printTable = (rows) => {
|
|
332
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
333
|
+
console.log("");
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
console.log(`${row.label.padEnd(labelWidth)} : ${row.value}`);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const printSample = (title, lines) => {
|
|
339
|
+
console.log(`\n${title} (${lines.length})`);
|
|
340
|
+
const limit = 12;
|
|
341
|
+
for (const line of lines.slice(0, limit)) {
|
|
342
|
+
console.log(`- ${line}`);
|
|
343
|
+
}
|
|
344
|
+
if (lines.length > limit) {
|
|
345
|
+
console.log(`- ... +${lines.length - limit} more`);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
export const printGlossCheck = (result, format) => {
|
|
349
|
+
if (format === "human" || format === "both") {
|
|
350
|
+
console.log(`Gloss check for ${result.rootDir}`);
|
|
351
|
+
printTable([
|
|
352
|
+
{ label: "Missing translations", value: result.summary.missingTranslations },
|
|
353
|
+
{ label: "Orphan keys", value: result.summary.orphanKeys },
|
|
354
|
+
{ label: "Invalid keys", value: result.summary.invalidKeys },
|
|
355
|
+
{
|
|
356
|
+
label: "Placeholder mismatches",
|
|
357
|
+
value: result.summary.placeholderMismatches,
|
|
358
|
+
},
|
|
359
|
+
{ label: "Hardcoded text candidates", value: result.summary.hardcodedTexts },
|
|
360
|
+
{ label: "Total issues", value: result.summary.totalIssues },
|
|
361
|
+
]);
|
|
362
|
+
printSample("Missing translations", result.missingTranslations.map((issue) => `${issue.key} -> missing in [${issue.missingLocales.join(", ")}]${issue.usedInCode ? " (used)" : ""}`));
|
|
363
|
+
printSample("Orphan keys", result.orphanKeys.map((issue) => `${issue.key} -> present in [${issue.localesWithValue.join(", ")}]`));
|
|
364
|
+
printSample("Invalid keys", result.invalidKeys.map((issue) => `${issue.key} -> ${issue.reason}`));
|
|
365
|
+
printSample("Placeholder mismatches", result.placeholderMismatches.map((issue) => {
|
|
366
|
+
const pluralInfo = issue.pluralMismatches.length > 0
|
|
367
|
+
? `; plural mismatches: ${issue.pluralMismatches.length}`
|
|
368
|
+
: "";
|
|
369
|
+
return `${issue.key} -> expected [${issue.expectedPlaceholders.join(", ")}] from ${issue.referenceLocale}; locales: [${issue.mismatchedLocales.join(", ")}]${pluralInfo}`;
|
|
370
|
+
}));
|
|
371
|
+
printSample("Hardcoded text candidates", result.hardcodedTexts.map((issue) => `${issue.file}:${issue.line} [${issue.kind}] ${issue.text}`));
|
|
372
|
+
console.log(result.ok
|
|
373
|
+
? "\nResult: PASS"
|
|
374
|
+
: "\nResult: FAIL (non-zero exit code for CI guardrails)");
|
|
375
|
+
}
|
|
376
|
+
if (format === "json" || format === "both") {
|
|
377
|
+
if (format === "both") {
|
|
378
|
+
console.log("\nJSON output:");
|
|
379
|
+
}
|
|
380
|
+
console.log(JSON.stringify(result, null, 2));
|
|
381
|
+
}
|
|
382
|
+
};
|
package/dist/config.js
CHANGED
|
@@ -27,10 +27,11 @@ const normalizeScanConfig = (value) => {
|
|
|
27
27
|
const scan = value;
|
|
28
28
|
const include = normalizeScanPatterns(scan.include);
|
|
29
29
|
const exclude = normalizeScanPatterns(scan.exclude);
|
|
30
|
-
|
|
30
|
+
const mode = scan.mode === "regex" || scan.mode === "ast" ? scan.mode : undefined;
|
|
31
|
+
if (!include && !exclude && !mode) {
|
|
31
32
|
return undefined;
|
|
32
33
|
}
|
|
33
|
-
return { include, exclude };
|
|
34
|
+
return { include, exclude, mode };
|
|
34
35
|
};
|
|
35
36
|
const CONFIG_FILE_NAMES = [
|
|
36
37
|
"gloss.config.ts",
|
|
@@ -39,6 +40,37 @@ const CONFIG_FILE_NAMES = [
|
|
|
39
40
|
"gloss.config.mjs",
|
|
40
41
|
"gloss.config.cjs",
|
|
41
42
|
];
|
|
43
|
+
const LOCALE_CODE_PATTERN = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
|
|
44
|
+
const AUTO_DISCOVERY_IGNORED_DIRECTORIES = new Set([
|
|
45
|
+
"node_modules",
|
|
46
|
+
".git",
|
|
47
|
+
".next",
|
|
48
|
+
".nuxt",
|
|
49
|
+
".turbo",
|
|
50
|
+
"dist",
|
|
51
|
+
"build",
|
|
52
|
+
"coverage",
|
|
53
|
+
"out",
|
|
54
|
+
]);
|
|
55
|
+
const DIRECTORY_NAME_SCORES = new Map([
|
|
56
|
+
["i18n", 80],
|
|
57
|
+
["locales", 80],
|
|
58
|
+
["locale", 60],
|
|
59
|
+
["translations", 55],
|
|
60
|
+
["translation", 45],
|
|
61
|
+
["lang", 35],
|
|
62
|
+
["langs", 35],
|
|
63
|
+
["messages", 25],
|
|
64
|
+
]);
|
|
65
|
+
const normalizePath = (filePath) => filePath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
66
|
+
const isLikelyLocaleCode = (value) => LOCALE_CODE_PATTERN.test(value);
|
|
67
|
+
const scoreDirectoryName = (directoryPath) => {
|
|
68
|
+
const segments = normalizePath(directoryPath).split("/");
|
|
69
|
+
return segments.reduce((score, segment) => {
|
|
70
|
+
const nextScore = DIRECTORY_NAME_SCORES.get(segment.toLowerCase());
|
|
71
|
+
return score + (nextScore ?? 0);
|
|
72
|
+
}, 0);
|
|
73
|
+
};
|
|
42
74
|
const resolveLocalesDirectory = (cwd, localesPath) => {
|
|
43
75
|
if (path.isAbsolute(localesPath)) {
|
|
44
76
|
return localesPath;
|
|
@@ -49,9 +81,13 @@ const discoverLocales = async (cwd, localesPath) => {
|
|
|
49
81
|
const directory = resolveLocalesDirectory(cwd, localesPath);
|
|
50
82
|
try {
|
|
51
83
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
52
|
-
|
|
84
|
+
const localeCandidates = entries
|
|
53
85
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
54
86
|
.map((entry) => path.basename(entry.name, ".json").trim())
|
|
87
|
+
.filter((entry) => entry.length > 0);
|
|
88
|
+
const likelyLocales = localeCandidates.filter(isLikelyLocaleCode);
|
|
89
|
+
const localesToUse = likelyLocales.length > 0 ? likelyLocales : localeCandidates;
|
|
90
|
+
return localesToUse
|
|
55
91
|
.filter((entry) => entry.length > 0)
|
|
56
92
|
.sort((a, b) => a.localeCompare(b));
|
|
57
93
|
}
|
|
@@ -59,6 +95,80 @@ const discoverLocales = async (cwd, localesPath) => {
|
|
|
59
95
|
return [];
|
|
60
96
|
}
|
|
61
97
|
};
|
|
98
|
+
const discoverLocaleDirectoryCandidates = async (cwd) => {
|
|
99
|
+
const candidates = [];
|
|
100
|
+
const visit = async (directory) => {
|
|
101
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
102
|
+
const directoryLocales = entries
|
|
103
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
104
|
+
.map((entry) => path.basename(entry.name, ".json").trim())
|
|
105
|
+
.filter((entry) => isLikelyLocaleCode(entry));
|
|
106
|
+
if (directoryLocales.length > 0) {
|
|
107
|
+
const normalizedDirectory = normalizePath(path.relative(cwd, directory));
|
|
108
|
+
const uniqueLocales = Array.from(new Set(directoryLocales)).sort((a, b) => a.localeCompare(b));
|
|
109
|
+
const depth = normalizedDirectory.length === 0
|
|
110
|
+
? 0
|
|
111
|
+
: normalizedDirectory.split("/").length;
|
|
112
|
+
candidates.push({
|
|
113
|
+
directoryPath: directory,
|
|
114
|
+
locales: uniqueLocales,
|
|
115
|
+
depth,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (!entry.isDirectory()) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (entry.name.startsWith(".") ||
|
|
123
|
+
AUTO_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
await visit(path.join(directory, entry.name));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
await visit(cwd);
|
|
130
|
+
return candidates;
|
|
131
|
+
};
|
|
132
|
+
const selectLocaleDirectoryCandidate = (candidates, preferredLocales) => {
|
|
133
|
+
const scored = candidates.map((candidate) => {
|
|
134
|
+
const localeMatches = preferredLocales.filter((locale) => candidate.locales.includes(locale));
|
|
135
|
+
const allPreferredMatch = preferredLocales.length > 0 &&
|
|
136
|
+
preferredLocales.every((locale) => candidate.locales.includes(locale));
|
|
137
|
+
const directoryNameScore = scoreDirectoryName(candidate.directoryPath);
|
|
138
|
+
const srcHintScore = normalizePath(candidate.directoryPath).includes("/src/")
|
|
139
|
+
? 15
|
|
140
|
+
: 0;
|
|
141
|
+
const depthScore = Math.max(0, 30 - candidate.depth * 3);
|
|
142
|
+
const localeCountScore = candidate.locales.length * 10;
|
|
143
|
+
const preferredScore = localeMatches.length * 25 + (allPreferredMatch ? 80 : 0);
|
|
144
|
+
const score = directoryNameScore +
|
|
145
|
+
srcHintScore +
|
|
146
|
+
depthScore +
|
|
147
|
+
localeCountScore +
|
|
148
|
+
preferredScore;
|
|
149
|
+
return { candidate, score };
|
|
150
|
+
});
|
|
151
|
+
scored.sort((left, right) => {
|
|
152
|
+
if (left.score !== right.score) {
|
|
153
|
+
return right.score - left.score;
|
|
154
|
+
}
|
|
155
|
+
if (left.candidate.depth !== right.candidate.depth) {
|
|
156
|
+
return left.candidate.depth - right.candidate.depth;
|
|
157
|
+
}
|
|
158
|
+
return left.candidate.directoryPath.localeCompare(right.candidate.directoryPath);
|
|
159
|
+
});
|
|
160
|
+
return scored[0]?.candidate;
|
|
161
|
+
};
|
|
162
|
+
const resolveDiscoveredPath = (cwd, directoryPath) => {
|
|
163
|
+
const relative = path.relative(cwd, directoryPath);
|
|
164
|
+
if (!relative || relative === ".") {
|
|
165
|
+
return ".";
|
|
166
|
+
}
|
|
167
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
168
|
+
return normalizePath(relative);
|
|
169
|
+
}
|
|
170
|
+
return directoryPath;
|
|
171
|
+
};
|
|
62
172
|
const resolveConfigPath = async (cwd) => {
|
|
63
173
|
for (const fileName of CONFIG_FILE_NAMES) {
|
|
64
174
|
const candidatePath = path.join(cwd, fileName);
|
|
@@ -93,10 +203,10 @@ export async function loadGlossConfig() {
|
|
|
93
203
|
if (!cfg || typeof cfg !== "object") {
|
|
94
204
|
throw new GlossConfigError("INVALID_CONFIG", "Default export must be a config object.");
|
|
95
205
|
}
|
|
96
|
-
if (
|
|
97
|
-
|
|
206
|
+
if (cfg.path !== undefined &&
|
|
207
|
+
(typeof cfg.path !== "string" || !cfg.path.trim())) {
|
|
208
|
+
throw new GlossConfigError("INVALID_CONFIG", "`path` must be a non-empty string when provided.");
|
|
98
209
|
}
|
|
99
|
-
const translationsPath = cfg.path.trim();
|
|
100
210
|
if (cfg.locales !== undefined && !Array.isArray(cfg.locales)) {
|
|
101
211
|
throw new GlossConfigError("INVALID_CONFIG", "`locales` must be an array of locale codes when provided.");
|
|
102
212
|
}
|
|
@@ -105,16 +215,35 @@ export async function loadGlossConfig() {
|
|
|
105
215
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
106
216
|
.filter((entry) => entry.length > 0)
|
|
107
217
|
: [];
|
|
218
|
+
const configuredPath = typeof cfg.path === "string" ? cfg.path.trim() : "";
|
|
219
|
+
const discoveredDirectoryCandidate = configuredPath
|
|
220
|
+
? null
|
|
221
|
+
: selectLocaleDirectoryCandidate(await discoverLocaleDirectoryCandidates(cwd), configuredLocales);
|
|
222
|
+
const translationsPath = configuredPath
|
|
223
|
+
? configuredPath
|
|
224
|
+
: discoveredDirectoryCandidate
|
|
225
|
+
? resolveDiscoveredPath(cwd, discoveredDirectoryCandidate.directoryPath)
|
|
226
|
+
: "";
|
|
227
|
+
if (!translationsPath) {
|
|
228
|
+
throw new GlossConfigError("NO_LOCALES", "No locale directory found. Set `path` in config or add locale JSON files (for example `src/locales/en.json`).");
|
|
229
|
+
}
|
|
108
230
|
const locales = configuredLocales.length > 0
|
|
109
231
|
? configuredLocales
|
|
110
|
-
:
|
|
232
|
+
: discoveredDirectoryCandidate
|
|
233
|
+
? discoveredDirectoryCandidate.locales
|
|
234
|
+
: await discoverLocales(cwd, translationsPath);
|
|
111
235
|
if (locales.length === 0) {
|
|
112
236
|
throw new GlossConfigError("NO_LOCALES", `No locales found. Add "locales" in config or place *.json files in ${translationsPath}.`);
|
|
113
237
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
238
|
+
if (cfg.defaultLocale !== undefined &&
|
|
239
|
+
(typeof cfg.defaultLocale !== "string" || !cfg.defaultLocale.trim())) {
|
|
240
|
+
throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be a non-empty string when provided.");
|
|
116
241
|
}
|
|
117
|
-
const defaultLocale = cfg.defaultLocale.trim()
|
|
242
|
+
const defaultLocale = typeof cfg.defaultLocale === "string" && cfg.defaultLocale.trim()
|
|
243
|
+
? cfg.defaultLocale.trim()
|
|
244
|
+
: locales.includes("en")
|
|
245
|
+
? "en"
|
|
246
|
+
: locales[0];
|
|
118
247
|
if (!locales.includes(defaultLocale)) {
|
|
119
248
|
throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be included in `locales`.");
|
|
120
249
|
}
|