@walkinissue/angy 0.2.17

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/server.js ADDED
@@ -0,0 +1,1094 @@
1
+ // src/lib/server/route.ts
2
+ import { json as json4 } from "@sveltejs/kit";
3
+
4
+ // src/lib/server/commit.ts
5
+ import { json } from "@sveltejs/kit";
6
+
7
+ // src/lib/server/catalog.ts
8
+ import { constants as fsConstants } from "node:fs";
9
+ import { access, copyFile, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
10
+ import { dirname as dirname2, extname as extname2, join, basename as basename2 } from "node:path";
11
+ import gettextParser from "gettext-parser";
12
+ import Fuse from "fuse.js";
13
+
14
+ // src/lib/server/config.ts
15
+ import { readFile, unlink, writeFile } from "node:fs/promises";
16
+ import { basename, dirname, extname, isAbsolute, normalize, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { pathToFileURL } from "node:url";
19
+ import { transform } from "esbuild";
20
+ var __dirname = dirname(fileURLToPath(import.meta.url));
21
+ var CONFIG_FILENAMES = [
22
+ "angy.config.ts",
23
+ "angy.config.js",
24
+ "angy.config.mjs",
25
+ "angy.config.cjs"
26
+ ];
27
+ function buildDefaultSystemMessage(sourceLocale, targetLocale) {
28
+ return `You are an expert product localization assistant translating ${sourceLocale} UI copy into polished ${targetLocale}. Keep already-${targetLocale} text unchanged. Preserve placeholders like {0}, {1}, ICU fragments, HTML-like markers such as <0/> and <strong>, line breaks, punctuation, capitalization, and button-like brevity. Prefer terminology and syntax that stay close to the translated examples so the product voice remains cohesive. Do not invent extra context, do not expand abbreviations unless the examples already do, and avoid semantic drift. Return only high-confidence translation suggestions for the provided untranslated strings.`;
29
+ }
30
+ function assertNonEmptyString(value, key) {
31
+ if (typeof value !== "string" || !value.trim()) {
32
+ throw new Error(`[angy] ${key} is required and must be a non-empty string.`);
33
+ }
34
+ }
35
+ function validateRoutePath(routePath) {
36
+ if (routePath == null || routePath === "") return;
37
+ if (typeof routePath !== "string" || !routePath.startsWith("/")) {
38
+ throw new Error(`[angy] routePath must be an absolute path starting with "/".`);
39
+ }
40
+ }
41
+ function validateWatchIgnore(watchIgnore) {
42
+ if (watchIgnore == null) return;
43
+ if (!Array.isArray(watchIgnore) || watchIgnore.some((item) => typeof item !== "string")) {
44
+ throw new Error(`[angy] watchIgnore must be an array of strings.`);
45
+ }
46
+ }
47
+ function validateSuggestionProvider(suggestionProvider) {
48
+ if (suggestionProvider == null) return;
49
+ if (typeof suggestionProvider !== "function") {
50
+ throw new Error(`[angy] suggestionProvider must be a function.`);
51
+ }
52
+ }
53
+ var config = {
54
+ basePoPath: resolve(__dirname, "../../locales/en.po"),
55
+ workingPoPath: resolve(__dirname, "../../locales/en-working.po"),
56
+ sourceLocale: "sv",
57
+ targetLocale: "en",
58
+ routePath: "/api/translations",
59
+ apiKey: "",
60
+ systemMessage: buildDefaultSystemMessage("sv", "en"),
61
+ suggestionModel: "gpt-4.1-mini",
62
+ watchIgnore: ["**/en-working.po"]
63
+ };
64
+ var loadedConfigRoot = null;
65
+ var workingCatalogWatchControllers = /* @__PURE__ */ new Set();
66
+ function configureTranslationHelper(next) {
67
+ Object.assign(config, next);
68
+ }
69
+ function getTranslationHelperConfig() {
70
+ return config;
71
+ }
72
+ function normalizeFsPath(path) {
73
+ return normalize(path).replace(/\\/g, "/");
74
+ }
75
+ function inferLocaleFromCatalogPath(path) {
76
+ const fileName = basename(path);
77
+ if (!fileName) return null;
78
+ return fileName.slice(0, fileName.length - extname(fileName).length) || null;
79
+ }
80
+ function resolveLocaleAlias(value, paths) {
81
+ if (value === "working") {
82
+ const locale = inferLocaleFromCatalogPath(paths.workingPoPath);
83
+ if (!locale) {
84
+ throw new Error("[angy] Unable to infer locale from workingPoPath.");
85
+ }
86
+ return locale;
87
+ }
88
+ if (value === "base") {
89
+ const locale = inferLocaleFromCatalogPath(paths.basePoPath);
90
+ if (!locale) {
91
+ throw new Error("[angy] Unable to infer locale from basePoPath.");
92
+ }
93
+ return locale;
94
+ }
95
+ return value;
96
+ }
97
+ function validateLocaleAliasUsage(sourceLocale, targetLocale) {
98
+ if (sourceLocale === "working") {
99
+ throw new Error('[angy] sourceLocale cannot be "working". Use an explicit source locale.');
100
+ }
101
+ if (targetLocale === "base") {
102
+ throw new Error('[angy] targetLocale cannot be "base". Use an explicit target locale or "working".');
103
+ }
104
+ }
105
+ function suspendWorkingCatalogWatch(path, delayMs = 800) {
106
+ const normalizedPath = normalizeFsPath(path);
107
+ for (const controller of workingCatalogWatchControllers) {
108
+ controller(normalizedPath, delayMs);
109
+ }
110
+ }
111
+ function normalizeTranslationHelperConfig(root, next) {
112
+ const normalized = { ...next };
113
+ if (normalized.basePoPath && !isAbsolute(normalized.basePoPath)) {
114
+ normalized.basePoPath = resolve(root, normalized.basePoPath);
115
+ }
116
+ if (normalized.workingPoPath && !isAbsolute(normalized.workingPoPath)) {
117
+ normalized.workingPoPath = resolve(root, normalized.workingPoPath);
118
+ }
119
+ return normalized;
120
+ }
121
+ function resolveConfiguredLocaleAliases(next) {
122
+ const rawSourceLocale = next.sourceLocale;
123
+ const rawTargetLocale = next.targetLocale;
124
+ const resolvedSourceLocale = resolveLocaleAlias(rawSourceLocale, next);
125
+ const resolvedTargetLocale = resolveLocaleAlias(rawTargetLocale, next);
126
+ const usesDefaultSystemMessage = next.systemMessage === buildDefaultSystemMessage(rawSourceLocale, rawTargetLocale);
127
+ return {
128
+ ...next,
129
+ sourceLocale: resolvedSourceLocale,
130
+ targetLocale: resolvedTargetLocale,
131
+ systemMessage: usesDefaultSystemMessage ? buildDefaultSystemMessage(resolvedSourceLocale, resolvedTargetLocale) : next.systemMessage
132
+ };
133
+ }
134
+ function completeAngyConfig(input) {
135
+ assertNonEmptyString(input.basePoPath, "basePoPath");
136
+ assertNonEmptyString(input.workingPoPath, "workingPoPath");
137
+ assertNonEmptyString(input.sourceLocale, "sourceLocale");
138
+ assertNonEmptyString(input.targetLocale, "targetLocale");
139
+ validateLocaleAliasUsage(input.sourceLocale, input.targetLocale);
140
+ if (typeof input.apiKey !== "string") {
141
+ throw new Error(`[angy] apiKey is required and must be a string. Use an empty string to disable suggestions.`);
142
+ }
143
+ validateRoutePath(input.routePath);
144
+ validateWatchIgnore(input.watchIgnore);
145
+ validateSuggestionProvider(input.suggestionProvider);
146
+ return {
147
+ basePoPath: input.basePoPath,
148
+ workingPoPath: input.workingPoPath,
149
+ sourceLocale: input.sourceLocale,
150
+ targetLocale: input.targetLocale,
151
+ routePath: input.routePath ?? "/api/translations",
152
+ apiKey: input.apiKey,
153
+ systemMessage: input.systemMessage ?? buildDefaultSystemMessage(input.sourceLocale, input.targetLocale),
154
+ suggestionModel: input.suggestionModel ?? "gpt-4.1-mini",
155
+ watchIgnore: input.watchIgnore ?? ["**/en-working.po"],
156
+ suggestionProvider: input.suggestionProvider
157
+ };
158
+ }
159
+ function defineAngyConfig(config2) {
160
+ return completeAngyConfig(config2);
161
+ }
162
+ async function fileExists(path) {
163
+ try {
164
+ await readFile(path);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+ async function loadTsConfigModule(path) {
171
+ const source = await readFile(path, "utf8");
172
+ const transformed = await transform(source, {
173
+ loader: "ts",
174
+ format: "esm",
175
+ target: "es2022"
176
+ });
177
+ const tempPath = `${path}.angy.tmp.mjs`;
178
+ await writeFile(tempPath, transformed.code, "utf8");
179
+ try {
180
+ return await import(`${pathToFileURL(tempPath).href}?t=${Date.now()}`);
181
+ } finally {
182
+ await unlink(tempPath).catch(() => void 0);
183
+ }
184
+ }
185
+ async function loadJsConfigModule(path) {
186
+ return import(pathToFileURL(path).href);
187
+ }
188
+ async function loadAngyConfigFromRoot(root) {
189
+ if (loadedConfigRoot === root) {
190
+ return config;
191
+ }
192
+ for (const filename of CONFIG_FILENAMES) {
193
+ const fullPath = `${root}/${filename}`.replace(/\\/g, "/");
194
+ if (!await fileExists(fullPath)) continue;
195
+ const module = filename.endsWith(".ts") ? await loadTsConfigModule(fullPath) : await loadJsConfigModule(fullPath);
196
+ const raw = module.default ?? module.config ?? {};
197
+ const next = resolveConfiguredLocaleAliases(
198
+ normalizeTranslationHelperConfig(root, completeAngyConfig(raw))
199
+ );
200
+ configureTranslationHelper(next);
201
+ loadedConfigRoot = root;
202
+ return config;
203
+ }
204
+ loadedConfigRoot = root;
205
+ return config;
206
+ }
207
+
208
+ // src/lib/server/catalog.ts
209
+ var runtimeTranslations = /* @__PURE__ */ new Map();
210
+ function catalogEntryKey(msgid, msgctxt) {
211
+ return `${msgctxt ?? ""}::${msgid}`;
212
+ }
213
+ function normalizeWhitespace(value) {
214
+ return value.replace(/\s+/g, " ").trim();
215
+ }
216
+ function normalizeForLookup(value) {
217
+ return normalizeWhitespace(value).replace(/<\/?[a-z][^>]*>/gi, "<x>").replace(/<\/?\d+>/g, "<x>").replace(/\{\{?\s*[^}]+\s*\}?\}/g, "{0}").replace(/\{\d+\}/g, "{0}").replace(/[""'`´]/g, "'").toLowerCase();
218
+ }
219
+ function tokenizeForLookup(value) {
220
+ return normalizeForLookup(value).split(/[\s,.;:!?()[\]{}]+/).map((token) => token.trim()).filter(Boolean);
221
+ }
222
+ function buildLookupVariants(raw) {
223
+ const normalized = normalizeForLookup(raw);
224
+ const variants = /* @__PURE__ */ new Set([normalized]);
225
+ variants.add(normalized.replace(/<x>/g, "<0>"));
226
+ variants.add(normalized.replace(/<x>/g, "").replace(/\s+/g, " ").trim());
227
+ variants.add(normalized.replace(/\{0\}/g, "").replace(/\s+/g, " ").trim());
228
+ return [...variants].filter(Boolean);
229
+ }
230
+ function flattenPoEntries(parsed, translationOrigin) {
231
+ const entries = [];
232
+ const translations = parsed?.translations ?? {};
233
+ for (const [ctxKey, group] of Object.entries(translations)) {
234
+ for (const [msgidKey, raw] of Object.entries(group)) {
235
+ if (!msgidKey) continue;
236
+ const entry = raw;
237
+ const msgid = entry.msgid ?? msgidKey;
238
+ const msgctxt = entry.msgctxt ?? (ctxKey === "" ? null : ctxKey);
239
+ const msgidPlural = entry.msgid_plural ?? null;
240
+ const msgstr = Array.isArray(entry.msgstr) ? entry.msgstr.filter(Boolean) : [];
241
+ const references = entry.comments?.reference ? entry.comments.reference.split("\n").map((item) => item.trim()).filter(Boolean) : [];
242
+ const extractedComments = entry.comments?.extracted ? entry.comments.extracted.split("\n").map((item) => item.trim()).filter(Boolean) : [];
243
+ const flags = entry.comments?.flag ? entry.comments.flag.split(",").map((item) => item.trim()).filter(Boolean) : [];
244
+ const previous = entry.comments?.previous ? entry.comments.previous.split("\n").map((item) => item.trim()).filter(Boolean) : [];
245
+ const translated = entry.msgstr?.some((item) => item.trim().length > 0) ?? false;
246
+ const fuzzy = entry.comments?.flag?.includes("fuzzy");
247
+ entries.push({
248
+ msgid,
249
+ msgctxt,
250
+ msgidPlural,
251
+ msgstr,
252
+ references,
253
+ extractedComments,
254
+ flags,
255
+ previous,
256
+ obsolete: Boolean(entry.obsolete),
257
+ searchText: normalizeForLookup(msgid),
258
+ searchTokens: tokenizeForLookup(msgid),
259
+ isFuzzy: fuzzy,
260
+ hasTranslation: translated,
261
+ translationOrigin
262
+ });
263
+ }
264
+ }
265
+ return entries;
266
+ }
267
+ function createFuse(entries) {
268
+ return new Fuse(entries, {
269
+ includeScore: true,
270
+ ignoreLocation: true,
271
+ threshold: 0.34,
272
+ minMatchCharLength: 2,
273
+ keys: [
274
+ { name: "msgid", weight: 0.7 },
275
+ { name: "searchText", weight: 0.2 },
276
+ { name: "references", weight: 0.1 }
277
+ ]
278
+ });
279
+ }
280
+ function buildEntryMap(entries) {
281
+ return new Map(entries.map((entry) => [catalogEntryKey(entry.msgid, entry.msgctxt), entry]));
282
+ }
283
+ function applyWorkingState(baseEntry, workingEntry) {
284
+ if (!workingEntry) {
285
+ return baseEntry;
286
+ }
287
+ return {
288
+ ...baseEntry,
289
+ msgstr: workingEntry.msgstr,
290
+ flags: workingEntry.flags,
291
+ previous: workingEntry.previous,
292
+ obsolete: workingEntry.obsolete,
293
+ hasTranslation: workingEntry.hasTranslation,
294
+ isFuzzy: workingEntry.isFuzzy,
295
+ translationOrigin: workingEntry.translationOrigin
296
+ };
297
+ }
298
+ async function fileExists2(path) {
299
+ try {
300
+ await access(path, fsConstants.F_OK);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+ async function readCatIndex(catalog) {
307
+ const { basePoPath, workingPoPath } = getTranslationHelperConfig();
308
+ let path = "";
309
+ if (catalog === "base") {
310
+ path = basePoPath;
311
+ } else {
312
+ const exists = await fileExists2(workingPoPath);
313
+ if (!exists) return null;
314
+ path = workingPoPath;
315
+ }
316
+ const raw = await readFile2(path);
317
+ const parsed = gettextParser.po.parse(raw);
318
+ const entries = flattenPoEntries(parsed, catalog);
319
+ return {
320
+ entries,
321
+ fuse: createFuse(entries)
322
+ };
323
+ }
324
+ async function readParsedCatalog(catalog) {
325
+ const { basePoPath, workingPoPath } = getTranslationHelperConfig();
326
+ const path = catalog === "base" ? basePoPath : workingPoPath;
327
+ if (catalog === "working") {
328
+ const exists = await fileExists2(path);
329
+ if (!exists) return null;
330
+ }
331
+ const raw = await readFile2(path);
332
+ return gettextParser.po.parse(raw);
333
+ }
334
+ async function ensureWorkingCatalog() {
335
+ const { basePoPath, workingPoPath } = getTranslationHelperConfig();
336
+ const exists = await fileExists2(workingPoPath);
337
+ if (!exists) {
338
+ await copyFile(basePoPath, workingPoPath);
339
+ }
340
+ }
341
+ function removeFuzzyFlag(flagString) {
342
+ if (!flagString) return "";
343
+ return flagString.split(",").map((item) => item.trim()).filter(Boolean).filter((item) => item !== "fuzzy").join(", ");
344
+ }
345
+ async function writeWorkingCatalog(parsed) {
346
+ const { workingPoPath } = getTranslationHelperConfig();
347
+ suspendWorkingCatalogWatch(workingPoPath);
348
+ const compiled = gettextParser.po.compile(parsed);
349
+ await writeFile2(workingPoPath, compiled);
350
+ }
351
+ function timestampForBackup() {
352
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
353
+ }
354
+ function buildBackupPath(path, label) {
355
+ const directory = dirname2(path);
356
+ const extension = extname2(path);
357
+ const stem = basename2(path, extension);
358
+ return join(directory, `${stem}.${label}.${timestampForBackup()}${extension}`);
359
+ }
360
+ async function rotateCatalogs() {
361
+ const { basePoPath, workingPoPath } = getTranslationHelperConfig();
362
+ const workingExists = await fileExists2(workingPoPath);
363
+ if (!workingExists) {
364
+ return { ok: false, error: "Working catalog does not exist" };
365
+ }
366
+ const baseBackupPath = buildBackupPath(basePoPath, "base-backup");
367
+ const workingBackupPath = buildBackupPath(workingPoPath, "working-backup");
368
+ await copyFile(basePoPath, baseBackupPath);
369
+ await copyFile(workingPoPath, workingBackupPath);
370
+ await copyFile(workingPoPath, basePoPath);
371
+ await copyFile(basePoPath, workingPoPath);
372
+ return {
373
+ ok: true,
374
+ basePoPath,
375
+ workingPoPath,
376
+ baseBackupPath,
377
+ workingBackupPath
378
+ };
379
+ }
380
+
381
+ // src/lib/server/commit.ts
382
+ function runtimeKey(msgid, msgctxt) {
383
+ return `${msgctxt ?? ""}::${msgid}`;
384
+ }
385
+ async function writeTranslationToWorkingCatalog(resolvedMsgid, resolvedMsgctxt, translationValue) {
386
+ await ensureWorkingCatalog();
387
+ const [baseParsed, workingParsed] = await Promise.all([
388
+ readParsedCatalog("base"),
389
+ readParsedCatalog("working")
390
+ ]);
391
+ if (!baseParsed || !workingParsed) {
392
+ return { ok: false, error: "Unable to load catalogs" };
393
+ }
394
+ const baseTranslations = baseParsed.translations ?? {};
395
+ const workingTranslations = workingParsed.translations ?? {};
396
+ const ctxKey = resolvedMsgctxt ?? "";
397
+ const baseGroup = baseTranslations[ctxKey];
398
+ if (!baseGroup) {
399
+ return { ok: false, error: "Context group not found in base catalog" };
400
+ }
401
+ const baseEntry = baseGroup[resolvedMsgid];
402
+ if (!baseEntry) {
403
+ return { ok: false, error: "Target msgid not found in base catalog" };
404
+ }
405
+ const workingGroup = workingTranslations[ctxKey] ??= {};
406
+ const entry = workingGroup[resolvedMsgid] ??= cloneEntryForWorking(baseEntry, resolvedMsgid, resolvedMsgctxt);
407
+ entry.msgstr = [translationValue];
408
+ entry.comments ??= {};
409
+ entry.comments.flag = removeFuzzyFlag(entry.comments.flag);
410
+ await writeWorkingCatalog(workingParsed);
411
+ runtimeTranslations.set(runtimeKey(resolvedMsgid, resolvedMsgctxt), translationValue);
412
+ return {
413
+ ok: true,
414
+ msgid: resolvedMsgid,
415
+ msgctxt: resolvedMsgctxt,
416
+ workingCatalog: "en-working.po"
417
+ };
418
+ }
419
+ function cloneEntryForWorking(baseEntry, msgid, msgctxt) {
420
+ return {
421
+ msgid,
422
+ msgctxt: msgctxt ?? void 0,
423
+ msgid_plural: baseEntry.msgid_plural,
424
+ msgstr: Array.isArray(baseEntry.msgstr) ? [...baseEntry.msgstr] : [""],
425
+ obsolete: Boolean(baseEntry.obsolete),
426
+ comments: baseEntry.comments ? {
427
+ reference: baseEntry.comments.reference,
428
+ extracted: baseEntry.comments.extracted,
429
+ flag: baseEntry.comments.flag,
430
+ previous: baseEntry.comments.previous
431
+ } : void 0
432
+ };
433
+ }
434
+ async function handleCommitBatch(request) {
435
+ const data = await request.json().catch(() => null);
436
+ const items = Array.isArray(data?.items) ? data.items : [];
437
+ if (!items.length) {
438
+ return json(
439
+ {
440
+ success: false,
441
+ error: "No items to commit"
442
+ },
443
+ { status: 400 }
444
+ );
445
+ }
446
+ const results = [];
447
+ for (const item of items) {
448
+ const resolvedMsgid = item.resolvedMsgid?.trim();
449
+ const resolvedMsgctxt = item.resolvedMsgctxt && item.resolvedMsgctxt.trim().length > 0 ? item.resolvedMsgctxt.trim() : null;
450
+ const translationValue = item.translationValue?.trim();
451
+ if (!resolvedMsgid || !translationValue) {
452
+ results.push({
453
+ msgid: resolvedMsgid ?? "",
454
+ msgctxt: resolvedMsgctxt,
455
+ ok: false,
456
+ error: "resolvedMsgid and translationValue are required"
457
+ });
458
+ continue;
459
+ }
460
+ const result = await writeTranslationToWorkingCatalog(
461
+ resolvedMsgid,
462
+ resolvedMsgctxt,
463
+ translationValue
464
+ );
465
+ if (!result.ok) {
466
+ results.push({
467
+ msgid: resolvedMsgid,
468
+ msgctxt: resolvedMsgctxt,
469
+ ok: false,
470
+ error: result.error
471
+ });
472
+ continue;
473
+ }
474
+ results.push({
475
+ msgid: resolvedMsgid,
476
+ msgctxt: resolvedMsgctxt,
477
+ ok: true
478
+ });
479
+ }
480
+ const failed = results.filter((item) => !item.ok);
481
+ if (failed.length) {
482
+ return json(
483
+ {
484
+ success: false,
485
+ error: "Some translations failed to commit",
486
+ results
487
+ },
488
+ { status: 207 }
489
+ );
490
+ }
491
+ return json({
492
+ success: true,
493
+ message: `Committed ${results.length} translations to en-working.po`,
494
+ results
495
+ });
496
+ }
497
+ async function handleCommit(request) {
498
+ const data = await request.formData();
499
+ const resolvedMsgid = data.get("resolvedMsgid")?.toString().trim();
500
+ const resolvedMsgctxtRaw = data.get("resolvedMsgctxt")?.toString();
501
+ const translationValue = data.get("translationValue")?.toString().trim();
502
+ const resolvedMsgctxt = resolvedMsgctxtRaw && resolvedMsgctxtRaw.trim().length > 0 ? resolvedMsgctxtRaw.trim() : null;
503
+ if (!resolvedMsgid || !translationValue) {
504
+ return json(
505
+ {
506
+ success: false,
507
+ error: "resolvedMsgid and translationValue are required"
508
+ },
509
+ { status: 400 }
510
+ );
511
+ }
512
+ const result = await writeTranslationToWorkingCatalog(
513
+ resolvedMsgid,
514
+ resolvedMsgctxt,
515
+ translationValue
516
+ );
517
+ if (!result.ok) {
518
+ return json(
519
+ {
520
+ success: false,
521
+ error: result.error
522
+ },
523
+ { status: 404 }
524
+ );
525
+ }
526
+ return json({
527
+ success: true,
528
+ message: "Translation written to en-working.po",
529
+ msgid: result.msgid,
530
+ msgctxt: result.msgctxt,
531
+ workingCatalog: result.workingCatalog
532
+ });
533
+ }
534
+ async function handleRotateCatalogs() {
535
+ const result = await rotateCatalogs();
536
+ if (!result.ok) {
537
+ return json(
538
+ {
539
+ success: false,
540
+ error: result.error
541
+ },
542
+ { status: 400 }
543
+ );
544
+ }
545
+ return json({
546
+ success: true,
547
+ message: "Catalogs rotated",
548
+ basePoPath: result.basePoPath,
549
+ workingPoPath: result.workingPoPath,
550
+ baseBackupPath: result.baseBackupPath,
551
+ workingBackupPath: result.workingBackupPath
552
+ });
553
+ }
554
+
555
+ // src/lib/server/context.ts
556
+ import { json as json2 } from "@sveltejs/kit";
557
+ var DIRECT_MATCH_LIMIT = 5;
558
+ var MAX_ALTERNATIVES = 300;
559
+ var TRANSLATED_TARGET = Math.floor(MAX_ALTERNATIVES * 0.2);
560
+ var UNTRANSLATED_TARGET = MAX_ALTERNATIVES - TRANSLATED_TARGET;
561
+ var UNTRANSLATED_RANK_BONUS = 0.03;
562
+ function entryKey(msgid, msgctxt) {
563
+ return catalogEntryKey(msgid, msgctxt);
564
+ }
565
+ function normalizeCurrentPath(currentPath) {
566
+ try {
567
+ return new URL(currentPath, "http://localhost").pathname;
568
+ } catch {
569
+ return currentPath;
570
+ }
571
+ }
572
+ function inferPageReference(currentPath) {
573
+ const normalizedPath = normalizeCurrentPath(currentPath);
574
+ if (normalizedPath === "/") {
575
+ return "src/routes/+page.svelte";
576
+ }
577
+ const trimmedPath = normalizedPath.replace(/\/+$/, "");
578
+ return `src/routes${trimmedPath}/+page.svelte`;
579
+ }
580
+ function routeLooksRelevant(routeReference, refs, extractedComments) {
581
+ if (!routeReference) return false;
582
+ const haystack = [...refs, ...extractedComments].join(" ").toLowerCase();
583
+ return haystack.includes(routeReference.toLowerCase());
584
+ }
585
+ function dedupeCandidates(candidates) {
586
+ const deduped = /* @__PURE__ */ new Map();
587
+ for (const item of candidates) {
588
+ const id = entryKey(item.entry.msgid, item.entry.msgctxt);
589
+ const existing = deduped.get(id);
590
+ if (!existing || item.score < existing.score) {
591
+ deduped.set(id, item);
592
+ }
593
+ }
594
+ return [...deduped.values()];
595
+ }
596
+ function rankDirectMatches(candidates, routeReference) {
597
+ return [...candidates].sort((left, right) => {
598
+ const leftRoute = routeLooksRelevant(
599
+ routeReference,
600
+ left.entry.references,
601
+ left.entry.extractedComments
602
+ ) ? -0.08 : 0;
603
+ const rightRoute = routeLooksRelevant(
604
+ routeReference,
605
+ right.entry.references,
606
+ right.entry.extractedComments
607
+ ) ? -0.08 : 0;
608
+ const leftUntranslated = !left.entry.hasTranslation ? -UNTRANSLATED_RANK_BONUS : 0;
609
+ const rightUntranslated = !right.entry.hasTranslation ? -UNTRANSLATED_RANK_BONUS : 0;
610
+ return left.score + leftRoute + leftUntranslated - (right.score + rightRoute + rightUntranslated);
611
+ });
612
+ }
613
+ function getReferenceTokens(reference) {
614
+ return reference.toLowerCase().split(/[\/\[\].:_-]+/).map((token) => token.trim()).filter(Boolean);
615
+ }
616
+ function getReferenceSimilarity(reference, routeReference) {
617
+ const referenceTokens = new Set(getReferenceTokens(reference));
618
+ const routeTokens = getReferenceTokens(routeReference);
619
+ if (!routeTokens.length || !referenceTokens.size) return 0;
620
+ let matches = 0;
621
+ for (const token of routeTokens) {
622
+ if (referenceTokens.has(token)) {
623
+ matches += 1;
624
+ }
625
+ }
626
+ return matches / routeTokens.length;
627
+ }
628
+ function collectSharedReferenceAlternatives(entries, bestEntry, routeReference, excludedKeys) {
629
+ const sharedReferences = new Set(bestEntry.references.map((reference) => reference.toLowerCase()));
630
+ return entries.filter((entry) => !excludedKeys.has(entryKey(entry.msgid, entry.msgctxt))).map((entry) => {
631
+ const referenceMatches = entry.references.filter(
632
+ (reference) => sharedReferences.has(reference.toLowerCase())
633
+ );
634
+ const routeMatch = entry.references.some(
635
+ (reference) => reference.toLowerCase().includes(routeReference.toLowerCase())
636
+ );
637
+ if (!referenceMatches.length && !routeMatch) {
638
+ return null;
639
+ }
640
+ const referenceSimilarity = Math.max(
641
+ 0,
642
+ ...entry.references.map((reference) => getReferenceSimilarity(reference, routeReference))
643
+ );
644
+ const untranslatedBonus = !entry.hasTranslation ? 0.35 : 0;
645
+ return {
646
+ entry,
647
+ score: referenceMatches.length * 100 + (routeMatch ? 25 : 0) + referenceSimilarity + untranslatedBonus,
648
+ variant: "shared-reference"
649
+ };
650
+ }).filter((item) => Boolean(item)).sort((left, right) => right.score - left.score);
651
+ }
652
+ function collectRouteFillAlternatives(entries, routeReference, excludedKeys) {
653
+ return entries.filter((entry) => !excludedKeys.has(entryKey(entry.msgid, entry.msgctxt))).map((entry) => {
654
+ const referenceSimilarity = Math.max(
655
+ 0,
656
+ ...entry.references.map((reference) => getReferenceSimilarity(reference, routeReference))
657
+ );
658
+ if (referenceSimilarity <= 0) {
659
+ return null;
660
+ }
661
+ const untranslatedBonus = !entry.hasTranslation ? 0.2 : 0;
662
+ return {
663
+ entry,
664
+ score: referenceSimilarity + untranslatedBonus,
665
+ variant: "route-reference"
666
+ };
667
+ }).filter((item) => Boolean(item)).sort((left, right) => right.score - left.score);
668
+ }
669
+ function collectTranslatedCohesionAlternatives(directMatches, excludedKeys) {
670
+ return directMatches.filter((item) => item.entry.hasTranslation).filter((item) => !excludedKeys.has(entryKey(item.entry.msgid, item.entry.msgctxt))).map((item) => ({
671
+ entry: item.entry,
672
+ score: 1 - item.score,
673
+ variant: "translated-cohesion"
674
+ })).sort((left, right) => right.score - left.score);
675
+ }
676
+ function collectUntranslatedSimilarityAlternatives(directMatches, excludedKeys) {
677
+ return directMatches.filter((item) => !item.entry.hasTranslation).filter((item) => !excludedKeys.has(entryKey(item.entry.msgid, item.entry.msgctxt))).map((item) => ({
678
+ entry: item.entry,
679
+ score: 1 - item.score,
680
+ variant: "untranslated-similarity"
681
+ })).sort((left, right) => right.score - left.score);
682
+ }
683
+ function buildAlternativePool(topDirectAlternatives, sharedReferenceAlternatives, routeFillAlternatives, translatedCohesionAlternatives, untranslatedSimilarityAlternatives) {
684
+ const alternatives = [...topDirectAlternatives];
685
+ const selectedKeys = new Set(
686
+ topDirectAlternatives.map((item) => entryKey(item.entry.msgid, item.entry.msgctxt))
687
+ );
688
+ let translatedCount = topDirectAlternatives.filter((item) => item.entry.hasTranslation).length;
689
+ let untranslatedCount = topDirectAlternatives.length - translatedCount;
690
+ const tryAdd = (item, honorQuota = true) => {
691
+ if (alternatives.length >= MAX_ALTERNATIVES) return false;
692
+ const id = entryKey(item.entry.msgid, item.entry.msgctxt);
693
+ if (selectedKeys.has(id)) return false;
694
+ if (honorQuota) {
695
+ if (item.entry.hasTranslation && translatedCount >= TRANSLATED_TARGET) return false;
696
+ if (!item.entry.hasTranslation && untranslatedCount >= UNTRANSLATED_TARGET) return false;
697
+ }
698
+ selectedKeys.add(id);
699
+ alternatives.push(item);
700
+ if (item.entry.hasTranslation) {
701
+ translatedCount += 1;
702
+ } else {
703
+ untranslatedCount += 1;
704
+ }
705
+ return true;
706
+ };
707
+ const prioritizedSources = [
708
+ sharedReferenceAlternatives,
709
+ routeFillAlternatives,
710
+ untranslatedSimilarityAlternatives,
711
+ translatedCohesionAlternatives
712
+ ];
713
+ for (const source of prioritizedSources) {
714
+ for (const item of source) {
715
+ tryAdd(item, true);
716
+ }
717
+ }
718
+ if (alternatives.length < MAX_ALTERNATIVES) {
719
+ for (const source of prioritizedSources) {
720
+ for (const item of source) {
721
+ tryAdd(item, false);
722
+ }
723
+ }
724
+ }
725
+ return alternatives.slice(0, MAX_ALTERNATIVES);
726
+ }
727
+ async function findTranslationContext(key, currentPath) {
728
+ const baseIndex = await readCatIndex("base");
729
+ if (!baseIndex) return null;
730
+ const workingIndex = await readCatIndex("working");
731
+ const workingMap = workingIndex ? buildEntryMap(workingIndex.entries) : /* @__PURE__ */ new Map();
732
+ const effectiveEntries = baseIndex.entries.map(
733
+ (entry) => applyWorkingState(entry, workingMap.get(entryKey(entry.msgid, entry.msgctxt)))
734
+ );
735
+ const effectiveIndex = {
736
+ entries: effectiveEntries,
737
+ fuse: createFuse(effectiveEntries)
738
+ };
739
+ const routeReference = inferPageReference(currentPath);
740
+ const directMatches = rankDirectMatches(
741
+ dedupeCandidates(
742
+ buildLookupVariants(key).flatMap(
743
+ (variant) => effectiveIndex.fuse.search(variant, { limit: 180 }).map((hit) => ({
744
+ entry: hit.item,
745
+ score: hit.score ?? 1,
746
+ variant
747
+ }))
748
+ )
749
+ ),
750
+ routeReference
751
+ );
752
+ const best = directMatches[0];
753
+ if (!best || best.score > 0.42) {
754
+ return null;
755
+ }
756
+ const effectiveDirectMatches = directMatches;
757
+ const bestEffective = effectiveDirectMatches[0];
758
+ const topDirectAlternatives = effectiveDirectMatches.slice(1, DIRECT_MATCH_LIMIT);
759
+ const excludedKeys = /* @__PURE__ */ new Set([
760
+ entryKey(best.entry.msgid, best.entry.msgctxt),
761
+ ...topDirectAlternatives.map((item) => entryKey(item.entry.msgid, item.entry.msgctxt))
762
+ ]);
763
+ const sharedReferenceAlternatives = collectSharedReferenceAlternatives(
764
+ effectiveEntries,
765
+ bestEffective.entry,
766
+ routeReference,
767
+ excludedKeys
768
+ );
769
+ const routeFillAlternatives = collectRouteFillAlternatives(
770
+ effectiveEntries,
771
+ routeReference,
772
+ excludedKeys
773
+ );
774
+ const translatedCohesionAlternatives = collectTranslatedCohesionAlternatives(
775
+ effectiveDirectMatches.slice(DIRECT_MATCH_LIMIT),
776
+ excludedKeys
777
+ );
778
+ const untranslatedSimilarityAlternatives = collectUntranslatedSimilarityAlternatives(
779
+ effectiveDirectMatches.slice(DIRECT_MATCH_LIMIT),
780
+ excludedKeys
781
+ );
782
+ return {
783
+ best,
784
+ alternatives: buildAlternativePool(
785
+ topDirectAlternatives,
786
+ sharedReferenceAlternatives,
787
+ routeFillAlternatives,
788
+ translatedCohesionAlternatives,
789
+ untranslatedSimilarityAlternatives
790
+ ),
791
+ routeReference,
792
+ workingMap
793
+ };
794
+ }
795
+ function toPublicEntry(entry, workingEntry) {
796
+ const effective = workingEntry ?? entry;
797
+ const baseValue = entry.msgstr[0] ?? "";
798
+ const workingValue = workingEntry?.msgstr[0] ?? "";
799
+ const workingDiffersFromBase = Boolean(workingEntry?.hasTranslation) && workingValue !== baseValue;
800
+ return {
801
+ msgid: entry.msgid,
802
+ msgctxt: entry.msgctxt,
803
+ msgidPlural: entry.msgidPlural,
804
+ msgstr: effective.msgstr,
805
+ references: entry.references,
806
+ extractedComments: entry.extractedComments,
807
+ flags: effective.flags,
808
+ previous: effective.previous,
809
+ obsolete: effective.obsolete,
810
+ hasTranslation: effective.hasTranslation,
811
+ isFuzzy: effective.isFuzzy,
812
+ isCommittedToWorking: workingDiffersFromBase,
813
+ matchesTargetTranslation: Boolean(effective.hasTranslation) && (!workingEntry?.hasTranslation || workingValue === baseValue),
814
+ translationOrigin: workingDiffersFromBase ? "working" : "base"
815
+ };
816
+ }
817
+ function toPublicAlternative(entry, score, workingEntry) {
818
+ const effective = workingEntry ?? entry;
819
+ const baseValue = entry.msgstr[0] ?? "";
820
+ const workingValue = workingEntry?.msgstr[0] ?? "";
821
+ const workingDiffersFromBase = Boolean(workingEntry?.hasTranslation) && workingValue !== baseValue;
822
+ return {
823
+ msgid: entry.msgid,
824
+ msgctxt: entry.msgctxt,
825
+ score,
826
+ references: entry.references,
827
+ msgstr: effective.msgstr,
828
+ hasTranslation: effective.hasTranslation,
829
+ isFuzzy: effective.isFuzzy,
830
+ isCommittedToWorking: workingDiffersFromBase,
831
+ matchesTargetTranslation: Boolean(effective.hasTranslation) && (!workingEntry?.hasTranslation || workingValue === baseValue),
832
+ translationOrigin: workingDiffersFromBase ? "working" : "base"
833
+ };
834
+ }
835
+ async function handleContext(request) {
836
+ const data = await request.formData();
837
+ const key = data.get("translationKey")?.toString().trim();
838
+ const currentPath = data.get("currentPath")?.toString().trim();
839
+ if (!key || !currentPath) {
840
+ return json2(
841
+ {
842
+ success: false,
843
+ error: "translationKey and currentPath are required"
844
+ },
845
+ { status: 400 }
846
+ );
847
+ }
848
+ const baseFound = await findTranslationContext(key, currentPath);
849
+ if (!baseFound) {
850
+ return json2(
851
+ {
852
+ success: false,
853
+ error: "Translation key context not found"
854
+ },
855
+ { status: 404 }
856
+ );
857
+ }
858
+ const bestBase = baseFound.best.entry;
859
+ const bestWorking = baseFound.workingMap.get(entryKey(bestBase.msgid, bestBase.msgctxt));
860
+ return json2({
861
+ success: true,
862
+ match: {
863
+ score: baseFound.best.score,
864
+ via: baseFound.best.variant
865
+ },
866
+ entry: toPublicEntry(bestBase, bestWorking),
867
+ alternatives: baseFound.alternatives.map((item) => {
868
+ const workingAlt = baseFound.workingMap.get(entryKey(item.entry.msgid, item.entry.msgctxt));
869
+ return toPublicAlternative(item.entry, item.score, workingAlt);
870
+ })
871
+ });
872
+ }
873
+
874
+ // src/lib/server/suggestions.ts
875
+ import { json as json3 } from "@sveltejs/kit";
876
+ function getEntriesForSuggestions(contextResult) {
877
+ return [contextResult.entry, ...contextResult.alternatives];
878
+ }
879
+ function getTranslatedExamples(contextResult) {
880
+ return getEntriesForSuggestions(contextResult).filter((entry) => entry.hasTranslation && entry.msgstr?.[0]).slice(0, 30).map((entry) => ({
881
+ msgid: entry.msgid,
882
+ msgctxt: entry.msgctxt,
883
+ translation: entry.msgstr[0]
884
+ }));
885
+ }
886
+ function buildUserMessage(contextResult, items) {
887
+ return JSON.stringify(
888
+ {
889
+ task: "Return translation suggestions for the untranslated UI strings.",
890
+ output_format: {
891
+ items: [
892
+ {
893
+ msgid: "source string",
894
+ msgctxt: "optional context or null",
895
+ suggestion: "translated suggestion"
896
+ }
897
+ ]
898
+ },
899
+ translated_examples: getTranslatedExamples(contextResult),
900
+ untranslated_items: items
901
+ },
902
+ null,
903
+ 2
904
+ );
905
+ }
906
+ function parseSuggestions(responseText) {
907
+ try {
908
+ const parsed = JSON.parse(responseText);
909
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
910
+ return items.filter(
911
+ (item) => typeof item?.msgid === "string" && (item.msgctxt === null || typeof item.msgctxt === "string") && typeof item?.suggestion === "string"
912
+ );
913
+ } catch {
914
+ return [];
915
+ }
916
+ }
917
+ async function handleSuggestions(request) {
918
+ const {
919
+ apiKey,
920
+ suggestionModel,
921
+ systemMessage,
922
+ sourceLocale,
923
+ targetLocale,
924
+ suggestionProvider
925
+ } = getTranslationHelperConfig();
926
+ const data = await request.json().catch(() => null);
927
+ const context = data?.context;
928
+ const items = Array.isArray(data?.items) ? data.items : [];
929
+ if (!context || !items.length) {
930
+ return json3(
931
+ {
932
+ success: false,
933
+ error: "context and items are required"
934
+ },
935
+ { status: 400 }
936
+ );
937
+ }
938
+ if (suggestionProvider) {
939
+ const providedItems = await suggestionProvider({
940
+ context,
941
+ items,
942
+ sourceLocale,
943
+ targetLocale,
944
+ systemMessage,
945
+ model: suggestionModel,
946
+ apiKey
947
+ });
948
+ return json3({
949
+ success: true,
950
+ items: providedItems
951
+ });
952
+ }
953
+ if (!apiKey.trim()) {
954
+ console.warn("[angy] Suggestions disabled because apiKey is empty.");
955
+ return json3({
956
+ success: true,
957
+ disabled: true,
958
+ items: []
959
+ });
960
+ }
961
+ try {
962
+ console.info("[angy] Suggestion request starting.", {
963
+ model: suggestionModel,
964
+ sourceLocale,
965
+ targetLocale,
966
+ itemCount: items.length,
967
+ hasApiKey: Boolean(apiKey.trim())
968
+ });
969
+ const response = await fetch("https://api.openai.com/v1/responses", {
970
+ method: "POST",
971
+ headers: {
972
+ Authorization: `Bearer ${apiKey}`,
973
+ "content-type": "application/json"
974
+ },
975
+ body: JSON.stringify({
976
+ model: suggestionModel,
977
+ reasoning: {
978
+ effort: "medium"
979
+ },
980
+ input: [
981
+ {
982
+ role: "system",
983
+ content: [{ type: "input_text", text: systemMessage }]
984
+ },
985
+ {
986
+ role: "user",
987
+ content: [{ type: "input_text", text: buildUserMessage(context, items) }]
988
+ }
989
+ ],
990
+ text: {
991
+ format: {
992
+ type: "json_schema",
993
+ name: "translation_suggestions",
994
+ schema: {
995
+ type: "object",
996
+ additionalProperties: false,
997
+ properties: {
998
+ items: {
999
+ type: "array",
1000
+ items: {
1001
+ type: "object",
1002
+ additionalProperties: false,
1003
+ properties: {
1004
+ msgid: { type: "string" },
1005
+ msgctxt: { type: ["string", "null"] },
1006
+ suggestion: { type: "string" }
1007
+ },
1008
+ required: ["msgid", "msgctxt", "suggestion"]
1009
+ }
1010
+ }
1011
+ },
1012
+ required: ["items"]
1013
+ }
1014
+ }
1015
+ }
1016
+ })
1017
+ });
1018
+ if (!response.ok) {
1019
+ const errorBody = await response.text().catch(() => "");
1020
+ console.error("[angy] Suggestion request failed.", {
1021
+ status: response.status,
1022
+ statusText: response.statusText,
1023
+ body: errorBody
1024
+ });
1025
+ return json3(
1026
+ {
1027
+ success: false,
1028
+ error: `Suggestion request failed: ${response.status}`
1029
+ },
1030
+ { status: 502 }
1031
+ );
1032
+ }
1033
+ const responseJson = await response.json();
1034
+ const responseText = responseJson?.output_text ?? responseJson?.output?.flatMap((item) => item?.content ?? []).find((part) => part?.text)?.text ?? "";
1035
+ return json3({
1036
+ success: true,
1037
+ items: parseSuggestions(responseText)
1038
+ });
1039
+ } catch (error) {
1040
+ console.error("[angy] Suggestion request threw.", error);
1041
+ return json3(
1042
+ {
1043
+ success: false,
1044
+ error: "Suggestion request failed before reaching the model"
1045
+ },
1046
+ { status: 502 }
1047
+ );
1048
+ }
1049
+ }
1050
+
1051
+ // src/lib/server/route.ts
1052
+ async function handleTranslationRequest(request, url, options) {
1053
+ const devOnly = options?.devOnly ?? true;
1054
+ const runtimeDev = typeof import.meta !== "undefined" && typeof import.meta.env !== "undefined" && import.meta.env.DEV === true;
1055
+ const isDev = options?.dev ?? runtimeDev;
1056
+ if (devOnly && !isDev) {
1057
+ return json4(
1058
+ {
1059
+ success: false,
1060
+ error: "Disabled outside dev"
1061
+ },
1062
+ { status: 404 }
1063
+ );
1064
+ }
1065
+ if (options?.config) {
1066
+ configureTranslationHelper(normalizeTranslationHelperConfig(process.cwd(), options.config));
1067
+ } else {
1068
+ await loadAngyConfigFromRoot(process.cwd());
1069
+ }
1070
+ const intent = url.searchParams.get("intent") ?? "commit";
1071
+ if (intent === "commit-batch") {
1072
+ return handleCommitBatch(request);
1073
+ }
1074
+ if (intent === "context") {
1075
+ return handleContext(request);
1076
+ }
1077
+ if (intent === "suggestions") {
1078
+ return handleSuggestions(request);
1079
+ }
1080
+ if (intent === "rotate-catalogs") {
1081
+ return handleRotateCatalogs();
1082
+ }
1083
+ return handleCommit(request);
1084
+ }
1085
+ function createAngyHandler(options) {
1086
+ return async ({ request, url }) => handleTranslationRequest(request, url, options);
1087
+ }
1088
+ var handler = createAngyHandler();
1089
+ export {
1090
+ defineAngyConfig,
1091
+ handleTranslationRequest,
1092
+ handler
1093
+ };
1094
+ //# sourceMappingURL=server.js.map