UNPKG

i18n-ai-translate

Version:

AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.

264 lines 11.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const po_plural_rules_1 = require("./po_plural_rules"); const gettext_parser_1 = require("gettext-parser"); /** * ASCII Record Separator — used to join msgctxt, msgid, and optional * plural-suffix segments into a single flat key. Chosen for the same * reason `DIRECTORY_KEY_DELIMITER` was picked elsewhere: no legal PO * content can contain a control character, so round-trip is unambiguous. */ const KEY_DELIMITER = "\x1e"; /** * printf-style placeholder: `%s`, `%d`, `%1$s`, `%2$.2f`, … Capture * groups: (1) optional `N$` positional index. * * Deliberately tolerant — the adapter's contract is "whatever we strip * on read, we restore on write"; we don't need to fully validate the * printf spec. */ const PRINTF_REGEX = /%(?:(\d+)\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|j|z|t|L)?[sdifFeEgGxXoucpn%]/g; function makeKey(msgctxt, msgid, suffix) { const ctx = msgctxt ?? ""; return suffix ? `${ctx}${KEY_DELIMITER}${msgid}${KEY_DELIMITER}${suffix}` : `${ctx}${KEY_DELIMITER}${msgid}`; } function stripPlaceholders(text) { const tokens = []; let positional = false; let autoIndex = 0; const normalized = text.replace(PRINTF_REGEX, (match, posIdx) => { if (match === "%%") return match; let index; if (posIdx) { positional = true; index = Number(posIdx); } else { autoIndex++; index = autoIndex; } // Array positions are 0-based but arg indices are 1-based; keep // them aligned so a re-read matches on the same index. tokens[index - 1] = match; return `{{arg${index}}}`; }); return { map: { positional, tokens }, normalized }; } function restorePlaceholders(text, map) { if (!map || map.tokens.length === 0) return text; return text.replace(/\{\{arg(\d+)\}\}/g, (_match, idx) => { const original = map.tokens[Number(idx) - 1]; // If the model invented an extra arg reference, leave the // placeholder literal — surfacing it is better than silently // deleting it. The verification step already guards this. return original ?? `{{arg${idx}}}`; }); } /** * Resolve the source language's plural category list from the PO * header. Falls back to English-style two-form if the header is * missing or unrecognized. * @param headers - the parsed PO header map * @returns the source language's ordered plural category list */ function inferSourceCategories(headers) { const lang = headers["Language"] ?? headers["language"] ?? ""; const code = lang.toLowerCase().split(/[-_]/)[0]; if (!code) return po_plural_rules_1.DEFAULT_RULE.categories; return (0, po_plural_rules_1.getPluralRule)(code).categories; } const POAdapter = { extensions: [".po"], name: "po", read(raw) { const parsed = gettext_parser_1.po.parse(raw); const sourceCategories = inferSourceCategories(parsed.headers); const flat = {}; const metaByKey = {}; for (const ctx of Object.keys(parsed.translations)) { const bucket = parsed.translations[ctx]; for (const msgid of Object.keys(bucket)) { const entry = bucket[msgid]; // The PO header is stored as the empty-msgid entry in // the empty-context bucket; skip it from the flat map. if (ctx === "" && msgid === "") continue; if (entry.msgid_plural) { const msgids = [entry.msgid, entry.msgid_plural]; for (let i = 0; i < msgids.length; i++) { // Source-language PO files typically only have // msgstr[0] / msgstr[1] populated when acting // as the *source*; we actually want to expose // the English msgid / msgid_plural for the // pipeline to translate, since msgstr is the // target-language slot. For a pristine source // PO (msgstr empty) this is the only sensible // shape anyway. const text = msgids[i]; const suffix = i === 0 ? "_one" : "_other"; const { normalized, map } = stripPlaceholders(text); const key = makeKey(entry.msgctxt, entry.msgid, suffix); flat[key] = normalized; metaByKey[key] = { comments: entry.comments, isPlural: true, msgctxt: entry.msgctxt, msgid: entry.msgid, placeholders: map, }; } } else { const { normalized, map } = stripPlaceholders(entry.msgid); const key = makeKey(entry.msgctxt, entry.msgid); flat[key] = normalized; metaByKey[key] = { comments: entry.comments, isPlural: false, msgctxt: entry.msgctxt, msgid: entry.msgid, placeholders: map, }; } } } return { flat, sidecar: { kind: "po", metaByKey, parsed, sourceCategories, }, }; }, readTranslated(raw) { // Diff mode: read an existing *target* catalogue, exposing the // translated `msgstr` values (not `msgid`) keyed exactly as // `read` keys the source, so unchanged keys line up and survive. // Values are kept verbatim (placeholders not normalized): on // write they pass through restorePlaceholders, which is a no-op // when no `{{argN}}` tokens are present. const parsed = gettext_parser_1.po.parse(raw); const sourceCategories = inferSourceCategories(parsed.headers); const oneIndex = Math.max(0, sourceCategories.indexOf("one")); const otherIndex = sourceCategories.findIndex((c) => c !== "one"); const flat = {}; const metaByKey = {}; for (const ctx of Object.keys(parsed.translations)) { const bucket = parsed.translations[ctx]; for (const msgid of Object.keys(bucket)) { const entry = bucket[msgid]; if (ctx === "" && msgid === "") continue; if (entry.msgid_plural) { const oneKey = makeKey(entry.msgctxt, entry.msgid, "_one"); const otherKey = makeKey(entry.msgctxt, entry.msgid, "_other"); flat[oneKey] = entry.msgstr[oneIndex] ?? ""; flat[otherKey] = entry.msgstr[otherIndex >= 0 ? otherIndex : 0] ?? ""; metaByKey[oneKey] = { comments: entry.comments, isPlural: true, msgctxt: entry.msgctxt, msgid: entry.msgid, }; metaByKey[otherKey] = metaByKey[oneKey]; } else { const key = makeKey(entry.msgctxt, entry.msgid); flat[key] = entry.msgstr[0] ?? ""; metaByKey[key] = { comments: entry.comments, isPlural: false, msgctxt: entry.msgctxt, msgid: entry.msgid, }; } } } return { flat, sidecar: { kind: "po", metaByKey, parsed, sourceCategories }, }; }, write(translated, sidecar, _inputLanguageCode, outputLanguageCode) { const targetRule = (0, po_plural_rules_1.getPluralRule)(outputLanguageCode); // Deep-clone the parsed table so we don't mutate the sidecar // held by the caller (translateDiff may reuse it). const out = { charset: sidecar.parsed.charset, headers: { ...sidecar.parsed.headers }, obsolete: sidecar.parsed.obsolete, translations: {}, }; // Update the header to reflect the target language's plurals. out.headers["Plural-Forms"] = targetRule.forms; out.headers["Language"] = outputLanguageCode; for (const ctx of Object.keys(sidecar.parsed.translations)) { out.translations[ctx] = {}; const bucket = sidecar.parsed.translations[ctx]; for (const msgid of Object.keys(bucket)) { const original = bucket[msgid]; if (ctx === "" && msgid === "") { // Preserve the PO header entry verbatim. out.translations[ctx][msgid] = { ...original, // gettext-parser expects msgstr to be an array. msgstr: original.msgstr, }; continue; } if (original.msgid_plural) { const oneKey = makeKey(original.msgctxt, original.msgid, "_one"); const otherKey = makeKey(original.msgctxt, original.msgid, "_other"); const oneMeta = sidecar.metaByKey[oneKey]; const otherMeta = sidecar.metaByKey[otherKey]; const translatedOne = translated[oneKey]; const translatedOther = translated[otherKey]; // Fan-in: re-expand the two i18next plural slots // (`_one` / `_other`) into the target language's // full msgstr[] array. For a language with >2 // forms we clone the `_other` slot into every // non-`one` category; it's the honest v1 behavior // given i18next's own two-form plural marking. // Each source slot carries its own placeholder map // (the singular and plural forms can differ), so // restore each pick with the matching one. const msgstr = []; for (let i = 0; i < targetRule.nplurals; i++) { const isOne = targetRule.categories[i] === "one"; const pick = isOne ? translatedOne : translatedOther; const map = isOne ? oneMeta?.placeholders : otherMeta?.placeholders; msgstr.push(restorePlaceholders(pick ?? "", map)); } out.translations[ctx][msgid] = { ...original, msgstr, }; } else { const key = makeKey(original.msgctxt, original.msgid); const meta = sidecar.metaByKey[key]; const translatedText = translated[key] ?? ""; out.translations[ctx][msgid] = { ...original, msgstr: [ restorePlaceholders(translatedText, meta?.placeholders), ], }; } } } return gettext_parser_1.po.compile(out).toString("utf-8"); }, }; exports.default = POAdapter; //# sourceMappingURL=po_adapter.js.map