locale-ai 0.6.18 → 0.6.19

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
@@ -31,13 +31,14 @@ Download 577 MB? (Y/n)
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), `.strings` (Apple) and `.properties` (Java) 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, Apple's `en.lproj`/`fr.lproj` directory-suffix convention (`--input en.lproj` or just the project root both work), and Java `ResourceBundle`'s `messages_en.properties`/`messages_fr.properties` filename prefix (region-qualified as `messages_pt_BR.properties`, with an underscore — Java's own convention, not this project's usual BCP-47 hyphen), on both the source and the generated target side.
32
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
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.
34
+ - **XLIFF 1.2** (Qt Linguist, Angular's `ng extract-i18n`, and most CAT tools): also only via `--input <file>.xlf`/`.xliff` — the source file's name carries no locale tag (XLIFF puts it in `source-language` instead), only the `<basename>.fr.xlf` convention the target file is written under. Inline markup inside a `<source>` — Angular's `<x id="INTERPOLATION" .../>`, or a generic `<g id="1">...</g>` — passes through untouched, the same as `<b>…</b>` everywhere else. `translate="no"` units, and the rarer non-nesting `<bpt>`/`<ept>` paired-tag markup, are left completely alone. A `<note>` is kept from the source.
34
35
  - **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.
35
36
  - **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
36
37
  - **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.
37
38
  - **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.
38
39
  - **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
39
40
 
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. For `.properties`, `#`/`!` comments and blank lines survive a rerun exactly like YAML's — the target file is edited in place rather than rebuilt from scratch; a `\`-continued multi-line value is read correctly but gets collapsed onto one line if it's ever actually rewritten (an untouched one keeps its original layout), and a line with no unescaped `=`/`:` at all is left completely alone rather than guessed at. `.properties` has no plural convention of its own, so ICU `{count, plural, ...}` inside a value is handled the same generic way it is everywhere else.
41
+ **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. For `.properties`, `#`/`!` comments and blank lines survive a rerun exactly like YAML's — the target file is edited in place rather than rebuilt from scratch; a `\`-continued multi-line value is read correctly but gets collapsed onto one line if it's ever actually rewritten (an untouched one keeps its original layout), and a line with no unescaped `=`/`:` at all is left completely alone rather than guessed at. `.properties` has no plural convention of its own, so ICU `{count, plural, ...}` inside a value is handled the same generic way it is everywhere else. For XLIFF, like Android/ARB/`.xcstrings`, each `<trans-unit>` is regenerated from the *source's* own structure every run rather than edited in place — a translator's own `<target state="...">` attribute won't survive a rerun; `<file target-language="...">` is set to the target locale on output.
41
42
 
42
43
  ## Why locale-ai?
43
44
 
package/dist/cli.js CHANGED
@@ -35,7 +35,8 @@ Translate options:
35
35
  --to <locales> Target locale(s), comma separated: fr,es,pt-BR
36
36
  --input <dir> Folder containing <from>/ or <from>.json/.yaml/.yml/.po/.pot/.arb/.strings,
37
37
  an Android res/ folder (values/strings.xml) (auto-detected),
38
- or a path straight to a .xcstrings catalog (not auto-detected)
38
+ a path straight to a .xcstrings catalog (not auto-detected),
39
+ or a path straight to a .xlf/.xliff file (not auto-detected)
39
40
  --overwrite Re-translate strings that already have a translation
40
41
  --dry-run Show what would be translated without downloading or writing
41
42
  --fast Greedy decoding: ~2x faster, slightly lower quality
package/dist/locales.js CHANGED
@@ -124,6 +124,17 @@ export function detectLayout(cwd, from, input) {
124
124
  }
125
125
  return { kind: "xcstrings", root: path.dirname(abs), file: abs };
126
126
  }
127
+ if ((abs.endsWith(".xlf") || abs.endsWith(".xliff")) && isFile(abs)) {
128
+ const t = readText(abs);
129
+ const fileTag = /<file\b[^>]*>/.exec(t);
130
+ if (!fileTag)
131
+ throw new Error(`Invalid XLIFF in ${input}: no <file> element found.`);
132
+ const sourceLanguage = /\bsource-language="([^"]*)"/.exec(fileTag[0])?.[1];
133
+ if (sourceLanguage !== undefined && sourceLanguage !== from) {
134
+ throw new Error(`${input} has source-language "${sourceLanguage}", not "${from}" — pass --from ${sourceLanguage}.`);
135
+ }
136
+ return { kind: "xliff", root: path.dirname(abs), file: abs, ext: abs.endsWith(".xliff") ? ".xliff" : ".xlf" };
137
+ }
127
138
  const base = path.basename(abs);
128
139
  const directExt = EXTENSIONS.find((ext) => base === `${propertiesTag(ext, from)}${ext}`);
129
140
  if (directExt && isFile(abs))
@@ -164,7 +175,7 @@ function listLocaleFiles(dir) {
164
175
  export function sourceFiles(layout, from) {
165
176
  if (layout.kind === "android")
166
177
  return [path.join(layout.root, "values", "strings.xml")];
167
- if (layout.kind === "xcstrings")
178
+ if (layout.kind === "xcstrings" || layout.kind === "xliff")
168
179
  return [layout.file];
169
180
  return layout.kind === "dir"
170
181
  ? listLocaleFiles(path.join(layout.root, dirNameFor(layout, from)))
@@ -177,6 +188,8 @@ export function targetFile(layout, from, to, src) {
177
188
  return path.join(layout.root, `values-${androidQualifier(to)}`, "strings.xml");
178
189
  if (layout.kind === "xcstrings")
179
190
  return layout.file;
191
+ if (layout.kind === "xliff")
192
+ return path.join(layout.root, `${path.basename(layout.file, layout.ext)}.${to}${layout.ext}`);
180
193
  if (layout.kind === "file")
181
194
  return path.join(layout.root, fileNameFor(layout, to, outputExt(layout.ext)));
182
195
  const rel = path.relative(path.join(layout.root, dirNameFor(layout, from)), src);
@@ -214,6 +227,17 @@ export function existingTargets(layout, from) {
214
227
  tags.delete(from);
215
228
  return [...tags].filter(looksLikeLocale).sort();
216
229
  }
230
+ if (layout.kind === "xliff") {
231
+ const base = path.basename(layout.file, layout.ext);
232
+ const prefix = `${base}.`;
233
+ const ext = layout.ext;
234
+ return fs
235
+ .readdirSync(layout.root)
236
+ .filter((f) => f.startsWith(prefix) && f.endsWith(ext) && f.length > prefix.length + ext.length)
237
+ .map((f) => f.slice(prefix.length, -ext.length))
238
+ .filter((tag) => tag !== from && looksLikeLocale(tag))
239
+ .sort();
240
+ }
217
241
  if (layout.kind === "android") {
218
242
  return fs
219
243
  .readdirSync(layout.root, { withFileTypes: true })
@@ -594,6 +618,129 @@ function readAndroidXmlDoc(file, text) {
594
618
  };
595
619
  return { data, serialize };
596
620
  }
621
+ /** XLIFF's `&entity;` references and `<![CDATA[...]]>` — there's no backslash-escape convention to also handle, unlike Android's XML. */
622
+ function unescapeXml(s) {
623
+ return s
624
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_m, inner) => inner)
625
+ .replace(/&lt;/g, "<")
626
+ .replace(/&gt;/g, ">")
627
+ .replace(/&quot;/g, '"')
628
+ .replace(/&apos;/g, "'")
629
+ .replace(/&amp;/g, "&");
630
+ }
631
+ /** Any opening/closing/self-closing tag — the same generic shape placeholders.ts already protects (`<b>`, `<x id="..."/>`, `<g id="1">...</g>`). */
632
+ const XML_TAG_RE = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
633
+ /** Escapes `&`/`<`/`>` in plain text while leaving any inline tag (see XML_TAG_RE) completely untouched, so a translated value's own `<x .../>`/`<g>...</g>` markup — carried through verbatim by the placeholder masking — stays valid, literal XML instead of getting entity-escaped into text. */
634
+ function escapeXmlKeepTags(s) {
635
+ const escapeText = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
636
+ let out = "";
637
+ let last = 0;
638
+ for (const m of s.matchAll(XML_TAG_RE)) {
639
+ out += escapeText(s.slice(last, m.index)) + m[0];
640
+ last = m.index + m[0].length;
641
+ }
642
+ return out + escapeText(s.slice(last));
643
+ }
644
+ /** Sets (inserting if absent) one attribute on a single opening XML tag's raw text. */
645
+ function setXmlAttr(tag, name, value) {
646
+ const re = new RegExp(`\\b${name}\\s*=\\s*"[^"]*"`);
647
+ return re.test(tag) ? tag.replace(re, `${name}="${value}"`) : tag.replace(/\/?>$/, (end) => ` ${name}="${value}"${end}`);
648
+ }
649
+ /** Splits XLIFF text into literal passthrough segments and parsed `<trans-unit>` entries, ignoring the surrounding `<file>`/`<body>`/`<group>` nesting (it's never rewritten, just reproduced). */
650
+ function parseXliffUnits(text) {
651
+ const unitRe = /<trans-unit\b([^>]*)>([\s\S]*?)<\/trans-unit>/g;
652
+ const segments = [];
653
+ const units = [];
654
+ let last = 0;
655
+ for (const m of text.matchAll(unitRe)) {
656
+ const before = text.slice(last, m.index);
657
+ segments.push({ text: before });
658
+ const [whole, attrsRaw, inner] = m;
659
+ const idMatch = /\bid="([^"]*)"/.exec(attrsRaw);
660
+ const sourceMatch = /<source\b[^>]*>([\s\S]*?)<\/source>/.exec(inner);
661
+ if (!idMatch || !sourceMatch) {
662
+ segments.push({ text: whole });
663
+ }
664
+ else {
665
+ const targetMatch = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(inner);
666
+ const noteMatch = /<note\b[^>]*>[\s\S]*?<\/note>/.exec(inner);
667
+ const translatable = !/\btranslate\s*=\s*"no"/.test(attrsRaw) && !/<(?:bpt|ept)\b/.test(sourceMatch[1]);
668
+ const lineIndent = /[ \t]*$/.exec(before)[0];
669
+ units.push({
670
+ id: unescapeXml(idMatch[1]),
671
+ attrsRaw,
672
+ translatable,
673
+ sourceInner: sourceMatch[1],
674
+ targetInner: targetMatch?.[1],
675
+ noteBlock: noteMatch?.[0] ?? "",
676
+ whole,
677
+ indent: before.includes("\n") ? lineIndent : "",
678
+ });
679
+ segments.push({ unit: units.length - 1 });
680
+ }
681
+ last = m.index + whole.length;
682
+ }
683
+ segments.push({ text: text.slice(last) });
684
+ return { segments, units };
685
+ }
686
+ /**
687
+ * XLIFF 1.2 (`<xliff version="1.2">`, the default for Qt Linguist, Angular's
688
+ * `ng extract-i18n`, and most CAT tools): each `<trans-unit id="...">` holds a
689
+ * `<source>` (always present) and a `<target>` (present once translated). A unit's
690
+ * inline markup — Angular's `<x id="INTERPOLATION" .../>`, or a generic `<g id="1">
691
+ * ... </g>` — is carried through to translation as literal text rather than stripped:
692
+ * it's exactly the shape `placeholders.ts` already protects for HTML-ish i18n values
693
+ * (`<b>`, `<Trans/>`), so it survives untouched with zero extra code. `translate="no"`
694
+ * units, and the rarer `<bpt>`/`<ept>` paired-tag markup (a different, non-nesting
695
+ * convention XLIFF also allows but modern tools rarely emit), are left completely
696
+ * untouched instead. `<note>` comments are kept from the source.
697
+ *
698
+ * Like Android/ARB/`.xcstrings`, this regenerates each unit from the *source's* own
699
+ * structure every run rather than editing an existing target in place — a translator's
700
+ * own `<target state="...">` attribute (if any) won't survive a rerun, the same
701
+ * documented tradeoff Android already makes for its own comments.
702
+ *
703
+ * Only reached via `--input <file>.xlf`/`.xliff`: the source file's name carries no
704
+ * locale tag at all (XLIFF puts it in `source-language` instead), so there's no
705
+ * filename convention to auto-detect from.
706
+ */
707
+ function readXliffDoc(file, text, isSource) {
708
+ if (!/<file\b[^>]*>/.test(text))
709
+ throw new Error(`Invalid XLIFF in ${file}: no <file> element found.`);
710
+ const { segments, units } = parseXliffUnits(text);
711
+ const data = newObj();
712
+ for (const u of units) {
713
+ if (!u.translatable)
714
+ continue;
715
+ const v = isSource ? u.sourceInner : u.targetInner;
716
+ if (v !== undefined)
717
+ data[u.id] = unescapeXml(v);
718
+ }
719
+ const step = /\r?\n([ \t]+)</.exec(text)?.[1] ?? " ";
720
+ const keepNewline = finalNewline(text);
721
+ const serialize = (d, targetTag) => {
722
+ const obj = isObj(d) ? d : newObj();
723
+ let patchedFileTag = false;
724
+ const parts = segments.map((seg) => {
725
+ if ("text" in seg) {
726
+ if (!patchedFileTag && /<file\b[^>]*>/.test(seg.text)) {
727
+ patchedFileTag = true;
728
+ return seg.text.replace(/<file\b[^>]*>/, (tag) => setXmlAttr(tag, "target-language", targetTag));
729
+ }
730
+ return seg.text;
731
+ }
732
+ const u = units[seg.unit];
733
+ if (!u.translatable)
734
+ return u.whole;
735
+ const v = typeof obj[u.id] === "string" ? obj[u.id] : "";
736
+ const inner = u.indent + step;
737
+ const noteLine = u.noteBlock ? `\n${inner}${u.noteBlock}` : "";
738
+ return `<trans-unit${u.attrsRaw}>\n${inner}<source>${u.sourceInner}</source>\n${inner}<target>${escapeXmlKeepTags(v)}</target>${noteLine}\n${u.indent}</trans-unit>`;
739
+ });
740
+ return withNewline(parts.join(""), keepNewline);
741
+ };
742
+ return { data, serialize };
743
+ }
597
744
  /** ARB (Flutter): plain JSON, but "@key" / "@@locale" entries are metadata, not translatable content. */
598
745
  function readArbDoc(file, text) {
599
746
  let raw;
@@ -1026,6 +1173,8 @@ export function readLocaleDoc(file, tag, isSource = false, fuzzy = false) {
1026
1173
  return readAndroidXmlDoc(file, text);
1027
1174
  if (ext === ".xcstrings")
1028
1175
  return readXcstringsDoc(file, text, tag, isSource);
1176
+ if (ext === ".xlf" || ext === ".xliff")
1177
+ return readXliffDoc(file, text, isSource);
1029
1178
  return readJsonDoc(file, text);
1030
1179
  }
1031
1180
  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.18",
3
+ "version": "0.6.19",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {