better-translation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vite.mjs ADDED
@@ -0,0 +1,855 @@
1
+ import { n as getMessageId, r as serializeMeta, t as getCallMessageId } from "./message-id-7Mx7G9xT.mjs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, relative, resolve } from "node:path";
4
+ import { Visitor, parseSync } from "oxc-parser";
5
+ //#region src/cache.ts
6
+ const CURRENT_VERSION = 1;
7
+ function createEmptyCache() {
8
+ return {
9
+ version: CURRENT_VERSION,
10
+ entries: {}
11
+ };
12
+ }
13
+ /** Loads the translation cache from disk, resetting it when the schema version changes. */
14
+ function loadCache(path) {
15
+ if (!existsSync(path)) return createEmptyCache();
16
+ try {
17
+ const data = JSON.parse(readFileSync(path, "utf-8"));
18
+ if (data.version !== CURRENT_VERSION) return createEmptyCache();
19
+ return data;
20
+ } catch {
21
+ return createEmptyCache();
22
+ }
23
+ }
24
+ /** Persists the translation cache so future runs can reuse existing translations. */
25
+ function saveCache(path, cache) {
26
+ const dir = dirname(path);
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+ const next = JSON.stringify(cache, null, 2);
29
+ if (existsSync(path) && readFileSync(path, "utf-8") === next) return;
30
+ writeFileSync(path, next);
31
+ }
32
+ /** Builds the cache key used to distinguish translations by stable message id and locale. */
33
+ function getCacheKey(messageId, locale) {
34
+ return `${messageId}\0${locale}`;
35
+ }
36
+ //#endregion
37
+ //#region src/extractor.ts
38
+ /** Extracts messages and source edits from a file in one coordinated parse pass. */
39
+ function analyzeSourceFile(code, filename, markers) {
40
+ const messages = [];
41
+ const edits = [];
42
+ const result = parseSync(filename, code);
43
+ if (result.errors.length > 0) return {
44
+ parsed: false,
45
+ messages,
46
+ edits
47
+ };
48
+ const lineStarts = getLineStarts(code);
49
+ new Visitor({
50
+ CallExpression(node) {
51
+ if (node.callee.type === "Identifier" && markers.call.includes(node.callee.name) && node.arguments.length >= 1 && isStringLiteral(node.arguments[0])) {
52
+ const value = node.arguments[0].value;
53
+ const meta = getMetaArgument(node.arguments[1]);
54
+ const id = getCallMessageId(value, meta);
55
+ messages.push({
56
+ id,
57
+ defaultMessage: value,
58
+ meta: meta ?? {},
59
+ placeholders: [],
60
+ source: createSource({
61
+ filename,
62
+ lineStarts,
63
+ marker: node.callee.name,
64
+ kind: "call",
65
+ start: node.start,
66
+ end: node.end
67
+ })
68
+ });
69
+ const callOptionsEdit = createCallOptionsEdit(code, node.arguments, id);
70
+ if (callOptionsEdit) edits.push(callOptionsEdit);
71
+ }
72
+ },
73
+ JSXElement(node) {
74
+ const opening = node.openingElement;
75
+ if (opening.name.type === "JSXIdentifier" && opening.name.name === "Var" && opening.attributes.length === 0) {
76
+ const identifier = getVarChildIdentifier(node.children);
77
+ if (identifier) edits.push({
78
+ start: node.start,
79
+ end: node.end,
80
+ replacement: `<Var ${identifier}={${identifier}} />`
81
+ });
82
+ }
83
+ if (opening.name.type !== "JSXIdentifier") return;
84
+ if (!markers.component.includes(opening.name.name)) return;
85
+ const extraction = extractJSXChildren(node.children);
86
+ if (!extraction.valid) {
87
+ if (markers.logging) console.warn(`[better-translation] Non-static <${opening.name.name}> in ${filename}, skipping`);
88
+ return;
89
+ }
90
+ const context = getJSXStringAttribute(opening.attributes, "context");
91
+ const meta = context ? { context } : void 0;
92
+ const id = getJSXStringAttribute(opening.attributes, "id") ?? getMessageId(extraction.message, meta);
93
+ messages.push({
94
+ id,
95
+ defaultMessage: extraction.message,
96
+ meta: meta ?? {},
97
+ placeholders: extraction.placeholders,
98
+ source: createSource({
99
+ filename,
100
+ lineStarts,
101
+ marker: opening.name.name,
102
+ kind: "component",
103
+ start: node.start,
104
+ end: node.end
105
+ })
106
+ });
107
+ if (!hasJSXAttribute(opening.attributes, "id")) edits.push({
108
+ start: opening.name.end,
109
+ end: opening.name.end,
110
+ replacement: ` id="${id}"`
111
+ });
112
+ },
113
+ TaggedTemplateExpression(node) {
114
+ const tag = node.tag;
115
+ if (tag.type !== "CallExpression" || tag.callee.type !== "Identifier" || !markers.taggedTemplate.includes(tag.callee.name) || tag.arguments.length < 1 || !isStringLiteral(tag.arguments[0])) return;
116
+ const id = tag.arguments[0].value;
117
+ const extraction = extractTaggedTemplate(node.quasi);
118
+ if (!extraction.valid) {
119
+ if (markers.logging) console.warn(`[better-translation] Non-static tagged template in ${filename}, skipping`);
120
+ return;
121
+ }
122
+ messages.push({
123
+ id,
124
+ defaultMessage: extraction.message,
125
+ meta: {},
126
+ placeholders: extraction.placeholders,
127
+ source: createSource({
128
+ filename,
129
+ lineStarts,
130
+ marker: tag.callee.name,
131
+ kind: "tagged-template",
132
+ start: node.start,
133
+ end: node.end
134
+ })
135
+ });
136
+ }
137
+ }).visit(result.program);
138
+ return {
139
+ parsed: true,
140
+ messages,
141
+ edits
142
+ };
143
+ }
144
+ function isStringLiteral(node) {
145
+ return node.type === "Literal" && typeof node.value === "string";
146
+ }
147
+ function getMetaArgument(node) {
148
+ if (!node) return void 0;
149
+ if (isStringLiteral(node)) return { context: node.value };
150
+ if (node.type !== "ObjectExpression") return void 0;
151
+ for (const property of node.properties) if (property.type === "ObjectProperty" && (property.key?.type === "Identifier" && property.key.name === "context" || property.key?.type === "Literal" && property.key.value === "context") && property.value && isStringLiteral(property.value)) return { context: property.value.value };
152
+ }
153
+ function createCallOptionsEdit(code, args, id) {
154
+ const optionsArg = args[1];
155
+ if (!optionsArg) return {
156
+ start: args[0].end,
157
+ end: args[0].end,
158
+ replacement: `, { id: ${JSON.stringify(id)} }`
159
+ };
160
+ if (isStringLiteral(optionsArg)) {
161
+ const contextValue = code.slice(optionsArg.start, optionsArg.end);
162
+ return {
163
+ start: optionsArg.start,
164
+ end: optionsArg.end,
165
+ replacement: `{ id: ${JSON.stringify(id)}, context: ${contextValue} }`
166
+ };
167
+ }
168
+ if (optionsArg.type !== "ObjectExpression") return void 0;
169
+ if (hasObjectProperty(optionsArg, "id")) return void 0;
170
+ const innerSource = code.slice(optionsArg.start, optionsArg.end).slice(1, -1);
171
+ const replacement = innerSource.trim().length > 0 ? `{ id: ${JSON.stringify(id)},${innerSource} }` : `{ id: ${JSON.stringify(id)} }`;
172
+ return {
173
+ start: optionsArg.start,
174
+ end: optionsArg.end,
175
+ replacement
176
+ };
177
+ }
178
+ function getJSXStringAttribute(attributes, name) {
179
+ for (const attr of attributes) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === name && attr.value?.type === "Literal" && typeof attr.value.value === "string") return attr.value.value;
180
+ }
181
+ function hasJSXAttribute(attributes, name) {
182
+ return attributes.some((attr) => {
183
+ const attribute = attr;
184
+ return attribute?.type === "JSXAttribute" && attribute.name?.type === "JSXIdentifier" && attribute.name.name === name;
185
+ });
186
+ }
187
+ function hasObjectProperty(node, name) {
188
+ return node.properties.some((entry) => {
189
+ const property = entry;
190
+ if (property?.type !== "ObjectProperty") return false;
191
+ return property.key?.type === "Identifier" && property.key.name === name || property.key?.type === "Literal" && property.key.value === name;
192
+ });
193
+ }
194
+ function createSource({ filename, lineStarts, marker, kind, start, end }) {
195
+ const startPosition = getPosition(start, lineStarts);
196
+ const endPosition = getPosition(end, lineStarts);
197
+ return {
198
+ file: filename,
199
+ kind,
200
+ marker,
201
+ line: startPosition.line,
202
+ column: startPosition.column,
203
+ endLine: endPosition.line,
204
+ endColumn: endPosition.column,
205
+ start,
206
+ end
207
+ };
208
+ }
209
+ function getLineStarts(code) {
210
+ const starts = [0];
211
+ for (let i = 0; i < code.length; i++) if (code[i] === "\n") starts.push(i + 1);
212
+ return starts;
213
+ }
214
+ function getPosition(offset, lineStarts) {
215
+ let low = 0;
216
+ let high = lineStarts.length - 1;
217
+ while (low <= high) {
218
+ const mid = Math.floor((low + high) / 2);
219
+ const lineStart = lineStarts[mid];
220
+ const nextLineStart = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY;
221
+ if (offset < lineStart) {
222
+ high = mid - 1;
223
+ continue;
224
+ }
225
+ if (offset >= nextLineStart) {
226
+ low = mid + 1;
227
+ continue;
228
+ }
229
+ return {
230
+ line: mid + 1,
231
+ column: offset - lineStart + 1
232
+ };
233
+ }
234
+ const lastLine = lineStarts.length - 1;
235
+ const lastStart = lineStarts[lastLine] ?? 0;
236
+ return {
237
+ line: lastLine + 1,
238
+ column: offset - lastStart + 1
239
+ };
240
+ }
241
+ function extractJSXChildren(children) {
242
+ const parts = [];
243
+ const placeholders = [];
244
+ for (const child of children) switch (child.type) {
245
+ case "JSXText":
246
+ parts.push(child.value);
247
+ break;
248
+ case "JSXElement": {
249
+ const name = child.openingElement.name;
250
+ if (name.type !== "JSXIdentifier" || name.name !== "Var") return {
251
+ message: "",
252
+ placeholders: [],
253
+ valid: false
254
+ };
255
+ const varName = getVarPlaceholderName(child);
256
+ if (!varName) return {
257
+ message: "",
258
+ placeholders: [],
259
+ valid: false
260
+ };
261
+ placeholders.push(varName);
262
+ parts.push(`{${varName}}`);
263
+ break;
264
+ }
265
+ case "JSXExpressionContainer":
266
+ if (child.expression.type !== "JSXEmptyExpression") return {
267
+ message: "",
268
+ placeholders: [],
269
+ valid: false
270
+ };
271
+ break;
272
+ default: break;
273
+ }
274
+ const message = parts.join("").replace(/\s+/g, " ").trim();
275
+ return {
276
+ message,
277
+ placeholders,
278
+ valid: message.length > 0
279
+ };
280
+ }
281
+ function getVarPlaceholderName(node) {
282
+ const explicitName = getJSXStringAttribute(node.openingElement.attributes, "name");
283
+ if (explicitName) return explicitName;
284
+ const customPropName = getSingleJSXAttributeName(node.openingElement.attributes);
285
+ if (customPropName) return customPropName;
286
+ return getVarChildIdentifier(node.children);
287
+ }
288
+ function getSingleJSXAttributeName(attributes) {
289
+ const keys = attributes.flatMap((attr) => {
290
+ const attribute = attr;
291
+ if (attribute?.type !== "JSXAttribute" || attribute.name?.type !== "JSXIdentifier") return [];
292
+ return [attribute.name.name];
293
+ });
294
+ return keys.length === 1 ? keys[0] : void 0;
295
+ }
296
+ function getVarChildIdentifier(children) {
297
+ const meaningfulChildren = children.filter((child) => !(child.type === "JSXText" && child.value.trim().length === 0));
298
+ if (meaningfulChildren.length !== 1) return void 0;
299
+ const [child] = meaningfulChildren;
300
+ if (!child || child.type !== "JSXExpressionContainer" || child.expression.type !== "Identifier") return void 0;
301
+ return child.expression.name;
302
+ }
303
+ function extractTaggedTemplate(quasi) {
304
+ const parts = [];
305
+ const placeholders = [];
306
+ for (let i = 0; i < quasi.quasis.length; i++) {
307
+ const element = quasi.quasis[i];
308
+ parts.push(element.value.cooked ?? element.value.raw);
309
+ if (i < quasi.expressions.length) {
310
+ const expr = quasi.expressions[i];
311
+ if (expr.type !== "CallExpression") return {
312
+ message: "",
313
+ placeholders: [],
314
+ valid: false
315
+ };
316
+ const call = expr;
317
+ if (call.callee.type !== "Identifier" || call.callee.name !== "v" || call.arguments.length < 2 || !isStringLiteral(call.arguments[0])) return {
318
+ message: "",
319
+ placeholders: [],
320
+ valid: false
321
+ };
322
+ const varName = call.arguments[0].value;
323
+ placeholders.push(varName);
324
+ parts.push(`{${varName}}`);
325
+ }
326
+ }
327
+ const message = parts.join("").trim();
328
+ return {
329
+ message,
330
+ placeholders,
331
+ valid: message.length > 0
332
+ };
333
+ }
334
+ //#endregion
335
+ //#region src/runtime-config.ts
336
+ const DEFAULT_LOCAL_OUTPUT_DIR = "src/lib/bt";
337
+ const RUNTIME_CONFIG_FILENAME = "runtime.json";
338
+ `${DEFAULT_LOCAL_OUTPUT_DIR}`;
339
+ function getRuntimeConfigPath(root, dir) {
340
+ return resolve(root, dir, RUNTIME_CONFIG_FILENAME);
341
+ }
342
+ //#endregion
343
+ //#region src/plugin.ts
344
+ const PREFIX = "\x1B[36m[better-translation]\x1B[0m";
345
+ const DIM = "\x1B[2m";
346
+ const RESET = "\x1B[0m";
347
+ const YELLOW = "\x1B[33m";
348
+ const BOLD = "\x1B[1m";
349
+ const CYAN = "\x1B[36m";
350
+ const REMOTE_API_BASE_URL = "https://better-translation.com";
351
+ const REMOTE_STUB = `${YELLOW}stub${RESET}`;
352
+ const DEFAULT_ROOT_DIR = "src";
353
+ const DEFAULT_SCAN_EXTENSIONS = [
354
+ ".ts",
355
+ ".tsx",
356
+ ".js",
357
+ ".jsx"
358
+ ];
359
+ const CALL_MARKERS = ["t", "useT"];
360
+ const COMPONENT_MARKERS = ["T"];
361
+ const TAGGED_TEMPLATE_MARKERS = ["msg"];
362
+ const PRIVATE_MANIFEST_FILENAME = "manifest.json";
363
+ const LOAD_MESSAGES_FILENAME = "load-messages.ts";
364
+ const LOCALES_SUBDIR = "locales";
365
+ const GITIGNORE_FILENAME = ".gitignore";
366
+ const GITIGNORE_CONTENTS = [
367
+ "# Generated by better-translation/vite",
368
+ "manifest.json",
369
+ ""
370
+ ].join("\n");
371
+ function formatLocale(locale) {
372
+ return locale.toUpperCase();
373
+ }
374
+ function formatLocales(locales) {
375
+ return locales.map(formatLocale).join(", ");
376
+ }
377
+ /** Scans source files for translatable messages and keeps locale JSON files in sync. */
378
+ function betterTranslate(options) {
379
+ const { locales, defaultLocale = locales[0] ?? "en", rootDir = DEFAULT_ROOT_DIR, cacheFile = ".cache/better-translation.json", logging = true, storage = {
380
+ type: "bundle",
381
+ output: DEFAULT_LOCAL_OUTPUT_DIR
382
+ }, translate } = options;
383
+ const usesBundleStorage = storage.type === "bundle";
384
+ const localesDir = storage.type === "bundle" ? storage.output ?? "src/lib/bt" : DEFAULT_LOCAL_OUTPUT_DIR;
385
+ const remoteUrl = storage.type === "remote" ? storage.url ?? REMOTE_API_BASE_URL : REMOTE_API_BASE_URL;
386
+ const manifest = {};
387
+ const fileMessages = /* @__PURE__ */ new Map();
388
+ let cache = createEmptyCache();
389
+ let root = "";
390
+ let isDev = false;
391
+ let translateTimer = null;
392
+ let warnedRemoteTranslateStub = false;
393
+ let warnedRemoteSyncStub = false;
394
+ let sourceRoots = [];
395
+ function log(message) {
396
+ if (logging) console.log(message);
397
+ }
398
+ async function remoteTranslate(messages, _locale) {
399
+ if (!warnedRemoteTranslateStub) {
400
+ warnedRemoteTranslateStub = true;
401
+ log(`${PREFIX} ${REMOTE_STUB} remote translate via ${DIM}${remoteUrl}${RESET} not implemented yet`);
402
+ }
403
+ return Object.fromEntries(messages.map((message) => [message.id, message.text]));
404
+ }
405
+ async function syncRemote() {
406
+ if (!warnedRemoteSyncStub) {
407
+ warnedRemoteSyncStub = true;
408
+ log(`${PREFIX} ${REMOTE_STUB} remote locale sync via ${DIM}${remoteUrl}${RESET} not implemented yet`);
409
+ }
410
+ }
411
+ const resolvedTranslate = translate ?? (usesBundleStorage ? void 0 : remoteTranslate);
412
+ function buildMessageManifest() {
413
+ return Object.fromEntries(Object.entries(manifest).map(([id, entry]) => [id, {
414
+ defaultMessage: entry.defaultMessage,
415
+ meta: entry.meta,
416
+ placeholders: entry.placeholders,
417
+ sources: entry.sources
418
+ }]));
419
+ }
420
+ function shouldScanFile(id) {
421
+ const cleanId = id.split("?", 1)[0] ?? id;
422
+ if (cleanId.includes("node_modules")) return false;
423
+ if (!DEFAULT_SCAN_EXTENSIONS.find((ext) => cleanId.endsWith(ext))) return false;
424
+ return sourceRoots.some((sourceRoot) => cleanId === sourceRoot || cleanId.startsWith(`${sourceRoot}/`) || cleanId.startsWith(`${sourceRoot}\\`));
425
+ }
426
+ function getPrivateManifestPath() {
427
+ return resolve(root, localesDir, PRIVATE_MANIFEST_FILENAME);
428
+ }
429
+ function getRuntimeConfig() {
430
+ return {
431
+ storage: usesBundleStorage ? {
432
+ type: "bundle",
433
+ output: localesDir
434
+ } : {
435
+ type: "remote",
436
+ url: remoteUrl
437
+ },
438
+ defaultLocale,
439
+ locales
440
+ };
441
+ }
442
+ function writeRuntimeConfig() {
443
+ if (!usesBundleStorage) return;
444
+ const runtimeConfig = JSON.stringify(getRuntimeConfig(), null, 2) + "\n";
445
+ const path = getRuntimeConfigPath(root, localesDir);
446
+ const dir = dirname(path);
447
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
448
+ writeFileIfChanged(path, runtimeConfig);
449
+ }
450
+ function getLocalesDirPath() {
451
+ return resolve(root, localesDir, LOCALES_SUBDIR);
452
+ }
453
+ function getLocalePath(locale) {
454
+ return resolve(getLocalesDirPath(), `${locale}.json`);
455
+ }
456
+ function readLocaleMessages(locale) {
457
+ const path = getLocalePath(locale);
458
+ if (!existsSync(path)) return {};
459
+ try {
460
+ return normalizeLocaleMessages(JSON.parse(readFileSync(path, "utf-8")));
461
+ } catch {
462
+ return {};
463
+ }
464
+ }
465
+ function writePrivateManifest() {
466
+ if (!usesBundleStorage) return;
467
+ const path = getPrivateManifestPath();
468
+ const dir = dirname(path);
469
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
470
+ writeFileIfChanged(path, JSON.stringify(buildMessageManifest(), null, 2) + "\n");
471
+ }
472
+ function assertFileContents(path, expected, label) {
473
+ if (!existsSync(path)) throw new Error(`${PREFIX} missing committed ${label} at ${relative(root, path)}`);
474
+ if (readFileSync(path, "utf-8") !== expected) throw new Error([
475
+ `${PREFIX} committed ${label} is out of date`,
476
+ `expected committed file at ${relative(root, path)} to match the generated output`,
477
+ `run the dev workflow to regenerate locale artifacts and commit the result`
478
+ ].join("\n"));
479
+ }
480
+ function buildLoadMessagesModule() {
481
+ const localeUnion = locales.map((locale) => JSON.stringify(locale)).join(" | ");
482
+ const importLines = locales.map((locale) => `import messages_${locale} from "./${LOCALES_SUBDIR}/${locale}.json"`);
483
+ const caseLines = locales.map((locale) => ` case ${JSON.stringify(locale)}: return messages_${locale}`);
484
+ return [
485
+ ...importLines,
486
+ "",
487
+ `export type AppLocale = ${localeUnion}`,
488
+ "",
489
+ "export async function loadMessages(locale: AppLocale): Promise<Record<string, string>> {",
490
+ " switch (locale) {",
491
+ ...caseLines,
492
+ ` default: return messages_${defaultLocale}`,
493
+ " }",
494
+ "}",
495
+ ""
496
+ ].join("\n");
497
+ }
498
+ function writeLoadMessagesModule() {
499
+ if (!usesBundleStorage) return;
500
+ const path = resolve(root, localesDir, LOAD_MESSAGES_FILENAME);
501
+ const dir = dirname(path);
502
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
503
+ const next = buildLoadMessagesModule();
504
+ if (existsSync(path) && readFileSync(path, "utf-8") === next) return;
505
+ writeFileSync(path, next);
506
+ }
507
+ function writeGitignore() {
508
+ if (!usesBundleStorage) return;
509
+ const path = resolve(root, localesDir, GITIGNORE_FILENAME);
510
+ const dir = dirname(path);
511
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
512
+ writeFileIfChanged(path, GITIGNORE_CONTENTS);
513
+ }
514
+ function assertGeneratedFilesCommitted() {
515
+ if (!usesBundleStorage) return;
516
+ assertFileContents(getRuntimeConfigPath(root, localesDir), JSON.stringify(getRuntimeConfig(), null, 2) + "\n", "runtime config");
517
+ assertFileContents(resolve(root, localesDir, LOAD_MESSAGES_FILENAME), buildLoadMessagesModule(), "load-messages module");
518
+ assertFileContents(resolve(root, localesDir, GITIGNORE_FILENAME), GITIGNORE_CONTENTS, "generated .gitignore");
519
+ }
520
+ function buildLocalLocaleMessages(locale, options) {
521
+ const existingMessages = readLocaleMessages(locale);
522
+ const messages = options.pruneOrphans ? {} : { ...existingMessages };
523
+ if (locale === defaultLocale) {
524
+ for (const [id, entry] of Object.entries(manifest)) messages[id] = entry.defaultMessage;
525
+ return messages;
526
+ }
527
+ for (const id of Object.keys(manifest)) {
528
+ if (Object.hasOwn(messages, id)) continue;
529
+ if (Object.hasOwn(existingMessages, id)) {
530
+ messages[id] = existingMessages[id];
531
+ continue;
532
+ }
533
+ const cachedMessage = cache.entries[getCacheKey(id, locale)]?.translation;
534
+ if (cachedMessage !== void 0) messages[id] = cachedMessage;
535
+ }
536
+ return messages;
537
+ }
538
+ function writeLocaleFilesToDisk(options = { pruneOrphans: false }) {
539
+ if (!usesBundleStorage) return;
540
+ const dir = getLocalesDirPath();
541
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
542
+ for (const locale of locales) writeFileIfChanged(resolve(dir, `${locale}.json`), JSON.stringify(buildLocalLocaleMessages(locale, options), null, 2) + "\n");
543
+ writeLoadMessagesModule();
544
+ }
545
+ function getMissingMessagesByLocale() {
546
+ const missingByLocale = /* @__PURE__ */ new Map();
547
+ for (const locale of locales) {
548
+ if (locale === defaultLocale) continue;
549
+ const existingMessages = readLocaleMessages(locale);
550
+ for (const [id, entry] of Object.entries(manifest)) if (!Object.hasOwn(existingMessages, id) && !Object.hasOwn(cache.entries, getCacheKey(id, locale))) {
551
+ const misses = missingByLocale.get(locale) ?? [];
552
+ misses.push({
553
+ id,
554
+ text: entry.defaultMessage,
555
+ meta: entry.meta,
556
+ placeholders: entry.placeholders,
557
+ sources: entry.sources
558
+ });
559
+ missingByLocale.set(locale, misses);
560
+ }
561
+ }
562
+ return missingByLocale;
563
+ }
564
+ function assertLocalBuildTranslationsComplete() {
565
+ const expectedIds = new Set(Object.keys(manifest));
566
+ const issues = [];
567
+ for (const locale of locales) {
568
+ const localePath = getLocalePath(locale);
569
+ if (!existsSync(localePath)) {
570
+ issues.push(`- ${locale}: missing file at ${relative(root, localePath)}`);
571
+ continue;
572
+ }
573
+ const localeMessages = readLocaleMessages(locale);
574
+ const missingIds = [...expectedIds].filter((id) => !Object.hasOwn(localeMessages, id));
575
+ const orphanIds = Object.keys(localeMessages).filter((id) => !expectedIds.has(id));
576
+ if (locale === defaultLocale) {
577
+ const staleIds = [...expectedIds].filter((id) => localeMessages[id] !== manifest[id].defaultMessage);
578
+ if (missingIds.length > 0) issues.push(formatLocaleIssue(locale, "missing", missingIds));
579
+ if (orphanIds.length > 0) issues.push(formatLocaleIssue(locale, "orphaned", orphanIds));
580
+ if (staleIds.length > 0) issues.push(formatLocaleIssue(locale, "outdated default messages", staleIds));
581
+ continue;
582
+ }
583
+ if (missingIds.length > 0) issues.push(formatLocaleIssue(locale, "missing", missingIds));
584
+ if (orphanIds.length > 0) issues.push(formatLocaleIssue(locale, "orphaned", orphanIds));
585
+ }
586
+ if (issues.length === 0) return;
587
+ throw new Error([
588
+ `${PREFIX} committed locale artifacts are out of sync for local production build`,
589
+ `local production builds are check-only and never regenerate locale files`,
590
+ `run the dev workflow to regenerate locale artifacts and commit the result`,
591
+ ...issues
592
+ ].join("\n"));
593
+ }
594
+ async function translateMissingMessages() {
595
+ if (!resolvedTranslate) return false;
596
+ const missingByLocale = getMissingMessagesByLocale();
597
+ const totalMisses = [...missingByLocale.values()].reduce((count, misses) => count + misses.length, 0);
598
+ if (totalMisses === 0) return false;
599
+ const missLocales = [...missingByLocale.keys()];
600
+ log(`${PREFIX} ${BOLD}Translating${RESET} ${CYAN}${totalMisses}${RESET} ${totalMisses === 1 ? "Message" : "Messages"} -> ${CYAN}${formatLocales(missLocales)}${RESET}`);
601
+ for (const [locale, misses] of missingByLocale) {
602
+ const result = await resolvedTranslate(misses, locale);
603
+ for (const miss of misses) {
604
+ const translated = result[miss.id] ?? miss.text;
605
+ cache.entries[getCacheKey(miss.id, locale)] = {
606
+ sourceText: miss.text,
607
+ meta: miss.meta,
608
+ locale,
609
+ translation: translated,
610
+ timestamp: Date.now()
611
+ };
612
+ }
613
+ }
614
+ return true;
615
+ }
616
+ function scheduleDevTranslation() {
617
+ if (!resolvedTranslate) return;
618
+ if (!isDev) return;
619
+ if (translateTimer) clearTimeout(translateTimer);
620
+ translateTimer = setTimeout(async () => {
621
+ if (await translateMissingMessages()) saveCache(resolve(root, cacheFile), cache);
622
+ writeLocaleFilesToDisk();
623
+ writePrivateManifest();
624
+ }, 1e3);
625
+ }
626
+ function removeFileMessages(file) {
627
+ const previous = fileMessages.get(file);
628
+ if (!previous) return false;
629
+ for (const message of previous) {
630
+ const entry = manifest[message.id];
631
+ if (!entry) continue;
632
+ entry.sources = entry.sources.filter((source) => !isSameSource(source, message.source));
633
+ if (entry.sources.length === 0) delete manifest[message.id];
634
+ }
635
+ fileMessages.delete(file);
636
+ return true;
637
+ }
638
+ function syncFileMessages(file, messages) {
639
+ const previousMessages = fileMessages.get(file) ?? [];
640
+ const nextEntries = groupMessagesById(messages);
641
+ for (const [id, entry] of Object.entries(nextEntries)) {
642
+ const existing = manifest[id];
643
+ if (existing && !hasSameMessageShape(existing, entry)) throw new Error(formatCollisionError(id, existing, entry));
644
+ }
645
+ removeFileMessages(file);
646
+ for (const [id, entry] of Object.entries(nextEntries)) {
647
+ if (!manifest[id]) {
648
+ manifest[id] = entry;
649
+ continue;
650
+ }
651
+ for (const source of entry.sources) if (!manifest[id].sources.some((existingSource) => isSameSource(existingSource, source))) manifest[id].sources.push(source);
652
+ }
653
+ if (messages.length > 0) fileMessages.set(file, messages);
654
+ return {
655
+ manifestChanged: previousMessages.length !== messages.length || previousMessages.some((message, index) => !isSameExtractedMessage(message, messages[index])),
656
+ localeMessagesChanged: previousMessages.length !== messages.length || previousMessages.some((message, index) => !hasSameMessageShape(message, messages[index]))
657
+ };
658
+ }
659
+ function syncSourceCode(file, code) {
660
+ const analysis = analyzeSourceFile(code, file, {
661
+ call: CALL_MARKERS,
662
+ component: COMPONENT_MARKERS,
663
+ taggedTemplate: TAGGED_TEMPLATE_MARKERS,
664
+ logging
665
+ });
666
+ if (!analysis.parsed) return null;
667
+ return syncFileMessages(file, analysis.messages.map((message) => ({
668
+ ...message,
669
+ source: {
670
+ ...message.source,
671
+ file: toRootRelativePath(message.source.file)
672
+ }
673
+ })));
674
+ }
675
+ function removeTrackedFile(file) {
676
+ const hadPreviousMessages = removeFileMessages(file);
677
+ return {
678
+ manifestChanged: hadPreviousMessages,
679
+ localeMessagesChanged: hadPreviousMessages
680
+ };
681
+ }
682
+ function applySyncResult(syncResult, options) {
683
+ if (!syncResult) return;
684
+ if (syncResult.localeMessagesChanged) {
685
+ writeLocaleFilesToDisk();
686
+ writePrivateManifest();
687
+ if (options.scheduleTranslation) scheduleDevTranslation();
688
+ return;
689
+ }
690
+ if (syncResult.manifestChanged) writePrivateManifest();
691
+ }
692
+ function scanAllSourceFiles() {
693
+ for (const id of Object.keys(manifest)) delete manifest[id];
694
+ fileMessages.clear();
695
+ for (const sourceRoot of sourceRoots) {
696
+ if (!existsSync(sourceRoot)) continue;
697
+ for (const file of collectScanFiles(sourceRoot).sort()) syncSourceCode(file, readFileSync(file, "utf-8"));
698
+ }
699
+ }
700
+ function toRootRelativePath(file) {
701
+ return relative(root, file).replaceAll("\\", "/");
702
+ }
703
+ return {
704
+ name: "better-translation-extract",
705
+ enforce: "pre",
706
+ configResolved(config) {
707
+ root = config.root;
708
+ isDev = config.command === "serve";
709
+ sourceRoots = (Array.isArray(rootDir) ? rootDir : [rootDir]).map((dir) => resolve(root, dir));
710
+ log(`${PREFIX} Locales: ${CYAN}${formatLocales(locales)}${RESET} | Default: ${CYAN}${formatLocale(defaultLocale)}${RESET} | Storage: ${CYAN}${usesBundleStorage ? "Bundle" : "Remote"}${RESET} | Out Dir: ${DIM}${usesBundleStorage ? localesDir : "n/a"}${RESET} | Roots: ${DIM}${(Array.isArray(rootDir) ? rootDir : [rootDir]).join(", ")}${RESET}`);
711
+ },
712
+ buildStart() {
713
+ cache = loadCache(resolve(root, cacheFile));
714
+ scanAllSourceFiles();
715
+ if (usesBundleStorage && !isDev) {
716
+ assertGeneratedFilesCommitted();
717
+ assertLocalBuildTranslationsComplete();
718
+ return;
719
+ }
720
+ writeRuntimeConfig();
721
+ writeLoadMessagesModule();
722
+ writeGitignore();
723
+ writeLocaleFilesToDisk();
724
+ writePrivateManifest();
725
+ },
726
+ configureServer(server) {
727
+ server.watcher.add(sourceRoots);
728
+ const syncFileFromDisk = (file) => {
729
+ if (!shouldScanFile(file) || !existsSync(file)) return;
730
+ applySyncResult(syncSourceCode(file, readFileSync(file, "utf-8")), { scheduleTranslation: true });
731
+ };
732
+ const removeFileFromManifest = (file) => {
733
+ if (!shouldScanFile(file)) return;
734
+ applySyncResult(removeTrackedFile(file), { scheduleTranslation: true });
735
+ };
736
+ server.watcher.on("add", syncFileFromDisk);
737
+ server.watcher.on("change", syncFileFromDisk);
738
+ server.watcher.on("unlink", removeFileFromManifest);
739
+ },
740
+ transform(code, id) {
741
+ const cleanId = id.split("?", 1)[0] ?? id;
742
+ if (!shouldScanFile(cleanId)) return;
743
+ const analysis = analyzeSourceFile(code, cleanId, {
744
+ call: CALL_MARKERS,
745
+ component: COMPONENT_MARKERS,
746
+ taggedTemplate: TAGGED_TEMPLATE_MARKERS,
747
+ logging
748
+ });
749
+ if (analysis.edits.length === 0) return;
750
+ return {
751
+ code: applyEdits(code, analysis.edits),
752
+ map: null
753
+ };
754
+ },
755
+ async generateBundle() {
756
+ if (usesBundleStorage) {
757
+ if (!isDev) assertGeneratedFilesCommitted();
758
+ assertLocalBuildTranslationsComplete();
759
+ } else await translateMissingMessages();
760
+ if (!usesBundleStorage) await syncRemote();
761
+ },
762
+ closeBundle() {
763
+ if (usesBundleStorage && !isDev) return;
764
+ saveCache(resolve(root, cacheFile), cache);
765
+ }
766
+ };
767
+ }
768
+ function formatLocaleIssue(locale, label, ids) {
769
+ return `- ${locale}: ${label} (${ids.slice(0, 5).map((id) => JSON.stringify(id)).join(", ")}${ids.length > 5 ? `, ... ${ids.length - 5} more` : ""})`;
770
+ }
771
+ function normalizeLocaleMessages(input) {
772
+ if (isRuntimeMessages(input)) return input;
773
+ if (typeof input === "object" && input !== null && "messages" in input && typeof input.messages === "object" && input.messages !== null) return Object.fromEntries(Object.entries(input.messages).flatMap(([id, entry]) => typeof entry === "object" && entry !== null && "translation" in entry && typeof entry.translation === "string" ? [[id, entry.translation]] : []));
774
+ return {};
775
+ }
776
+ function writeFileIfChanged(path, contents) {
777
+ if (existsSync(path) && readFileSync(path, "utf-8") === contents) return false;
778
+ writeFileSync(path, contents);
779
+ return true;
780
+ }
781
+ function collectScanFiles(root) {
782
+ const files = [];
783
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
784
+ const path = resolve(root, entry.name);
785
+ if (entry.isDirectory()) {
786
+ if (entry.name === "node_modules") continue;
787
+ files.push(...collectScanFiles(path));
788
+ continue;
789
+ }
790
+ files.push(path);
791
+ }
792
+ return files;
793
+ }
794
+ function isRuntimeMessages(input) {
795
+ return typeof input === "object" && input !== null && Object.values(input).every((value) => typeof value === "string");
796
+ }
797
+ function applyEdits(code, edits) {
798
+ let transformed = code;
799
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) transformed = `${transformed.slice(0, edit.start)}${edit.replacement}${transformed.slice(edit.end)}`;
800
+ return transformed;
801
+ }
802
+ function groupMessagesById(messages) {
803
+ const grouped = {};
804
+ for (const message of messages) {
805
+ const existing = grouped[message.id];
806
+ if (existing && !hasSameMessageShape(existing, message)) throw new Error(formatCollisionError(message.id, existing, message));
807
+ if (!existing) {
808
+ grouped[message.id] = {
809
+ defaultMessage: message.defaultMessage,
810
+ meta: message.meta,
811
+ placeholders: message.placeholders,
812
+ sources: [message.source]
813
+ };
814
+ continue;
815
+ }
816
+ if (!existing.sources.some((source) => isSameSource(source, message.source))) existing.sources.push(message.source);
817
+ }
818
+ return grouped;
819
+ }
820
+ function hasSameMessageShape(existing, incoming) {
821
+ return existing.defaultMessage === incoming.defaultMessage && serializeMeta(existing.meta) === serializeMeta(incoming.meta) && JSON.stringify(existing.placeholders) === JSON.stringify(incoming.placeholders);
822
+ }
823
+ function isSameSource(left, right) {
824
+ return left.file === right.file && left.kind === right.kind && left.marker === right.marker && left.start === right.start && left.end === right.end;
825
+ }
826
+ function isSameExtractedMessage(left, right) {
827
+ if (!right) return false;
828
+ return hasSameMessageShape(left, right) && isSameSource(left.source, right.source);
829
+ }
830
+ function formatCollisionError(id, existing, incoming) {
831
+ const existingSources = formatSources(existing.sources);
832
+ const incomingSources = formatSources("source" in incoming ? [incoming.source] : incoming.sources);
833
+ return [
834
+ `${PREFIX} conflicting message definition for ${BOLD}"${id}"${RESET}`,
835
+ `existing: ${JSON.stringify({
836
+ defaultMessage: existing.defaultMessage,
837
+ meta: existing.meta,
838
+ placeholders: existing.placeholders
839
+ })}`,
840
+ `existing sources: ${existingSources}`,
841
+ `incoming: ${JSON.stringify({
842
+ defaultMessage: incoming.defaultMessage,
843
+ meta: incoming.meta,
844
+ placeholders: incoming.placeholders
845
+ })}`,
846
+ `incoming sources: ${incomingSources}`
847
+ ].join("\n");
848
+ }
849
+ function formatSources(sources) {
850
+ return sources.map((source) => `${source.file}:${source.line}:${source.column}`).join(", ");
851
+ }
852
+ //#endregion
853
+ export { betterTranslate };
854
+
855
+ //# sourceMappingURL=vite.mjs.map