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/README.md
CHANGED
|
@@ -6,9 +6,11 @@ web UI, and you export to whatever locale formats your apps consume — no SaaS,
|
|
|
6
6
|
no hosted database, nothing leaves your machine except the AI calls you choose
|
|
7
7
|
to make.
|
|
8
8
|
|
|
9
|
+
**[glotfile.dev](https://glotfile.dev)** · [Docs](https://glotfile.dev/docs/) · [npm](https://www.npmjs.com/package/glotfile)
|
|
10
|
+
|
|
9
11
|
- **One source of truth** — every string and translation lives in `glotfile.json`, committed alongside your code. Versioning, review, and rollback come from git. For large catalogs, run `glotfile split` to store the catalog as a `glotfile/` directory with one file per locale — so a one-locale change is a one-file `git diff` instead of a multi-megabyte one. The in-app experience is identical.
|
|
10
12
|
- **Local web UI** — run one command, edit in the browser, changes save straight back to the file.
|
|
11
|
-
- **AI translation** — fill in missing languages with Anthropic, OpenAI, AWS Bedrock, OpenRouter, or a local Ollama model, using per-key context, a glossary, and screenshots.
|
|
13
|
+
- **AI translation** — fill in missing languages with Anthropic, OpenAI, AWS Bedrock, OpenRouter, Claude Code, or a local Ollama model, using per-key context, a glossary, and screenshots.
|
|
12
14
|
- **Export anywhere** — generate Flutter ARB, Laravel PHP, i18next JSON, gettext `.po`, and Apple `.stringsdict` from the same source.
|
|
13
15
|
|
|
14
16
|
---
|
|
@@ -19,21 +21,19 @@ to make.
|
|
|
19
21
|
|
|
20
22
|
## Getting started
|
|
21
23
|
|
|
22
|
-
Glotfile
|
|
23
|
-
not yet published to npm, so for now run it from a checkout of this repo:
|
|
24
|
+
Glotfile runs with no install via `npx`. It's pre-1.0, so expect rough edges:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
|
-
|
|
27
|
-
npm run build # builds the UI + server into dist/
|
|
28
|
-
node bin/glotfile.js # same as the `glotfile` command
|
|
27
|
+
npx glotfile
|
|
29
28
|
```
|
|
30
29
|
|
|
31
30
|
That starts a local server bound to `127.0.0.1`, opens your browser, and — if
|
|
32
31
|
there's no `glotfile.json` in the current directory yet — starts from sensible
|
|
33
32
|
defaults and writes the file as soon as you make your first edit.
|
|
34
33
|
|
|
35
|
-
> Working on Glotfile itself?
|
|
36
|
-
> the
|
|
34
|
+
> Working on Glotfile itself? Clone the repo, then `npm install` and `npm run dev`
|
|
35
|
+
> to run the Vite UI with hot-reload alongside the server. `npm run build` followed
|
|
36
|
+
> by `node bin/glotfile.js` runs the built CLI exactly like the published `glotfile`.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
@@ -181,11 +181,11 @@ in the project directory. For example:
|
|
|
181
181
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
(Amazon Nova, Claude, and Meta Llama), OpenRouter, and Ollama
|
|
186
|
-
no API key needed). For the full setup of
|
|
184
|
+
Six providers are supported — Anthropic (default), OpenAI, AWS Bedrock
|
|
185
|
+
(Amazon Nova, Claude, and Meta Llama), OpenRouter, Claude Code, and Ollama
|
|
186
|
+
(local, no API key needed). For the full setup of
|
|
187
187
|
each — required env vars, model ids, regions, and the optional SDKs to install — see
|
|
188
|
-
**[docs/ai-
|
|
188
|
+
**[AI Providers](https://glotfile.dev/docs/ai-translation/ai-providers/)**.
|
|
189
189
|
|
|
190
190
|
What the translator does for you:
|
|
191
191
|
|
package/dist/server/cli.js
CHANGED
|
@@ -959,17 +959,60 @@ function extractPlaceholders(value) {
|
|
|
959
959
|
function isIcuPluralOrSelect(value) {
|
|
960
960
|
return ICU_PLURAL_SELECT.test(value);
|
|
961
961
|
}
|
|
962
|
+
function withLiterals(value, convertGap, emitLiteral) {
|
|
963
|
+
let out = "";
|
|
964
|
+
let gap = "";
|
|
965
|
+
const flushGap = () => {
|
|
966
|
+
if (gap) {
|
|
967
|
+
out += convertGap(gap);
|
|
968
|
+
gap = "";
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
for (let i = 0; i < value.length; ) {
|
|
972
|
+
if (value[i] === "'") {
|
|
973
|
+
const next = value[i + 1];
|
|
974
|
+
if (next === "'") {
|
|
975
|
+
gap += "'";
|
|
976
|
+
i += 2;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (next === "{" || next === "}" || next === "#" || next === "|") {
|
|
980
|
+
flushGap();
|
|
981
|
+
let j = i + 1;
|
|
982
|
+
while (j < value.length && value[j] !== "'") j++;
|
|
983
|
+
out += emitLiteral(value.slice(i + 1, j));
|
|
984
|
+
i = j < value.length ? j + 1 : j;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
gap += value[i];
|
|
989
|
+
i++;
|
|
990
|
+
}
|
|
991
|
+
flushGap();
|
|
992
|
+
return out;
|
|
993
|
+
}
|
|
994
|
+
function extractLiterals(value) {
|
|
995
|
+
const out = [];
|
|
996
|
+
withLiterals(value, () => "", (lit) => {
|
|
997
|
+
out.push(lit);
|
|
998
|
+
return "";
|
|
999
|
+
});
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
function quotedLiterals(value) {
|
|
1003
|
+
return extractLiterals(value).map((content) => `'${content}'`);
|
|
1004
|
+
}
|
|
962
1005
|
function toLaravel(value) {
|
|
963
1006
|
if (isIcuPluralOrSelect(value)) return value;
|
|
964
|
-
return value.replace(/\{(\w+)\}/g, ":$1");
|
|
1007
|
+
return withLiterals(value, (gap) => gap.replace(/\{(\w+)\}/g, ":$1"), (lit) => lit);
|
|
965
1008
|
}
|
|
966
1009
|
function toI18next(value) {
|
|
967
1010
|
if (isIcuPluralOrSelect(value)) return value;
|
|
968
|
-
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
1011
|
+
return withLiterals(value, (gap) => gap.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}"), (lit) => lit);
|
|
969
1012
|
}
|
|
970
1013
|
function toRuby(value) {
|
|
971
1014
|
if (isIcuPluralOrSelect(value)) return value;
|
|
972
|
-
return value.replace(/(?<!%)\{(\w+)\}/g, "%{$1}");
|
|
1015
|
+
return withLiterals(value, (gap) => gap.replace(/(?<!%)\{(\w+)\}/g, "%{$1}"), (lit) => lit);
|
|
973
1016
|
}
|
|
974
1017
|
function placeholdersMatch(source, translation) {
|
|
975
1018
|
const a = extractPlaceholders(source).sort();
|
|
@@ -1186,6 +1229,20 @@ var init_laravel_php = __esm({
|
|
|
1186
1229
|
message: "laravel-php cannot represent ICU plural/select; written unconverted"
|
|
1187
1230
|
});
|
|
1188
1231
|
}
|
|
1232
|
+
if (raw) {
|
|
1233
|
+
const names = new Set(extractPlaceholders(raw));
|
|
1234
|
+
for (const m of raw.matchAll(/:([a-zA-Z][a-zA-Z0-9_]*)/g)) {
|
|
1235
|
+
if (names.has(m[1])) {
|
|
1236
|
+
warnings.push({
|
|
1237
|
+
code: "lossy-literal",
|
|
1238
|
+
key,
|
|
1239
|
+
locale,
|
|
1240
|
+
message: `literal ":${m[1]}" collides with the :${m[1]} placeholder; Laravel will interpolate both`
|
|
1241
|
+
});
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1189
1246
|
(tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
|
|
1190
1247
|
}
|
|
1191
1248
|
}
|
|
@@ -1277,6 +1334,9 @@ var init_i18next_json = __esm({
|
|
|
1277
1334
|
if (raw && isIcuPluralOrSelect(raw)) {
|
|
1278
1335
|
warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
|
|
1279
1336
|
}
|
|
1337
|
+
if (raw && extractLiterals(raw).some((lit) => /\{\{\w+\}\}/.test(lit))) {
|
|
1338
|
+
warnings.push({ code: "lossy-literal", key, locale, message: "i18next will interpolate a literal containing {{\u2026}}; i18next has no escape for it" });
|
|
1339
|
+
}
|
|
1280
1340
|
if (setNested(obj, segments, toI18next(raw))) collided.add(key);
|
|
1281
1341
|
}
|
|
1282
1342
|
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
|
|
@@ -1295,7 +1355,9 @@ function poString(s) {
|
|
|
1295
1355
|
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
|
|
1296
1356
|
}
|
|
1297
1357
|
function toGettext(body, arg) {
|
|
1298
|
-
|
|
1358
|
+
const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
|
|
1359
|
+
if (isIcuPluralOrSelect(body)) return gap(body);
|
|
1360
|
+
return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
1299
1361
|
}
|
|
1300
1362
|
var DEFAULT_LOCALE_CASE4, gettextPo;
|
|
1301
1363
|
var init_gettext_po = __esm({
|
|
@@ -1304,6 +1366,7 @@ var init_gettext_po = __esm({
|
|
|
1304
1366
|
init_adapters();
|
|
1305
1367
|
init_options();
|
|
1306
1368
|
init_plurals();
|
|
1369
|
+
init_placeholders();
|
|
1307
1370
|
DEFAULT_LOCALE_CASE4 = "lower-hyphen";
|
|
1308
1371
|
gettextPo = {
|
|
1309
1372
|
name: "gettext-po",
|
|
@@ -1362,8 +1425,10 @@ var init_gettext_po = __esm({
|
|
|
1362
1425
|
blocks.push(
|
|
1363
1426
|
[
|
|
1364
1427
|
`msgctxt ${poString(key)}`,
|
|
1365
|
-
|
|
1366
|
-
|
|
1428
|
+
// Scalar keys carry no count arg; toGettext still strips literal
|
|
1429
|
+
// spans and escapes a literal % to %%.
|
|
1430
|
+
`msgid ${poString(toGettext(src?.value ?? "", ""))}`,
|
|
1431
|
+
`msgstr ${poString(toGettext(lv?.value ?? "", ""))}`
|
|
1367
1432
|
].join("\n")
|
|
1368
1433
|
);
|
|
1369
1434
|
}
|
|
@@ -1389,7 +1454,9 @@ function xmlEscape(s) {
|
|
|
1389
1454
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1390
1455
|
}
|
|
1391
1456
|
function toApple(body, arg) {
|
|
1392
|
-
|
|
1457
|
+
const gap = (text) => text.replace(/%/g, "%%").split(`{${arg}}`).join("%d");
|
|
1458
|
+
if (isIcuPluralOrSelect(body)) return gap(body);
|
|
1459
|
+
return withLiterals(body, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
1393
1460
|
}
|
|
1394
1461
|
var DEFAULT_LOCALE_CASE5, appleStringsdict;
|
|
1395
1462
|
var init_apple_stringsdict = __esm({
|
|
@@ -1398,6 +1465,7 @@ var init_apple_stringsdict = __esm({
|
|
|
1398
1465
|
init_adapters();
|
|
1399
1466
|
init_options();
|
|
1400
1467
|
init_schema();
|
|
1468
|
+
init_placeholders();
|
|
1401
1469
|
DEFAULT_LOCALE_CASE5 = "lower-hyphen";
|
|
1402
1470
|
appleStringsdict = {
|
|
1403
1471
|
name: "apple-stringsdict",
|
|
@@ -1461,12 +1529,18 @@ var init_apple_stringsdict = __esm({
|
|
|
1461
1529
|
function escape(s) {
|
|
1462
1530
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
|
|
1463
1531
|
}
|
|
1532
|
+
function toApple2(value) {
|
|
1533
|
+
const gap = (text) => text.replace(/%/g, "%%");
|
|
1534
|
+
if (isIcuPluralOrSelect(value)) return gap(value);
|
|
1535
|
+
return withLiterals(value, gap, (lit) => lit.replace(/%/g, "%%"));
|
|
1536
|
+
}
|
|
1464
1537
|
var DEFAULT_LOCALE_CASE6, appleStrings;
|
|
1465
1538
|
var init_apple_strings = __esm({
|
|
1466
1539
|
"src/server/adapters/apple-strings.ts"() {
|
|
1467
1540
|
"use strict";
|
|
1468
1541
|
init_adapters();
|
|
1469
1542
|
init_options();
|
|
1543
|
+
init_placeholders();
|
|
1470
1544
|
DEFAULT_LOCALE_CASE6 = "bcp47-hyphen";
|
|
1471
1545
|
appleStrings = {
|
|
1472
1546
|
name: "apple-strings",
|
|
@@ -1493,7 +1567,7 @@ var init_apple_strings = __esm({
|
|
|
1493
1567
|
if (entry.plural) continue;
|
|
1494
1568
|
const value = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1495
1569
|
if (value === null) continue;
|
|
1496
|
-
lines.push(`"${escape(key)}" = "${escape(value)}";`);
|
|
1570
|
+
lines.push(`"${escape(key)}" = "${escape(toApple2(value))}";`);
|
|
1497
1571
|
}
|
|
1498
1572
|
const contents = lines.length ? lines.join("\n") + "\n" : "";
|
|
1499
1573
|
files.push({
|
|
@@ -1508,6 +1582,10 @@ var init_apple_strings = __esm({
|
|
|
1508
1582
|
});
|
|
1509
1583
|
|
|
1510
1584
|
// src/server/adapters/vue-i18n-json.ts
|
|
1585
|
+
function toVueI18n(value) {
|
|
1586
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1587
|
+
return withLiterals(value, (gap) => gap, (content) => `{'${content}'}`);
|
|
1588
|
+
}
|
|
1511
1589
|
var DEFAULT_LOCALE_CASE7, vueI18nJson;
|
|
1512
1590
|
var init_vue_i18n_json = __esm({
|
|
1513
1591
|
"src/server/adapters/vue-i18n-json.ts"() {
|
|
@@ -1544,7 +1622,7 @@ var init_vue_i18n_json = __esm({
|
|
|
1544
1622
|
if (entry.plural) {
|
|
1545
1623
|
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1546
1624
|
if (!forms) continue;
|
|
1547
|
-
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
|
|
1625
|
+
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map(toVueI18n);
|
|
1548
1626
|
flat[key] = parts.join(" | ");
|
|
1549
1627
|
} else {
|
|
1550
1628
|
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
@@ -1557,7 +1635,7 @@ var init_vue_i18n_json = __esm({
|
|
|
1557
1635
|
message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
|
|
1558
1636
|
});
|
|
1559
1637
|
}
|
|
1560
|
-
flat[key] = raw;
|
|
1638
|
+
flat[key] = toVueI18n(raw);
|
|
1561
1639
|
}
|
|
1562
1640
|
}
|
|
1563
1641
|
let payload = flat;
|
|
@@ -1594,27 +1672,30 @@ function angularXMeta(placeholders, name) {
|
|
|
1594
1672
|
return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
|
|
1595
1673
|
}
|
|
1596
1674
|
function renderInterpolations(text, ids, placeholders) {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
const
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
id
|
|
1611
|
-
|
|
1675
|
+
const convertGap = (gap) => {
|
|
1676
|
+
let out = "";
|
|
1677
|
+
let last = 0;
|
|
1678
|
+
for (const m of gap.matchAll(/\{(\w+)\}/g)) {
|
|
1679
|
+
const name = m[1];
|
|
1680
|
+
out += xmlEscape2(gap.slice(last, m.index));
|
|
1681
|
+
const meta = angularXMeta(placeholders, name);
|
|
1682
|
+
if (meta) {
|
|
1683
|
+
const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
|
|
1684
|
+
const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
|
|
1685
|
+
out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
|
|
1686
|
+
} else {
|
|
1687
|
+
let id = ids.get(name);
|
|
1688
|
+
if (id === void 0) {
|
|
1689
|
+
id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
|
|
1690
|
+
ids.set(name, id);
|
|
1691
|
+
}
|
|
1692
|
+
out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
|
|
1612
1693
|
}
|
|
1613
|
-
|
|
1694
|
+
last = m.index + m[0].length;
|
|
1614
1695
|
}
|
|
1615
|
-
|
|
1616
|
-
}
|
|
1617
|
-
return
|
|
1696
|
+
return out + xmlEscape2(gap.slice(last));
|
|
1697
|
+
};
|
|
1698
|
+
return withLiterals(text, convertGap, (lit) => xmlEscape2(`'${lit}'`));
|
|
1618
1699
|
}
|
|
1619
1700
|
function renderPluralIcu(forms, ids, placeholders) {
|
|
1620
1701
|
const cats = [
|
|
@@ -1992,10 +2073,11 @@ function buildSystemPrompt(hasPluralItems) {
|
|
|
1992
2073
|
"You are a professional software localization engine for a UI string catalog.",
|
|
1993
2074
|
"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.",
|
|
1994
2075
|
"",
|
|
1995
|
-
"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.",
|
|
2076
|
+
"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.",
|
|
1996
2077
|
"",
|
|
1997
2078
|
"Hard rules:",
|
|
1998
2079
|
"- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
|
|
2080
|
+
"- 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.",
|
|
1999
2081
|
"- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
|
|
2000
2082
|
"- 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.",
|
|
2001
2083
|
"- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
|
|
@@ -2024,6 +2106,7 @@ function buildBatchPrompt(reqs) {
|
|
|
2024
2106
|
// Wrap in braces so the model sees "{site}" not "site" — makes the visual
|
|
2025
2107
|
// connection to the source string obvious and reduces rename errors.
|
|
2026
2108
|
placeholders: r.placeholders.map((p) => `{${p}}`),
|
|
2109
|
+
...r.literals?.length ? { literals: r.literals } : {},
|
|
2027
2110
|
...r.glossary?.length ? { glossary: r.glossary } : {},
|
|
2028
2111
|
hasScreenshot: r.image !== void 0
|
|
2029
2112
|
};
|
|
@@ -3084,6 +3167,7 @@ function selectRequests(state, opts) {
|
|
|
3084
3167
|
const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
|
|
3085
3168
|
if (opts.onlyMissing && complete) continue;
|
|
3086
3169
|
const glossary = relevantGlossary(other, locale, state.glossary);
|
|
3170
|
+
const literals = quotedLiterals(other);
|
|
3087
3171
|
reqs.push({
|
|
3088
3172
|
id: String(id++),
|
|
3089
3173
|
key,
|
|
@@ -3093,6 +3177,7 @@ function selectRequests(state, opts) {
|
|
|
3093
3177
|
targetLocale: locale,
|
|
3094
3178
|
maxLength: entry.maxLength,
|
|
3095
3179
|
placeholders: extractPlaceholders(other),
|
|
3180
|
+
...literals.length ? { literals } : {},
|
|
3096
3181
|
...glossary.length ? { glossary } : {},
|
|
3097
3182
|
plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
|
|
3098
3183
|
});
|
|
@@ -3105,6 +3190,7 @@ function selectRequests(state, opts) {
|
|
|
3105
3190
|
const existing = entry.values[locale]?.value;
|
|
3106
3191
|
if (opts.onlyMissing && existing) continue;
|
|
3107
3192
|
const glossary = relevantGlossary(source, locale, state.glossary);
|
|
3193
|
+
const literals = quotedLiterals(source);
|
|
3108
3194
|
reqs.push({
|
|
3109
3195
|
id: String(id++),
|
|
3110
3196
|
key,
|
|
@@ -3114,6 +3200,7 @@ function selectRequests(state, opts) {
|
|
|
3114
3200
|
targetLocale: locale,
|
|
3115
3201
|
maxLength: entry.maxLength,
|
|
3116
3202
|
placeholders: extractPlaceholders(source),
|
|
3203
|
+
...literals.length ? { literals } : {},
|
|
3117
3204
|
...glossary.length ? { glossary } : {}
|
|
3118
3205
|
});
|
|
3119
3206
|
}
|
|
@@ -3605,7 +3692,8 @@ function buildContextSystemPrompt() {
|
|
|
3605
3692
|
"- Do NOT restate the source string itself.",
|
|
3606
3693
|
"- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
|
|
3607
3694
|
"- Keep it under 500 characters.",
|
|
3608
|
-
"- If no code snippets are available, infer from the key path and source value."
|
|
3695
|
+
"- If no code snippets are available, infer from the key path and source value.",
|
|
3696
|
+
"- 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."
|
|
3609
3697
|
].join("\n");
|
|
3610
3698
|
}
|
|
3611
3699
|
function buildContextBatchPrompt(reqs) {
|
|
@@ -3617,7 +3705,14 @@ function buildContextBatchPrompt(reqs) {
|
|
|
3617
3705
|
${s.lines}
|
|
3618
3706
|
\`\`\``;
|
|
3619
3707
|
}).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
|
|
3620
|
-
|
|
3708
|
+
const literals = quotedLiterals(r.source);
|
|
3709
|
+
return {
|
|
3710
|
+
id: r.id,
|
|
3711
|
+
key: r.key,
|
|
3712
|
+
source: r.source,
|
|
3713
|
+
...literals.length ? { literals } : {},
|
|
3714
|
+
codeSnippets: snippetText
|
|
3715
|
+
};
|
|
3621
3716
|
});
|
|
3622
3717
|
return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
|
|
3623
3718
|
}
|
|
@@ -3655,6 +3750,7 @@ var init_context = __esm({
|
|
|
3655
3750
|
"src/server/ai/context.ts"() {
|
|
3656
3751
|
"use strict";
|
|
3657
3752
|
init_state();
|
|
3753
|
+
init_placeholders();
|
|
3658
3754
|
MAX_CONTEXT_LENGTH = 500;
|
|
3659
3755
|
SNIPPET_WINDOW = 15;
|
|
3660
3756
|
MAX_SNIPPETS = 3;
|
|
@@ -4065,7 +4161,7 @@ function extractPrefixes(content, scanner) {
|
|
|
4065
4161
|
result.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
4066
4162
|
return result;
|
|
4067
4163
|
}
|
|
4068
|
-
function
|
|
4164
|
+
function extractLiterals2(content) {
|
|
4069
4165
|
const starts = lineStartOffsets(content);
|
|
4070
4166
|
const result = [];
|
|
4071
4167
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4166,7 +4262,7 @@ function runScan(projectRoot, opts, existing) {
|
|
|
4166
4262
|
size,
|
|
4167
4263
|
refs: extractRefs(content, scanner, opts),
|
|
4168
4264
|
prefixes: extractPrefixes(content, scanner),
|
|
4169
|
-
literals:
|
|
4265
|
+
literals: extractLiterals2(content)
|
|
4170
4266
|
};
|
|
4171
4267
|
}
|
|
4172
4268
|
saveUsageCache(projectRoot, cache2);
|
|
@@ -4554,6 +4650,9 @@ var init_flatten = __esm({
|
|
|
4554
4650
|
// src/server/import/parsers/vue-i18n-json.ts
|
|
4555
4651
|
import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
|
|
4556
4652
|
import { join as join7 } from "path";
|
|
4653
|
+
function fromVueI18n(value) {
|
|
4654
|
+
return value.replace(/\{'([^']*)'\}/g, "'$1'");
|
|
4655
|
+
}
|
|
4557
4656
|
var LOCALE_RE2, vueI18nJson2;
|
|
4558
4657
|
var init_vue_i18n_json2 = __esm({
|
|
4559
4658
|
"src/server/import/parsers/vue-i18n-json.ts"() {
|
|
@@ -4580,7 +4679,7 @@ var init_vue_i18n_json2 = __esm({
|
|
|
4580
4679
|
}
|
|
4581
4680
|
if (!locales.includes(locale)) locales.push(locale);
|
|
4582
4681
|
for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
|
|
4583
|
-
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
4682
|
+
(keys[key] ??= { values: {} }).values[locale] = fromVueI18n(value);
|
|
4584
4683
|
}
|
|
4585
4684
|
}
|
|
4586
4685
|
return { locales, keys, warnings };
|
|
@@ -4590,8 +4689,14 @@ var init_vue_i18n_json2 = __esm({
|
|
|
4590
4689
|
});
|
|
4591
4690
|
|
|
4592
4691
|
// src/server/import/placeholders.ts
|
|
4692
|
+
function markBareBracesLiteral(value) {
|
|
4693
|
+
return value.replace(/(?<!%)\{(\w+)\}/g, "'{$1}'");
|
|
4694
|
+
}
|
|
4593
4695
|
function laravelToCanonical(value) {
|
|
4594
|
-
return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
|
|
4696
|
+
return markBareBracesLiteral(value).replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
|
|
4697
|
+
}
|
|
4698
|
+
function railsToCanonical(value) {
|
|
4699
|
+
return markBareBracesLiteral(value).replace(/%\{(\w+)\}/g, "{$1}");
|
|
4595
4700
|
}
|
|
4596
4701
|
var init_placeholders2 = __esm({
|
|
4597
4702
|
"src/server/import/placeholders.ts"() {
|
|
@@ -4753,6 +4858,9 @@ function localeFromLproj(dir) {
|
|
|
4753
4858
|
if (!m) return null;
|
|
4754
4859
|
return LOCALE_RE4.test(m[1]) ? m[1] : null;
|
|
4755
4860
|
}
|
|
4861
|
+
function printfToCanonical(s) {
|
|
4862
|
+
return s.replace(/%%/g, "%");
|
|
4863
|
+
}
|
|
4756
4864
|
function unescape(body) {
|
|
4757
4865
|
return body.replace(/\\(U[0-9a-fA-F]{4}|u[0-9a-fA-F]{4}|.)/g, (_m, esc) => {
|
|
4758
4866
|
const c = esc[0];
|
|
@@ -4878,7 +4986,7 @@ var init_apple_strings2 = __esm({
|
|
|
4878
4986
|
warnings.push(`apple-strings: ${dir} has other .strings tables (${others.join(", ")}); only ${TABLE} is imported`);
|
|
4879
4987
|
}
|
|
4880
4988
|
for (const { key, value } of parseStrings(text, file, warnings)) {
|
|
4881
|
-
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
4989
|
+
(keys[key] ??= { values: {} }).values[locale] = printfToCanonical(value);
|
|
4882
4990
|
}
|
|
4883
4991
|
}
|
|
4884
4992
|
return { locales, keys, warnings };
|
|
@@ -5015,6 +5123,24 @@ function unescapePo(s) {
|
|
|
5015
5123
|
(_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
|
|
5016
5124
|
);
|
|
5017
5125
|
}
|
|
5126
|
+
function printfToCanonical2(s, arg) {
|
|
5127
|
+
let out = "";
|
|
5128
|
+
for (let i = 0; i < s.length; ) {
|
|
5129
|
+
if (s[i] === "%" && s[i + 1] === "%") {
|
|
5130
|
+
out += "%";
|
|
5131
|
+
i += 2;
|
|
5132
|
+
continue;
|
|
5133
|
+
}
|
|
5134
|
+
if (arg && s[i] === "%" && s[i + 1] === "d") {
|
|
5135
|
+
out += `{${arg}}`;
|
|
5136
|
+
i += 2;
|
|
5137
|
+
continue;
|
|
5138
|
+
}
|
|
5139
|
+
out += s[i];
|
|
5140
|
+
i++;
|
|
5141
|
+
}
|
|
5142
|
+
return out;
|
|
5143
|
+
}
|
|
5018
5144
|
function parseEntries(text) {
|
|
5019
5145
|
const entries = [];
|
|
5020
5146
|
let cur = null;
|
|
@@ -5143,13 +5269,13 @@ var init_gettext_po2 = __esm({
|
|
|
5143
5269
|
);
|
|
5144
5270
|
continue;
|
|
5145
5271
|
}
|
|
5146
|
-
forms[cat] = body
|
|
5272
|
+
forms[cat] = printfToCanonical2(body, "count");
|
|
5147
5273
|
}
|
|
5148
5274
|
if (!forms.other) continue;
|
|
5149
5275
|
(keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
|
|
5150
5276
|
} else {
|
|
5151
5277
|
if (!entry.msgstr) continue;
|
|
5152
|
-
(keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
|
|
5278
|
+
(keys[key] ??= { values: {} }).values[locale] = printfToCanonical2(entry.msgstr, "");
|
|
5153
5279
|
}
|
|
5154
5280
|
}
|
|
5155
5281
|
}
|
|
@@ -5259,9 +5385,6 @@ var init_i18next_json2 = __esm({
|
|
|
5259
5385
|
// src/server/import/parsers/rails-yaml.ts
|
|
5260
5386
|
import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
|
|
5261
5387
|
import { join as join14 } from "path";
|
|
5262
|
-
function fromRuby(value) {
|
|
5263
|
-
return value.replace(/%\{(\w+)\}/g, "{$1}");
|
|
5264
|
-
}
|
|
5265
5388
|
function makeNode() {
|
|
5266
5389
|
return /* @__PURE__ */ Object.create(null);
|
|
5267
5390
|
}
|
|
@@ -5447,7 +5570,7 @@ function synthesizeIcu(forms, file, key, warnings) {
|
|
|
5447
5570
|
`rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
|
|
5448
5571
|
);
|
|
5449
5572
|
}
|
|
5450
|
-
parts.push(`${cat} {${
|
|
5573
|
+
parts.push(`${cat} {${railsToCanonical(body)}}`);
|
|
5451
5574
|
}
|
|
5452
5575
|
return `{count, plural, ${parts.join(" ")}}`;
|
|
5453
5576
|
}
|
|
@@ -5456,6 +5579,7 @@ var init_rails_yaml2 = __esm({
|
|
|
5456
5579
|
"src/server/import/parsers/rails-yaml.ts"() {
|
|
5457
5580
|
"use strict";
|
|
5458
5581
|
init_schema();
|
|
5582
|
+
init_placeholders2();
|
|
5459
5583
|
LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
|
|
5460
5584
|
CATEGORY_SET = new Set(PLURAL_CATEGORIES);
|
|
5461
5585
|
railsYaml2 = {
|
|
@@ -5472,7 +5596,7 @@ var init_rails_yaml2 = __esm({
|
|
|
5472
5596
|
for (const [k, v] of Object.entries(node)) {
|
|
5473
5597
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
5474
5598
|
if (typeof v === "string") {
|
|
5475
|
-
if (v !== "") addValue(key, locale,
|
|
5599
|
+
if (v !== "") addValue(key, locale, railsToCanonical(v));
|
|
5476
5600
|
continue;
|
|
5477
5601
|
}
|
|
5478
5602
|
const forms = asPluralForms(v);
|
|
@@ -5598,6 +5722,24 @@ function parsePlistDict(xml) {
|
|
|
5598
5722
|
if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
|
|
5599
5723
|
return tag.selfClosing ? {} : readDict();
|
|
5600
5724
|
}
|
|
5725
|
+
function printfToCanonical3(body, token, arg) {
|
|
5726
|
+
let out = "";
|
|
5727
|
+
for (let i = 0; i < body.length; ) {
|
|
5728
|
+
if (body[i] === "%" && body[i + 1] === "%") {
|
|
5729
|
+
out += "%";
|
|
5730
|
+
i += 2;
|
|
5731
|
+
continue;
|
|
5732
|
+
}
|
|
5733
|
+
if (body.startsWith(token, i)) {
|
|
5734
|
+
out += `{${arg}}`;
|
|
5735
|
+
i += token.length;
|
|
5736
|
+
continue;
|
|
5737
|
+
}
|
|
5738
|
+
out += body[i];
|
|
5739
|
+
i++;
|
|
5740
|
+
}
|
|
5741
|
+
return out;
|
|
5742
|
+
}
|
|
5601
5743
|
function entryToIcu(key, entry, file, warnings) {
|
|
5602
5744
|
const warn = (msg) => {
|
|
5603
5745
|
warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
|
|
@@ -5626,7 +5768,7 @@ function entryToIcu(key, entry, file, warnings) {
|
|
|
5626
5768
|
for (const cat of PLURAL_CATEGORIES) {
|
|
5627
5769
|
const body = varDict[cat];
|
|
5628
5770
|
if (typeof body !== "string") continue;
|
|
5629
|
-
forms[cat] = prefix + body
|
|
5771
|
+
forms[cat] = prefix + printfToCanonical3(body, token, arg) + suffix;
|
|
5630
5772
|
}
|
|
5631
5773
|
if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
|
|
5632
5774
|
return formsToIcu(arg, forms);
|