@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 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
- if (!include && !exclude) {
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
- return entries
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 (typeof cfg.path !== "string" || !cfg.path.trim()) {
97
- throw new GlossConfigError("INVALID_CONFIG", "`path` must be a non-empty string.");
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
- : await discoverLocales(cwd, translationsPath);
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 (typeof cfg.defaultLocale !== "string" || !cfg.defaultLocale.trim()) {
115
- throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be a non-empty string.");
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
  }