@menapo/vi18n 0.0.0-stage → 0.1.1

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 (100) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +110 -2
  3. package/dist/apply-Bre-lY9J.js +228 -0
  4. package/dist/captured-Bj8bO6vd.js +1034 -0
  5. package/dist/check-BIKEuu6B.js +201 -0
  6. package/dist/check-HsmmpJZg.js +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +41 -0
  9. package/dist/codec-DuvU2VQy.js +207 -0
  10. package/dist/collect-Cx4_SKOe.js +13 -0
  11. package/dist/collisions-CfpZU8e4.js +217 -0
  12. package/dist/compile-BaDh0cHG.js +245 -0
  13. package/dist/compile-Cc97JaW2.js +2502 -0
  14. package/dist/concurrency-C9UUWuDC.js +96 -0
  15. package/dist/concurrency-DONL7t4V.js +1 -0
  16. package/dist/config.d.ts +21 -0
  17. package/dist/config.js +39 -0
  18. package/dist/dir-zw6bGMKQ.js +290 -0
  19. package/dist/duplicates-yuRljQ6h.js +185 -0
  20. package/dist/engine-C2ENBRJe.js +531 -0
  21. package/dist/engine.d.ts +43 -0
  22. package/dist/engine.js +1241 -0
  23. package/dist/exclude-Du-Zd_qI.js +26 -0
  24. package/dist/export-C9Sy6UWv.js +103 -0
  25. package/dist/extract-C3S8btx0.js +298 -0
  26. package/dist/formats-9unqj_fZ.js +781 -0
  27. package/dist/i18next-9HAoxwUI.js +1 -0
  28. package/dist/i18next-BEJgv_S8.js +1596 -0
  29. package/dist/import-nANvQutw.js +225 -0
  30. package/dist/index.d.ts +156 -0
  31. package/dist/index.js +239 -0
  32. package/dist/init-C2ScuW6a.js +792 -0
  33. package/dist/library-D5CWvTQs.js +1882 -0
  34. package/dist/lingui-zUiCl9Um.js +833 -0
  35. package/dist/lint.d.ts +105 -0
  36. package/dist/lint.js +971 -0
  37. package/dist/load-CQRz4PpM.js +2950 -0
  38. package/dist/load-gW-oJt1Q.js +747 -0
  39. package/dist/locales-DX4MOBFE.js +827 -0
  40. package/dist/lsp-RMMMc88P.js +31 -0
  41. package/dist/match-BGr8EXB3.js +89 -0
  42. package/dist/mcp-CHVwBoRP.js +28 -0
  43. package/dist/merge-driver-BTX87aVm.js +422 -0
  44. package/dist/messages-BfjIExyB.js +166 -0
  45. package/dist/migrate-IpLDMXmq.js +1484 -0
  46. package/dist/migrate-rCQuOGsf.js +1 -0
  47. package/dist/openai-CTaMDcl-.js +346 -0
  48. package/dist/output-DWE3SIUA.js +164 -0
  49. package/dist/parse.d.ts +62 -0
  50. package/dist/parse.js +156 -0
  51. package/dist/pipeline-C9Dbotkh.js +2031 -0
  52. package/dist/pipeline-DbX6rlo5.js +1 -0
  53. package/dist/project-Bw9UPIdx.js +1046 -0
  54. package/dist/project-C4GpaN-n.js +381 -0
  55. package/dist/pseudo-DRv30iN5.js +145 -0
  56. package/dist/raw-C4Y6vyGY.js +4708 -0
  57. package/dist/react-intl-BP0u-D5m.js +519 -0
  58. package/dist/restore-CiYv-jxS.js +175 -0
  59. package/dist/rewrite-EsIw_lsq.js +803 -0
  60. package/dist/rich-DCUKl5HM.js +97 -0
  61. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  62. package/dist/run-BEcnBbu-.js +694 -0
  63. package/dist/runtime-DTMmliT1.js +64 -0
  64. package/dist/scan-worker.d.ts +62 -0
  65. package/dist/scan-worker.js +1 -0
  66. package/dist/scoped-BMYpSthj.js +44 -0
  67. package/dist/server-Da1Ot9IN.js +10485 -0
  68. package/dist/server-deBOq2vq.js +11823 -0
  69. package/dist/server.d.ts +169 -0
  70. package/dist/server.js +1234 -0
  71. package/dist/sitemap-DJXzKoza.js +159 -0
  72. package/dist/sources-D19czDSA.js +2242 -0
  73. package/dist/status-zr4jY59Y.js +149 -0
  74. package/dist/stdio-CVaJfxA7.js +29 -0
  75. package/dist/stdio-FPOKQAgu.js +95 -0
  76. package/dist/sync-B9V2KFwb.js +658 -0
  77. package/dist/t-DC-c9zE5.js +454 -0
  78. package/dist/test.d.ts +116 -0
  79. package/dist/test.js +319 -0
  80. package/dist/translate-CQnJfLjO.js +299 -0
  81. package/dist/ts-BaU5yOf0.js +4863 -0
  82. package/dist/types-CgUCkFU1.d.ts +707 -0
  83. package/dist/types-D3QGM8XO.js +146 -0
  84. package/dist/types-DsdWBG0b.d.ts +558 -0
  85. package/dist/types-DtvQ7UdD.js +1948 -0
  86. package/dist/types-wSh9fCBY.js +11 -0
  87. package/dist/units-CQ5Q99RC.js +150 -0
  88. package/dist/url-CRfCOhUI.js +172 -0
  89. package/dist/url-CsBkRaJy.js +225 -0
  90. package/dist/validate-BxpupdAf.js +2615 -0
  91. package/dist/validate-D6DJepZv.js +365 -0
  92. package/dist/version-Byqi2guX.js +28 -0
  93. package/dist/vite-scan-CT_vsp4g.js +242 -0
  94. package/dist/vite.d.ts +34 -0
  95. package/dist/vite.js +7558 -0
  96. package/dist/vue-i18n-g0iGy6CZ.js +651 -0
  97. package/dist/which-BOemZ5Q8.js +116 -0
  98. package/dist/worker-84U6uYB3.js +1656 -0
  99. package/package.json +97 -3
  100. package/schemas/catalog.json +57 -0
package/dist/test.js ADDED
@@ -0,0 +1,319 @@
1
+ import { c as page, n as S, p as subs, r as changed, t as textDirection } from "./dir-zw6bGMKQ.js";
2
+ import { c as parseICU, i as normalizeSource, n as canonicalize, t as compileMessage } from "./compile-Cc97JaW2.js";
3
+ import { t as createScopedApi } from "./scoped-BMYpSthj.js";
4
+ import { t as pseudoLocalize } from "./pseudo-DRv30iN5.js";
5
+ //#region src/test/catalog.ts
6
+ /**
7
+ * `loadCatalog` (INTERNALS §3.8, §6 D194): one locale's translations read
8
+ * from the project's catalog files, as the build compiles them, for
9
+ * `createTestI18n({ catalog })`. The Node side loads on first use, so the
10
+ * `/test` entry itself stays loadable in browser test runners.
11
+ */
12
+ const loads = /* @__PURE__ */ new Map();
13
+ /**
14
+ * The locale's translations, fallbacks applied; untranslated and unusable
15
+ * entries are left out, so those messages render their source text, as in
16
+ * `vite dev`. Memoised per project and locale for the test file.
17
+ */
18
+ function loadCatalog(locale, options = {}) {
19
+ const project = S.registration?.project;
20
+ const root = options.root ?? project?.root ?? globalThis.process?.cwd() ?? ".";
21
+ const configFile = options.configFile ?? (options.root === void 0 ? project?.configFile : void 0) ?? null;
22
+ const key = JSON.stringify([
23
+ root,
24
+ configFile,
25
+ locale
26
+ ]);
27
+ let load = loads.get(key);
28
+ if (load === void 0) {
29
+ load = import("./load-CQRz4PpM.js").then((m) => m.compileCatalog(locale, root, configFile));
30
+ loads.set(key, load);
31
+ load.catch(() => loads.delete(key));
32
+ }
33
+ return load;
34
+ }
35
+ //#endregion
36
+ //#region src/test/i18n.ts
37
+ /**
38
+ * `createTestI18n` (PLAN §5.22, INTERNALS §3.8): unit and component tests
39
+ * render in a locale, with real or hand-written translations, with message
40
+ * ids or pseudo text, and with a fixed time zone and `now`.
41
+ *
42
+ * It configures the page scope, the scope `t()` resolves in a test (under
43
+ * the plugin's test registration and without the plugin), rather than a
44
+ * scope of its own: components import the global `t`, and mounted ones
45
+ * re-render through their glue's trackers (§6 D190). The scope's `test` hook
46
+ * picks every message, because only it sees the descriptor (§6 D189).
47
+ */
48
+ const DEFAULT_NOW = Date.UTC(2026, 0, 1);
49
+ const PSEUDO = /* @__PURE__ */ new Set(["en-XA", "ar-XB"]);
50
+ /** What the page held before the first `createTestI18n`; null while nothing is applied. */
51
+ let saved = null;
52
+ const html = () => typeof document === "object" && document?.documentElement ? document.documentElement : null;
53
+ function notify() {
54
+ changed(true);
55
+ for (const fn of subs) fn(page.locale);
56
+ }
57
+ /** Puts the page back as the first `createTestI18n` found it. */
58
+ function resetTestI18n() {
59
+ const s = saved;
60
+ if (s === null) return;
61
+ saved = null;
62
+ page.locale = s.locale;
63
+ page.messages = s.messages;
64
+ page.timeZone = s.timeZone;
65
+ page.now = s.now;
66
+ page.labels = s.labels;
67
+ delete page.test;
68
+ S.registration = s.registration;
69
+ S.locales.splice(0, S.locales.length, ...s.locales);
70
+ const h = html();
71
+ if (h && s.html) Object.assign(h, s.html);
72
+ notify();
73
+ }
74
+ /** A registration for suites the plugin does not run in, so `t()` and `/server` treat them as tests. */
75
+ function testRegistration(locale) {
76
+ const locales = [.../* @__PURE__ */ new Set(["en", locale])];
77
+ return {
78
+ mode: "dev",
79
+ consumer: "client",
80
+ config: {
81
+ sourceLocale: "en",
82
+ locales,
83
+ pseudoLocales: locales.filter((l) => PSEUDO.has(l)),
84
+ fallbacks: {},
85
+ routing: {
86
+ strategy: ["default"],
87
+ routeStrategies: {},
88
+ url: "no_prefix",
89
+ redirectRoot: false,
90
+ propagate: false,
91
+ switch: "in-place",
92
+ cookie: {
93
+ name: "vi18n_locale",
94
+ maxAge: 31536e3
95
+ },
96
+ base: "/"
97
+ },
98
+ ssr: {
99
+ timeZone: "UTC",
100
+ timeZoneFrom: {},
101
+ normalizeSpaces: true
102
+ }
103
+ },
104
+ test: true
105
+ };
106
+ }
107
+ function memo(cache, key, make) {
108
+ let value = cache.get(key);
109
+ if (value === void 0) {
110
+ if (cache.size > 9999) cache.clear();
111
+ cache.set(key, value = make());
112
+ }
113
+ return value;
114
+ }
115
+ const parsed = /* @__PURE__ */ new Map();
116
+ const sourceIr = /* @__PURE__ */ new Map();
117
+ const pseudoIr = /* @__PURE__ */ new Map();
118
+ const canonical = /* @__PURE__ */ new Map();
119
+ function parse(source) {
120
+ return memo(parsed, source, () => {
121
+ try {
122
+ return parseICU(normalizeSource(source));
123
+ } catch (error) {
124
+ throw new Error(`vi18n: ${JSON.stringify(source)} is not a valid message: ${error.message}`, { cause: error });
125
+ }
126
+ });
127
+ }
128
+ const compiled = (source) => memo(sourceIr, source, () => compileMessage(parse(source)));
129
+ /** The text as a build's pseudo-locale renders it (`compileLocales` does the same). */
130
+ const pseudo = (source, locale) => memo(pseudoIr, locale + "\0" + source, () => compileMessage(pseudoLocalize(parse(source), locale)));
131
+ /** The canonical form, or the text itself when it does not parse (it then matches nothing). */
132
+ const canonicalOf = (source) => memo(canonical, source, () => {
133
+ try {
134
+ return canonicalize(source);
135
+ } catch {
136
+ return source;
137
+ }
138
+ });
139
+ /**
140
+ * A generated id (INTERNALS §2.2.1): sha256 of the canonical text plus
141
+ * U+001F, base64url. Read through `getBuiltinModule` so the entry loads in
142
+ * browser test runners too; only this mode needs Node there.
143
+ */
144
+ function generatedId(canonicalText, length) {
145
+ const crypto = globalThis.process?.getBuiltinModule?.("node:crypto");
146
+ if (crypto === void 0) throw new Error("vi18n: ids mode needs node:crypto for calls the plugin did not rewrite; run the test under the plugin or in Node");
147
+ return crypto.hash("sha256", canonicalText + "", "base64url").slice(0, length);
148
+ }
149
+ function check(condition, message) {
150
+ if (!condition) throw new RangeError("vi18n: " + message);
151
+ }
152
+ /**
153
+ * Renders every `t()` of the test in `options.locale` and `options.mode`
154
+ * until `reset()` (or the next `createTestI18n`, which replaces it). Without
155
+ * a call, tests under the plugin render the source locale as before.
156
+ */
157
+ function createTestI18n(options = {}) {
158
+ const mode = options.mode ?? "source";
159
+ check([
160
+ "source",
161
+ "ids",
162
+ "pseudo"
163
+ ].includes(mode), `unknown test mode ${JSON.stringify(mode)}`);
164
+ const timeZone = options.timeZone ?? "UTC";
165
+ new Intl.DateTimeFormat("en", { timeZone });
166
+ const now = options.now instanceof Date ? options.now.getTime() : options.now ?? DEFAULT_NOW;
167
+ check(Number.isFinite(now), "now must be a finite time");
168
+ const idLength = options.idLength ?? 12;
169
+ check(Number.isInteger(idLength) && idLength >= 8 && idLength <= 16, "idLength must be an integer from 8 to 16");
170
+ const catalog = options.catalog;
171
+ const requested = options.locale ?? catalog?.locale;
172
+ if (catalog && requested !== catalog.locale) throw new RangeError(`vi18n: the catalog is for ${catalog.locale}, not ${requested}`);
173
+ const own = /* @__PURE__ */ new Map();
174
+ for (const [key, text] of Object.entries(options.messages ?? {})) own.set(canonicalOf(key), compiled(text));
175
+ const base = saved ? saved.registration : S.registration;
176
+ const locale = requested ?? (mode === "pseudo" ? "en-XA" : base?.config.sourceLocale ?? "en");
177
+ Intl.getCanonicalLocales(locale);
178
+ check(base === null || base.config.locales.includes(locale) || PSEUDO.has(locale), `unknown locale ${locale} (${base?.config.locales.join(", ")})`);
179
+ const h = html();
180
+ saved ??= {
181
+ locale: page.locale,
182
+ messages: page.messages,
183
+ timeZone: page.timeZone,
184
+ now: page.now,
185
+ labels: page.labels,
186
+ registration: S.registration,
187
+ locales: [...S.locales],
188
+ html: h && {
189
+ lang: h.lang,
190
+ dir: h.dir
191
+ }
192
+ };
193
+ if (base === null) S.register(testRegistration(locale));
194
+ const style = locale === "ar-XB" ? "ar-XB" : "en-XA";
195
+ page.locale = locale;
196
+ page.messages = catalog?.messages ?? saved.messages;
197
+ page.timeZone = timeZone;
198
+ page.now = now;
199
+ page.test = (id, d, text) => {
200
+ const source = d?.message ?? text;
201
+ if (mode === "ids") return d?.id ?? (page.messages.has(id) ? id : generatedId(canonicalOf(source), idLength));
202
+ if (mode === "pseudo") return pseudo(source, style);
203
+ if (page.locale === locale && own.size > 0) {
204
+ const m = own.get(id) ?? own.get(canonicalOf(source));
205
+ if (m !== void 0) return m;
206
+ }
207
+ return page.messages.get(id) ?? d?.ir ?? compiled(source);
208
+ };
209
+ if (h) Object.assign(h, {
210
+ lang: locale,
211
+ dir: textDirection(locale)
212
+ });
213
+ notify();
214
+ return {
215
+ ...createScopedApi(page),
216
+ mode,
217
+ reset: resetTestI18n
218
+ };
219
+ }
220
+ //#endregion
221
+ //#region src/test/pseudo.ts
222
+ const LETTER = /\p{L}/u;
223
+ const BIDI_CONTROL = /[\u202A-\u202E\u2066-\u2069\u200E\u200F]/g;
224
+ /**
225
+ * Runs of letters at bracket depth 0 in `text`. The depth is per line: page
226
+ * text joins blocks with newlines, and a stray `]` in one block must not hide
227
+ * a leak in the next.
228
+ */
229
+ function findPseudoLeaks(text, options = {}) {
230
+ const allow = (options.allow ?? []).map((a) => typeof a === "string" ? (run) => run.split(a).join(" ") : (run) => run.replace(new RegExp(a.source, a.flags.includes("g") ? a.flags : a.flags + "g"), " "));
231
+ const isAllowed = (run) => !LETTER.test(allow.reduce((rest, cut) => cut(rest), run));
232
+ const leaks = [];
233
+ for (const line of text.replace(BIDI_CONTROL, "").split("\n")) {
234
+ let depth = 0;
235
+ let run = "";
236
+ let runStart = 0;
237
+ const flush = (end) => {
238
+ const trimmed = run.trim();
239
+ if (trimmed && LETTER.test(trimmed) && !isAllowed(trimmed)) leaks.push({
240
+ text: trimmed,
241
+ context: line.slice(Math.max(0, runStart - 10), Math.min(line.length, end + 10))
242
+ });
243
+ run = "";
244
+ };
245
+ for (let i = 0; i < line.length; i++) {
246
+ const ch = line[i];
247
+ if (ch === "[") {
248
+ if (depth === 0) flush(i);
249
+ depth++;
250
+ } else if (ch === "]") {
251
+ depth = Math.max(0, depth - 1);
252
+ if (depth === 0) runStart = i + 1;
253
+ } else if (depth === 0) {
254
+ if (!run) runStart = i;
255
+ run += ch;
256
+ }
257
+ }
258
+ flush(line.length);
259
+ }
260
+ return leaks;
261
+ }
262
+ /**
263
+ * The visible text of the page's document, one line per block. Runs in the
264
+ * page, so it is self-contained. Inline siblings stay on one line, so a
265
+ * message split by markup (`[Ŕéáď ţĥé <a>ďóçš</a>]`) is joined back.
266
+ */
267
+ function pageText(arg) {
268
+ const skip = [
269
+ "script",
270
+ "style",
271
+ "noscript",
272
+ "template",
273
+ "[data-pseudo-ignore]"
274
+ ].concat(arg).join(",");
275
+ const out = [document.title, "\n"];
276
+ const visit = (node) => {
277
+ if (node.nodeType === Node.TEXT_NODE) {
278
+ const parent = node.parentElement;
279
+ if (parent && !parent.closest(skip)) out.push(node.nodeValue ?? "");
280
+ return;
281
+ }
282
+ if (!(node instanceof Element)) return;
283
+ if (node.matches(skip)) return;
284
+ if (typeof node.checkVisibility === "function" && !node.checkVisibility()) return;
285
+ const display = getComputedStyle(node).display;
286
+ const block = display !== "inline" && display !== "contents" && !display.startsWith("inline-");
287
+ if (block) out.push("\n");
288
+ for (const child of node.childNodes) visit(child);
289
+ if (block) out.push("\n");
290
+ };
291
+ visit(document.body);
292
+ return out.join("");
293
+ }
294
+ /** Text outside pseudo brackets on the page as it is now; empty means clean. */
295
+ async function scanPseudo(page, options = {}) {
296
+ return findPseudoLeaks(await page.evaluate(pageText, [...options.ignore ?? []]), options);
297
+ }
298
+ /**
299
+ * Switches the page to a pseudo-locale through its own vi18n runtime
300
+ * (`setLocale`, in place or by navigation, as the app's routing does it) and
301
+ * waits until `<html lang>` names it and its strings are loaded. Pseudo-locales
302
+ * exist in `vite dev` and non-production builds that list them in
303
+ * `pseudoLocales`.
304
+ */
305
+ async function setPseudoLocale(page, locale = "en-XA", options = {}) {
306
+ const problem = await page.evaluate(async (arg) => {
307
+ const l = arg;
308
+ const state = globalThis[Symbol.for("menapo.vi18n")];
309
+ if (!state?.locales) return "vi18n is not loaded on this page";
310
+ if (!state.locales.includes(l)) return `${l} is not an active locale here (${state.locales.join(", ")})`;
311
+ await state.setLocale(l);
312
+ return null;
313
+ }, locale).catch(() => null);
314
+ if (typeof problem === "string") throw new Error(`vi18n: ${problem}; pseudo-locales exist in vite dev and in builds of another mode than production (vite build --mode staging) that list them in pseudoLocales`);
315
+ await page.waitForFunction((arg) => document.documentElement.lang === arg, locale, options.timeout === void 0 ? {} : { timeout: options.timeout });
316
+ await page.evaluate(() => globalThis[Symbol.for("menapo.vi18n")]?.ready());
317
+ }
318
+ //#endregion
319
+ export { createTestI18n, findPseudoLeaks, loadCatalog, resetTestI18n, scanPseudo, setPseudoLocale };
@@ -0,0 +1,299 @@
1
+ import { o as isDiagnosticError } from "./types-D3QGM8XO.js";
2
+ import { a as ownedMessages, n as libraryCoverage, o as catalogLayout } from "./library-D5CWvTQs.js";
3
+ import { a as table, r as plural } from "./output-DWE3SIUA.js";
4
+ import { t as EXIT } from "./types-wSh9fCBY.js";
5
+ import { a as loadCatalogs, f as scan, i as hasErrors, o as loadProject, p as targetLocales, t as CliExit, u as readErrors } from "./project-C4GpaN-n.js";
6
+ import { t as adaptiveConcurrency } from "./concurrency-C9UUWuDC.js";
7
+ import { a as firstRequests, c as runTranslations, i as estimateUsage, n as catalogPersister, o as planTranslations, r as createProvider, s as replayProvider } from "./pipeline-C9Dbotkh.js";
8
+ //#region src/cli/commands/translate.ts
9
+ /**
10
+ * `vi18n translate [--locale l]… [--provider name] [--model id] [--tier t]
11
+ * [--retranslate ai|all] [--dry-run] [--max-cost usd]` (INTERNALS §2.15,
12
+ * PLAN §5.20): plan every locale against the catalogs on disk, estimate,
13
+ * then translate one locale after another, writing each finished batch
14
+ * before the next is paid for. Exit 1 when any item still failed after its
15
+ * retries.
16
+ *
17
+ * `--tier batch` first submits every locale's first requests as Gemini
18
+ * batch jobs and waits for them; their answers then replay into the same
19
+ * per-locale runs, whose retries go out synchronously.
20
+ */
21
+ async function translate(ctx, flags) {
22
+ const project = await loadProject(ctx);
23
+ const { config, codec } = project;
24
+ const locales = targetLocales(config, flags.locales);
25
+ const scanned = await scan(project);
26
+ ctx.out.report(scanned.diagnostics.filter((d) => d.severity === "error"));
27
+ const loaded = await loadCatalogs(project);
28
+ const unreadable = readErrors(loaded.diagnostics);
29
+ let provider;
30
+ try {
31
+ provider = createProvider(config, {
32
+ ...flags.model === null ? {} : { model: flags.model },
33
+ ...flags.provider === null ? {} : { provider: flags.provider },
34
+ tier: flags.tier,
35
+ ...ctx.io.fetch === void 0 ? {} : { fetch: ctx.io.fetch },
36
+ env: ctx.io.env
37
+ });
38
+ } catch (error) {
39
+ if (isDiagnosticError(error)) throw new CliExit(EXIT.usage, [error.diagnostic]);
40
+ throw error;
41
+ }
42
+ if (typeof provider.apiKeyEnv === "string") ctx.out.addSecret(ctx.io.env[provider.apiKeyEnv]);
43
+ const configured = config.ai.provider;
44
+ if (flags.model !== null && flags.provider === null && configured !== "gemini" && configured.withModel === void 0) ctx.out.note(`vi18n: --model is ignored: ai.provider is a custom provider (${provider.name}, ${provider.model})`);
45
+ const data = {
46
+ dryRun: flags.dryRun,
47
+ provider: provider.name,
48
+ model: provider.model,
49
+ tier: flags.tier,
50
+ locales: [],
51
+ estimate: {
52
+ requests: 0,
53
+ inputTokens: 0,
54
+ outputTokens: 0,
55
+ cost: 0
56
+ }
57
+ };
58
+ if (unreadable.length > 0) {
59
+ ctx.out.report(unreadable);
60
+ return {
61
+ exitCode: EXIT.failure,
62
+ data
63
+ };
64
+ }
65
+ const plans = /* @__PURE__ */ new Map();
66
+ const coverage = libraryCoverage(loaded.set, scanned.messages, config, codec);
67
+ for (const locale of locales) {
68
+ const plan = planTranslations({
69
+ config,
70
+ codec,
71
+ messages: ownedMessages(scanned.messages, locale, loaded.set.get(locale)?.catalog ?? null, coverage, {
72
+ sourceLocale: config.sourceLocale,
73
+ layout: catalogLayout(config),
74
+ codec
75
+ }),
76
+ catalogs: loaded.set,
77
+ locale,
78
+ retranslate: flags.retranslate
79
+ });
80
+ plans.set(locale, plan);
81
+ const usage = estimateUsage({
82
+ config,
83
+ locale,
84
+ plan
85
+ });
86
+ const estimate = {
87
+ ...usage,
88
+ cost: costOf(provider, usage)
89
+ };
90
+ data.locales.push({
91
+ ...emptySummary(locale),
92
+ estimate
93
+ });
94
+ addEstimate(data.estimate, estimate);
95
+ }
96
+ if (flags.maxCost !== null && data.estimate.requests > 0) {
97
+ const cost = data.estimate.cost;
98
+ if (cost === null || cost > flags.maxCost) {
99
+ ctx.out.report([{
100
+ code: "ai-cost-limit",
101
+ severity: "error",
102
+ message: cost === null ? `no price for ${provider.model}, so the cost of ${plural(data.estimate.requests, "request")} cannot be checked against --max-cost ${flags.maxCost}; nothing was sent` : `the estimated cost ${usd(cost)} of ${plural(data.estimate.requests, "request")} is over --max-cost ${usd(flags.maxCost)}; nothing was sent${flags.dryRun ? "" : " (see vi18n translate --dry-run)"}`
103
+ }]);
104
+ if (flags.dryRun) printEstimate(ctx, data);
105
+ return {
106
+ exitCode: EXIT.failure,
107
+ data
108
+ };
109
+ }
110
+ }
111
+ if (flags.dryRun) {
112
+ printEstimate(ctx, data);
113
+ return {
114
+ exitCode: hasErrors(ctx.out.diagnostics) ? EXIT.failure : EXIT.ok,
115
+ data
116
+ };
117
+ }
118
+ if (data.estimate.requests > 0) {
119
+ const missing = provider.missingKey?.() ?? null;
120
+ if (missing !== null) {
121
+ ctx.out.report([missing]);
122
+ return {
123
+ exitCode: EXIT.failure,
124
+ data
125
+ };
126
+ }
127
+ }
128
+ let failed = false;
129
+ let finished = 0;
130
+ const aborted = () => ctx.io.signal?.aborted === true;
131
+ const interrupted = () => {
132
+ data.interrupted = true;
133
+ data.locales = data.locales.filter((_, index) => index < finished);
134
+ ctx.out.note("vi18n translate: interrupted; finished batches are saved, rerun to translate the rest");
135
+ return {
136
+ exitCode: EXIT.failure,
137
+ data
138
+ };
139
+ };
140
+ let runner = provider;
141
+ if (flags.tier === "batch" && data.estimate.requests > 0) {
142
+ const requests = [];
143
+ for (const locale of locales) requests.push(...firstRequests({
144
+ config,
145
+ locale,
146
+ plan: plans.get(locale) ?? []
147
+ }));
148
+ if (provider.translateBatch === void 0) throw new CliExit(EXIT.usage, [{
149
+ code: "config-invalid",
150
+ severity: "error",
151
+ message: `--tier batch needs a provider with a batch API, not ${provider.name}`
152
+ }]);
153
+ ctx.out.note(`vi18n translate: sending ${plural(requests.length, "request")} as batch jobs; results can take minutes to hours (Ctrl-C cancels)`);
154
+ const states = /* @__PURE__ */ new Map();
155
+ try {
156
+ const answers = await provider.translateBatch(requests, {
157
+ ...ctx.io.signal === void 0 ? {} : { signal: ctx.io.signal },
158
+ onProgress: ({ job, state }) => {
159
+ if (states.get(job) === state) return;
160
+ states.set(job, state);
161
+ ctx.out.note(`vi18n translate: ${job}: ${state}`);
162
+ }
163
+ });
164
+ runner = replayProvider(provider, requests, answers);
165
+ } catch (error) {
166
+ if (aborted()) return interrupted();
167
+ if (!isDiagnosticError(error)) throw error;
168
+ ctx.out.report([error.diagnostic]);
169
+ return {
170
+ exitCode: EXIT.failure,
171
+ data
172
+ };
173
+ }
174
+ }
175
+ const concurrency = adaptiveConcurrency(config.ai.concurrency);
176
+ for (let i = 0; i < locales.length; i++) {
177
+ if (aborted()) return interrupted();
178
+ const locale = locales[i];
179
+ const plan = plans.get(locale);
180
+ const persist = catalogPersister({
181
+ config,
182
+ codec,
183
+ locale,
184
+ messages: scanned.messages,
185
+ catalog: loaded.set.get(locale),
186
+ sourceCatalog: loaded.set.get(config.sourceLocale),
187
+ model: provider.model
188
+ });
189
+ let summary;
190
+ try {
191
+ summary = await runTranslations({
192
+ provider: runner,
193
+ config,
194
+ locale,
195
+ plan,
196
+ persist,
197
+ concurrency,
198
+ ...ctx.io.signal === void 0 ? {} : { signal: ctx.io.signal }
199
+ });
200
+ } catch (error) {
201
+ if (aborted()) return interrupted();
202
+ if (!isDiagnosticError(error)) throw error;
203
+ ctx.out.report([{
204
+ ...error.diagnostic,
205
+ locale: error.diagnostic.locale ?? locale
206
+ }]);
207
+ return {
208
+ exitCode: EXIT.failure,
209
+ data
210
+ };
211
+ }
212
+ const entry = data.locales[i];
213
+ Object.assign(entry, summary, { estimate: entry.estimate });
214
+ ctx.out.report(summaryDiagnostics(summary));
215
+ if (summary.failed.length > 0) failed = true;
216
+ if (summary.rateLimited !== void 0) ctx.out.note(`vi18n translate: ${locale} was rate-limited ${plural(summary.rateLimited.count, "time")}; requests in flight now ${summary.rateLimited.concurrency} of ${concurrency.max}`);
217
+ finished = i + 1;
218
+ ctx.out.result(`${locale}: ${summary.translated} translated, ${summary.reused} reused, ${summary.failed.length} failed of ${summary.requested} requested (${summary.usage.inputTokens} input / ${summary.usage.outputTokens} output tokens)`);
219
+ }
220
+ return {
221
+ exitCode: failed || hasErrors(ctx.out.diagnostics) ? EXIT.failure : EXIT.ok,
222
+ data
223
+ };
224
+ }
225
+ function emptySummary(locale) {
226
+ return {
227
+ locale,
228
+ requested: 0,
229
+ translated: 0,
230
+ reused: 0,
231
+ identical: [],
232
+ failed: [],
233
+ usage: {
234
+ inputTokens: 0,
235
+ outputTokens: 0
236
+ }
237
+ };
238
+ }
239
+ /** USD from the provider's pinned price table; null when it has none for the model. */
240
+ function costOf(provider, usage) {
241
+ if (usage.inputTokens === 0 && usage.outputTokens === 0) return 0;
242
+ const cost = provider.estimateCost?.(usage) ?? NaN;
243
+ return Number.isFinite(cost) ? cost : null;
244
+ }
245
+ function addEstimate(total, part) {
246
+ total.requests += part.requests;
247
+ total.inputTokens += part.inputTokens;
248
+ total.outputTokens += part.outputTokens;
249
+ total.cost = total.cost === null || part.cost === null ? null : total.cost + part.cost;
250
+ }
251
+ function usd(value) {
252
+ return `$${value < .01 && value > 0 ? value.toFixed(4) : value.toFixed(2)}`;
253
+ }
254
+ function printEstimate(ctx, data) {
255
+ const rows = data.locales.map((l) => [
256
+ l.locale,
257
+ l.estimate.requests,
258
+ l.estimate.inputTokens,
259
+ l.estimate.outputTokens,
260
+ l.estimate.cost === null ? "no price" : usd(l.estimate.cost)
261
+ ]);
262
+ const t = data.estimate;
263
+ rows.push([
264
+ "total",
265
+ t.requests,
266
+ t.inputTokens,
267
+ t.outputTokens,
268
+ t.cost === null ? "no price" : usd(t.cost)
269
+ ]);
270
+ ctx.out.result(table([
271
+ "locale",
272
+ "requests",
273
+ "input tokens",
274
+ "output tokens",
275
+ "cost"
276
+ ], rows));
277
+ const tier = data.tier === "standard" ? "" : `, ${data.tier} tier`;
278
+ ctx.out.result(`dry run with ${data.model}${tier}: nothing was sent (tokens estimated at 4 characters each)`);
279
+ }
280
+ function summaryDiagnostics(summary) {
281
+ const out = [];
282
+ for (const id of summary.identical) out.push({
283
+ code: "ai-identical-output",
284
+ severity: "info",
285
+ message: `the ${summary.locale} translation of ${id} is identical to the source (often right for names and words like "OK"); review it if unexpected`,
286
+ locale: summary.locale,
287
+ id
288
+ });
289
+ for (const failure of summary.failed) out.push({
290
+ code: "ai-validation-failed",
291
+ severity: "error",
292
+ message: `${failure.id} was left untranslated after its retries: ${failure.checks.join("; ")}`,
293
+ locale: summary.locale,
294
+ id: failure.id
295
+ });
296
+ return out;
297
+ }
298
+ //#endregion
299
+ export { translate };