UNPKG

@lingui/format-po-gettext

Version:

Gettext PO format with gettext-style plurals for Lingui Catalogs

429 lines (425 loc) 14.3 kB
import { parse } from '@messageformat/parser'; import pluralsCldr from 'plurals-cldr'; import PO from 'pofile'; import gettextPlurals from 'node-gettext/lib/plurals'; import { generateMessageId } from '@lingui/message-utils/generateMessageId'; import { formatter as formatter$1 } from '@lingui/format-po'; import cardinals from 'cldr-core/supplemental/plurals.json'; function renameKeys(rules) { const result = {}; Object.keys(rules).forEach((k) => { const newKey = k.match(/[^-]+$/)[0]; result[newKey] = rules[k]; }); return result; } function fillRange(value) { const [start, end] = value.split("~"); const decimals = (start.split(".")[1] || "").length; const mult = Math.pow(10, decimals); const startNum = Number(start); const endNum = Number(end); const range = Array(Math.ceil(endNum * mult - startNum * mult + 1)).fill(0).map((v, idx) => (idx + startNum * mult) / mult); const last = range[range.length - 1]; if (endNum !== last) { throw new Error(`Range create error for ${value}: last value is ${last}`); } return range.map((v) => Number(v)); } function createSamples(src) { let result = []; src.replace(/…/, "").trim().replace(/,$/, "").split(",").map(function(val) { return val.trim(); }).forEach((val) => { if (val.indexOf("~") !== -1) { result = result.concat(fillRange(val)); } else { result.push(Number(val)); } }); return result; } function createLocaleTest(rules) { const result = {}; Object.keys(rules).forEach((form) => { const samples = rules[form].split(/@integer|@decimal/).slice(1); result[form] = []; samples.forEach((sample) => { result[form] = result[form].concat(createSamples(sample)); }); }); return result; } function getCldrPluralSamples() { const pluralRules = {}; Object.entries(cardinals.supplemental["plurals-type-cardinal"]).forEach( ([loc, ruleset]) => { const rules = renameKeys(ruleset); pluralRules[loc.toLowerCase()] = createLocaleTest(rules); } ); return pluralRules; } const cldrSamples = getCldrPluralSamples(); function stringifyICUCase(icuCase) { return icuCase.tokens.map((token) => { if (token.type === "content") { return token.value; } else if (token.type === "octothorpe") { return "#"; } else if (token.type === "argument") { return "{" + token.arg + "}"; } else { console.warn( `Unexpected token "${token}" while stringifying plural case "${icuCase}". Token will be ignored.` ); return ""; } }).join(""); } const ICU_PLURAL_REGEX = /^{.*, plural, .*}$/; const ICU_SELECT_REGEX = /^{.*, select(Ordinal)?, .*}$/; const LINE_ENDINGS = /\r?\n/g; const DEFAULT_CTX_PREFIX = "js-lingui:"; function serializePlurals(item, message, id, isGeneratedId, options, formatterCtx) { const icuMessage = message.message; const ctxPrefix = options.customICUPrefix || DEFAULT_CTX_PREFIX; if (!icuMessage) { return item; } const _simplifiedMessage = icuMessage.replace(LINE_ENDINGS, " "); if (ICU_PLURAL_REGEX.test(_simplifiedMessage)) { try { const messageAst = parse(icuMessage)[0]; if (messageAst.cases.some( (icuCase) => icuCase.tokens.some((token) => token.type === "plural") )) { console.warn( `Nested plurals cannot be expressed with gettext plurals. Message with key "%s" will not be saved correctly.`, id ); } const ctx = { pluralizeOn: [messageAst.arg] }; if (isGeneratedId) { item.msgid = stringifyICUCase(messageAst.cases[0]); item.msgid_plural = stringifyICUCase( messageAst.cases[messageAst.cases.length - 1] ); ctx.icu = icuMessage; } else { item.msgid_plural = id + "_plural"; } item.extractedComments.push(ctxPrefix + serializeContextToComment(ctx)); if (message.translation?.length > 0) { const ast = parse(message.translation)[0]; if (ast.cases == null) { console.warn( `Found translation without plural cases for key "${id}". This likely means that a translated .po file misses multiple msgstr[] entries for the key. Translation found: "${message.translation}"` ); item.msgstr = [message.translation]; } else { item.msgstr = ast.cases.map(stringifyICUCase); } } else if (!isGeneratedId && (formatterCtx.locale === formatterCtx.sourceLocale || formatterCtx.locale === null)) { item.msgstr = messageAst.cases.map(stringifyICUCase); } } catch (e) { console.error(`Error parsing message ICU for key "${id}":`, e); } } else { if (!options.disableSelectWarning && ICU_SELECT_REGEX.test(_simplifiedMessage)) { console.warn( `ICU 'select' and 'selectOrdinal' formats cannot be expressed natively in gettext format. Item with key "%s" will be included in the catalog as raw ICU message. To disable this warning, include '{ disableSelectWarning: true }' in the config's 'formatOptions'`, id ); } item.msgstr = [message.translation]; } return item; } const getPluralCases = (lang, pluralFormsHeader) => { let gettextPluralsInfo; if (pluralFormsHeader) { gettextPluralsInfo = parsePluralFormsFn(pluralFormsHeader); } const [correctLang] = lang.split(/[-_]/g); if (!gettextPluralsInfo) { gettextPluralsInfo = gettextPlurals[correctLang]; } if (!gettextPluralsInfo) { if (lang !== "pseudo") { console.warn( `No plural rules found for language "${lang}". Please add a Plural-Forms header.` ); } return void 0; } const cases = [...Array(pluralsCldr.forms(correctLang).length)]; for (const form of pluralsCldr.forms(correctLang)) { const samples = cldrSamples[correctLang][form]; const pluralForm = Number( gettextPluralsInfo.pluralsFunc(Number(samples[0])) ); cases[pluralForm] = form; } return cases; }; function parsePluralFormsFn(pluralFormsHeader) { const [npluralsExpr, expr] = pluralFormsHeader.split(";"); try { const nplurals = new Function(npluralsExpr + "; return nplurals;")(); const pluralsFunc = new Function( "n", expr + "; return plural;" ); return { nplurals, pluralsFunc }; } catch (e) { console.warn( `Plural-Forms header has incorrect value: ${pluralFormsHeader}` ); return void 0; } } const convertPluralsToICU = (item, pluralForms, lang, ctxPrefix = DEFAULT_CTX_PREFIX) => { const translationCount = item.msgstr.length; const messageKey = item.msgid; if (translationCount <= 1 && !item.msgid_plural) { return; } if (!item.msgid_plural) { console.warn( `Multiple translations for item with key "%s" but missing 'msgid_plural' in catalog "${lang}". This is not supported and the plural cases will be ignored.`, messageKey ); return; } const ctx = getContextFromComments(item.extractedComments, ctxPrefix); if (ctx) { item.extractedComments = item.extractedComments.filter( (comment) => !comment.startsWith(ctxPrefix) ); } const storedICU = ctx?.icu; if (storedICU != null) { item.msgid = storedICU; } if (item.msgstr.every((str) => str.length === 0)) { return; } if (pluralForms == null) { console.warn( `Multiple translations for item with key "%s" in language "${lang}", but no plural cases were found. This prohibits the translation of .po plurals into ICU plurals. Pluralization will not work for this key.`, messageKey ); return; } const pluralCount = pluralForms.length; if (translationCount > pluralCount) { console.warn( `More translations provided (${translationCount}) for item with key "%s" in language "${lang}" than there are plural cases available (${pluralCount}). This will result in not all translations getting picked up.`, messageKey ); } const pluralClauses = item.msgstr.map( (str, index) => pluralForms[index] ? pluralForms[index] + " {" + str + "}" : "" ).join(" "); let pluralizeOn = ctx?.pluralizeOn?.[0]; if (!pluralizeOn) { console.warn( `Unable to determine plural placeholder name for item with key "%s" in language "${lang}" (should be stored in a comment starting with "#. ${ctxPrefix}"), assuming "count".`, messageKey ); pluralizeOn = "count"; } item.msgstr = ["{" + pluralizeOn + ", plural, " + pluralClauses + "}"]; }; const updateContextComment = (item, contextComment, ctxPrefix) => { item.extractedComments = [ ...item.extractedComments.filter((c) => !c.startsWith(ctxPrefix)), ctxPrefix + contextComment ]; }; function serializeContextToComment(ctx) { const urlParams = new URLSearchParams(); if (ctx.icu) { urlParams.set("icu", ctx.icu); } if (ctx.pluralizeOn) { urlParams.set("pluralize_on", ctx.pluralizeOn.join(",")); } urlParams.sort(); return urlParams.toString(); } function getContextFromComments(extractedComments, ctxPrefix) { const contextComment = extractedComments.find( (comment) => comment.startsWith(ctxPrefix) ); if (!contextComment) { return void 0; } const urlParams = new URLSearchParams( contextComment?.substring(ctxPrefix.length) ); return { icu: urlParams.get("icu"), pluralizeOn: urlParams.get("pluralize_on") ? urlParams.get("pluralize_on").split(",") : [] }; } function mergeDuplicatePluralEntries(items, options) { const ctxPrefix = options.customICUPrefix || DEFAULT_CTX_PREFIX; const itemMap = /* @__PURE__ */ new Map(); for (const item of items) { if (item.msgid_plural) { const key = `${item.msgid}|||${item.msgid_plural}`; if (!itemMap.has(key)) { itemMap.set(key, []); } itemMap.get(key).push(item); } } const mergedItems = []; for (const duplicateItems of itemMap.values()) { if (duplicateItems.length === 1) { mergedItems.push(duplicateItems[0]); } else { const mergedItem = duplicateItems[0]; const allVariables = duplicateItems.map((item) => { const ctx2 = getContextFromComments(item.extractedComments, ctxPrefix); return ctx2?.pluralizeOn[0] || "count"; }); const ctx = getContextFromComments( mergedItem.extractedComments, ctxPrefix ); if (!ctx) { continue; } updateContextComment( mergedItem, serializeContextToComment({ icu: replaceArgInIcu(ctx.icu, allVariables[0], "$var"), pluralizeOn: allVariables }), ctxPrefix ); mergedItem.references = duplicateItems.flatMap((item) => item.references); mergedItems.push(mergedItem); } } return mergedItems; } function replaceArgInIcu(icu, oldVar, newVar) { return icu.replace(new RegExp(`{${oldVar}, plural,`), `{${newVar}, plural,`); } function expandMergedPluralEntries(items, options) { const ctxPrefix = options.customICUPrefix || DEFAULT_CTX_PREFIX; const expandedItems = []; for (const item of items) { if (!item.msgid_plural) { expandedItems.push(item); continue; } const ctx = getContextFromComments(item.extractedComments, ctxPrefix); if (!ctx) { console.warn( `Plural entry with msgid "${item.msgid}" is missing context information for expansion.` ); expandedItems.push(item); continue; } const variableList = ctx.pluralizeOn; if (variableList.length === 1) { expandedItems.push(item); continue; } for (const variable of variableList) { const newItem = new PO.Item(); newItem.msgid = item.msgid; newItem.msgid_plural = item.msgid_plural; newItem.msgstr = [...item.msgstr]; newItem.msgctxt = item.msgctxt; newItem.comments = [...item.comments]; updateContextComment( item, serializeContextToComment({ pluralizeOn: [variable], // get icu comment, replace variable placeholder with current variable icu: replaceArgInIcu(ctx.icu, "\\$var", variable) }), ctxPrefix ); newItem.extractedComments = item.extractedComments; newItem.flags = { ...item.flags }; expandedItems.push(newItem); } } return expandedItems; } function formatter(options = {}) { options = { origins: true, lineNumbers: true, ...options }; const formatter2 = formatter$1(options); return { catalogExtension: ".po", templateExtension: ".pot", parse(content, ctx) { const po = PO.parse(content); if (options.mergePlurals) { po.items = expandMergedPluralEntries(po.items, options); } const pluralForms = getPluralCases( po.headers.Language, po.headers["Plural-Forms"] ); po.items.forEach((item) => { convertPluralsToICU( item, pluralForms, po.headers.Language, options.customICUPrefix ); }); return formatter2.parse(po.toString(), ctx); }, serialize(catalog, ctx) { const po = PO.parse(formatter2.serialize(catalog, ctx)); po.items = po.items.map((item) => { const isGeneratedId = !item.extractedComments.includes( "js-lingui-explicit-id" ); const id = isGeneratedId ? generateMessageId(item.msgid, item.msgctxt) : item.msgid; const message = catalog[id]; return serializePlurals(item, message, id, isGeneratedId, options, ctx); }); if (options.mergePlurals) { const mergedPlurals = mergeDuplicatePluralEntries(po.items, options); const newItems = []; const processed = /* @__PURE__ */ new Set(); po.items.forEach((item) => { if (!item.msgid_plural) { newItems.push(item); } else { const mergedItem = mergedPlurals.find( (merged) => merged.msgid === item.msgid && merged.msgid_plural === item.msgid_plural ); if (mergedItem && !processed.has(mergedItem)) { processed.add(mergedItem); newItems.push(mergedItem); } } }); po.items = newItems; } return po.toString(); } }; } export { formatter };