@lingui/format-po-gettext
Version:
Gettext PO format with gettext-style plurals for Lingui Catalogs
429 lines (425 loc) • 14.3 kB
JavaScript
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 };