glotfile 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/dist/server/cli.js +187 -45
- package/dist/server/server.js +244 -105
- package/dist/ui/assets/{index-amdKG3Do.js → index-BgJsS1zp.js} +95 -23
- package/dist/ui/index.html +1 -1
- package/package.json +2 -2
- package/skill/SKILL.md +1 -1
- package/skill/references/cli-reference.md +25 -8
package/dist/server/server.js
CHANGED
|
@@ -1215,6 +1215,116 @@ function runScan(projectRoot, opts, existing) {
|
|
|
1215
1215
|
// src/server/ai/context.ts
|
|
1216
1216
|
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
1217
1217
|
import { resolve as resolve3 } from "path";
|
|
1218
|
+
|
|
1219
|
+
// src/server/placeholders.ts
|
|
1220
|
+
var ICU_PLURAL_SELECT = /\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/;
|
|
1221
|
+
function withoutQuotedSpans(value) {
|
|
1222
|
+
if (!value.includes("'")) return value;
|
|
1223
|
+
let out = "";
|
|
1224
|
+
for (let i = 0; i < value.length; ) {
|
|
1225
|
+
if (value[i] === "'") {
|
|
1226
|
+
const next = value[i + 1];
|
|
1227
|
+
if (next === "'") {
|
|
1228
|
+
out += " ";
|
|
1229
|
+
i += 2;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (next === "{" || next === "}" || next === "#" || next === "|") {
|
|
1233
|
+
i += 2;
|
|
1234
|
+
while (i < value.length && value[i] !== "'") i++;
|
|
1235
|
+
i++;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
out += value[i];
|
|
1240
|
+
i++;
|
|
1241
|
+
}
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
function extractPlaceholders(value) {
|
|
1245
|
+
const scan = withoutQuotedSpans(value);
|
|
1246
|
+
const names = /* @__PURE__ */ new Set();
|
|
1247
|
+
for (const m of scan.matchAll(/\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,/g)) {
|
|
1248
|
+
names.add(m[1]);
|
|
1249
|
+
}
|
|
1250
|
+
if (!isIcuPluralOrSelect(scan)) {
|
|
1251
|
+
for (const m of scan.matchAll(/\{\s*(\w+)\s*\}/g)) names.add(m[1]);
|
|
1252
|
+
}
|
|
1253
|
+
return [...names];
|
|
1254
|
+
}
|
|
1255
|
+
function isIcuPluralOrSelect(value) {
|
|
1256
|
+
return ICU_PLURAL_SELECT.test(value);
|
|
1257
|
+
}
|
|
1258
|
+
function withLiterals(value, convertGap, emitLiteral) {
|
|
1259
|
+
let out = "";
|
|
1260
|
+
let gap = "";
|
|
1261
|
+
const flushGap = () => {
|
|
1262
|
+
if (gap) {
|
|
1263
|
+
out += convertGap(gap);
|
|
1264
|
+
gap = "";
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
for (let i = 0; i < value.length; ) {
|
|
1268
|
+
if (value[i] === "'") {
|
|
1269
|
+
const next = value[i + 1];
|
|
1270
|
+
if (next === "'") {
|
|
1271
|
+
gap += "'";
|
|
1272
|
+
i += 2;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
if (next === "{" || next === "}" || next === "#" || next === "|") {
|
|
1276
|
+
flushGap();
|
|
1277
|
+
let j = i + 1;
|
|
1278
|
+
while (j < value.length && value[j] !== "'") j++;
|
|
1279
|
+
out += emitLiteral(value.slice(i + 1, j));
|
|
1280
|
+
i = j < value.length ? j + 1 : j;
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
gap += value[i];
|
|
1285
|
+
i++;
|
|
1286
|
+
}
|
|
1287
|
+
flushGap();
|
|
1288
|
+
return out;
|
|
1289
|
+
}
|
|
1290
|
+
function extractLiterals2(value) {
|
|
1291
|
+
const out = [];
|
|
1292
|
+
withLiterals(value, () => "", (lit) => {
|
|
1293
|
+
out.push(lit);
|
|
1294
|
+
return "";
|
|
1295
|
+
});
|
|
1296
|
+
return out;
|
|
1297
|
+
}
|
|
1298
|
+
function quotedLiterals(value) {
|
|
1299
|
+
return extractLiterals2(value).map((content) => `'${content}'`);
|
|
1300
|
+
}
|
|
1301
|
+
function toLaravel(value) {
|
|
1302
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1303
|
+
return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
|
|
1304
|
+
}
|
|
1305
|
+
function toI18next(value) {
|
|
1306
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1307
|
+
return withLiterals(value, (gap) => gap.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}"), (lit) => lit);
|
|
1308
|
+
}
|
|
1309
|
+
function toRuby(value) {
|
|
1310
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1311
|
+
return withLiterals(value, (gap) => gap.replace(/(?<!%)\{(\w+)\}/g, "%{$1}"), (lit) => lit);
|
|
1312
|
+
}
|
|
1313
|
+
function placeholdersMatch(source, translation) {
|
|
1314
|
+
const a = extractPlaceholders(source).sort();
|
|
1315
|
+
const b = extractPlaceholders(translation).sort();
|
|
1316
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
1317
|
+
}
|
|
1318
|
+
function placeholdersSubset(source, translation) {
|
|
1319
|
+
const allowed = new Set(extractPlaceholders(source));
|
|
1320
|
+
return extractPlaceholders(translation).every((p) => allowed.has(p));
|
|
1321
|
+
}
|
|
1322
|
+
var COUNT_OPTIONAL = /* @__PURE__ */ new Set(["zero", "one", "two"]);
|
|
1323
|
+
function pluralFormPlaceholdersMatch(category, source, form) {
|
|
1324
|
+
return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// src/server/ai/context.ts
|
|
1218
1328
|
var MAX_CONTEXT_LENGTH = 500;
|
|
1219
1329
|
var SNIPPET_WINDOW = 15;
|
|
1220
1330
|
var MAX_SNIPPETS = 3;
|
|
@@ -1300,7 +1410,8 @@ function buildContextSystemPrompt() {
|
|
|
1300
1410
|
"- Do NOT restate the source string itself.",
|
|
1301
1411
|
"- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
|
|
1302
1412
|
"- Keep it under 500 characters.",
|
|
1303
|
-
"- If no code snippets are available, infer from the key path and source value."
|
|
1413
|
+
"- If no code snippets are available, infer from the key path and source value.",
|
|
1414
|
+
"- Tokens: a source may contain interpolation placeholders ({name}, {{name}}, :name, %s) and ICU-apostrophe-quoted LITERAL tokens (e.g. '{{visitor}}', '{name}') that the app fills at runtime. Any provided `literals` are literal tokens, NOT plain placeholders. If you reference a token, write it EXACTLY as it appears in the source \u2014 keep apostrophe-quoted literals quoted, and never relabel a quoted literal as a placeholder or strip its quotes. The translation engine needs these to survive verbatim, so a note may simply remind translators to reproduce them exactly."
|
|
1304
1415
|
].join("\n");
|
|
1305
1416
|
}
|
|
1306
1417
|
function buildContextBatchPrompt(reqs) {
|
|
@@ -1312,7 +1423,14 @@ function buildContextBatchPrompt(reqs) {
|
|
|
1312
1423
|
${s.lines}
|
|
1313
1424
|
\`\`\``;
|
|
1314
1425
|
}).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
|
|
1315
|
-
|
|
1426
|
+
const literals = quotedLiterals(r.source);
|
|
1427
|
+
return {
|
|
1428
|
+
id: r.id,
|
|
1429
|
+
key: r.key,
|
|
1430
|
+
source: r.source,
|
|
1431
|
+
...literals.length ? { literals } : {},
|
|
1432
|
+
codeSnippets: snippetText
|
|
1433
|
+
};
|
|
1316
1434
|
});
|
|
1317
1435
|
return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
|
|
1318
1436
|
}
|
|
@@ -1476,71 +1594,6 @@ function computeStats(state) {
|
|
|
1476
1594
|
};
|
|
1477
1595
|
}
|
|
1478
1596
|
|
|
1479
|
-
// src/server/placeholders.ts
|
|
1480
|
-
var ICU_PLURAL_SELECT = /\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/;
|
|
1481
|
-
function withoutQuotedSpans(value) {
|
|
1482
|
-
if (!value.includes("'")) return value;
|
|
1483
|
-
let out = "";
|
|
1484
|
-
for (let i = 0; i < value.length; ) {
|
|
1485
|
-
if (value[i] === "'") {
|
|
1486
|
-
const next = value[i + 1];
|
|
1487
|
-
if (next === "'") {
|
|
1488
|
-
out += " ";
|
|
1489
|
-
i += 2;
|
|
1490
|
-
continue;
|
|
1491
|
-
}
|
|
1492
|
-
if (next === "{" || next === "}" || next === "#" || next === "|") {
|
|
1493
|
-
i += 2;
|
|
1494
|
-
while (i < value.length && value[i] !== "'") i++;
|
|
1495
|
-
i++;
|
|
1496
|
-
continue;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
out += value[i];
|
|
1500
|
-
i++;
|
|
1501
|
-
}
|
|
1502
|
-
return out;
|
|
1503
|
-
}
|
|
1504
|
-
function extractPlaceholders(value) {
|
|
1505
|
-
const scan = withoutQuotedSpans(value);
|
|
1506
|
-
const names = /* @__PURE__ */ new Set();
|
|
1507
|
-
for (const m of scan.matchAll(/\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,/g)) {
|
|
1508
|
-
names.add(m[1]);
|
|
1509
|
-
}
|
|
1510
|
-
if (!isIcuPluralOrSelect(scan)) {
|
|
1511
|
-
for (const m of scan.matchAll(/\{\s*(\w+)\s*\}/g)) names.add(m[1]);
|
|
1512
|
-
}
|
|
1513
|
-
return [...names];
|
|
1514
|
-
}
|
|
1515
|
-
function isIcuPluralOrSelect(value) {
|
|
1516
|
-
return ICU_PLURAL_SELECT.test(value);
|
|
1517
|
-
}
|
|
1518
|
-
function toLaravel(value) {
|
|
1519
|
-
if (isIcuPluralOrSelect(value)) return value;
|
|
1520
|
-
return value.replace(/\{(\w+)\}/g, ":$1");
|
|
1521
|
-
}
|
|
1522
|
-
function toI18next(value) {
|
|
1523
|
-
if (isIcuPluralOrSelect(value)) return value;
|
|
1524
|
-
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
1525
|
-
}
|
|
1526
|
-
function toRuby(value) {
|
|
1527
|
-
if (isIcuPluralOrSelect(value)) return value;
|
|
1528
|
-
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
1529
|
-
}
|
|
1530
|
-
function placeholdersMatch(source, translation) {
|
|
1531
|
-
const a = extractPlaceholders(source).sort();
|
|
1532
|
-
const b = extractPlaceholders(translation).sort();
|
|
1533
|
-
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
1534
|
-
}
|
|
1535
|
-
function placeholdersSubset(source, translation) {
|
|
1536
|
-
const allowed = new Set(extractPlaceholders(source));
|
|
1537
|
-
return extractPlaceholders(translation).every((p) => allowed.has(p));
|
|
1538
|
-
}
|
|
1539
|
-
var COUNT_OPTIONAL = /* @__PURE__ */ new Set(["zero", "one", "two"]);
|
|
1540
|
-
function pluralFormPlaceholdersMatch(category, source, form) {
|
|
1541
|
-
return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
1597
|
// src/server/glossary.ts
|
|
1545
1598
|
function contains(haystack, needle, caseSensitive) {
|
|
1546
1599
|
return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
|
|
@@ -2266,6 +2319,20 @@ var laravelPhp = {
|
|
|
2266
2319
|
message: "laravel-php cannot represent ICU plural/select; written unconverted"
|
|
2267
2320
|
});
|
|
2268
2321
|
}
|
|
2322
|
+
if (raw) {
|
|
2323
|
+
const names = new Set(extractPlaceholders(raw));
|
|
2324
|
+
for (const m of raw.matchAll(/:([a-zA-Z][a-zA-Z0-9_]*)/g)) {
|
|
2325
|
+
if (names.has(m[1])) {
|
|
2326
|
+
warnings.push({
|
|
2327
|
+
code: "lossy-literal",
|
|
2328
|
+
key,
|
|
2329
|
+
locale,
|
|
2330
|
+
message: `literal ":${m[1]}" collides with the :${m[1]} placeholder; Laravel will interpolate both`
|
|
2331
|
+
});
|
|
2332
|
+
break;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2269
2336
|
(tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
|
|
2270
2337
|
}
|
|
2271
2338
|
}
|
|
@@ -2346,6 +2413,9 @@ var i18nextJson = {
|
|
|
2346
2413
|
if (raw && isIcuPluralOrSelect(raw)) {
|
|
2347
2414
|
warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
|
|
2348
2415
|
}
|
|
2416
|
+
if (raw && extractLiterals2(raw).some((lit) => /\{\{\w+\}\}/.test(lit))) {
|
|
2417
|
+
warnings.push({ code: "lossy-literal", key, locale, message: "i18next will interpolate a literal containing {{\u2026}}; i18next has no escape for it" });
|
|
2418
|
+
}
|
|
2349
2419
|
if (setNested(obj, segments, toI18next(raw))) collided.add(key);
|
|
2350
2420
|
}
|
|
2351
2421
|
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
|
|
@@ -2362,7 +2432,9 @@ function poString(s) {
|
|
|
2362
2432
|
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
|
|
2363
2433
|
}
|
|
2364
2434
|
function toGettext(body, arg) {
|
|
2365
|
-
|
|
2435
|
+
const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
|
|
2436
|
+
if (isIcuPluralOrSelect(body)) return gap(body);
|
|
2437
|
+
return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
2366
2438
|
}
|
|
2367
2439
|
var DEFAULT_LOCALE_CASE4 = "lower-hyphen";
|
|
2368
2440
|
var gettextPo = {
|
|
@@ -2422,8 +2494,10 @@ var gettextPo = {
|
|
|
2422
2494
|
blocks.push(
|
|
2423
2495
|
[
|
|
2424
2496
|
`msgctxt ${poString(key)}`,
|
|
2425
|
-
|
|
2426
|
-
|
|
2497
|
+
// Scalar keys carry no count arg; toGettext still strips literal
|
|
2498
|
+
// spans and escapes a literal % to %%.
|
|
2499
|
+
`msgid ${poString(toGettext(src?.value ?? "", ""))}`,
|
|
2500
|
+
`msgstr ${poString(toGettext(lv?.value ?? "", ""))}`
|
|
2427
2501
|
].join("\n")
|
|
2428
2502
|
);
|
|
2429
2503
|
}
|
|
@@ -2447,7 +2521,9 @@ function xmlEscape(s) {
|
|
|
2447
2521
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2448
2522
|
}
|
|
2449
2523
|
function toApple(body, arg) {
|
|
2450
|
-
|
|
2524
|
+
const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
|
|
2525
|
+
if (isIcuPluralOrSelect(body)) return gap(body);
|
|
2526
|
+
return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
2451
2527
|
}
|
|
2452
2528
|
var DEFAULT_LOCALE_CASE5 = "lower-hyphen";
|
|
2453
2529
|
var appleStringsdict = {
|
|
@@ -2511,6 +2587,11 @@ var DEFAULT_LOCALE_CASE6 = "bcp47-hyphen";
|
|
|
2511
2587
|
function escape(s) {
|
|
2512
2588
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
|
|
2513
2589
|
}
|
|
2590
|
+
function toApple2(value) {
|
|
2591
|
+
const gap = (text) => text.replace(/%/g, "%%");
|
|
2592
|
+
if (isIcuPluralOrSelect(value)) return gap(value);
|
|
2593
|
+
return withLiterals(value, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
2594
|
+
}
|
|
2514
2595
|
var appleStrings = {
|
|
2515
2596
|
name: "apple-strings",
|
|
2516
2597
|
capabilities: {
|
|
@@ -2536,7 +2617,7 @@ var appleStrings = {
|
|
|
2536
2617
|
if (entry.plural) continue;
|
|
2537
2618
|
const value = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2538
2619
|
if (value === null) continue;
|
|
2539
|
-
lines.push(`"${escape(key)}" = "${escape(value)}";`);
|
|
2620
|
+
lines.push(`"${escape(key)}" = "${escape(toApple2(value))}";`);
|
|
2540
2621
|
}
|
|
2541
2622
|
const contents = lines.length ? lines.join("\n") + "\n" : "";
|
|
2542
2623
|
files.push({
|
|
@@ -2550,6 +2631,10 @@ var appleStrings = {
|
|
|
2550
2631
|
|
|
2551
2632
|
// src/server/adapters/vue-i18n-json.ts
|
|
2552
2633
|
var DEFAULT_LOCALE_CASE7 = "lower-hyphen";
|
|
2634
|
+
function toVueI18n(value) {
|
|
2635
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
2636
|
+
return withLiterals(value, (gap) => gap, (content) => `{'${content}'}`);
|
|
2637
|
+
}
|
|
2553
2638
|
var vueI18nJson = {
|
|
2554
2639
|
name: "vue-i18n-json",
|
|
2555
2640
|
capabilities: {
|
|
@@ -2575,7 +2660,7 @@ var vueI18nJson = {
|
|
|
2575
2660
|
if (entry.plural) {
|
|
2576
2661
|
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2577
2662
|
if (!forms) continue;
|
|
2578
|
-
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
|
|
2663
|
+
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map(toVueI18n);
|
|
2579
2664
|
flat[key] = parts.join(" | ");
|
|
2580
2665
|
} else {
|
|
2581
2666
|
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
@@ -2588,7 +2673,7 @@ var vueI18nJson = {
|
|
|
2588
2673
|
message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
|
|
2589
2674
|
});
|
|
2590
2675
|
}
|
|
2591
|
-
flat[key] = raw;
|
|
2676
|
+
flat[key] = toVueI18n(raw);
|
|
2592
2677
|
}
|
|
2593
2678
|
}
|
|
2594
2679
|
let payload = flat;
|
|
@@ -2623,27 +2708,30 @@ function angularXMeta(placeholders, name) {
|
|
|
2623
2708
|
return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
|
|
2624
2709
|
}
|
|
2625
2710
|
function renderInterpolations(text, ids, placeholders) {
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
const
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
id
|
|
2640
|
-
|
|
2711
|
+
const convertGap = (gap) => {
|
|
2712
|
+
let out = "";
|
|
2713
|
+
let last = 0;
|
|
2714
|
+
for (const m of gap.matchAll(/\{(\w+)\}/g)) {
|
|
2715
|
+
const name = m[1];
|
|
2716
|
+
out += xmlEscape2(gap.slice(last, m.index));
|
|
2717
|
+
const meta = angularXMeta(placeholders, name);
|
|
2718
|
+
if (meta) {
|
|
2719
|
+
const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
|
|
2720
|
+
const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
|
|
2721
|
+
out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
|
|
2722
|
+
} else {
|
|
2723
|
+
let id = ids.get(name);
|
|
2724
|
+
if (id === void 0) {
|
|
2725
|
+
id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
|
|
2726
|
+
ids.set(name, id);
|
|
2727
|
+
}
|
|
2728
|
+
out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
|
|
2641
2729
|
}
|
|
2642
|
-
|
|
2730
|
+
last = m.index + m[0].length;
|
|
2643
2731
|
}
|
|
2644
|
-
|
|
2645
|
-
}
|
|
2646
|
-
return
|
|
2732
|
+
return out + xmlEscape2(gap.slice(last));
|
|
2733
|
+
};
|
|
2734
|
+
return withLiterals(text, convertGap, (lit) => xmlEscape2(`'${lit}'`));
|
|
2647
2735
|
}
|
|
2648
2736
|
function renderPluralIcu(forms, ids, placeholders) {
|
|
2649
2737
|
const cats = [
|
|
@@ -2885,10 +2973,11 @@ function buildSystemPrompt(hasPluralItems) {
|
|
|
2885
2973
|
"You are a professional software localization engine for a UI string catalog.",
|
|
2886
2974
|
"Your goal: translate each source UI string into its target locale accurately and idiomatically, as a native speaker would phrase it in a real app interface.",
|
|
2887
2975
|
"",
|
|
2888
|
-
"You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
|
|
2976
|
+
"You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, an optional `literals` list, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
|
|
2889
2977
|
"",
|
|
2890
2978
|
"Hard rules:",
|
|
2891
2979
|
"- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
|
|
2980
|
+
"- Reproduce every entry of the item's `literals` array EXACTLY, including its surrounding apostrophes (e.g. '{{visitor}}', '{name}'). These are app-managed literal tokens, not prose: translate the words around them, but never translate, rename, unquote, or drop them. The apostrophes are required \u2014 a result with bare {{visitor}} instead of '{{visitor}}' is wrong.",
|
|
2892
2981
|
"- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
|
|
2893
2982
|
"- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
|
|
2894
2983
|
"- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
|
|
@@ -2917,6 +3006,7 @@ function buildBatchPrompt(reqs) {
|
|
|
2917
3006
|
// Wrap in braces so the model sees "{site}" not "site" — makes the visual
|
|
2918
3007
|
// connection to the source string obvious and reduces rename errors.
|
|
2919
3008
|
placeholders: r.placeholders.map((p) => `{${p}}`),
|
|
3009
|
+
...r.literals?.length ? { literals: r.literals } : {},
|
|
2920
3010
|
...r.glossary?.length ? { glossary: r.glossary } : {},
|
|
2921
3011
|
hasScreenshot: r.image !== void 0
|
|
2922
3012
|
};
|
|
@@ -3735,6 +3825,7 @@ function selectRequests(state, opts) {
|
|
|
3735
3825
|
const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
|
|
3736
3826
|
if (opts.onlyMissing && complete) continue;
|
|
3737
3827
|
const glossary = relevantGlossary(other, locale, state.glossary);
|
|
3828
|
+
const literals = quotedLiterals(other);
|
|
3738
3829
|
reqs.push({
|
|
3739
3830
|
id: String(id++),
|
|
3740
3831
|
key,
|
|
@@ -3744,6 +3835,7 @@ function selectRequests(state, opts) {
|
|
|
3744
3835
|
targetLocale: locale,
|
|
3745
3836
|
maxLength: entry.maxLength,
|
|
3746
3837
|
placeholders: extractPlaceholders(other),
|
|
3838
|
+
...literals.length ? { literals } : {},
|
|
3747
3839
|
...glossary.length ? { glossary } : {},
|
|
3748
3840
|
plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
|
|
3749
3841
|
});
|
|
@@ -3756,6 +3848,7 @@ function selectRequests(state, opts) {
|
|
|
3756
3848
|
const existing = entry.values[locale]?.value;
|
|
3757
3849
|
if (opts.onlyMissing && existing) continue;
|
|
3758
3850
|
const glossary = relevantGlossary(source, locale, state.glossary);
|
|
3851
|
+
const literals = quotedLiterals(source);
|
|
3759
3852
|
reqs.push({
|
|
3760
3853
|
id: String(id++),
|
|
3761
3854
|
key,
|
|
@@ -3765,6 +3858,7 @@ function selectRequests(state, opts) {
|
|
|
3765
3858
|
targetLocale: locale,
|
|
3766
3859
|
maxLength: entry.maxLength,
|
|
3767
3860
|
placeholders: extractPlaceholders(source),
|
|
3861
|
+
...literals.length ? { literals } : {},
|
|
3768
3862
|
...glossary.length ? { glossary } : {}
|
|
3769
3863
|
});
|
|
3770
3864
|
}
|
|
@@ -4579,6 +4673,9 @@ function flattenObject(value, prefix, warnings) {
|
|
|
4579
4673
|
|
|
4580
4674
|
// src/server/import/parsers/vue-i18n-json.ts
|
|
4581
4675
|
var LOCALE_RE2 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
4676
|
+
function fromVueI18n(value) {
|
|
4677
|
+
return value.replace(/\{'([^']*)'\}/g, "'$1'");
|
|
4678
|
+
}
|
|
4582
4679
|
var vueI18nJson2 = {
|
|
4583
4680
|
name: "vue-i18n-json",
|
|
4584
4681
|
parse(localeRoot, opts) {
|
|
@@ -4599,7 +4696,7 @@ var vueI18nJson2 = {
|
|
|
4599
4696
|
}
|
|
4600
4697
|
if (!locales.includes(locale)) locales.push(locale);
|
|
4601
4698
|
for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
|
|
4602
|
-
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
4699
|
+
(keys[key] ??= { values: {} }).values[locale] = fromVueI18n(value);
|
|
4603
4700
|
}
|
|
4604
4701
|
}
|
|
4605
4702
|
return { locales, keys, warnings };
|
|
@@ -4612,8 +4709,14 @@ import { join as join8, relative as relative2 } from "path";
|
|
|
4612
4709
|
import { execFileSync } from "child_process";
|
|
4613
4710
|
|
|
4614
4711
|
// src/server/import/placeholders.ts
|
|
4712
|
+
function markBareBracesLiteral(value) {
|
|
4713
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
|
|
4714
|
+
}
|
|
4615
4715
|
function laravelToCanonical(value) {
|
|
4616
|
-
return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
|
|
4716
|
+
return markBareBracesLiteral(value).replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
|
|
4717
|
+
}
|
|
4718
|
+
function railsToCanonical(value) {
|
|
4719
|
+
return markBareBracesLiteral(value).replace(/%\{(\w+)\}/g, "{$1}");
|
|
4617
4720
|
}
|
|
4618
4721
|
|
|
4619
4722
|
// src/server/import/parsers/laravel-php.ts
|
|
@@ -4755,6 +4858,9 @@ function localeFromLproj(dir) {
|
|
|
4755
4858
|
if (!m) return null;
|
|
4756
4859
|
return LOCALE_RE4.test(m[1]) ? m[1] : null;
|
|
4757
4860
|
}
|
|
4861
|
+
function printfToCanonical(s) {
|
|
4862
|
+
return s.replace(/%%/g, "%");
|
|
4863
|
+
}
|
|
4758
4864
|
function unescape(body) {
|
|
4759
4865
|
return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
|
|
4760
4866
|
const c = esc[0];
|
|
@@ -4874,7 +4980,7 @@ var appleStrings2 = {
|
|
|
4874
4980
|
warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
|
|
4875
4981
|
}
|
|
4876
4982
|
for (const { key, value } of parseStrings(text, file, warnings)) {
|
|
4877
|
-
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
4983
|
+
(keys[key] ??= { values: {} }).values[locale] = printfToCanonical(value);
|
|
4878
4984
|
}
|
|
4879
4985
|
}
|
|
4880
4986
|
return { locales, keys, warnings };
|
|
@@ -5006,6 +5112,24 @@ function unescapePo(s) {
|
|
|
5006
5112
|
(_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
|
|
5007
5113
|
);
|
|
5008
5114
|
}
|
|
5115
|
+
function printfToCanonical2(s, arg) {
|
|
5116
|
+
let out = "";
|
|
5117
|
+
for (let i = 0; i < s.length; ) {
|
|
5118
|
+
if (s[i] === "%" && s[i + 1] === "%") {
|
|
5119
|
+
out += "%";
|
|
5120
|
+
i += 2;
|
|
5121
|
+
continue;
|
|
5122
|
+
}
|
|
5123
|
+
if (arg && s[i] === "%" && s[i + 1] === "d") {
|
|
5124
|
+
out += `{${arg}}`;
|
|
5125
|
+
i += 2;
|
|
5126
|
+
continue;
|
|
5127
|
+
}
|
|
5128
|
+
out += s[i];
|
|
5129
|
+
i++;
|
|
5130
|
+
}
|
|
5131
|
+
return out;
|
|
5132
|
+
}
|
|
5009
5133
|
function parseEntries(text) {
|
|
5010
5134
|
const entries = [];
|
|
5011
5135
|
let cur = null;
|
|
@@ -5126,13 +5250,13 @@ var gettextPo2 = {
|
|
|
5126
5250
|
);
|
|
5127
5251
|
continue;
|
|
5128
5252
|
}
|
|
5129
|
-
forms[cat] = body
|
|
5253
|
+
forms[cat] = printfToCanonical2(body, "count");
|
|
5130
5254
|
}
|
|
5131
5255
|
if (!forms.other) continue;
|
|
5132
5256
|
(keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
|
|
5133
5257
|
} else {
|
|
5134
5258
|
if (!entry.msgstr) continue;
|
|
5135
|
-
(keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
|
|
5259
|
+
(keys[key] ??= { values: {} }).values[locale] = printfToCanonical2(entry.msgstr, "");
|
|
5136
5260
|
}
|
|
5137
5261
|
}
|
|
5138
5262
|
}
|
|
@@ -5233,9 +5357,6 @@ import { readdirSync as readdirSync11, readFileSync as readFileSync18 } from "fs
|
|
|
5233
5357
|
import { join as join14 } from "path";
|
|
5234
5358
|
var LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
|
|
5235
5359
|
var CATEGORY_SET = new Set(PLURAL_CATEGORIES);
|
|
5236
|
-
function fromRuby(value) {
|
|
5237
|
-
return value.replace(/%\{(\w+)\}/g, "{$1}");
|
|
5238
|
-
}
|
|
5239
5360
|
function makeNode() {
|
|
5240
5361
|
return /* @__PURE__ */ Object.create(null);
|
|
5241
5362
|
}
|
|
@@ -5421,7 +5542,7 @@ function synthesizeIcu(forms, file, key, warnings) {
|
|
|
5421
5542
|
`rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
|
|
5422
5543
|
);
|
|
5423
5544
|
}
|
|
5424
|
-
parts.push(`${cat} {${
|
|
5545
|
+
parts.push(`${cat} {${railsToCanonical(body)}}`);
|
|
5425
5546
|
}
|
|
5426
5547
|
return `{count, plural, ${parts.join(" ")}}`;
|
|
5427
5548
|
}
|
|
@@ -5439,7 +5560,7 @@ var railsYaml2 = {
|
|
|
5439
5560
|
for (const [k, v] of Object.entries(node)) {
|
|
5440
5561
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
5441
5562
|
if (typeof v === "string") {
|
|
5442
|
-
if (v !== "") addValue(key, locale,
|
|
5563
|
+
if (v !== "") addValue(key, locale, railsToCanonical(v));
|
|
5443
5564
|
continue;
|
|
5444
5565
|
}
|
|
5445
5566
|
const forms = asPluralForms(v);
|
|
@@ -5566,6 +5687,24 @@ function parsePlistDict(xml) {
|
|
|
5566
5687
|
return tag.selfClosing ? {} : readDict();
|
|
5567
5688
|
}
|
|
5568
5689
|
var VAR_RE = /%#@([^@]*)@/g;
|
|
5690
|
+
function printfToCanonical3(body, token, arg) {
|
|
5691
|
+
let out = "";
|
|
5692
|
+
for (let i = 0; i < body.length; ) {
|
|
5693
|
+
if (body[i] === "%" && body[i + 1] === "%") {
|
|
5694
|
+
out += "%";
|
|
5695
|
+
i += 2;
|
|
5696
|
+
continue;
|
|
5697
|
+
}
|
|
5698
|
+
if (body.startsWith(token, i)) {
|
|
5699
|
+
out += `{${arg}}`;
|
|
5700
|
+
i += token.length;
|
|
5701
|
+
continue;
|
|
5702
|
+
}
|
|
5703
|
+
out += body[i];
|
|
5704
|
+
i++;
|
|
5705
|
+
}
|
|
5706
|
+
return out;
|
|
5707
|
+
}
|
|
5569
5708
|
function entryToIcu(key, entry, file, warnings) {
|
|
5570
5709
|
const warn = (msg) => {
|
|
5571
5710
|
warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
|
|
@@ -5594,7 +5733,7 @@ function entryToIcu(key, entry, file, warnings) {
|
|
|
5594
5733
|
for (const cat of PLURAL_CATEGORIES) {
|
|
5595
5734
|
const body = varDict[cat];
|
|
5596
5735
|
if (typeof body !== "string") continue;
|
|
5597
|
-
forms[cat] = prefix + body
|
|
5736
|
+
forms[cat] = prefix + printfToCanonical3(body, token, arg) + suffix;
|
|
5598
5737
|
}
|
|
5599
5738
|
if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
|
|
5600
5739
|
return formsToIcu(arg, forms);
|