@the-i18n-kit/cli 5.0.0 → 7.0.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -58
  3. package/dist/bin.js +1 -6
  4. package/dist/bin.js.map +1 -1
  5. package/dist/{_shared-DFyy3sh8.js → cli-waEjbUM1.js} +157 -27
  6. package/dist/cli-waEjbUM1.js.map +1 -0
  7. package/dist/config/framework/stubs/{next-intl-routing-Cwaa3f4R.d.ts → next-intl-routing-i6Mlxwdt.d.ts} +1 -1
  8. package/dist/config/framework/stubs/{next-intl-routing-Cwaa3f4R.d.ts.map → next-intl-routing-i6Mlxwdt.d.ts.map} +1 -1
  9. package/dist/{define-config-BaZ1dZph.d.ts → define-config-ChY3jk6K.d.ts} +26 -4
  10. package/dist/define-config-ChY3jk6K.d.ts.map +1 -0
  11. package/dist/{define-config-CLgKN-7N.d.ts → define-config-e0B0VHq7.d.ts} +1 -1
  12. package/dist/define-config.d.ts +1 -1
  13. package/dist/descriptors-10sgyqEs.js +1225 -0
  14. package/dist/descriptors-10sgyqEs.js.map +1 -0
  15. package/dist/detector-BJTkyhQ0.js +5 -0
  16. package/dist/detector-DtFY4qm3.js +2189 -0
  17. package/dist/detector-DtFY4qm3.js.map +1 -0
  18. package/dist/{index-DCG3dOdC.d.ts → index-CdjJ71Ag.d.ts} +665 -250
  19. package/dist/index-CdjJ71Ag.d.ts.map +1 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +8 -5
  22. package/dist/json-writer-D0b6vEax.js +2 -0
  23. package/dist/json-writer-ek8yEI5R.js +342 -0
  24. package/dist/json-writer-ek8yEI5R.js.map +1 -0
  25. package/dist/{operations-CTo44gPu.js → operations-CNqEBD__.js} +1505 -3285
  26. package/dist/operations-CNqEBD__.js.map +1 -0
  27. package/dist/operations-D7IMu_xY.js +8 -0
  28. package/dist/php-reader-BGx6Ii2d.js +2 -0
  29. package/dist/{php-reader-3Fgw80zK.js → php-reader-DRpcRVzv.js} +6 -3
  30. package/dist/{php-reader-3Fgw80zK.js.map → php-reader-DRpcRVzv.js.map} +1 -1
  31. package/dist/{providers-CHE2ffi6.js → project-config-C3ao4Uii.js} +24 -214
  32. package/dist/project-config-C3ao4Uii.js.map +1 -0
  33. package/dist/providers-BbVPelvp.js +406 -0
  34. package/dist/providers-BbVPelvp.js.map +1 -0
  35. package/dist/report-By1-5JtO.js +37 -0
  36. package/dist/report-By1-5JtO.js.map +1 -0
  37. package/dist/report-CHZgH9Wy.js +2 -0
  38. package/package.json +11 -23
  39. package/dist/_shared-DFyy3sh8.js.map +0 -1
  40. package/dist/add-C-qs8iBa.js +0 -40
  41. package/dist/add-C-qs8iBa.js.map +0 -1
  42. package/dist/check-BI1orjXb.js +0 -41
  43. package/dist/check-BI1orjXb.js.map +0 -1
  44. package/dist/cli-DsFuVJNP.js +0 -74
  45. package/dist/cli-DsFuVJNP.js.map +0 -1
  46. package/dist/config/framework/stubs/unplugin-vue-i18n-1ud7-Ly8.d.ts +0 -25
  47. package/dist/config/framework/stubs/unplugin-vue-i18n-1ud7-Ly8.d.ts.map +0 -1
  48. package/dist/config/framework/stubs/unplugin-vue-i18n.js +0 -26
  49. package/dist/config/framework/stubs/unplugin-vue-i18n.js.map +0 -1
  50. package/dist/define-config-BaZ1dZph.d.ts.map +0 -1
  51. package/dist/detect-7cVfsNJP.js +0 -17
  52. package/dist/detect-7cVfsNJP.js.map +0 -1
  53. package/dist/empty-DB8Hl_8u.js +0 -36
  54. package/dist/empty-DB8Hl_8u.js.map +0 -1
  55. package/dist/find-duplicates-CmEqBfby.js +0 -42
  56. package/dist/find-duplicates-CmEqBfby.js.map +0 -1
  57. package/dist/get-BE21Iy6N.js +0 -41
  58. package/dist/get-BE21Iy6N.js.map +0 -1
  59. package/dist/index-DCG3dOdC.d.ts.map +0 -1
  60. package/dist/init-BtzmhsOM.js +0 -33
  61. package/dist/init-BtzmhsOM.js.map +0 -1
  62. package/dist/list-dirs-DUQbwvlE.js +0 -17
  63. package/dist/list-dirs-DUQbwvlE.js.map +0 -1
  64. package/dist/missing-CcYrgd54.js +0 -51
  65. package/dist/missing-CcYrgd54.js.map +0 -1
  66. package/dist/move-B2px6Ck-.js +0 -50
  67. package/dist/move-B2px6Ck-.js.map +0 -1
  68. package/dist/operations-CTo44gPu.js.map +0 -1
  69. package/dist/php-reader-CpnaPSpZ.js +0 -2
  70. package/dist/providers-CHE2ffi6.js.map +0 -1
  71. package/dist/remove-DTbqM4bR.js +0 -41
  72. package/dist/remove-DTbqM4bR.js.map +0 -1
  73. package/dist/remove-orphans-Dfzu7hRz.js +0 -57
  74. package/dist/remove-orphans-Dfzu7hRz.js.map +0 -1
  75. package/dist/rename-C_rFKkU4.js +0 -45
  76. package/dist/rename-C_rFKkU4.js.map +0 -1
  77. package/dist/rename-notice-BV8HNX3O.js +0 -25
  78. package/dist/rename-notice-BV8HNX3O.js.map +0 -1
  79. package/dist/rename-notice-Cx1vuTRz.js +0 -2
  80. package/dist/scaffold-CUimlNqR.js +0 -39
  81. package/dist/scaffold-CUimlNqR.js.map +0 -1
  82. package/dist/scan-BkOIYhjZ.js +0 -31
  83. package/dist/scan-BkOIYhjZ.js.map +0 -1
  84. package/dist/search-0io1BcsY.js +0 -49
  85. package/dist/search-0io1BcsY.js.map +0 -1
  86. package/dist/status-Bp-SzjTN.js +0 -45
  87. package/dist/status-Bp-SzjTN.js.map +0 -1
  88. package/dist/translate-cCb2Z57w.js +0 -92
  89. package/dist/translate-cCb2Z57w.js.map +0 -1
  90. package/dist/translate-key-DHLWEmrA.js +0 -73
  91. package/dist/translate-key-DHLWEmrA.js.map +0 -1
  92. package/dist/update-DV4ijRVB.js +0 -40
  93. package/dist/update-DV4ijRVB.js.map +0 -1
  94. package/dist/write-Czzm_Nb0.js +0 -47
  95. package/dist/write-Czzm_Nb0.js.map +0 -1
  96. /package/dist/{bin-DrKRgnr9.d.ts → bin-NyzIHE2F.d.ts} +0 -0
@@ -0,0 +1,2189 @@
1
+ import { i as toErrorMessage, n as FileIOError, t as ConfigError } from "./errors-coI1dhw1.js";
2
+ import { i as log, n as loadProjectConfig } from "./project-config-C3ao4Uii.js";
3
+ import { _ as readLocaleFile, a as atomicWrite, g as clearFileCacheEntry, h as clearFileCache, l as orderKeysPreserving, p as sortKeysDeep, r as writeLocaleFile, t as mutateLocaleFile, v as createReadCache } from "./json-writer-ek8yEI5R.js";
4
+ import { n as clearPhpFileCacheEntry, r as readPhpLocaleFile, t as clearPhpFileCache } from "./php-reader-DRpcRVzv.js";
5
+ import { createRequire } from "node:module";
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { readFile, readdir, realpath } from "node:fs/promises";
8
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
9
+ import { parseAllDocuments, stringify } from "yaml";
10
+ import { z } from "zod";
11
+ import { fileURLToPath } from "node:url";
12
+ import { glob } from "tinyglobby";
13
+ //#region src/io/php-writer.ts
14
+ async function writePhpLocaleFile(filePath, data, options = {}) {
15
+ const { quoteStyle = "double", indent = " ", sortKeys = true } = options;
16
+ try {
17
+ await atomicWrite(filePath, serializePhpArray(sortKeys ? sortKeysDeep(data) : data, quoteStyle, indent), () => clearPhpFileCacheEntry(filePath));
18
+ } catch (error) {
19
+ if (error instanceof FileIOError) throw error;
20
+ throw new FileIOError(`Failed to write PHP locale file: ${filePath}: ${toErrorMessage(error)}`, filePath);
21
+ }
22
+ }
23
+ function serializePhpArray(data, quoteStyle, indent) {
24
+ const q = quoteStyle === "single" ? "'" : "\"";
25
+ const lines = [
26
+ "<?php",
27
+ "",
28
+ "return ["
29
+ ];
30
+ renderEntries(data, lines, q, indent, 1);
31
+ lines.push("];", "");
32
+ return lines.join("\n");
33
+ }
34
+ function renderEntries(obj, lines, q, indent, depth) {
35
+ const prefix = indent.repeat(depth);
36
+ const entries = Object.entries(obj);
37
+ for (const [key, value] of entries) {
38
+ const keyPart = `${prefix}${q}${escapePhpString(key, q)}${q}`;
39
+ if (value === null) lines.push(`${keyPart} => null,`);
40
+ else if (typeof value === "boolean") lines.push(`${keyPart} => ${value ? "true" : "false"},`);
41
+ else if (typeof value === "number") lines.push(`${keyPart} => ${value},`);
42
+ else if (Array.isArray(value)) {
43
+ lines.push(`${keyPart} => [`);
44
+ renderArray(value, lines, q, indent, depth + 1);
45
+ lines.push(`${prefix}],`);
46
+ } else if (typeof value === "object") {
47
+ lines.push(`${keyPart} => [`);
48
+ renderEntries(value, lines, q, indent, depth + 1);
49
+ lines.push(`${prefix}],`);
50
+ } else {
51
+ const escapedValue = escapePhpString(String(value), q);
52
+ lines.push(`${keyPart} => ${q}${escapedValue}${q},`);
53
+ }
54
+ }
55
+ }
56
+ function escapePhpString(str, quote) {
57
+ if (quote === "'") return str.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
58
+ return str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
59
+ }
60
+ function renderArray(arr, lines, q, indent, depth) {
61
+ const prefix = indent.repeat(depth);
62
+ for (const value of arr) if (value === null) lines.push(`${prefix}null,`);
63
+ else if (typeof value === "boolean") lines.push(`${prefix}${value ? "true" : "false"},`);
64
+ else if (typeof value === "number") lines.push(`${prefix}${value},`);
65
+ else if (Array.isArray(value)) {
66
+ lines.push(`${prefix}[`);
67
+ renderArray(value, lines, q, indent, depth + 1);
68
+ lines.push(`${prefix}],`);
69
+ } else if (typeof value === "object") {
70
+ lines.push(`${prefix}[`);
71
+ renderEntries(value, lines, q, indent, depth + 1);
72
+ lines.push(`${prefix}],`);
73
+ } else {
74
+ const escaped = escapePhpString(String(value), q);
75
+ lines.push(`${prefix}${q}${escaped}${q},`);
76
+ }
77
+ }
78
+ function detectPhpStyle(content) {
79
+ const keyPattern = /(['"])([^'"]+)\1\s*=>/g;
80
+ let singleCount = 0;
81
+ let doubleCount = 0;
82
+ let match;
83
+ while ((match = keyPattern.exec(content)) !== null) if (match[1] === "'") singleCount++;
84
+ else doubleCount++;
85
+ return {
86
+ quoteStyle: singleCount > doubleCount ? "single" : "double",
87
+ indent: content.match(/^([ \t]+)['"]/m)?.[1] ?? " "
88
+ };
89
+ }
90
+ /**
91
+ * Read, mutate, and write back a single PHP locale file while preserving its
92
+ * existing style (quote style + indentation, via detectPhpStyle) and key
93
+ * order: existing keys keep their on-disk order; keys added by the mutation
94
+ * are inserted in sorted position among their siblings.
95
+ */
96
+ async function mutatePhpLocaleFile(filePath, mutate) {
97
+ const { readPhpLocaleFile } = await import("./php-reader-BGx6Ii2d.js");
98
+ const data = await readPhpLocaleFile(filePath);
99
+ const { quoteStyle, indent } = detectPhpStyle(await readFile(filePath, "utf-8"));
100
+ const reference = structuredClone(data);
101
+ mutate(data);
102
+ await writePhpLocaleFile(filePath, orderKeysPreserving(data, reference), {
103
+ quoteStyle,
104
+ indent,
105
+ sortKeys: false
106
+ });
107
+ }
108
+ //#endregion
109
+ //#region src/io/yaml-reader.ts
110
+ /**
111
+ * Parse a YAML locale file into plain data.
112
+ *
113
+ * A locale file is one mapping, so a stream of several documents is refused by
114
+ * name rather than silently reduced to its first one. Parse errors are
115
+ * collected on the document rather than thrown, so the first one is raised
116
+ * here to reach the same wrapping the JSON reader gets.
117
+ */
118
+ function parseYaml(content) {
119
+ const documents = parseAllDocuments(content);
120
+ if (documents.length > 1) throw new SyntaxError(`YAML file holds ${documents.length} documents (separated by '---') — a locale file must hold exactly one`);
121
+ const document = documents[0];
122
+ if (!document) return null;
123
+ const error = document.errors[0];
124
+ if (error) throw new SyntaxError(error.message);
125
+ return document.toJS();
126
+ }
127
+ const { read: readYamlLocaleFile, clear: clearYamlFileCache, clearEntry: clearYamlFileCacheEntry } = createReadCache(parseYaml, "YAML", (parsed, filePath) => {
128
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new FileIOError(`YAML file must contain a mapping: ${filePath}`, filePath, "INVALID_YAML");
129
+ });
130
+ //#endregion
131
+ //#region src/io/yaml-writer.ts
132
+ /**
133
+ * Fixed rather than detected from the file being written: YAML has one legal
134
+ * indent character, and `lineWidth: 0` keeps long UI strings on a single line.
135
+ * Folded ones re-read as the same string, but every wrap point moves as soon
136
+ * as a word changes, which turns a one-word edit into a multi-line diff.
137
+ */
138
+ const STRINGIFY_OPTIONS = {
139
+ indent: 2,
140
+ lineWidth: 0
141
+ };
142
+ /**
143
+ * Comments in an existing file do not survive a write: the file is
144
+ * re-serialized from the parsed values, not patched into the document tree
145
+ * that carries them. Keep notes for translators in the kit config
146
+ * (`localeNotes`) rather than in the locale files it rewrites.
147
+ */
148
+ async function writeYamlLocaleFile(filePath, data, options = {}) {
149
+ const { sortKeys = true } = options;
150
+ try {
151
+ await atomicWrite(filePath, stringify(sortKeys ? sortKeysDeep(data) : data, STRINGIFY_OPTIONS), () => clearYamlFileCacheEntry(filePath));
152
+ } catch (error) {
153
+ if (error instanceof FileIOError) throw error;
154
+ throw new FileIOError(`Failed to write YAML locale file: ${filePath}: ${toErrorMessage(error)}`, filePath);
155
+ }
156
+ }
157
+ /**
158
+ * Read, mutate and write back a single YAML locale file, preserving key order:
159
+ * existing keys keep their on-disk order and keys added by the mutation are
160
+ * inserted in sorted position among their siblings.
161
+ */
162
+ async function mutateYamlLocaleFile(filePath, mutate) {
163
+ const data = await readYamlLocaleFile(filePath);
164
+ const reference = structuredClone(data);
165
+ mutate(data);
166
+ await writeYamlLocaleFile(filePath, orderKeysPreserving(data, reference), { sortKeys: false });
167
+ }
168
+ //#endregion
169
+ //#region src/io/formats.ts
170
+ /**
171
+ * One registry for everything that differs between locale file formats.
172
+ *
173
+ * The readers and writers used to be reachable only through
174
+ * `if (format === 'php-array')` branches scattered across locale IO, format
175
+ * detection, the read operations and the scaffolder, so every new format meant
176
+ * finding all of them again. A format now declares its extensions, its IO
177
+ * functions and the two layout facts those callers used the format id to
178
+ * guess, and they dispatch through `getFormat` / `formatForFile` instead of
179
+ * comparing strings.
180
+ */
181
+ const formats = /* @__PURE__ */ new Map();
182
+ const byExtension = /* @__PURE__ */ new Map();
183
+ function registerFormat(format) {
184
+ formats.set(format.id, format);
185
+ for (const extension of format.extensions) byExtension.set(extension.toLowerCase(), format);
186
+ }
187
+ /**
188
+ * The format a config's `localeFileFormat` names. Undefined resolves to JSON,
189
+ * which is what an adapter that declares nothing has always meant.
190
+ */
191
+ function getFormat(id) {
192
+ const format = formats.get(id ?? "json");
193
+ if (!format) throw new ConfigError(`Unknown locale file format: ${id}. Known formats: ${[...formats.keys()].join(", ")}`);
194
+ return format;
195
+ }
196
+ /** The format that owns a file's extension. */
197
+ function formatForFile(filePath) {
198
+ const ext = extname(filePath).toLowerCase();
199
+ const format = byExtension.get(ext);
200
+ if (!format) throw new FileIOError(`Unsupported locale file format: ${ext}`, filePath);
201
+ return format;
202
+ }
203
+ /**
204
+ * Guess the format of a locale directory from the files in it — flat
205
+ * `en.json` / `en.php` / `en.yaml` first, then a directory per locale. Returns
206
+ * null when nothing recognizable is there, leaving the default to the caller. A
207
+ * directory holding more than one format resolves to whichever has the most
208
+ * files and warns, because the format that loses is invisible from then on.
209
+ */
210
+ async function detectFormatInDir(localeDir) {
211
+ let entries;
212
+ try {
213
+ entries = await readdir(localeDir, { withFileTypes: true });
214
+ } catch {
215
+ return null;
216
+ }
217
+ const flat = decide(countFormats(entries.filter((e) => e.isFile()).map((e) => e.name)), localeDir);
218
+ if (flat) return flat;
219
+ const nested = /* @__PURE__ */ new Map();
220
+ for (const entry of entries) {
221
+ if (!entry.isDirectory()) continue;
222
+ const subFiles = await readdir(join(localeDir, entry.name)).catch(() => []);
223
+ for (const [id, count] of countFormats(subFiles)) nested.set(id, (nested.get(id) ?? 0) + count);
224
+ }
225
+ return decide(nested, localeDir);
226
+ }
227
+ function countFormats(fileNames) {
228
+ const counts = /* @__PURE__ */ new Map();
229
+ for (const name of fileNames) {
230
+ const format = byExtension.get(extname(name).toLowerCase());
231
+ if (!format) continue;
232
+ counts.set(format.id, (counts.get(format.id) ?? 0) + 1);
233
+ }
234
+ return counts;
235
+ }
236
+ function decide(counts, localeDir) {
237
+ let winner = null;
238
+ let best = 0;
239
+ for (const id of formats.keys()) {
240
+ const count = counts.get(id) ?? 0;
241
+ if (count > best) {
242
+ winner = id;
243
+ best = count;
244
+ }
245
+ }
246
+ if (!winner) return null;
247
+ if (counts.size > 1) {
248
+ const seen = [...counts.entries()].map(([id, count]) => `${id} (${count})`).join(", ");
249
+ log.warn(`Locale directory ${localeDir} mixes file formats: ${seen}. Reading and writing it as '${winner}' — files in the other formats are ignored. Keep one format per locale directory.`);
250
+ }
251
+ return winner;
252
+ }
253
+ registerFormat({
254
+ id: "json",
255
+ extensions: [".json"],
256
+ defaultLayout: "flat",
257
+ flatFileFromDisk: false,
258
+ read: readLocaleFile,
259
+ write: (filePath, data, options) => writeLocaleFile(filePath, data, options),
260
+ mutate: mutateLocaleFile,
261
+ clearCache: clearFileCache,
262
+ clearCacheEntry: clearFileCacheEntry
263
+ });
264
+ registerFormat({
265
+ id: "php-array",
266
+ extensions: [".php"],
267
+ defaultLayout: "namespaced",
268
+ flatFileFromDisk: true,
269
+ read: readPhpLocaleFile,
270
+ write: (filePath, data, options) => writePhpLocaleFile(filePath, data, options),
271
+ mutate: mutatePhpLocaleFile,
272
+ clearCache: clearPhpFileCache,
273
+ clearCacheEntry: clearPhpFileCacheEntry
274
+ });
275
+ registerFormat({
276
+ id: "yaml",
277
+ extensions: [".yaml", ".yml"],
278
+ defaultLayout: "flat",
279
+ flatFileFromDisk: false,
280
+ read: readYamlLocaleFile,
281
+ write: (filePath, data, options) => writeYamlLocaleFile(filePath, data, options),
282
+ mutate: mutateYamlLocaleFile,
283
+ clearCache: clearYamlFileCache,
284
+ clearCacheEntry: clearYamlFileCacheEntry
285
+ });
286
+ //#endregion
287
+ //#region src/adapters/registry.ts
288
+ const adapters = [];
289
+ function registerAdapter(adapter) {
290
+ adapters.push(adapter);
291
+ }
292
+ /**
293
+ * Names that no longer have an adapter of their own, mapped to the one that
294
+ * resolves those projects now. Kept so a config written against a retired
295
+ * adapter keeps validating and resolving instead of failing on an unknown
296
+ * hint, and warned about because the replacement needs more from the config
297
+ * than the retired adapter did.
298
+ */
299
+ const RETIRED_HINTS = { vue: {
300
+ replacement: "generic",
301
+ warning: "vue projects resolve through the generic adapter; declare localeDirs"
302
+ } };
303
+ function resolveHint(hint) {
304
+ const retired = RETIRED_HINTS[hint];
305
+ if (!retired) return hint;
306
+ log.warn(`framework: "${hint}" — ${retired.warning}`);
307
+ return retired.replacement;
308
+ }
309
+ /**
310
+ * Detect the framework and report how confidently. `detectFramework` returns
311
+ * only the winner; `init` needs the score to tell a user why Nuxt was chosen
312
+ * over generic, and a bare directory needs to be distinguishable from a
313
+ * confident match rather than surfacing as a thrown error.
314
+ */
315
+ async function detectFrameworkMatch(projectDir, hint) {
316
+ if (adapters.length === 0) throw new ConfigError("No framework adapters registered.");
317
+ if (hint) {
318
+ const name = resolveHint(hint);
319
+ const match = adapters.find((a) => a.name === name);
320
+ if (!match) return void 0;
321
+ return {
322
+ adapter: match,
323
+ confidence: 0,
324
+ runnersUp: []
325
+ };
326
+ }
327
+ const ranked = (await Promise.all(adapters.map(async (adapter) => {
328
+ try {
329
+ return {
330
+ adapter,
331
+ confidence: await adapter.detect(projectDir)
332
+ };
333
+ } catch (error) {
334
+ log.warn(`Adapter '${adapter.name}' detection failed: ${toErrorMessage(error)}`);
335
+ return {
336
+ adapter,
337
+ confidence: 0
338
+ };
339
+ }
340
+ }))).filter((s) => s.confidence > 0).sort((a, b) => b.confidence - a.confidence);
341
+ const [best] = ranked;
342
+ if (!best) return void 0;
343
+ return {
344
+ adapter: best.adapter,
345
+ confidence: best.confidence,
346
+ runnersUp: ranked.slice(1).map((s) => ({
347
+ name: s.adapter.name,
348
+ confidence: s.confidence
349
+ }))
350
+ };
351
+ }
352
+ async function detectFramework(projectDir, hint) {
353
+ const match = await detectFrameworkMatch(projectDir, hint);
354
+ if (match) return match.adapter;
355
+ if (hint) throw new ConfigError(`Framework hint "${hint}" does not match any registered adapter. Available: ${adapters.map((a) => a.name).join(", ")}`);
356
+ throw new ConfigError(`No framework detected for ${projectDir}. Registered adapters: ${adapters.map((a) => a.name).join(", ")}. Declare localeDirs and defaultLocale to resolve the project through the generic adapter.`);
357
+ }
358
+ //#endregion
359
+ //#region src/config/paths.ts
360
+ /**
361
+ * Directory helpers every framework shares: identifying a project directory,
362
+ * and naming a layer after where it sits. Finding the apps in a project is a
363
+ * framework's own business and lives with its adapter.
364
+ */
365
+ function canonicalPath(dir) {
366
+ try {
367
+ return realpathSync(resolve(dir));
368
+ } catch {
369
+ return resolve(dir);
370
+ }
371
+ }
372
+ /**
373
+ * Derive a human-friendly layer name from its root directory.
374
+ * Uses the discovery root as the reference point for naming.
375
+ *
376
+ * When `usedNames` is provided, disambiguates collisions by progressively
377
+ * including parent path segments (e.g., `admin` → `apps/admin`).
378
+ */
379
+ function deriveLayerName(layerRootDir, discoveryRoot, usedNames) {
380
+ const rel = relative(discoveryRoot, layerRootDir);
381
+ if (rel === "" || rel === ".") return "root";
382
+ const posixRel = rel.replace(/\\/g, "/");
383
+ if (!posixRel.startsWith("..")) {
384
+ const segments = posixRel.split("/");
385
+ let candidate = segments.pop() ?? posixRel;
386
+ if (usedNames) for (let i = segments.length - 1; i >= 0; i--) {
387
+ if (!usedNames.has(candidate)) break;
388
+ candidate = `${segments[i]}/${candidate}`;
389
+ }
390
+ return candidate;
391
+ }
392
+ const parts = layerRootDir.replace(/\\/g, "/").split("/");
393
+ let candidate = parts.pop() ?? layerRootDir;
394
+ if (usedNames) for (let i = parts.length - 1; i >= 0; i--) {
395
+ if (!usedNames.has(candidate)) break;
396
+ candidate = `${parts[i]}/${candidate}`;
397
+ }
398
+ return candidate;
399
+ }
400
+ //#endregion
401
+ //#region src/config/locale-override.ts
402
+ /**
403
+ * Apply the optional `locales` override from `.i18n-mcp.json` on top of the
404
+ * locales auto-discovered by a framework adapter.
405
+ *
406
+ * Semantics:
407
+ * - If `projectLocales` is unset/empty → return `discovered` unchanged.
408
+ * - Else → restrict to the configured codes, preserving the order from
409
+ * `projectLocales` and keeping the metadata (file, language, name) from
410
+ * the discovered entries. Configured codes with no matching discovered
411
+ * entry get a minimal `{code, language: code}` fallback and a warning.
412
+ */
413
+ function applyLocaleOverride(discovered, projectLocales) {
414
+ if (!projectLocales || projectLocales.length === 0) return discovered;
415
+ const byCode = new Map(discovered.map((l) => [l.code, l]));
416
+ const missing = [];
417
+ const result = projectLocales.map((code) => {
418
+ const found = byCode.get(code);
419
+ if (found) return found;
420
+ missing.push(code);
421
+ return {
422
+ code,
423
+ language: code
424
+ };
425
+ });
426
+ if (missing.length > 0) log.warn(`Configured locales not found in framework auto-detection: ${missing.join(", ")}. Translations for these codes may fail to write — make sure the locale files exist.`);
427
+ const dropped = discovered.filter((l) => !projectLocales.includes(l.code));
428
+ if (dropped.length > 0) log.debug(`Locale override active: dropped ${dropped.length} auto-detected locale(s) (${dropped.map((l) => l.code).join(", ")})`);
429
+ return result;
430
+ }
431
+ //#endregion
432
+ //#region src/config/fallback-locale.ts
433
+ /**
434
+ * Normalise `@nuxtjs/i18n`'s `fallbackLocale` — a string, an array, or a
435
+ * per-locale map — into the single map shape the rest of the CLI reads.
436
+ *
437
+ * Shared by both paths on purpose. The Nuxt module publishes this value exactly
438
+ * as Nuxt resolved it rather than normalising it itself: two implementations of
439
+ * the same normalisation, in two packages that do not depend on each other, is
440
+ * precisely the drift the module exists to remove.
441
+ */
442
+ function normalizeFallbackLocale(fallback, defaultLocale) {
443
+ if (typeof fallback === "string") return { default: [fallback] };
444
+ if (Array.isArray(fallback)) return { default: fallback.map(String) };
445
+ if (fallback && typeof fallback === "object") {
446
+ const result = {};
447
+ for (const [key, value] of Object.entries(fallback)) if (Array.isArray(value)) result[key] = value.map(String);
448
+ else if (typeof value === "string") result[key] = [value];
449
+ return result;
450
+ }
451
+ return { default: [defaultLocale] };
452
+ }
453
+ //#endregion
454
+ //#region src/adapters/nuxt/discovery.ts
455
+ const NUXT_CONFIG_FILES$1 = [
456
+ "nuxt.config.ts",
457
+ "nuxt.config.js",
458
+ "nuxt.config.mjs"
459
+ ];
460
+ const SKIP_DIRS$1 = new Set([
461
+ "node_modules",
462
+ ".nuxt",
463
+ ".output",
464
+ ".git",
465
+ "dist",
466
+ ".cache"
467
+ ]);
468
+ const MAX_DISCOVERY_DEPTH = 4;
469
+ function findNuxtConfig(dir) {
470
+ for (const name of NUXT_CONFIG_FILES$1) if (existsSync(resolve(dir, name))) return name;
471
+ return null;
472
+ }
473
+ async function hasI18nConfig(configPath) {
474
+ try {
475
+ const content = await readFile(configPath, "utf-8");
476
+ return /\bi18n\b/.test(content);
477
+ } catch {
478
+ return false;
479
+ }
480
+ }
481
+ /**
482
+ * Discover all Nuxt apps with i18n configuration under a root directory.
483
+ * Scans recursively up to MAX_DISCOVERY_DEPTH levels deep,
484
+ * skipping common non-project directories.
485
+ *
486
+ * Returns sorted absolute paths to directories that:
487
+ * 1. Contain a nuxt.config.{ts,js,mjs} file
488
+ * 2. The config file references i18n
489
+ */
490
+ async function discoverNuxtApps(rootDir) {
491
+ const apps = [];
492
+ await scanForApps(rootDir, 0, apps, true);
493
+ const root = resolve(rootDir);
494
+ apps.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
495
+ return apps;
496
+ }
497
+ async function scanForApps(dir, depth, results, isRoot = false) {
498
+ if (depth > MAX_DISCOVERY_DEPTH) return;
499
+ const configFile = findNuxtConfig(dir);
500
+ if (configFile) {
501
+ if (await hasI18nConfig(resolve(dir, configFile))) results.push(dir);
502
+ if (!isRoot) return;
503
+ }
504
+ let entries;
505
+ try {
506
+ entries = await readdir(dir);
507
+ } catch {
508
+ return;
509
+ }
510
+ for (const entry of entries) {
511
+ if (entry.startsWith(".") || SKIP_DIRS$1.has(entry)) continue;
512
+ const childDir = resolve(dir, entry);
513
+ try {
514
+ if (!statSync(childDir).isDirectory()) continue;
515
+ } catch {
516
+ continue;
517
+ }
518
+ await scanForApps(childDir, depth + 1, results);
519
+ }
520
+ }
521
+ //#endregion
522
+ //#region src/adapters/nuxt/kit-loader.ts
523
+ /** Import `id` resolved against the project's node_modules rather than our own. */
524
+ async function importModuleFrom(id, rootDir) {
525
+ const { createRequire } = await import("node:module");
526
+ return import(createRequire(rootDir + "/").resolve(id));
527
+ }
528
+ /**
529
+ * Load @nuxt/kit from the project's own node_modules.
530
+ * Falls back to trying common Nuxt package names.
531
+ */
532
+ async function loadKit(rootDir) {
533
+ try {
534
+ const kit = await importModuleFrom("@nuxt/kit", rootDir);
535
+ log.debug(`Loaded @nuxt/kit from project: ${rootDir}`);
536
+ return kit;
537
+ } catch (e) {
538
+ if (String(e).includes("Cannot find module '@nuxt/kit'")) throw new Error("@nuxt/kit is required for Nuxt projects but was not found. Install it with: npm install --save-dev @nuxt/kit\nLaravel projects do not need @nuxt/kit — if this is a Laravel project, check that your .i18n-mcp.json has \"framework\": \"laravel\" or that your project structure is detected correctly.");
539
+ throw e;
540
+ }
541
+ }
542
+ //#endregion
543
+ //#region src/adapters/nuxt/layer-dedup.ts
544
+ function isAncestorOf(ancestorDir, childPath) {
545
+ const normalized = ancestorDir.endsWith(sep) ? ancestorDir : ancestorDir + sep;
546
+ return childPath.startsWith(normalized) || childPath === ancestorDir;
547
+ }
548
+ function ancestorDepth(ancestorDir, childPath) {
549
+ if (!isAncestorOf(ancestorDir, childPath)) return -1;
550
+ return ancestorDir.split(sep).filter(Boolean).length;
551
+ }
552
+ function resolveLayerOwnership(existing, incoming, localeDirPath) {
553
+ const existingIsAncestor = isAncestorOf(existing.layerRootDir, localeDirPath);
554
+ const incomingIsAncestor = isAncestorOf(incoming.layerRootDir, localeDirPath);
555
+ if (existingIsAncestor && !incomingIsAncestor) return {
556
+ owner: existing.layer,
557
+ alias: incoming.layer
558
+ };
559
+ if (incomingIsAncestor && !existingIsAncestor) return {
560
+ owner: incoming.layer,
561
+ alias: existing.layer
562
+ };
563
+ if (existingIsAncestor && incomingIsAncestor) {
564
+ const existingDepth = ancestorDepth(existing.layerRootDir, localeDirPath);
565
+ if (ancestorDepth(incoming.layerRootDir, localeDirPath) > existingDepth) return {
566
+ owner: incoming.layer,
567
+ alias: existing.layer
568
+ };
569
+ return {
570
+ owner: existing.layer,
571
+ alias: incoming.layer
572
+ };
573
+ }
574
+ return {
575
+ owner: existing.layer,
576
+ alias: incoming.layer
577
+ };
578
+ }
579
+ /**
580
+ * Claim a locale directory for a layer, deduplicating by real path.
581
+ *
582
+ * Mutates `dirs` and `claims`:
583
+ * - First claim for `realPath`: the incoming dir is pushed and recorded as owner.
584
+ * - Same path already claimed and the incoming layer wins ownership
585
+ * (see `resolveLayerOwnership`): the incoming dir replaces the previous
586
+ * owner in place, and the previous owner is re-pushed demoted to an alias.
587
+ * - Same path already claimed and the existing layer keeps ownership: the
588
+ * incoming dir is pushed as an alias of the owner (unless it *is* the owner).
589
+ * - If the recorded owner cannot be found as a non-alias entry (it was itself
590
+ * demoted to an alias by an earlier takeover on another path), the incoming
591
+ * dir is still pushed as an alias — a claim never silently drops a dir.
592
+ */
593
+ function claimLocaleDir(dirs, claims, incoming, realPath) {
594
+ const existing = claims.get(realPath);
595
+ if (!existing) {
596
+ claims.set(realPath, {
597
+ layer: incoming.layer,
598
+ layerRootDir: incoming.layerRootDir
599
+ });
600
+ dirs.push({ ...incoming });
601
+ return;
602
+ }
603
+ const { owner, alias } = resolveLayerOwnership({
604
+ layer: existing.layer,
605
+ layerRootDir: existing.layerRootDir
606
+ }, {
607
+ layer: incoming.layer,
608
+ layerRootDir: incoming.layerRootDir
609
+ }, realPath);
610
+ if (owner !== existing.layer) {
611
+ const ownerIndex = dirs.findIndex((d) => d.layer === existing.layer && !d.aliasOf);
612
+ const previousOwner = ownerIndex !== -1 ? dirs[ownerIndex] : void 0;
613
+ if (previousOwner) {
614
+ const promoted = { ...incoming };
615
+ delete promoted.aliasOf;
616
+ dirs[ownerIndex] = promoted;
617
+ dirs.push({
618
+ ...previousOwner,
619
+ aliasOf: owner
620
+ });
621
+ claims.set(realPath, {
622
+ layer: incoming.layer,
623
+ layerRootDir: incoming.layerRootDir
624
+ });
625
+ log.debug(`Layer '${alias}' is alias of '${owner}' (ancestor-based ownership, same path: ${incoming.path})`);
626
+ } else {
627
+ dirs.push({
628
+ ...incoming,
629
+ aliasOf: existing.layer
630
+ });
631
+ log.debug(`Layer '${incoming.layer}' is alias of '${existing.layer}' (owner already aliased, same path: ${incoming.path})`);
632
+ }
633
+ } else if (incoming.layer !== existing.layer) {
634
+ dirs.push({
635
+ ...incoming,
636
+ aliasOf: owner
637
+ });
638
+ log.debug(`Layer '${alias}' is alias of '${owner}' (same path: ${incoming.path})`);
639
+ }
640
+ }
641
+ //#endregion
642
+ //#region src/adapters/nuxt/artifact.ts
643
+ /** Where @the-i18n-kit/nuxt publishes what Nuxt resolved, relative to an app. */
644
+ const ARTIFACT_PATH = ".nuxt/i18n-kit.json";
645
+ const localeSchema = z.object({
646
+ code: z.string().min(1),
647
+ language: z.string(),
648
+ file: z.string().min(1),
649
+ name: z.string().optional()
650
+ });
651
+ const layerSchema = z.object({
652
+ rootDir: z.string().min(1),
653
+ localeDir: z.string().min(1).optional()
654
+ });
655
+ const artifactSchema = z.object({
656
+ version: z.literal(1),
657
+ generator: z.string(),
658
+ appDir: z.string().min(1),
659
+ defaultLocale: z.string().min(1),
660
+ fallbackLocale: z.union([
661
+ z.string(),
662
+ z.array(z.string()),
663
+ z.record(z.string(), z.union([z.string(), z.array(z.string())])),
664
+ z.null()
665
+ ]),
666
+ localeFileFormat: z.literal("json"),
667
+ locales: z.array(localeSchema).min(1),
668
+ layers: z.array(layerSchema).min(1)
669
+ });
670
+ /**
671
+ * Read an app's artifact, or null when there isn't a usable one.
672
+ *
673
+ * Never throws: every failure here — absent, malformed, a version this CLI
674
+ * does not know, or older than the config it claims to describe — means
675
+ * falling back to loading the app through Nuxt, which is what happens today
676
+ * anyway. A module that cannot be trusted must not be able to break a project
677
+ * that worked without it.
678
+ */
679
+ async function readArtifact(appDir) {
680
+ const path = resolve(appDir, ARTIFACT_PATH);
681
+ if (!existsSync(path)) return null;
682
+ let parsed;
683
+ try {
684
+ parsed = JSON.parse(await readFile(path, "utf-8"));
685
+ } catch (error) {
686
+ log.warn(`Ignoring ${path}: ${error instanceof Error ? error.message : String(error)}`);
687
+ return null;
688
+ }
689
+ const result = artifactSchema.safeParse(parsed);
690
+ if (!result.success) {
691
+ const [issue] = result.error.issues;
692
+ const where = issue?.path.join(".") || "(root)";
693
+ log.warn(`Ignoring ${path}: not a shape this CLI understands (${where}: ${issue?.message}).`);
694
+ return null;
695
+ }
696
+ const changed = staleAgainst(result.data, path);
697
+ if (changed) {
698
+ log.warn(`Ignoring ${path}: ${changed} has changed since it was generated. Run \`nuxt prepare\` to refresh it — falling back to loading the app.`);
699
+ return null;
700
+ }
701
+ log.info(`Using the layer graph published by ${result.data.generator}`);
702
+ return result.data;
703
+ }
704
+ /**
705
+ * An artifact older than any config it describes is a lie about the current
706
+ * project, and a quiet one. Cheaper to distrust it than to explain later why a
707
+ * renamed locale never took effect.
708
+ *
709
+ * Returns the config that outdates it, for the message; undefined when the
710
+ * artifact is current, when no config can be found, or when a stat fails —
711
+ * unreadable timestamps are not evidence of staleness.
712
+ */
713
+ function staleAgainst(artifact, artifactPath) {
714
+ let artifactMtime;
715
+ try {
716
+ artifactMtime = statSync(artifactPath).mtimeMs;
717
+ } catch {
718
+ return;
719
+ }
720
+ const dirs = [artifact.appDir, ...artifact.layers.map((l) => l.rootDir)];
721
+ for (const dir of new Set(dirs)) {
722
+ const configFile = findNuxtConfig(dir);
723
+ if (!configFile) continue;
724
+ const configPath = resolve(dir, configFile);
725
+ try {
726
+ if (statSync(configPath).mtimeMs > artifactMtime) return configPath;
727
+ } catch {
728
+ continue;
729
+ }
730
+ }
731
+ }
732
+ /**
733
+ * Turn one app's artifact into the config shape every command consumes.
734
+ *
735
+ * Layer *names* are derived here rather than read from the artifact: they
736
+ * depend on the directory the CLI was pointed at, which the module cannot
737
+ * know. Deriving them in one place is what keeps the artifact path and the
738
+ * fallback path producing the same names for the same project.
739
+ */
740
+ async function artifactToConfig(artifact, appDir, discoveryRoot, projectConfig) {
741
+ const localeDirs = [];
742
+ const claimed = /* @__PURE__ */ new Map();
743
+ const usedLayerNames = /* @__PURE__ */ new Set();
744
+ for (const layer of artifact.layers) {
745
+ const layerName = deriveLayerName(layer.rootDir, discoveryRoot, usedLayerNames);
746
+ usedLayerNames.add(layerName);
747
+ if (!layer.localeDir) continue;
748
+ const realDir = await realpath(layer.localeDir).catch(() => layer.localeDir);
749
+ claimLocaleDir(localeDirs, claimed, {
750
+ path: layer.localeDir,
751
+ layer: layerName,
752
+ layerRootDir: layer.rootDir
753
+ }, realDir);
754
+ }
755
+ const layerRootDirs = [...new Set(artifact.layers.map((l) => l.rootDir))];
756
+ if (layerRootDirs.length === 0) layerRootDirs.push(appDir);
757
+ const namesForApp = /* @__PURE__ */ new Set();
758
+ const app = {
759
+ name: deriveLayerName(appDir, discoveryRoot, /* @__PURE__ */ new Set()),
760
+ rootDir: appDir,
761
+ layers: artifact.layers.map((l) => deriveLayerName(l.rootDir, discoveryRoot, namesForApp))
762
+ };
763
+ const locales = artifact.locales.map((l) => ({
764
+ code: l.code,
765
+ language: l.language,
766
+ file: l.file,
767
+ ...l.name === void 0 ? {} : { name: l.name }
768
+ }));
769
+ return {
770
+ rootDir: appDir,
771
+ defaultLocale: artifact.defaultLocale,
772
+ fallbackLocale: normalizeFallbackLocale(artifact.fallbackLocale, artifact.defaultLocale),
773
+ locales: applyLocaleOverride(locales, projectConfig?.locales),
774
+ localeDirs,
775
+ layerRootDirs,
776
+ projectConfig: projectConfig ?? void 0,
777
+ apps: [app]
778
+ };
779
+ }
780
+ //#endregion
781
+ //#region src/adapters/nuxt/merge-apps.ts
782
+ /**
783
+ * Folds several Nuxt apps into one project config.
784
+ *
785
+ * A monorepo's apps overlap: they extend the same root layer, sometimes alias
786
+ * each other's locale directories, and each reports the full locale table its
787
+ * layers declare. Merging them is therefore four separate reconciliations —
788
+ * directories claimed once by real path, locales deduplicated by code, layer
789
+ * roots kept unique, and app→layer edges rewritten to whatever the directory
790
+ * ended up being called. Keeping that state in one place stops the caller from
791
+ * having to hold all four in its head at once.
792
+ */
793
+ var AppMerger = class {
794
+ localeDirs = [];
795
+ locales = [];
796
+ layerRootDirs = [];
797
+ apps = [];
798
+ defaultLocale = "en";
799
+ fallbackLocale = { default: ["en"] };
800
+ claimedPaths = /* @__PURE__ */ new Map();
801
+ seenLocaleCodes = /* @__PURE__ */ new Set();
802
+ usedLayerNames = /* @__PURE__ */ new Set();
803
+ async add(appConfig, discoveryRoot) {
804
+ if (this.localeDirs.length === 0) {
805
+ this.defaultLocale = appConfig.defaultLocale;
806
+ this.fallbackLocale = appConfig.fallbackLocale;
807
+ }
808
+ const renamedLayers = await this.claimDirs(appConfig, discoveryRoot);
809
+ this.collectLocales(appConfig);
810
+ this.collectLayerRoots(appConfig);
811
+ this.collectApps(appConfig, renamedLayers);
812
+ }
813
+ toConfig(discoveryRoot, projectConfig, locales) {
814
+ return {
815
+ rootDir: discoveryRoot,
816
+ defaultLocale: this.defaultLocale,
817
+ fallbackLocale: this.fallbackLocale,
818
+ locales,
819
+ localeDirs: this.localeDirs,
820
+ layerRootDirs: this.layerRootDirs,
821
+ projectConfig,
822
+ apps: this.apps
823
+ };
824
+ }
825
+ /**
826
+ * Claim each of this app's locale directories, renaming a layer whose name is
827
+ * already taken by a different directory. Returns the renames, because the
828
+ * app's own layer list still refers to the old names.
829
+ */
830
+ async claimDirs(appConfig, discoveryRoot) {
831
+ const renamed = /* @__PURE__ */ new Map();
832
+ for (const dir of appConfig.localeDirs) {
833
+ const realPath = await realpath(dir.path).catch(() => dir.path);
834
+ let claim = dir;
835
+ if (!this.claimedPaths.has(realPath)) {
836
+ const layer = this.uniqueLayerName(dir, discoveryRoot);
837
+ if (layer !== dir.layer) renamed.set(dir.layer, layer);
838
+ this.usedLayerNames.add(layer);
839
+ claim = {
840
+ ...dir,
841
+ layer
842
+ };
843
+ }
844
+ claimLocaleDir(this.localeDirs, this.claimedPaths, claim, realPath);
845
+ }
846
+ return renamed;
847
+ }
848
+ uniqueLayerName(dir, discoveryRoot) {
849
+ if (!this.usedLayerNames.has(dir.layer)) return dir.layer;
850
+ return deriveLayerName(dir.layerRootDir, discoveryRoot, this.usedLayerNames);
851
+ }
852
+ collectLocales(appConfig) {
853
+ for (const locale of appConfig.locales) {
854
+ if (this.seenLocaleCodes.has(locale.code)) continue;
855
+ this.seenLocaleCodes.add(locale.code);
856
+ this.locales.push(locale);
857
+ }
858
+ }
859
+ collectLayerRoots(appConfig) {
860
+ for (const rootDir of appConfig.layerRootDirs) if (!this.layerRootDirs.includes(rootDir)) this.layerRootDirs.push(rootDir);
861
+ }
862
+ collectApps(appConfig, renamedLayers) {
863
+ for (const app of appConfig.apps) this.apps.push({
864
+ ...app,
865
+ layers: app.layers.map((name) => renamedLayers.get(name) ?? name)
866
+ });
867
+ }
868
+ };
869
+ //#endregion
870
+ //#region src/adapters/nuxt/index.ts
871
+ var NuxtAdapter = class {
872
+ name = "nuxt";
873
+ label = "Nuxt";
874
+ localeFileFormat = "json";
875
+ async detect(projectDir) {
876
+ const configFile = findNuxtConfig(projectDir);
877
+ if (configFile) {
878
+ try {
879
+ const content = await readFile(resolve(projectDir, configFile), "utf-8");
880
+ if (/\bi18n\b/.test(content)) return 2;
881
+ } catch {}
882
+ return (await discoverNuxtApps(projectDir)).length > 0 ? 2 : 1;
883
+ }
884
+ return (await discoverNuxtApps(projectDir)).length > 0 ? 2 : 0;
885
+ }
886
+ async resolve(projectDir, projectConfig) {
887
+ const appDirs = await discoverNuxtApps(projectDir);
888
+ if (findNuxtConfig(projectDir) && !appDirs.includes(projectDir)) appDirs.unshift(projectDir);
889
+ if (appDirs.length === 0) throw new ConfigError(`No Nuxt apps with i18n configuration found under ${projectDir}. Make sure your Nuxt apps have a nuxt.config.ts with i18n configured.`);
890
+ const [soleAppDir] = appDirs;
891
+ if (appDirs.length === 1 && soleAppDir !== void 0) {
892
+ const config = await loadApp(soleAppDir, projectDir, projectConfig);
893
+ if (soleAppDir !== projectDir) {
894
+ config.rootDir = projectDir;
895
+ const rootApp = config.apps[0];
896
+ if (rootApp) rootApp.rootDir = projectDir;
897
+ }
898
+ log.info(`Detected ${config.locales.length} locales, ${config.localeDirs.length} locale directories`);
899
+ return config;
900
+ }
901
+ log.info(`Discovered ${appDirs.length} Nuxt app(s) with i18n: ${appDirs.map((d) => relative(projectDir, d) || ".").join(", ")}`);
902
+ const config = await loadAndMergeApps(appDirs, projectDir, projectConfig);
903
+ log.info(`Detected ${config.locales.length} locales, ${config.localeDirs.length} locale directories from ${appDirs.length} app(s)`);
904
+ return config;
905
+ }
906
+ };
907
+ /**
908
+ * One app's config, preferring what @the-i18n-kit/nuxt published from inside
909
+ * the build over reconstructing it from outside. The artifact is additive: any
910
+ * reason not to trust it falls through to loading the app exactly as before,
911
+ * so a project without the module — or with a stale one — behaves as it always
912
+ * has.
913
+ */
914
+ async function loadApp(appDir, discoveryRoot, projectConfig) {
915
+ const artifact = await readArtifact(appDir);
916
+ if (!artifact) return loadSingleApp(appDir, discoveryRoot, projectConfig);
917
+ const config = await artifactToConfig(artifact, appDir, discoveryRoot, projectConfig);
918
+ if (config.localeDirs.length === 0) {
919
+ log.warn(`The artifact for ${appDir} describes no locale directories — loading the app instead.`);
920
+ return loadSingleApp(appDir, discoveryRoot, projectConfig);
921
+ }
922
+ return config;
923
+ }
924
+ async function loadSingleApp(appDir, discoveryRoot, projectConfig) {
925
+ const kit = await loadKit(appDir);
926
+ let nuxt;
927
+ try {
928
+ nuxt = await kit.loadNuxt({
929
+ cwd: appDir,
930
+ dotenv: { cwd: appDir },
931
+ overrides: {
932
+ logLevel: "silent",
933
+ vite: { clearScreen: false }
934
+ }
935
+ });
936
+ } catch (_error) {
937
+ log.warn(`Initial loadNuxt failed for ${appDir}, retrying with ready:false...`);
938
+ try {
939
+ nuxt = await kit.loadNuxt({
940
+ cwd: appDir,
941
+ dotenv: { cwd: appDir },
942
+ ready: false,
943
+ overrides: {
944
+ logLevel: "silent",
945
+ vite: { clearScreen: false }
946
+ }
947
+ });
948
+ } catch (retryError) {
949
+ throw new ConfigError(`Failed to load Nuxt config from ${appDir}: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
950
+ }
951
+ }
952
+ try {
953
+ return await extractI18nConfig(nuxt, appDir, discoveryRoot, projectConfig);
954
+ } finally {
955
+ await nuxt.close();
956
+ }
957
+ }
958
+ async function loadAndMergeApps(appDirs, discoveryRoot, projectConfig) {
959
+ const merger = new AppMerger();
960
+ for (const appDir of appDirs) {
961
+ log.info(`Loading Nuxt app: ${relative(discoveryRoot, appDir) || "."}`);
962
+ let appConfig;
963
+ try {
964
+ appConfig = await loadApp(appDir, discoveryRoot, projectConfig);
965
+ } catch (error) {
966
+ log.warn(`Failed to load app at ${appDir}: ${toErrorMessage(error)}`);
967
+ continue;
968
+ }
969
+ await merger.add(appConfig, discoveryRoot);
970
+ }
971
+ if (merger.localeDirs.length === 0) throw new ConfigError(`No locale directories found in any Nuxt app under ${discoveryRoot}. Make sure your Nuxt apps have i18n/locales/ directories with JSON files.`);
972
+ return merger.toConfig(discoveryRoot, projectConfig ?? void 0, applyLocaleOverride(merger.locales, projectConfig?.locales));
973
+ }
974
+ async function extractI18nConfig(nuxt, appDir, discoveryRoot, projectConfig) {
975
+ const nuxtOptions = nuxt.options;
976
+ const i18nOptions = nuxtOptions.i18n;
977
+ const layers = nuxtOptions._layers ?? [];
978
+ if (!i18nOptions) throw new ConfigError(`No i18n configuration found in nuxt.config at ${appDir}. Make sure @nuxtjs/i18n is configured.`);
979
+ const defaultLocale = i18nOptions.defaultLocale ?? "en";
980
+ log.debug(`Default locale: ${defaultLocale}`);
981
+ const locales = (i18nOptions.locales ?? []).filter((l) => typeof l === "object" && l !== null).map((l) => ({
982
+ code: String(l.code ?? ""),
983
+ language: String(l.language ?? l.iso ?? ""),
984
+ file: String(l.file ?? ""),
985
+ name: l.name ? String(l.name) : void 0
986
+ })).filter((l) => l.code && l.file);
987
+ if (locales.length === 0) throw new ConfigError("No locales found in i18n configuration. Make sure locales are defined with code and file properties.");
988
+ const fallbackLocale = normalizeFallbackLocale(i18nOptions.fallbackLocale, defaultLocale);
989
+ const localeDirs = await discoverLocaleDirs(layers, i18nOptions, discoveryRoot);
990
+ const layerRootDirs = [...new Set(layers.map((l) => l.config.rootDir))];
991
+ if (layerRootDirs.length === 0) layerRootDirs.push(appDir);
992
+ const usedLayerNamesForApp = /* @__PURE__ */ new Set();
993
+ const appLayerNames = layers.map((l) => deriveLayerName(l.config.rootDir, discoveryRoot, usedLayerNamesForApp));
994
+ const appInfo = {
995
+ name: deriveLayerName(appDir, discoveryRoot, /* @__PURE__ */ new Set()),
996
+ rootDir: appDir,
997
+ layers: appLayerNames
998
+ };
999
+ return {
1000
+ rootDir: appDir,
1001
+ defaultLocale,
1002
+ fallbackLocale,
1003
+ locales: applyLocaleOverride(locales, projectConfig?.locales),
1004
+ localeDirs,
1005
+ layerRootDirs,
1006
+ projectConfig: projectConfig ?? void 0,
1007
+ apps: [appInfo]
1008
+ };
1009
+ }
1010
+ async function discoverLocaleDirs(layers, i18nOptions, discoveryRoot) {
1011
+ const dirs = [];
1012
+ const resolvedPaths = /* @__PURE__ */ new Map();
1013
+ const usedLayerNames = /* @__PURE__ */ new Set();
1014
+ for (const layer of layers) {
1015
+ const layerRootDir = layer.config.rootDir;
1016
+ const layerName = deriveLayerName(layerRootDir, discoveryRoot, usedLayerNames);
1017
+ usedLayerNames.add(layerName);
1018
+ const layerI18n = layer.config.i18n ?? i18nOptions;
1019
+ const langDir = layerI18n.langDir ?? "locales";
1020
+ const resolvedDir = resolve(layerRootDir, layerI18n.restructureDir ?? "i18n", langDir);
1021
+ if (!existsSync(resolvedDir)) {
1022
+ log.debug(`Locale dir not found for layer '${layerName}': ${resolvedDir}`);
1023
+ continue;
1024
+ }
1025
+ const realDir = await realpath(resolvedDir).catch(() => resolvedDir);
1026
+ if (!resolvedPaths.has(realDir)) {
1027
+ const jsonFiles = (await readdir(resolvedDir)).filter((f) => f.endsWith(".json"));
1028
+ if (jsonFiles.length === 0) {
1029
+ log.debug(`No JSON files in locale dir for layer '${layerName}': ${resolvedDir}`);
1030
+ continue;
1031
+ }
1032
+ log.debug(`Found locale dir for layer '${layerName}': ${resolvedDir} (${jsonFiles.length} files)`);
1033
+ }
1034
+ claimLocaleDir(dirs, resolvedPaths, {
1035
+ path: resolvedDir,
1036
+ layer: layerName,
1037
+ layerRootDir
1038
+ }, realDir);
1039
+ }
1040
+ if (dirs.length === 0) throw new ConfigError("No locale directories found. Make sure your Nuxt layers have i18n/locales/ directories with JSON files.");
1041
+ return dirs;
1042
+ }
1043
+ //#endregion
1044
+ //#region src/adapters/laravel/index.ts
1045
+ var LaravelAdapter = class {
1046
+ name = "laravel";
1047
+ label = "Laravel";
1048
+ localeFileFormat = "php-array";
1049
+ async detect(projectDir) {
1050
+ let score = 0;
1051
+ if (existsSync(join(projectDir, "artisan"))) score += 2;
1052
+ try {
1053
+ const raw = await readFile(join(projectDir, "composer.json"), "utf-8");
1054
+ const require = JSON.parse(raw).require;
1055
+ if (require && "laravel/framework" in require) score += 2;
1056
+ } catch {}
1057
+ const langDir = findLangDir(projectDir);
1058
+ if (langDir) {
1059
+ if (await hasLocaleContent(langDir)) score += 1;
1060
+ }
1061
+ return score;
1062
+ }
1063
+ async resolve(projectDir, projectConfig) {
1064
+ const langDir = findLangDir(projectDir);
1065
+ if (!langDir) throw new ConfigError(`No lang/ or resources/lang/ directory found in ${projectDir}. Make sure your Laravel project has a locale directory.`);
1066
+ const format = resolveFormat(langDir, projectConfig?.localeFileFormat);
1067
+ const localeCodes = await discoverLocaleCodes(langDir, format);
1068
+ if (localeCodes.length === 0) throw new ConfigError(getFormatError(langDir, format));
1069
+ const { defaultLocale, fallbackLocale, configLocales } = await extractLocaleConfig(projectDir);
1070
+ const allCodes = mergeLocaleCodes(localeCodes, configLocales);
1071
+ const locales = applyLocaleOverride(allCodes.map((code) => ({
1072
+ code,
1073
+ language: code,
1074
+ ...format === "json" ? { file: `${code}.json` } : {}
1075
+ })), projectConfig?.locales);
1076
+ const localeDirs = [{
1077
+ path: langDir,
1078
+ layer: "root",
1079
+ layerRootDir: projectDir
1080
+ }];
1081
+ log.info(`Discovered ${locales.length} locale(s) in ${langDir} (${format}): ${allCodes.join(", ")}`);
1082
+ return {
1083
+ rootDir: projectDir,
1084
+ defaultLocale,
1085
+ fallbackLocale,
1086
+ locales,
1087
+ localeDirs,
1088
+ layerRootDirs: [projectDir],
1089
+ projectConfig: projectConfig ?? void 0,
1090
+ localeFileFormat: format,
1091
+ apps: [{
1092
+ name: "root",
1093
+ rootDir: projectDir,
1094
+ layers: ["root"]
1095
+ }]
1096
+ };
1097
+ }
1098
+ };
1099
+ function resolveFormat(langDir, override) {
1100
+ if (override) return override;
1101
+ const hasJson = existsSync(join(langDir, "en.json")) || existsSync(join(langDir, "de.json")) || existsSync(join(langDir, "fr.json"));
1102
+ if (hasPhpSubdirsSync(langDir)) return "php-array";
1103
+ if (hasJson) return "json";
1104
+ return "php-array";
1105
+ }
1106
+ function hasPhpSubdirsSync(langDir) {
1107
+ try {
1108
+ const entries = readdirSync(langDir, { withFileTypes: true });
1109
+ for (const entry of entries) {
1110
+ if (!entry.isDirectory() || entry.name === "vendor") continue;
1111
+ if (readdirSync(join(langDir, entry.name)).some((f) => f.endsWith(".php"))) return true;
1112
+ }
1113
+ } catch {}
1114
+ return false;
1115
+ }
1116
+ function getFormatError(langDir, format) {
1117
+ if (format === "json") return `No JSON locale files found in ${langDir}. Expected files like lang/en.json, lang/de.json.`;
1118
+ return `No locale subdirectories found in ${langDir}. Expected directories like lang/en/, lang/de/.`;
1119
+ }
1120
+ async function hasLocaleContent(langDir) {
1121
+ if ((await readdir(langDir).catch(() => [])).some((f) => f.endsWith(".json"))) return true;
1122
+ return hasPhpContent(langDir);
1123
+ }
1124
+ async function discoverLocaleCodes(langDir, format) {
1125
+ if (format === "json") return discoverJsonLocales(langDir);
1126
+ return findLocaleSubdirs(langDir);
1127
+ }
1128
+ async function discoverJsonLocales(langDir) {
1129
+ return (await readdir(langDir).catch(() => [])).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, "")).sort();
1130
+ }
1131
+ async function hasPhpContent(langDir) {
1132
+ return (await findLocaleSubdirs(langDir)).length > 0;
1133
+ }
1134
+ /**
1135
+ * Laravel 9+ uses root-level `lang/`, older versions use `resources/lang/`.
1136
+ */
1137
+ function findLangDir(projectDir) {
1138
+ const candidates = [join(projectDir, "lang"), join(projectDir, "resources", "lang")];
1139
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1140
+ return null;
1141
+ }
1142
+ async function findLocaleSubdirs(langDir) {
1143
+ let entries;
1144
+ try {
1145
+ entries = await readdir(langDir, { withFileTypes: true });
1146
+ } catch {
1147
+ return [];
1148
+ }
1149
+ const locales = [];
1150
+ for (const entry of entries) {
1151
+ if (!entry.isDirectory() || entry.name === "vendor") continue;
1152
+ if ((await readdir(join(langDir, entry.name)).catch(() => [])).some((f) => f.endsWith(".php"))) locales.push(entry.name);
1153
+ }
1154
+ return locales.sort();
1155
+ }
1156
+ /**
1157
+ * Extract default and fallback locale from config/app.php.
1158
+ */
1159
+ async function extractLocaleConfig(projectDir) {
1160
+ const configPath = join(projectDir, "config", "app.php");
1161
+ let content;
1162
+ try {
1163
+ content = await readFile(configPath, "utf-8");
1164
+ } catch {
1165
+ log.debug("No config/app.php found, using defaults");
1166
+ return {
1167
+ defaultLocale: "en",
1168
+ fallbackLocale: { default: ["en"] },
1169
+ configLocales: []
1170
+ };
1171
+ }
1172
+ const defaultLocale = extractPhpConfigValue(content, "locale") ?? "en";
1173
+ const fallbackValue = extractPhpConfigValue(content, "fallback_locale") ?? defaultLocale;
1174
+ const configLocales = extractPhpArrayValue(content, "locales");
1175
+ log.debug(`Extracted locale config: locale=${defaultLocale}, fallback=${fallbackValue}, config locales=${configLocales.length}`);
1176
+ return {
1177
+ defaultLocale,
1178
+ fallbackLocale: { default: [fallbackValue] },
1179
+ configLocales
1180
+ };
1181
+ }
1182
+ function extractPhpConfigValue(content, key) {
1183
+ const envPattern = new RegExp(`['"]${key}['"]\\s*=>\\s*env\\s*\\(\\s*['"][^'"]*['"]\\s*,\\s*['"]([^'"]+)['"]\\s*\\)`);
1184
+ const envMatch = content.match(envPattern);
1185
+ if (envMatch) return envMatch[1] ?? null;
1186
+ const directPattern = new RegExp(`['"]${key}['"]\\s*=>\\s*['"]([^'"]+)['"]`);
1187
+ const directMatch = content.match(directPattern);
1188
+ if (directMatch) return directMatch[1] ?? null;
1189
+ return null;
1190
+ }
1191
+ function extractPhpArrayValue(content, key) {
1192
+ const pattern = new RegExp(`['"]${key}['"]\\s*=>\\s*\\[([^\\]]*)]`);
1193
+ const arrayBody = content.match(pattern)?.[1];
1194
+ if (arrayBody === void 0) return [];
1195
+ const itemPattern = /['"]([^'"]+)['"]/g;
1196
+ const items = [];
1197
+ let itemMatch;
1198
+ while ((itemMatch = itemPattern.exec(arrayBody)) !== null) {
1199
+ const item = itemMatch[1];
1200
+ if (item) items.push(item);
1201
+ }
1202
+ return items;
1203
+ }
1204
+ function mergeLocaleCodes(filesystemCodes, configCodes) {
1205
+ const seen = new Set(filesystemCodes);
1206
+ const merged = [...filesystemCodes];
1207
+ for (const code of configCodes) if (!seen.has(code)) {
1208
+ seen.add(code);
1209
+ merged.push(code);
1210
+ }
1211
+ return merged.sort();
1212
+ }
1213
+ //#endregion
1214
+ //#region src/adapters/generic/index.ts
1215
+ /**
1216
+ * Where a project that declares no `localeDirs` plausibly keeps its locale
1217
+ * files, probed in order. Deliberately short and framework-neutral: it holds
1218
+ * the layouts a Vue, Svelte or plain-bundler project ends up with by
1219
+ * convention, and none that belong to an adapter of their own (`lang/` is
1220
+ * Laravel's, `messages/` and `public/locales` are React's).
1221
+ *
1222
+ * Ordered the way `init` probes the same layouts, so that a project with two
1223
+ * of them resolves to the directory init writes down rather than to a
1224
+ * different one.
1225
+ */
1226
+ const COMMON_LOCALE_DIRS$1 = [
1227
+ "locales",
1228
+ "src/locales",
1229
+ "i18n/locales",
1230
+ "src/i18n/locales",
1231
+ "src/i18n"
1232
+ ];
1233
+ /** A declaration beats every other adapter's signals. */
1234
+ const DECLARED = 10;
1235
+ /**
1236
+ * A probed directory beats nothing: every framework adapter scores at least
1237
+ * this much on a project it recognizes, so discovery only decides projects no
1238
+ * adapter claims.
1239
+ */
1240
+ const DISCOVERED = 1;
1241
+ var GenericAdapter = class {
1242
+ name = "generic";
1243
+ label = "Generic";
1244
+ localeFileFormat = "json";
1245
+ async detect(projectDir) {
1246
+ const config = await loadProjectConfig(projectDir);
1247
+ if (config?.localeDirs && config.localeDirs.length > 0 && config.defaultLocale) return DECLARED;
1248
+ return await findLocaleDir$1(projectDir) ? DISCOVERED : 0;
1249
+ }
1250
+ async resolve(projectDir, projectConfig) {
1251
+ const localeDirs = await resolveLocaleDirs(projectDir, projectConfig);
1252
+ for (const dir of localeDirs) if (!existsSync(dir.path)) throw new ConfigError(`Locale directory does not exist: ${dir.path}`);
1253
+ const detectedFormat = await detectFormatInDir(localeDirs[0].path) ?? "json";
1254
+ const discoveredLocales = projectConfig?.locales ?? await discoverLocales$1(localeDirs, detectedFormat);
1255
+ const [firstLocale] = discoveredLocales;
1256
+ if (firstLocale === void 0) throw new ConfigError(`No locale files found in ${localeDirs.map((d) => d.path).join(", ")}`);
1257
+ const defaultLocale = projectConfig?.defaultLocale ?? firstLocale;
1258
+ const locales = await Promise.all(discoveredLocales.map(async (code) => ({
1259
+ code,
1260
+ language: code,
1261
+ ...await flatFileFor(localeDirs, code, detectedFormat)
1262
+ })));
1263
+ log.info(`Generic adapter: ${locales.length} locale(s), format=${detectedFormat}, dirs=${localeDirs.map((d) => d.layer).join(", ")}`);
1264
+ return {
1265
+ rootDir: projectDir,
1266
+ defaultLocale,
1267
+ fallbackLocale: { default: [defaultLocale] },
1268
+ locales,
1269
+ localeDirs,
1270
+ layerRootDirs: [projectDir],
1271
+ ...projectConfig ? { projectConfig } : {},
1272
+ localeFileFormat: detectedFormat,
1273
+ apps: [{
1274
+ name: "default",
1275
+ rootDir: projectDir,
1276
+ layers: localeDirs.map((d) => d.layer)
1277
+ }]
1278
+ };
1279
+ }
1280
+ };
1281
+ /**
1282
+ * The directories to read, declared first: a project that writes down where
1283
+ * its locale files are has settled the question, and probing is only for the
1284
+ * one that has not.
1285
+ */
1286
+ async function resolveLocaleDirs(projectDir, projectConfig) {
1287
+ const declared = projectConfig?.localeDirs ?? [];
1288
+ if (declared.length > 0) return declared.map((entry) => typeof entry === "string" ? {
1289
+ path: resolve(projectDir, entry),
1290
+ layer: "default",
1291
+ layerRootDir: projectDir
1292
+ } : {
1293
+ path: resolve(projectDir, entry.path),
1294
+ layer: entry.layer,
1295
+ layerRootDir: projectDir
1296
+ });
1297
+ const probed = await findLocaleDir$1(projectDir);
1298
+ if (!probed) throw new ConfigError(`GenericAdapter requires both "localeDirs" and "defaultLocale" in .i18n-mcp.json when the locale files are not in a conventional directory. Looked in ${projectDir} for: ${COMMON_LOCALE_DIRS$1.join(", ")}.`);
1299
+ return [{
1300
+ path: probed,
1301
+ layer: "default",
1302
+ layerRootDir: projectDir
1303
+ }];
1304
+ }
1305
+ /**
1306
+ * The first conventional directory that holds locales, by the same rule that
1307
+ * resolves one. Judging a candidate on its contents rather than its existence
1308
+ * is what keeps an empty `src/i18n` beside a populated `locales` from claiming
1309
+ * a project and then resolving to nothing.
1310
+ *
1311
+ * Runs on every project the CLI is pointed at, including ones this adapter
1312
+ * will lose, so it reads directory entries and nothing else — no project code.
1313
+ */
1314
+ async function findLocaleDir$1(projectDir) {
1315
+ for (const candidate of COMMON_LOCALE_DIRS$1) {
1316
+ const path = join(projectDir, candidate);
1317
+ if (!existsSync(path)) continue;
1318
+ const format = await detectFormatInDir(path);
1319
+ if (!format) continue;
1320
+ if ((await discoverLocales$1([{
1321
+ path,
1322
+ layer: "default",
1323
+ layerRootDir: projectDir
1324
+ }], format)).length > 0) return path;
1325
+ }
1326
+ return null;
1327
+ }
1328
+ /**
1329
+ * The flat file for a locale, when there is one. Absent for a namespaced
1330
+ * layout, where each namespace is its own file and no single name applies.
1331
+ */
1332
+ async function flatFileFor(localeDirs, code, format) {
1333
+ for (const ext of getFormat(format).extensions) for (const dir of localeDirs) if (existsSync(join(dir.path, `${code}${ext}`))) return { file: `${code}${ext}` };
1334
+ return {};
1335
+ }
1336
+ const NON_LOCALE_NAMES = new Set([
1337
+ "index",
1338
+ "readme",
1339
+ "config",
1340
+ "vendor",
1341
+ "node_modules",
1342
+ ".git",
1343
+ ".DS_Store"
1344
+ ]);
1345
+ async function discoverLocales$1(localeDirs, format) {
1346
+ const codes = /* @__PURE__ */ new Set();
1347
+ for (const dir of localeDirs) {
1348
+ let entries;
1349
+ try {
1350
+ entries = await readdir(dir.path, { withFileTypes: true });
1351
+ } catch {
1352
+ continue;
1353
+ }
1354
+ const extensions = getFormat(format).extensions;
1355
+ for (const entry of entries) {
1356
+ if (entry.name.startsWith(".")) continue;
1357
+ const flatExt = extensions.find((ext) => entry.name.toLowerCase().endsWith(ext));
1358
+ if (entry.isFile() && flatExt) {
1359
+ const code = entry.name.slice(0, -flatExt.length);
1360
+ if (!NON_LOCALE_NAMES.has(code.toLowerCase())) codes.add(code);
1361
+ } else if (entry.isDirectory() && !NON_LOCALE_NAMES.has(entry.name.toLowerCase())) {
1362
+ if ((await readdir(join(dir.path, entry.name)).catch(() => [])).some((f) => extensions.some((ext) => f.toLowerCase().endsWith(ext)))) codes.add(entry.name);
1363
+ }
1364
+ }
1365
+ }
1366
+ return [...codes].sort();
1367
+ }
1368
+ //#endregion
1369
+ //#region src/config/framework/execute.ts
1370
+ /**
1371
+ * Executing config files that belong to *other* tools.
1372
+ *
1373
+ * Where a project already declares its locales authoritatively — next-intl's
1374
+ * `defineRouting`, next-translate's `i18n.js`, `next.config`'s `i18n` block —
1375
+ * reading it beats guessing from directory order. The only reason the adapters
1376
+ * ever guessed is that the CLI could not run TypeScript.
1377
+ *
1378
+ * The rule for everything in here: **never load-bearing**. A real
1379
+ * `next.config.js` is wrapped in `withNextIntl(...)` or `withSentryConfig(...)`
1380
+ * and throws without the right env or plugins installed. A file we cannot run
1381
+ * means falling back to the heuristics that were the only behaviour until now,
1382
+ * with a warning — never a failed command. It is the rule the Nuxt adapter's
1383
+ * artifact reader already follows, for the same reason.
1384
+ */
1385
+ /**
1386
+ * Run one config file and hand back its exports, or null if anything at all
1387
+ * went wrong. Callers treat null as "no information", not as an error.
1388
+ */
1389
+ async function executeConfig(path, options = {}) {
1390
+ try {
1391
+ const { createJiti } = await import("jiti");
1392
+ const module = await createJiti(import.meta.url, {
1393
+ interopDefault: false,
1394
+ ...options.alias ? { alias: options.alias } : {}
1395
+ }).import(path);
1396
+ return module && typeof module === "object" ? module : null;
1397
+ } catch (error) {
1398
+ log.warn(`Could not read ${path}: ${toErrorMessage(error)}. Falling back to directory detection — declare the value in i18n-kit.config.ts to be sure of it.`);
1399
+ return null;
1400
+ }
1401
+ }
1402
+ /**
1403
+ * The first of `candidates` that exists under `projectDir`, as an absolute
1404
+ * path.
1405
+ *
1406
+ * Absolute, not merely joined: `--projectDir playground/react` is a relative
1407
+ * path, and a relative path handed to jiti is read as a bare module specifier
1408
+ * and resolved against `node_modules`, where it is never found.
1409
+ */
1410
+ function firstExisting(projectDir, candidates) {
1411
+ for (const candidate of candidates) {
1412
+ const path = resolve(projectDir, candidate);
1413
+ if (existsSync(path)) return path;
1414
+ }
1415
+ return null;
1416
+ }
1417
+ /**
1418
+ * Locate one of the stub modules in `./stubs/`, as a path jiti can be aliased
1419
+ * to. Returns null when it cannot be found, which leaves normal resolution in
1420
+ * place.
1421
+ *
1422
+ * A stub has to survive the build as a *file* — it is referred to by path and
1423
+ * never imported, so it is declared as its own tsdown entry rather than
1424
+ * bundled. That gives it two possible homes, and this resolves against the
1425
+ * location of this module, which sits beside `stubs/` in the source tree and
1426
+ * at the root of `dist/` in the bundle.
1427
+ */
1428
+ function resolveStub(name) {
1429
+ const candidates = [`./stubs/${name}.ts`, `./config/framework/stubs/${name}.js`];
1430
+ for (const candidate of candidates) {
1431
+ const path = fileURLToPath(new URL(candidate, import.meta.url));
1432
+ if (existsSync(path)) return path;
1433
+ }
1434
+ return null;
1435
+ }
1436
+ /** A module's default export, or the module itself for a CJS `module.exports`. */
1437
+ function defaultExport(module) {
1438
+ return "default" in module ? module.default : module;
1439
+ }
1440
+ /** Narrow an unknown to a plain object, the shape every reader here digs into. */
1441
+ function asObject(value) {
1442
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1443
+ }
1444
+ /** A non-empty string, or undefined — config values are not to be trusted. */
1445
+ function asString(value) {
1446
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1447
+ }
1448
+ /** An array of non-empty strings, or undefined. */
1449
+ function asStringArray(value) {
1450
+ if (!Array.isArray(value)) return void 0;
1451
+ const strings = value.filter((v) => typeof v === "string" && v.length > 0);
1452
+ return strings.length > 0 ? strings : void 0;
1453
+ }
1454
+ //#endregion
1455
+ //#region src/config/framework/next.ts
1456
+ /**
1457
+ * Reading what a Next.js project already says about its locales.
1458
+ *
1459
+ * Three setups declare it authoritatively, and the adapter used to read none
1460
+ * of them — it took the alphabetically first file in `messages/` and called
1461
+ * that the default locale, so `{de,en,fr}.json` made German the source of
1462
+ * truth for an English project (#296).
1463
+ */
1464
+ /** next-intl: `defineRouting({ locales, defaultLocale })`, usually re-exported as `routing`. */
1465
+ const ROUTING_FILES = [
1466
+ "src/i18n/routing.ts",
1467
+ "src/i18n/routing.js",
1468
+ "i18n/routing.ts",
1469
+ "i18n/routing.js"
1470
+ ];
1471
+ /** next-translate: a top-level `i18n.js` consumed by `nextTranslate(...)`. */
1472
+ const NEXT_TRANSLATE_FILES = [
1473
+ "i18n.js",
1474
+ "i18n.ts",
1475
+ "i18n.mjs"
1476
+ ];
1477
+ /** Pages Router: `i18n: { locales, defaultLocale }` in the Next config itself. */
1478
+ const NEXT_CONFIG_FILES$1 = [
1479
+ "next.config.ts",
1480
+ "next.config.js",
1481
+ "next.config.mjs"
1482
+ ];
1483
+ /**
1484
+ * The first source that yields something, in order of how specifically it is
1485
+ * about i18n: a routing file exists only to declare locales, whereas
1486
+ * `next.config` is mostly about everything else and the likeliest to throw.
1487
+ *
1488
+ * Returns null when no source could be read — every failure is a warning and a
1489
+ * fallback to directory detection, never an error.
1490
+ */
1491
+ async function readNextI18n(projectDir) {
1492
+ const readers = [
1493
+ readRouting,
1494
+ readNextTranslate,
1495
+ readNextConfig
1496
+ ];
1497
+ for (const reader of readers) {
1498
+ const found = await reader(projectDir);
1499
+ if (found?.defaultLocale || found?.locales) {
1500
+ log.info(`Read i18n from ${found.source}: defaultLocale=${found.defaultLocale ?? "(none)"}`);
1501
+ return found;
1502
+ }
1503
+ }
1504
+ return null;
1505
+ }
1506
+ /**
1507
+ * next-intl. `defineRouting` returns the object it was handed, so the exported
1508
+ * routing object carries `locales` and `defaultLocale` verbatim — which is why
1509
+ * substituting it (only when it is missing; see `routingAlias`) cannot change
1510
+ * the answer.
1511
+ */
1512
+ async function readRouting(projectDir) {
1513
+ const path = firstExisting(projectDir, ROUTING_FILES);
1514
+ if (!path) return null;
1515
+ const module = await executeConfig(path, { alias: routingAlias(path) });
1516
+ if (!module) return null;
1517
+ for (const candidate of [module.routing, defaultExport(module)]) {
1518
+ const routing = asObject(candidate);
1519
+ if (!routing) continue;
1520
+ const defaultLocale = asString(routing.defaultLocale);
1521
+ const locales = asStringArray(routing.locales);
1522
+ if (defaultLocale || locales) return {
1523
+ ...defaultLocale ? { defaultLocale } : {},
1524
+ ...locales ? { locales } : {},
1525
+ source: path
1526
+ };
1527
+ }
1528
+ return null;
1529
+ }
1530
+ /**
1531
+ * Substitute `defineRouting` only when the real one cannot be found from the
1532
+ * routing file's own location.
1533
+ *
1534
+ * The real package always wins where it is installed — it is the authority on
1535
+ * its own API, and a substitution that silently shadowed it would be the kind
1536
+ * of quiet divergence this whole line of work is about. The stub is purely a
1537
+ * rescue for the case where the file exists and its dependencies do not, which
1538
+ * is a fresh clone, a CI job that skipped install, or a fixture. Reading a
1539
+ * declaration should not require a `node_modules` to be present.
1540
+ */
1541
+ function routingAlias(routingPath) {
1542
+ if (resolvableFrom("next-intl/routing", routingPath)) return void 0;
1543
+ const path = resolveStub("next-intl-routing");
1544
+ if (!path) return void 0;
1545
+ log.debug(`next-intl is not installed for ${routingPath} — reading it with a stubbed defineRouting`);
1546
+ return { "next-intl/routing": path };
1547
+ }
1548
+ /**
1549
+ * `next.config.js` may export `(phase, { defaultConfig }) => config`, sync or
1550
+ * async, which Next.js calls for you. An uncalled function is not a config, so
1551
+ * without this the `i18n` block of every project using that form is invisible.
1552
+ *
1553
+ * A function that throws yields nothing, like any other unreadable config.
1554
+ */
1555
+ async function resolveConfigExport(exported, path) {
1556
+ if (typeof exported !== "function") return exported;
1557
+ try {
1558
+ return await exported("phase-production-build", { defaultConfig: {} });
1559
+ } catch (error) {
1560
+ log.warn(`Could not evaluate the config function in ${path}: ${toErrorMessage(error)}`);
1561
+ return;
1562
+ }
1563
+ }
1564
+ function resolvableFrom(specifier, fromFile) {
1565
+ try {
1566
+ createRequire(fromFile).resolve(specifier);
1567
+ return true;
1568
+ } catch {
1569
+ return false;
1570
+ }
1571
+ }
1572
+ /** next-translate's `i18n.js`, a plain object of `{ locales, defaultLocale, pages }`. */
1573
+ async function readNextTranslate(projectDir) {
1574
+ const path = firstExisting(projectDir, NEXT_TRANSLATE_FILES);
1575
+ if (!path) return null;
1576
+ const module = await executeConfig(path);
1577
+ if (!module) return null;
1578
+ const config = asObject(defaultExport(module));
1579
+ if (!config) return null;
1580
+ const defaultLocale = asString(config.defaultLocale);
1581
+ const locales = asStringArray(config.locales);
1582
+ if (!defaultLocale && !locales) return null;
1583
+ return {
1584
+ ...defaultLocale ? { defaultLocale } : {},
1585
+ ...locales ? { locales } : {},
1586
+ source: path
1587
+ };
1588
+ }
1589
+ /**
1590
+ * The Pages Router's `i18n` block. Read last because this is the file most
1591
+ * likely to throw: in a real project it is wrapped in `withNextIntl(...)`,
1592
+ * `withSentryConfig(...)` and whatever else, and needs their env to run.
1593
+ */
1594
+ async function readNextConfig(projectDir) {
1595
+ const path = firstExisting(projectDir, NEXT_CONFIG_FILES$1);
1596
+ if (!path) return null;
1597
+ const module = await executeConfig(path);
1598
+ if (!module) return null;
1599
+ const config = asObject(await resolveConfigExport(defaultExport(module), path));
1600
+ const i18n = config && asObject(config.i18n);
1601
+ if (!i18n) return null;
1602
+ const defaultLocale = asString(i18n.defaultLocale);
1603
+ const locales = asStringArray(i18n.locales);
1604
+ if (!defaultLocale && !locales) return null;
1605
+ return {
1606
+ ...defaultLocale ? { defaultLocale } : {},
1607
+ ...locales ? { locales } : {},
1608
+ source: path
1609
+ };
1610
+ }
1611
+ //#endregion
1612
+ //#region src/adapters/shared.ts
1613
+ /**
1614
+ * Helpers for an adapter that resolves a project to a single locale directory.
1615
+ */
1616
+ const NUXT_CONFIG_FILES = [
1617
+ "nuxt.config.ts",
1618
+ "nuxt.config.js",
1619
+ "nuxt.config.mjs"
1620
+ ];
1621
+ async function loadPackageJson(projectDir) {
1622
+ try {
1623
+ const raw = await readFile(join(projectDir, "package.json"), "utf-8");
1624
+ return JSON.parse(raw);
1625
+ } catch {
1626
+ return null;
1627
+ }
1628
+ }
1629
+ function collectDependencies(pkg) {
1630
+ return {
1631
+ ...pkg.dependencies ?? {},
1632
+ ...pkg.devDependencies ?? {}
1633
+ };
1634
+ }
1635
+ function hasNuxtConfig(dir) {
1636
+ return NUXT_CONFIG_FILES.some((f) => existsSync(join(dir, f)));
1637
+ }
1638
+ function noLocaleDirError(projectDir, searchedDirs) {
1639
+ return new ConfigError(`No locale directory found in ${projectDir}. Looked in: ` + searchedDirs.join(", ") + ". Configure a .i18n-mcp.json with localeDirs for custom paths.");
1640
+ }
1641
+ /** Build the I18nConfig for a project with a single locale directory ('root' layer). */
1642
+ function buildSingleDirConfig(opts) {
1643
+ return {
1644
+ rootDir: opts.projectDir,
1645
+ defaultLocale: opts.defaultLocale,
1646
+ fallbackLocale: { default: [opts.defaultLocale] },
1647
+ locales: opts.locales,
1648
+ localeDirs: [{
1649
+ path: opts.localeDir,
1650
+ layer: "root",
1651
+ layerRootDir: opts.projectDir
1652
+ }],
1653
+ layerRootDirs: [opts.projectDir],
1654
+ projectConfig: opts.projectConfig ?? void 0,
1655
+ apps: [{
1656
+ name: "default",
1657
+ rootDir: opts.projectDir,
1658
+ layers: ["root"]
1659
+ }]
1660
+ };
1661
+ }
1662
+ //#endregion
1663
+ //#region src/adapters/react/index.ts
1664
+ const COMMON_LOCALE_DIRS = [
1665
+ "messages",
1666
+ "public/locales",
1667
+ "locales",
1668
+ "src/i18n",
1669
+ "src/locales",
1670
+ "i18n"
1671
+ ];
1672
+ const NEXT_CONFIG_FILES = [
1673
+ "next.config.ts",
1674
+ "next.config.js",
1675
+ "next.config.mjs"
1676
+ ];
1677
+ const NUXT_INDICATORS = ["@nuxt/kit", "nuxt"];
1678
+ const VUE_INDICATORS = ["vue"];
1679
+ const I18N_DEPS = [
1680
+ "next-intl",
1681
+ "next-translate",
1682
+ "next-i18next",
1683
+ "react-i18next",
1684
+ "react-intl"
1685
+ ];
1686
+ const SKIP_DIRS = new Set([
1687
+ "node_modules",
1688
+ ".git",
1689
+ ".DS_Store"
1690
+ ]);
1691
+ var ReactAdapter = class {
1692
+ name = "react";
1693
+ label = "React / Next.js";
1694
+ localeFileFormat = "json";
1695
+ async detect(projectDir) {
1696
+ const pkg = await loadPackageJson(projectDir);
1697
+ if (!pkg) return 0;
1698
+ const allDeps = collectDependencies(pkg);
1699
+ if (isConflictingFramework(projectDir, allDeps)) return 0;
1700
+ const isNext = "next" in allDeps;
1701
+ const isReact = "react" in allDeps && "react-dom" in allDeps;
1702
+ if (!isNext && !isReact) return 0;
1703
+ return computeScore(projectDir, allDeps, isNext);
1704
+ }
1705
+ async resolve(projectDir, projectConfig) {
1706
+ const localeDir = await findLocaleDir(projectDir);
1707
+ if (!localeDir) throw noLocaleDirError(projectDir, COMMON_LOCALE_DIRS);
1708
+ const locales = await discoverLocales(localeDir);
1709
+ const [firstLocale] = locales;
1710
+ if (firstLocale === void 0) throw new ConfigError(`No locale files found in ${localeDir}. Make sure your React/Next.js project has locale directories like messages/en/ or locale JSON files.`);
1711
+ const framework = await readNextI18n(projectDir);
1712
+ return buildSingleDirConfig({
1713
+ projectDir,
1714
+ localeDir,
1715
+ defaultLocale: projectConfig?.defaultLocale ?? framework?.defaultLocale ?? firstLocale.code,
1716
+ locales: applyLocaleOverride(locales, projectConfig?.locales),
1717
+ projectConfig
1718
+ });
1719
+ }
1720
+ };
1721
+ function isConflictingFramework(projectDir, deps) {
1722
+ for (const indicator of NUXT_INDICATORS) if (indicator in deps) return true;
1723
+ for (const indicator of VUE_INDICATORS) if (indicator in deps) return true;
1724
+ return hasNuxtConfig(projectDir);
1725
+ }
1726
+ function hasNextConfig(projectDir) {
1727
+ return NEXT_CONFIG_FILES.some((f) => existsSync(join(projectDir, f)));
1728
+ }
1729
+ async function computeScore(projectDir, deps, isNext) {
1730
+ let score = isNext ? 5 : 3;
1731
+ if (I18N_DEPS.some((dep) => dep in deps)) score += 2;
1732
+ if (isNext && hasNextConfig(projectDir)) score += 1;
1733
+ try {
1734
+ const localeDir = findLocaleDirSync(projectDir);
1735
+ if (localeDir) {
1736
+ if (readdirSync(localeDir).some((e) => isLocaleContent(join(localeDir, e)))) score += 2;
1737
+ }
1738
+ } catch {}
1739
+ return score;
1740
+ }
1741
+ async function findLocaleDir(projectDir) {
1742
+ const fromConfig = await tryNextConfig(projectDir);
1743
+ if (fromConfig) return fromConfig;
1744
+ return findLocaleDirSync(projectDir);
1745
+ }
1746
+ function findLocaleDirSync(projectDir) {
1747
+ for (const dir of COMMON_LOCALE_DIRS) {
1748
+ const fullPath = join(projectDir, dir);
1749
+ if (existsSync(fullPath)) return fullPath;
1750
+ }
1751
+ return null;
1752
+ }
1753
+ function isLocaleContent(path) {
1754
+ if (path.endsWith(".json")) return true;
1755
+ return isLocaleSubdir(path);
1756
+ }
1757
+ function isLocaleSubdir(path) {
1758
+ if (!existsSync(path)) return false;
1759
+ try {
1760
+ if (!statSync(path).isDirectory()) return false;
1761
+ return readdirSync(path).some((f) => f.endsWith(".json"));
1762
+ } catch {
1763
+ return false;
1764
+ }
1765
+ }
1766
+ async function tryNextConfig(projectDir) {
1767
+ for (const file of NEXT_CONFIG_FILES) {
1768
+ const fullPath = join(projectDir, file);
1769
+ if (!existsSync(fullPath)) continue;
1770
+ const result = await tryExtractFromNextConfig(projectDir, fullPath);
1771
+ if (result) return result;
1772
+ }
1773
+ return null;
1774
+ }
1775
+ async function tryExtractFromNextConfig(projectDir, configPath) {
1776
+ try {
1777
+ const content = await readFile(configPath, "utf-8");
1778
+ if (/createNextIntlPlugin/.test(content) && existsSync(join(projectDir, "messages"))) return join(projectDir, "messages");
1779
+ if (/next-translate/.test(content) && existsSync(join(projectDir, "locales"))) return join(projectDir, "locales");
1780
+ return tryExtractPathFromConfig(projectDir, content);
1781
+ } catch {
1782
+ return null;
1783
+ }
1784
+ }
1785
+ function tryExtractPathFromConfig(projectDir, content) {
1786
+ const rawPath = content.match(/(?:messages|localeDir|locales)\s*:\s*['"]([^'"]+)/)?.[1];
1787
+ if (!rawPath) return null;
1788
+ const path = rawPath.replace(/\/\*\/\*$/, "").replace(/\/\*$/, "");
1789
+ return existsSync(join(projectDir, path)) ? join(projectDir, path) : null;
1790
+ }
1791
+ async function discoverLocales(localeDir) {
1792
+ let entries;
1793
+ try {
1794
+ entries = await readdir(localeDir);
1795
+ } catch {
1796
+ return [];
1797
+ }
1798
+ const localeCodes = /* @__PURE__ */ new Set();
1799
+ for (const entry of entries) {
1800
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
1801
+ if (isLocaleSubdir(join(localeDir, entry))) localeCodes.add(entry);
1802
+ }
1803
+ if (localeCodes.size > 0) return [...localeCodes].sort().map((code) => ({
1804
+ code,
1805
+ language: code
1806
+ }));
1807
+ return entries.filter((entry) => entry.endsWith(".json")).sort().map((file) => ({
1808
+ code: file.replace(/\.json$/, ""),
1809
+ language: file.replace(/\.json$/, ""),
1810
+ file
1811
+ }));
1812
+ }
1813
+ //#endregion
1814
+ //#region src/config/cache.ts
1815
+ const configs = /* @__PURE__ */ new Map();
1816
+ let lastConfig = null;
1817
+ /** The resolved config for a canonical project directory, if one is cached. */
1818
+ function getCachedConfigFor(canonicalDir) {
1819
+ return configs.get(canonicalDir);
1820
+ }
1821
+ function cacheConfig(canonicalDir, config) {
1822
+ configs.set(canonicalDir, config);
1823
+ lastConfig = config;
1824
+ }
1825
+ /** The most recently resolved config, or null when nothing has been resolved. */
1826
+ function getCachedConfig() {
1827
+ return lastConfig;
1828
+ }
1829
+ function clearConfigCache() {
1830
+ configs.clear();
1831
+ lastConfig = null;
1832
+ log.debug("Config cache cleared");
1833
+ }
1834
+ //#endregion
1835
+ //#region src/config/workspace-graph.ts
1836
+ /**
1837
+ * Consumer-graph inference from the package manager workspace.
1838
+ *
1839
+ * Everything the kit does across layers — app-scoped orphan evidence,
1840
+ * misplaced usages, unconsumed layers — runs on `config.apps`: which app can
1841
+ * render which layer's keys. Only the Nuxt adapter can answer that from
1842
+ * framework knowledge, because a Nuxt `extends` chain *is* a consumer graph.
1843
+ * For every other stack the same answer is already written down in the
1844
+ * workspace: a package that contains a locale dir owns that layer, and a
1845
+ * `package.json` dependency edge from one such package to another is a
1846
+ * consumption edge.
1847
+ *
1848
+ * ## What counts as an app
1849
+ *
1850
+ * An app is a layer-owning workspace package that no *other* layer-owning
1851
+ * package depends on, directly or transitively — the roots of the
1852
+ * layer-owning subgraph. A package something else depends on (a UI kit, a
1853
+ * shared locale package) is not an app of its own: its keys are rendered by
1854
+ * its dependents, and calling it an app would tell the orphan scan that its
1855
+ * own directory is a place where its keys may legitimately be used, which is
1856
+ * evidence the scan must never invent. When every layer-owning package is
1857
+ * depended on by another one — a dependency cycle, which package managers
1858
+ * permit — there is no root to pick, and then every layer-owning package
1859
+ * becomes an app: a graph wider than the truth, never narrower, and narrower
1860
+ * is the direction that deletes keys. Each app consumes its own layers plus
1861
+ * the layers of its transitive dependencies. Layers that lie outside every
1862
+ * workspace package are attached to every app, so an unclaimed locale dir
1863
+ * keeps behaving exactly as it does without inference.
1864
+ *
1865
+ * Inference is refused (`null`) when the project is not a workspace, when no
1866
+ * package owns a layer, or when fewer than two apps come out of it — the
1867
+ * single-app fallback the adapters build is already correct for those, and
1868
+ * replacing it would change names for no gain.
1869
+ */
1870
+ const DEPENDENCY_FIELDS = [
1871
+ "dependencies",
1872
+ "devDependencies",
1873
+ "peerDependencies"
1874
+ ];
1875
+ /**
1876
+ * Derive the consumer graph for `layers` from the workspace `projectDir` is
1877
+ * the root of. Returns null when the workspace cannot answer the question —
1878
+ * see the module header for which cases those are and why.
1879
+ */
1880
+ async function inferWorkspaceApps(projectDir, layers) {
1881
+ const workspace = await readWorkspacePatterns(projectDir);
1882
+ if (!workspace) return null;
1883
+ const packages = await readWorkspacePackages(projectDir, workspace.patterns);
1884
+ if (packages.length === 0) return null;
1885
+ log.debug(`Workspace consumer graph: ${packages.length} package(s) from ${workspace.source} (${workspace.patterns.join(", ")})`);
1886
+ return buildWorkspaceApps(packages, layers);
1887
+ }
1888
+ /**
1889
+ * The inference itself, over already-read workspace packages: ownership by
1890
+ * directory containment, consumption by dependency edges.
1891
+ */
1892
+ function buildWorkspaceApps(packages, layers) {
1893
+ const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
1894
+ const canonical = layers.filter((layer) => !layer.aliasOf);
1895
+ const ownedBy = /* @__PURE__ */ new Map();
1896
+ const unowned = [];
1897
+ for (const layer of canonical) {
1898
+ const owner = ownerOfDir(packages, layer.path);
1899
+ if (!owner) {
1900
+ unowned.push(layer.layer);
1901
+ continue;
1902
+ }
1903
+ const owned = ownedBy.get(owner.name) ?? [];
1904
+ owned.push(layer.layer);
1905
+ ownedBy.set(owner.name, owned);
1906
+ }
1907
+ if (ownedBy.size === 0) return null;
1908
+ const closure = transitiveClosure(byName);
1909
+ const depended = /* @__PURE__ */ new Set();
1910
+ for (const name of ownedBy.keys()) for (const dep of closure.get(name) ?? []) if (dep !== name && ownedBy.has(dep)) depended.add(dep);
1911
+ const roots = [...ownedBy.keys()].filter((name) => !depended.has(name));
1912
+ const appNames = (roots.length > 0 ? roots : [...ownedBy.keys()]).sort();
1913
+ if (appNames.length < 2) return null;
1914
+ return appNames.map((name) => {
1915
+ const consumed = new Set(ownedBy.get(name) ?? []);
1916
+ for (const dep of closure.get(name) ?? []) for (const layer of ownedBy.get(dep) ?? []) consumed.add(layer);
1917
+ for (const layer of unowned) consumed.add(layer);
1918
+ return {
1919
+ name,
1920
+ rootDir: byName.get(name).dir,
1921
+ layers: canonical.filter((layer) => consumed.has(layer.layer)).map((layer) => layer.layer),
1922
+ source: "workspace"
1923
+ };
1924
+ });
1925
+ }
1926
+ /**
1927
+ * The package globs of the workspace rooted at `projectDir`, from whichever
1928
+ * package manager declares one. Only `projectDir` itself is inspected: a
1929
+ * project detected from inside one workspace package is that package's
1930
+ * project, and reading the enclosing workspace would widen the graph past
1931
+ * what the caller asked about.
1932
+ */
1933
+ async function readWorkspacePatterns(projectDir) {
1934
+ for (const file of ["pnpm-workspace.yaml", "pnpm-workspace.yml"]) {
1935
+ const raw = await readFileOrNull(join(projectDir, file));
1936
+ if (raw === null) continue;
1937
+ const patterns = parsePnpmWorkspacePackages(raw);
1938
+ if (patterns.length > 0) return {
1939
+ patterns,
1940
+ source: file
1941
+ };
1942
+ }
1943
+ const manifest = await readJsonOrNull(join(projectDir, "package.json"));
1944
+ const patterns = manifest ? workspacesField(manifest) : [];
1945
+ return patterns.length > 0 ? {
1946
+ patterns,
1947
+ source: "package.json"
1948
+ } : null;
1949
+ }
1950
+ /**
1951
+ * The `packages:` sequence of a `pnpm-workspace.yaml`, in block or flow form.
1952
+ *
1953
+ * A hand-rolled reader rather than a YAML parser: the file's other keys are
1954
+ * none of our business, one list of strings is the whole contract, and a
1955
+ * parser dependency for it would ship in every install.
1956
+ */
1957
+ function parsePnpmWorkspacePackages(yamlText) {
1958
+ const patterns = [];
1959
+ let inBlock = false;
1960
+ for (const line of yamlText.split(/\r?\n/)) {
1961
+ if (!inBlock) {
1962
+ const header = /^packages:\s*(.*)$/.exec(line);
1963
+ if (!header) continue;
1964
+ const inline = header[1].trim();
1965
+ if (inline.startsWith("[")) {
1966
+ const end = inline.lastIndexOf("]");
1967
+ return inline.slice(1, end === -1 ? inline.length : end).split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
1968
+ }
1969
+ inBlock = true;
1970
+ continue;
1971
+ }
1972
+ const trimmed = line.trim();
1973
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
1974
+ const item = /^-\s*(.+)$/.exec(trimmed);
1975
+ if (!item) break;
1976
+ const value = unquote(item[1].trim());
1977
+ if (value.length > 0) patterns.push(value);
1978
+ }
1979
+ return patterns;
1980
+ }
1981
+ /** The npm/yarn `workspaces` field, in both of its accepted spellings. */
1982
+ function workspacesField(manifest) {
1983
+ const field = manifest.workspaces;
1984
+ if (Array.isArray(field)) return field.filter((p) => typeof p === "string");
1985
+ if (field && typeof field === "object") {
1986
+ const nested = field.packages;
1987
+ if (Array.isArray(nested)) return nested.filter((p) => typeof p === "string");
1988
+ }
1989
+ return [];
1990
+ }
1991
+ /** Expand the workspace globs to the packages they name. */
1992
+ async function readWorkspacePackages(projectDir, patterns) {
1993
+ const include = patterns.filter((p) => !p.startsWith("!"));
1994
+ const exclude = patterns.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
1995
+ if (include.length === 0) return [];
1996
+ let manifests;
1997
+ try {
1998
+ manifests = await glob(include.map(toManifestPattern), {
1999
+ cwd: projectDir,
2000
+ absolute: true,
2001
+ ignore: ["**/node_modules/**", ...exclude.map(toManifestPattern)]
2002
+ });
2003
+ } catch (error) {
2004
+ log.debug(`Workspace consumer graph: glob failed (${String(error)})`);
2005
+ return [];
2006
+ }
2007
+ const packages = [];
2008
+ for (const manifest of manifests) {
2009
+ const pkg = await readJsonOrNull(manifest);
2010
+ if (!pkg) continue;
2011
+ const dir = dirname(resolve(manifest));
2012
+ const declared = typeof pkg.name === "string" ? pkg.name.trim() : "";
2013
+ packages.push({
2014
+ dir,
2015
+ name: declared.length > 0 ? declared : basename(dir),
2016
+ deps: dependencyNames(pkg)
2017
+ });
2018
+ }
2019
+ const names = new Set(packages.map((pkg) => pkg.name));
2020
+ return packages.map((pkg) => ({
2021
+ ...pkg,
2022
+ deps: pkg.deps.filter((dep) => dep !== pkg.name && names.has(dep))
2023
+ }));
2024
+ }
2025
+ /**
2026
+ * A workspace glob names directories; matching their manifests instead keeps
2027
+ * directories that are not packages (a `packages/*` that also holds docs) out
2028
+ * of the graph.
2029
+ */
2030
+ function toManifestPattern(pattern) {
2031
+ const trimmed = pattern.replace(/\/+$/, "");
2032
+ if (trimmed === "" || trimmed === ".") return "package.json";
2033
+ return `${trimmed}/package.json`;
2034
+ }
2035
+ /**
2036
+ * Dependency names across every field that can carry a workspace edge. The
2037
+ * version range is irrelevant — `workspace:*`, a published range, or a file
2038
+ * link all mean the same thing once the name matches a workspace package.
2039
+ */
2040
+ function dependencyNames(manifest) {
2041
+ const names = [];
2042
+ for (const field of DEPENDENCY_FIELDS) {
2043
+ const deps = manifest[field];
2044
+ if (deps && typeof deps === "object") names.push(...Object.keys(deps));
2045
+ }
2046
+ return names;
2047
+ }
2048
+ /** The innermost workspace package containing `dir`, if any. */
2049
+ function ownerOfDir(packages, dir) {
2050
+ let best;
2051
+ for (const pkg of packages) {
2052
+ if (!isWithin(dir, pkg.dir)) continue;
2053
+ if (!best || pkg.dir.length > best.dir.length) best = pkg;
2054
+ }
2055
+ return best;
2056
+ }
2057
+ /** True when `child` equals `parent` or lies inside it. */
2058
+ function isWithin(child, parent) {
2059
+ const rel = relative(parent, resolve(child));
2060
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2061
+ }
2062
+ /** Package name → every workspace package reachable from it. Cycle-safe. */
2063
+ function transitiveClosure(byName) {
2064
+ const closure = /* @__PURE__ */ new Map();
2065
+ for (const [name, pkg] of byName) {
2066
+ const reachable = /* @__PURE__ */ new Set();
2067
+ const stack = [...pkg.deps];
2068
+ while (stack.length > 0) {
2069
+ const next = stack.pop();
2070
+ if (reachable.has(next)) continue;
2071
+ const dep = byName.get(next);
2072
+ if (!dep) continue;
2073
+ reachable.add(next);
2074
+ stack.push(...dep.deps);
2075
+ }
2076
+ closure.set(name, reachable);
2077
+ }
2078
+ return closure;
2079
+ }
2080
+ async function readFileOrNull(path) {
2081
+ try {
2082
+ return await readFile(path, "utf-8");
2083
+ } catch {
2084
+ return null;
2085
+ }
2086
+ }
2087
+ async function readJsonOrNull(path) {
2088
+ const raw = await readFileOrNull(path);
2089
+ if (raw === null) return null;
2090
+ try {
2091
+ const parsed = JSON.parse(raw);
2092
+ return parsed && typeof parsed === "object" ? parsed : null;
2093
+ } catch {
2094
+ log.debug(`Workspace consumer graph: ignoring unparseable ${path}`);
2095
+ return null;
2096
+ }
2097
+ }
2098
+ /** Strip YAML quoting and any trailing comment from a sequence entry. */
2099
+ function unquote(value) {
2100
+ const quoted = /^(['"])(.*?)\1/.exec(value);
2101
+ if (quoted) return quoted[2];
2102
+ const hash = value.indexOf(" #");
2103
+ return (hash === -1 ? value : value.slice(0, hash)).trim();
2104
+ }
2105
+ //#endregion
2106
+ //#region src/config/detector.ts
2107
+ registerAdapter(new NuxtAdapter());
2108
+ registerAdapter(new LaravelAdapter());
2109
+ registerAdapter(new GenericAdapter());
2110
+ registerAdapter(new ReactAdapter());
2111
+ async function detectI18nConfig(projectDir) {
2112
+ const canonDir = canonicalPath(projectDir);
2113
+ const cached = getCachedConfigFor(canonDir);
2114
+ if (cached) {
2115
+ log.debug("Using cached i18n config");
2116
+ return cached;
2117
+ }
2118
+ log.info(`Detecting i18n config from: ${projectDir}`);
2119
+ const projectConfig = await loadProjectConfig(projectDir);
2120
+ const hint = projectConfig?.framework;
2121
+ const adapter = await detectFramework(projectDir, hint);
2122
+ log.info(`Detected framework: ${adapter.label}`);
2123
+ const config = await adapter.resolve(projectDir, projectConfig);
2124
+ config.framework = adapter.name;
2125
+ await applyConsumerGraph(config, projectConfig, projectDir);
2126
+ cacheConfig(canonDir, config);
2127
+ log.info(`Detected ${config.locales.length} locales, ${config.localeDirs.length} locale directories`);
2128
+ return config;
2129
+ }
2130
+ /**
2131
+ * Settle the consumer graph in `config.apps`, which every cross-layer
2132
+ * operation reads, once the adapter has had its say.
2133
+ *
2134
+ * A declared `apps` list wins outright: it is the only way to correct a graph
2135
+ * the tool got wrong, so nothing may override it. Otherwise an adapter that
2136
+ * discovered real apps (Nuxt, from its `extends` chain) is left alone, and
2137
+ * only the single placeholder app the other adapters emit is open to being
2138
+ * replaced by what the workspace says. This lives here rather than in an
2139
+ * adapter because it is framework-independent: locale dirs plus package
2140
+ * manifests, which every adapter has already produced by this point.
2141
+ */
2142
+ async function applyConsumerGraph(config, projectConfig, projectDir) {
2143
+ const declared = projectConfig?.apps;
2144
+ if (declared && declared.length > 0) {
2145
+ config.apps = declared.map((app) => declaredApp(app, config));
2146
+ warnUnknownDeclaredLayers(config);
2147
+ log.debug(`Consumer graph declared in the project config: ${describeApps(config.apps)}`);
2148
+ return;
2149
+ }
2150
+ if (projectConfig?.consumerGraph === "off") return;
2151
+ if (!(config.apps.length === 1 && config.apps[0]?.name === "default")) return;
2152
+ const inferred = await inferWorkspaceApps(projectDir, config.localeDirs);
2153
+ if (!inferred) return;
2154
+ config.apps = inferred;
2155
+ log.debug(`Consumer graph inferred from the workspace: ${describeApps(inferred)}`);
2156
+ }
2157
+ /**
2158
+ * A declared app carries no directory, and the orphan scan needs one to know
2159
+ * which source tree is that app's. The first of its layers that has a locale
2160
+ * dir supplies it — by convention an app lists its own layer first, and the
2161
+ * shared layers it consumes after — falling back to the project root, which
2162
+ * scopes nothing away.
2163
+ */
2164
+ function declaredApp(app, config) {
2165
+ const own = app.layers.map((layer) => config.localeDirs.find((dir) => dir.layer === layer)).find((dir) => dir !== void 0);
2166
+ return {
2167
+ name: app.name,
2168
+ rootDir: own?.layerRootDir ?? config.rootDir,
2169
+ layers: [...app.layers],
2170
+ source: "declared"
2171
+ };
2172
+ }
2173
+ /**
2174
+ * A declared layer name that matches no locale dir is a typo rather than a
2175
+ * layer, and it silently contributes nothing to the graph — so say so.
2176
+ */
2177
+ function warnUnknownDeclaredLayers(config) {
2178
+ const known = new Set(config.localeDirs.map((dir) => dir.layer));
2179
+ const unknown = [...new Set(config.apps.flatMap((app) => app.layers))].filter((layer) => !known.has(layer));
2180
+ if (unknown.length === 0) return;
2181
+ log.warn(`Declared apps reference unknown layer(s): ${unknown.join(", ")}. Detected layers: ${[...known].join(", ")}.`);
2182
+ }
2183
+ function describeApps(apps) {
2184
+ return apps.map((app) => `${app.name} -> [${app.layers.join(", ")}]`).join("; ");
2185
+ }
2186
+ //#endregion
2187
+ export { detectFrameworkMatch as a, discoverNuxtApps as i, clearConfigCache as n, formatForFile as o, getCachedConfig as r, getFormat as s, detectI18nConfig as t };
2188
+
2189
+ //# sourceMappingURL=detector-DtFY4qm3.js.map