locale-ai 0.6.15 → 0.6.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/README.md CHANGED
@@ -29,13 +29,15 @@ Download 577 MB? (Y/n)
29
29
  ## What it handles
30
30
 
31
31
  - **Layouts**: `locales/en/*.json` (nested folders too) → `locales/fr/...`, or `messages/en.json` → `messages/fr.json`. `.json`, `.yaml`, `.yml`, `.po`/`.pot` (gettext), `.arb` (Flutter) and `.strings` (Apple) are all supported, mixed freely in the same project. Common folders (`locales`, `src/locales`, `public/locales`, `src/i18n`, `messages`, `lang`, `config/locales` (Rails), ...) are found automatically, or pass `--input`. Framework-specific naming is also recognized: `flutter gen-l10n`'s `app_en.arb`/`app_fr.arb` filename prefix, and Apple's `en.lproj`/`fr.lproj` directory-suffix convention (`--input en.lproj` or just the project root both work), on both the source and the generated target side.
32
+ - **Android**: `res/values/strings.xml` → `res/values-fr/strings.xml` (or `values-pt-rBR` for a region). `<string>`, `<string-array>` and `<plurals>` are all translated; `translatable="false"` strings and `<!-- ... -->` comments are kept as-is. Resource-qualifier scripts/variants (`values-b+sr+Latn`) aren't supported — only a plain language or language-region qualifier.
33
+ - **Apple `.xcstrings`** (Xcode 15+'s String Catalog, replacing `.strings`): every locale lives inside this one file, so point `--input` straight at it (e.g. `--input Localizable.xcstrings`; it isn't auto-detected, since a project can have several). `shouldTranslate: false` entries, and Xcode's per-device variants (`variations.iphone`/`.ipad`) or multi-variable strings (`substitutions`), are left untouched rather than mistranslated — only a single cardinal plural (`variations.plural`) is supported. A key with no explicit source-language text falls back to the key itself, matching Xcode's own convention.
32
34
  - **Placeholders are never translated or lost**: `{name}`, `{{count}}`, `%s`, `%1$s`, `%(name)s`, `%{name}`, `${name}`, `:attribute`, `$t(key)`, `@:key`, `<b>…</b>`, `<0>…</0>`, `&nbsp;`, URLs, e-mails.
33
35
  - **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
34
- - **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...), so the app doesn't fall back to the English string — each added form copies the `_other` translation verbatim rather than inflecting it, so review and adjust the wording of `_zero`/`_two`/`_few`/`_many` by hand for languages where those forms use a different noun/adjective form. The `_one` form is translated with its `{{count}}` temporarily replaced by a literal "1", since the model otherwise sometimes picks the target's plural noun form despite the English source being grammatically singular. Rails-style nested plurals (`item:` with `one:`/`other:` children instead of `item_one`/`item_other`) get the same missing-forms treatment.
36
+ - **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...), so the app doesn't fall back to the English string — each added form copies the `_other` translation verbatim rather than inflecting it, so review and adjust the wording of `_zero`/`_two`/`_few`/`_many` by hand for languages where those forms use a different noun/adjective form. The `_one` form is translated with its `{{count}}` temporarily replaced by a literal "1", since the model otherwise sometimes picks the target's plural noun form despite the English source being grammatically singular. Rails-style nested plurals (`item:` with `one:`/`other:` children instead of `item_one`/`item_other`) get the same missing-forms treatment — Android's `<plurals>`/`<item quantity="...">` groups and `.xcstrings`' `variations.plural` are the same shape and get it too.
35
37
  - **Incremental, and edit-aware**: only missing or changed keys are translated; untouched translations are kept, so re-runs are no-ops. A small `locale-ai.lock.json` (commit it, like a package lock) remembers a hash of each source string and of the output last written for it per target locale — so editing a source string gets it re-translated automatically (it isn't enough for the target to already hold *some* text), while editing a target string by hand is detected and reported for review instead of being silently overwritten on the next run. `--overwrite` bypasses the lock and re-translates everything.
36
38
  - **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
37
39
 
38
- **Format notes**: for `.yaml`/`.yml`, `#` comments (a translator note, an inline `# ...` after a value) survive a translation run — the target file is edited in place rather than rebuilt from scratch, so an existing comment's own value is updated without disturbing the comment, and a brand-new key is appended with no comment of its own. A newly added plural-category key (see above) is likewise appended rather than inserted between existing ones, so its position in the file won't always match the canonical `zero`/`one`/`two`/`few`/`many`/`other` order. For `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as msgid". `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved.
40
+ **Format notes**: for `.yaml`/`.yml`, `#` comments (a translator note, an inline `# ...` after a value) survive a translation run — the target file is edited in place rather than rebuilt from scratch, so an existing comment's own value is updated without disturbing the comment, and a brand-new key is appended with no comment of its own. A newly added plural-category key (see above) is likewise appended rather than inserted between existing ones, so its position in the file won't always match the canonical `zero`/`one`/`two`/`few`/`many`/`other` order. For `.po`/`.pot`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English source's `msgstr` holds the source text when it's filled in (most generators do), and falls back to `msgid` itself when it's empty, which is normal for a `.pot` template or a project that just keeps the English text in `msgid`. That fallback only ever applies to the source file: an empty `msgstr` in an *existing target* still means "not translated yet", never "same as msgid". `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header, and any entry's own comments (a translator's note, a hand-added `#, fuzzy`), are otherwise left exactly as last edited instead of being regenerated from the source on every rerun. `--fuzzy` flags every entry a run actually (re)translated with gettext's standard `#, fuzzy` "needs review" marker, merging onto an existing `#,` line rather than adding a second one. A `.pot` source always writes a `.po` target, never a `.pot`. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output. For `.strings`, a file saved as UTF-16 (Xcode's traditional default) is read like a UTF-8 one, an unquoted key (`title = "Settings";`, without quotes around `title`) is read the same as a quoted one, and `\U00e9`-style escapes are decoded — output is always written as UTF-8 with quoted keys, regardless of how the source was saved. For Android `strings.xml`, `translatable="false"` strings and each element's own `<!-- ... -->` comment are kept on first write, but — unlike YAML/`.po`/`.strings` — a rerun regenerates the file from scratch rather than editing an existing target in place, so a hand-edited comment in an already-translated file won't survive a rerun. For `.xcstrings`, every target locale is written into the same one catalog file (each locale's run re-reads it first, so several locales share it safely in one invocation); an entry's `state` (e.g. a translator's `needs_review` flag) is only touched when the value it's attached to actually changes, so hand review survives a rerun.
39
41
 
40
42
  ## Why locale-ai?
41
43
 
package/dist/cli.js CHANGED
@@ -33,7 +33,9 @@ Usage:
33
33
  Translate options:
34
34
  --from <locale> Source locale (default: en)
35
35
  --to <locales> Target locale(s), comma separated: fr,es,pt-BR
36
- --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings (auto-detected)
36
+ --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings,
37
+ an Android res/ folder (values/strings.xml) (auto-detected),
38
+ or a path straight to a .xcstrings catalog (not auto-detected)
37
39
  --overwrite Re-translate strings that already have a translation
38
40
  --dry-run Show what would be translated without downloading or writing
39
41
  --fast Greedy decoding: ~2x faster, slightly lower quality
@@ -172,8 +174,10 @@ async function cmdTranslate(argv) {
172
174
  const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag, true, values.fuzzy) }));
173
175
  const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
174
176
  log(`locale-ai ${VERSION}`);
175
- log(`Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
176
- (layout.kind === "dir" ? `${from.tag}${layout.dirSuffix ?? ""}/` : ""));
177
+ log(layout.kind === "xcstrings"
178
+ ? `Found ${from.tag} strings in ${rel(layout.file)}`
179
+ : `Found ${sources.length} ${from.tag} file${sources.length === 1 ? "" : "s"} in ${rel(layout.root)}/` +
180
+ (layout.kind === "dir" ? `${from.tag}${layout.dirSuffix ?? ""}/` : ""));
177
181
  let failures = 0;
178
182
  const jobs = [];
179
183
  for (const locale of targets) {
package/dist/locales.js CHANGED
@@ -24,8 +24,23 @@ const CANDIDATES = [
24
24
  "app/i18n",
25
25
  "static/locales",
26
26
  "config/locales",
27
+ "app/src/main/res",
28
+ "src/main/res",
29
+ "res",
27
30
  ".",
28
31
  ];
32
+ /** Android's `values-<qualifier>` folder suffix for a locale tag: "fr" -> "fr", "pt-BR" -> "pt-rBR". */
33
+ function androidQualifier(tag) {
34
+ const l = parseLocale(tag);
35
+ if (l.script)
36
+ throw new Error(`Android resource qualifiers for scripts (got "${tag}") aren't supported; use a plain language or language-region tag.`);
37
+ return l.region ? `${l.lang}-r${l.region}` : l.lang;
38
+ }
39
+ /** Reverse of androidQualifier: "fr" -> "fr", "pt-rBR" -> "pt-BR". Undefined for a qualifier it can't parse (script/BCP-47 "b+..." variants). */
40
+ function tagFromAndroidQualifier(qualifier) {
41
+ const m = /^([a-z]{2,3})(?:-r([A-Za-z]{2}))?$/.exec(qualifier);
42
+ return m ? (m[2] ? `${m[1]}-${m[2].toUpperCase()}` : m[1]) : undefined;
43
+ }
29
44
  const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
30
45
  const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
31
46
  /** The source-locale file's extension, if `<root>/<from><ext>` exists for one of EXTENSIONS. */
@@ -51,6 +66,10 @@ function prefixedFileAt(root, from) {
51
66
  }
52
67
  return undefined;
53
68
  }
69
+ /** Android's default (untagged) `values/strings.xml` resource folder, if present. */
70
+ function androidLayoutAt(root) {
71
+ return isFile(path.join(root, "values", "strings.xml")) ? { kind: "android", root } : undefined;
72
+ }
54
73
  function layoutAt(root, from) {
55
74
  const dir = path.join(root, from);
56
75
  if (isDir(dir) && listLocaleFiles(dir).length > 0)
@@ -58,6 +77,9 @@ function layoutAt(root, from) {
58
77
  const lprojDir = path.join(root, `${from}.lproj`);
59
78
  if (isDir(lprojDir) && listLocaleFiles(lprojDir).length > 0)
60
79
  return { kind: "dir", root, dirSuffix: ".lproj" };
80
+ const android = androidLayoutAt(root);
81
+ if (android)
82
+ return android;
61
83
  const ext = fileExtAt(root, from);
62
84
  if (ext)
63
85
  return { kind: "file", root, ext };
@@ -77,6 +99,23 @@ export function detectLayout(cwd, from, input) {
77
99
  if (path.basename(abs) === `${from}.lproj` && isDir(abs)) {
78
100
  return { kind: "dir", root: path.dirname(abs), dirSuffix: ".lproj" };
79
101
  }
102
+ if (path.basename(abs) === "values" && isFile(path.join(abs, "strings.xml"))) {
103
+ return { kind: "android", root: path.dirname(abs) };
104
+ }
105
+ if (abs.endsWith(".xcstrings") && isFile(abs)) {
106
+ let raw;
107
+ try {
108
+ raw = JSON.parse(readText(abs));
109
+ }
110
+ catch (e) {
111
+ throw new Error(`Invalid .xcstrings (JSON) in ${input}: ${e.message}`);
112
+ }
113
+ const sourceLanguage = raw?.sourceLanguage;
114
+ if (typeof sourceLanguage === "string" && sourceLanguage !== from) {
115
+ throw new Error(`${input} has sourceLanguage "${sourceLanguage}", not "${from}" — pass --from ${sourceLanguage}.`);
116
+ }
117
+ return { kind: "xcstrings", root: path.dirname(abs), file: abs };
118
+ }
80
119
  const base = path.basename(abs);
81
120
  const directExt = EXTENSIONS.find((ext) => base === `${from}${ext}`);
82
121
  if (directExt && isFile(abs))
@@ -112,6 +151,10 @@ function listLocaleFiles(dir) {
112
151
  return out.sort();
113
152
  }
114
153
  export function sourceFiles(layout, from) {
154
+ if (layout.kind === "android")
155
+ return [path.join(layout.root, "values", "strings.xml")];
156
+ if (layout.kind === "xcstrings")
157
+ return [layout.file];
115
158
  return layout.kind === "dir"
116
159
  ? listLocaleFiles(path.join(layout.root, dirNameFor(layout, from)))
117
160
  : [path.join(layout.root, fileNameFor(layout, from, layout.ext))];
@@ -119,6 +162,10 @@ export function sourceFiles(layout, from) {
119
162
  /** A translated file is a real PO, never a template, regardless of what the source was. */
120
163
  const outputExt = (ext) => (ext === ".pot" ? ".po" : ext);
121
164
  export function targetFile(layout, from, to, src) {
165
+ if (layout.kind === "android")
166
+ return path.join(layout.root, `values-${androidQualifier(to)}`, "strings.xml");
167
+ if (layout.kind === "xcstrings")
168
+ return layout.file;
122
169
  if (layout.kind === "file")
123
170
  return path.join(layout.root, fileNameFor(layout, to, outputExt(layout.ext)));
124
171
  const rel = path.relative(path.join(layout.root, dirNameFor(layout, from)), src);
@@ -137,6 +184,33 @@ function looksLikeLocale(tag) {
137
184
  }
138
185
  /** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
139
186
  export function existingTargets(layout, from) {
187
+ if (layout.kind === "xcstrings") {
188
+ let raw;
189
+ try {
190
+ raw = JSON.parse(readText(layout.file));
191
+ }
192
+ catch {
193
+ return [];
194
+ }
195
+ const strings = isObj(raw) && isObj(raw.strings) ? raw.strings : {};
196
+ const tags = new Set();
197
+ for (const entry of Object.values(strings)) {
198
+ if (!isObj(entry) || !isObj(entry.localizations))
199
+ continue;
200
+ for (const tag of Object.keys(entry.localizations))
201
+ tags.add(tag);
202
+ }
203
+ tags.delete(from);
204
+ return [...tags].filter(looksLikeLocale).sort();
205
+ }
206
+ if (layout.kind === "android") {
207
+ return fs
208
+ .readdirSync(layout.root, { withFileTypes: true })
209
+ .filter((e) => e.isDirectory() && /^values-/.test(e.name) && isFile(path.join(layout.root, e.name, "strings.xml")))
210
+ .map((e) => tagFromAndroidQualifier(e.name.slice("values-".length)))
211
+ .filter((tag) => tag !== undefined && tag !== from)
212
+ .sort();
213
+ }
140
214
  if (layout.kind === "file") {
141
215
  const ext = layout.ext;
142
216
  const prefix = layout.prefix ? `${layout.prefix}_` : "";
@@ -293,6 +367,119 @@ function readStringsDoc(file, text) {
293
367
  };
294
368
  return { data, serialize };
295
369
  }
370
+ /** Android's `&entity;` plus its own backslash escapes (`\'`, `\"`, `\n`, `\t`) and `<![CDATA[...]]>`. */
371
+ function unescapeAndroidXml(s) {
372
+ return s
373
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_m, inner) => inner)
374
+ .replace(/&lt;/g, "<")
375
+ .replace(/&gt;/g, ">")
376
+ .replace(/&quot;/g, '"')
377
+ .replace(/&apos;/g, "'")
378
+ .replace(/&amp;/g, "&")
379
+ .replace(/\\(.)/g, (_m, c) => ({ n: "\n", t: "\t" })[c] ?? c);
380
+ }
381
+ function escapeAndroidXml(s) {
382
+ return s
383
+ .replace(/&/g, "&amp;")
384
+ .replace(/</g, "&lt;")
385
+ .replace(/>/g, "&gt;")
386
+ .replace(/'/g, "\\'")
387
+ .replace(/"/g, '\\"')
388
+ .replace(/\n/g, "\\n")
389
+ .replace(/\t/g, "\\t");
390
+ }
391
+ /**
392
+ * Android `strings.xml`: `<string>`/`<string-array>`/`<plurals>` elements under a
393
+ * single `<resources>` root, each optionally preceded by an `<!-- ... -->` translator
394
+ * comment and marked `translatable="false"` for text that must stay verbatim (an app ID,
395
+ * a format string nobody localizes). A `<plurals>` element's `quantity="one|other|..."`
396
+ * items become a nested one/other/... object — the same shape Rails-style nested
397
+ * plurals use, so `addNestedPluralForms` fills in whatever categories the target
398
+ * language needs with zero format-specific code.
399
+ *
400
+ * This is a simpler round-trip than YAML/.po/.strings: comments are kept from the
401
+ * source on first write, but a rerun regenerates the whole file from `data` rather
402
+ * than editing an existing target in place (so a translator's own XML comment edits in
403
+ * an already-translated file won't survive a rerun) — the same level of fidelity ARB
404
+ * already has in this project.
405
+ */
406
+ function readAndroidXmlDoc(file, text) {
407
+ const openMatch = /<resources\b[^>]*>/.exec(text);
408
+ const closeMatch = /<\/resources>/.exec(text);
409
+ if (!openMatch || !closeMatch)
410
+ throw new Error(`Invalid Android strings.xml in ${file}: missing a <resources> element.`);
411
+ const before = text.slice(0, openMatch.index + openMatch[0].length);
412
+ const after = text.slice(closeMatch.index);
413
+ const body = text.slice(openMatch.index + openMatch[0].length, closeMatch.index);
414
+ const data = newObj();
415
+ const entries = [];
416
+ const elementRe = /(?:(<!--[\s\S]*?-->)\s*\r?\n\s*)?<(string-array|string|plurals)\b([^>]*)(?:\/>|>([\s\S]*?)<\/\2>)/g;
417
+ for (const m of body.matchAll(elementRe)) {
418
+ const [, comment, tag, attrsRaw, inner] = m;
419
+ const nameMatch = /\bname="([^"]*)"/.exec(attrsRaw);
420
+ if (!nameMatch)
421
+ continue;
422
+ const name = unescapeAndroidXml(nameMatch[1]);
423
+ const translatable = !/\btranslatable\s*=\s*"false"/.test(attrsRaw);
424
+ if (tag === "string") {
425
+ entries.push({ comment, name, translatable, kind: "string" });
426
+ if (translatable)
427
+ data[name] = unescapeAndroidXml((inner ?? "").trim());
428
+ }
429
+ else if (tag === "string-array") {
430
+ entries.push({ comment, name, translatable, kind: "array" });
431
+ const items = [...(inner ?? "").matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/g)].map((im) => unescapeAndroidXml(im[1].trim()));
432
+ if (translatable)
433
+ data[name] = items;
434
+ }
435
+ else {
436
+ entries.push({ comment, name, translatable, kind: "plural" });
437
+ if (translatable) {
438
+ const plural = newObj();
439
+ for (const im of (inner ?? "").matchAll(/<item\s+quantity="(\w+)"[^>]*>([\s\S]*?)<\/item>/g)) {
440
+ plural[im[1]] = unescapeAndroidXml(im[2].trim());
441
+ }
442
+ data[name] = plural;
443
+ }
444
+ }
445
+ }
446
+ if (entries.length === 0 && body.trim() !== "") {
447
+ throw new Error(`No <string>/<string-array>/<plurals> entries found in ${file}`);
448
+ }
449
+ const indent = /\r?\n([ \t]+)</.exec(body)?.[1] ?? " ";
450
+ const keepNewline = finalNewline(text);
451
+ const raw = newObj();
452
+ for (const m of body.matchAll(elementRe))
453
+ raw[/\bname="([^"]*)"/.exec(m[3])[1]] = m[0];
454
+ const serialize = (d) => {
455
+ const obj = isObj(d) ? d : newObj();
456
+ const blocks = [];
457
+ for (const e of entries) {
458
+ const attr = e.translatable ? "" : ' translatable="false"';
459
+ let tag;
460
+ if (!e.translatable) {
461
+ tag = raw[e.name].replace(/^<!--[\s\S]*?-->\s*\r?\n\s*/, "");
462
+ }
463
+ else if (e.kind === "string") {
464
+ const v = typeof obj[e.name] === "string" ? obj[e.name] : "";
465
+ tag = `<string name="${escapeAndroidXml(e.name)}"${attr}>${escapeAndroidXml(v)}</string>`;
466
+ }
467
+ else if (e.kind === "array") {
468
+ const items = Array.isArray(obj[e.name]) ? obj[e.name] : [];
469
+ const lines = items.map((v) => `${indent}${indent}<item>${escapeAndroidXml(typeof v === "string" ? v : "")}</item>`);
470
+ tag = `<string-array name="${escapeAndroidXml(e.name)}"${attr}>\n${lines.join("\n")}\n${indent}</string-array>`;
471
+ }
472
+ else {
473
+ const plural = isObj(obj[e.name]) ? obj[e.name] : newObj();
474
+ const lines = CATEGORIES.filter((c) => typeof plural[c] === "string").map((c) => `${indent}${indent}<item quantity="${c}">${escapeAndroidXml(plural[c])}</item>`);
475
+ tag = `<plurals name="${escapeAndroidXml(e.name)}"${attr}>\n${lines.join("\n")}\n${indent}</plurals>`;
476
+ }
477
+ blocks.push(e.comment ? `${indent}${e.comment}\n${indent}${tag}` : `${indent}${tag}`);
478
+ }
479
+ return withNewline(`${before}\n${blocks.join("\n")}\n${after}`, keepNewline);
480
+ };
481
+ return { data, serialize };
482
+ }
296
483
  /** ARB (Flutter): plain JSON, but "@key" / "@@locale" entries are metadata, not translatable content. */
297
484
  function readArbDoc(file, text) {
298
485
  let raw;
@@ -328,6 +515,119 @@ function readArbDoc(file, text) {
328
515
  };
329
516
  return { data, serialize };
330
517
  }
518
+ /** True when `entry` uses a per-device variant (`variations.iphone`/`.ipad`/...) for `tag`, not just a plural. */
519
+ function xcstringsHasDeviceVariant(entry) {
520
+ const locs = entry.localizations;
521
+ if (!isObj(locs))
522
+ return false;
523
+ return Object.values(locs).some((loc) => isObj(loc) && isObj(loc.variations) && Object.keys(loc.variations).some((k) => k !== "plural"));
524
+ }
525
+ /** `shouldTranslate: false`, multi-variable `substitutions`, or any per-device variant — left untouched. */
526
+ function xcstringsSkip(entry) {
527
+ return !isObj(entry) || entry.shouldTranslate === false || entry.substitutions !== undefined || xcstringsHasDeviceVariant(entry);
528
+ }
529
+ /** An entry's value for one locale: a plain string, a `{zero,one,...}` plural object, or undefined if untranslated. */
530
+ function xcstringsValue(entry, tag) {
531
+ const loc = entry.localizations?.[tag];
532
+ if (!loc)
533
+ return undefined;
534
+ const plural = loc.variations?.plural;
535
+ if (plural) {
536
+ const out = newObj();
537
+ for (const cat of CATEGORIES) {
538
+ const v = plural[cat]?.stringUnit?.value;
539
+ if (typeof v === "string")
540
+ out[cat] = v;
541
+ }
542
+ return Object.keys(out).length ? out : undefined;
543
+ }
544
+ const v = loc.stringUnit?.value;
545
+ return typeof v === "string" ? v : undefined;
546
+ }
547
+ /**
548
+ * Apple's `.xcstrings` String Catalog (Xcode 15+'s default, replacing `.strings`):
549
+ * unlike every other format here, ALL locales — source included — live in ONE JSON
550
+ * file, keyed by the string's own identifier under `strings`. Each entry's
551
+ * `localizations.<tag>` holds either a plain `stringUnit` or, for a plural string, a
552
+ * `variations.plural` object whose zero/one/two/few/many/other categories are exactly
553
+ * the shape `addNestedPluralForms` already understands (see Android/Rails), so it's
554
+ * reused unchanged. An entry with no `localizations.<sourceLanguage>` at all falls
555
+ * back to its own key as the source text, matching Xcode's own convention for a
556
+ * string that was never given explicit source content.
557
+ *
558
+ * `shouldTranslate: false`, Xcode's per-device variants (`variations.iphone`/`.ipad`),
559
+ * and multi-variable strings (`substitutions`, used with `%#@name@`-style format
560
+ * references) are all left completely untouched rather than mistranslated — a bigger
561
+ * matrix than a single cardinal plural isn't supported yet.
562
+ *
563
+ * `targetFile`/`sourceFiles` point every locale at this same one file, so each
564
+ * locale's turn re-reads it fresh (via `existingText`) before writing — the same
565
+ * read-then-write-fresh sequencing the CLI already does per locale, which is what
566
+ * lets several locales share one file safely in a single run without clobbering each
567
+ * other. A key's translated value is only ever replaced, never its `state`/metadata,
568
+ * when the value hasn't actually changed — so a translator's own "needs_review" flag
569
+ * on an untouched entry survives a rerun.
570
+ */
571
+ function readXcstringsDoc(file, text, tag, isSource) {
572
+ let raw;
573
+ try {
574
+ raw = JSON.parse(text);
575
+ }
576
+ catch (e) {
577
+ throw new Error(`Invalid .xcstrings (JSON) in ${file}: ${e.message}`);
578
+ }
579
+ const strings = isObj(raw.strings) ? raw.strings : {};
580
+ const data = newObj();
581
+ for (const key of Object.keys(strings)) {
582
+ const entry = strings[key];
583
+ if (xcstringsSkip(entry))
584
+ continue;
585
+ const v = xcstringsValue(entry, tag);
586
+ if (v !== undefined)
587
+ data[key] = v;
588
+ else if (isSource)
589
+ data[key] = key;
590
+ }
591
+ const indent = /^\{\r?\n([ \t]+)/.exec(text)?.[1] ?? 2;
592
+ const keepNewline = finalNewline(text);
593
+ const serialize = (d, targetTag, existingText) => {
594
+ const obj = isObj(d) ? d : newObj();
595
+ let out = raw;
596
+ if (existingText !== undefined) {
597
+ try {
598
+ out = JSON.parse(existingText);
599
+ }
600
+ catch {
601
+ out = raw;
602
+ }
603
+ }
604
+ const outStrings = isObj(out.strings) ? out.strings : (out.strings = newObj());
605
+ for (const key of Object.keys(obj)) {
606
+ const entry = outStrings[key];
607
+ if (xcstringsSkip(entry))
608
+ continue;
609
+ const newVal = obj[key];
610
+ const curVal = xcstringsValue(entry, targetTag);
611
+ if (JSON.stringify(curVal) === JSON.stringify(newVal))
612
+ continue;
613
+ const locs = isObj(entry.localizations) ? entry.localizations : (entry.localizations = newObj());
614
+ if (isObj(newVal)) {
615
+ const plural = newObj();
616
+ for (const cat of CATEGORIES) {
617
+ if (typeof newVal[cat] === "string") {
618
+ plural[cat] = { stringUnit: { state: "translated", value: newVal[cat] } };
619
+ }
620
+ }
621
+ locs[targetTag] = { variations: { plural } };
622
+ }
623
+ else if (typeof newVal === "string") {
624
+ locs[targetTag] = { stringUnit: { state: "translated", value: newVal } };
625
+ }
626
+ }
627
+ return withNewline(JSON.stringify(out, null, indent), keepNewline);
628
+ };
629
+ return { data, serialize };
630
+ }
331
631
  const poKey = (e) => (e.msgctxt ?? "") + "\u0004" + e.msgid;
332
632
  /**
333
633
  * gettext's well-established Plural-Forms formulas, keyed by language. Copying the
@@ -606,6 +906,10 @@ export function readLocaleDoc(file, tag, isSource = false, fuzzy = false) {
606
906
  return readArbDoc(file, text);
607
907
  if (ext === ".strings")
608
908
  return readStringsDoc(file, text);
909
+ if (ext === ".xml")
910
+ return readAndroidXmlDoc(file, text);
911
+ if (ext === ".xcstrings")
912
+ return readXcstringsDoc(file, text, tag, isSource);
609
913
  return readJsonDoc(file, text);
610
914
  }
611
915
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {