import { isArgumentElement, isDateElement, isNumberElement, isPluralElement, isPoundElement, isSelectElement, isTagElement, isTimeElement, TYPE } from "./types.js"; function cloneDeep(obj) { if (Array.isArray(obj)) { // @ts-expect-error meh return obj.map(cloneDeep); } if (obj !== null && typeof obj === "object") { // @ts-expect-error meh return Object.keys(obj).reduce((cloned, k) => { // @ts-expect-error meh cloned[k] = cloneDeep(obj[k]); return cloned; }, {}); } return obj; } /** * Replace pound elements with number elements referencing the given variable. * This is needed when nesting plurals - the # in the outer plural should become * an explicit variable reference when nested inside another plural. * GH #4202 */ function replacePoundWithArgument(ast, variableName) { return ast.map((el) => { if (isPoundElement(el)) { // Replace # with {variableName, number} return { type: TYPE.number, value: variableName, style: null, location: el.location }; } if (isPluralElement(el) || isSelectElement(el)) { // Recursively process options const newOptions = {}; for (const key of Object.keys(el.options)) { newOptions[key] = { value: replacePoundWithArgument(el.options[key].value, variableName) }; } return { ...el, options: newOptions }; } if (isTagElement(el)) { return { ...el, children: replacePoundWithArgument(el.children, variableName) }; } return el; }); } function hoistPluralOrSelectElement(ast, el, positionToInject) { // pull this out of the ast and move it to the top const cloned = cloneDeep(el); const { options } = cloned; // GH #4202: Check if there are other plural/select elements after this one const afterElements = ast.slice(positionToInject + 1); const hasSubsequentPluralOrSelect = afterElements.some(isPluralOrSelectElement); cloned.options = Object.keys(options).reduce((all, k) => { let optionValue = options[k].value; // GH #4202: If there are subsequent plurals/selects and this is a plural, // replace # with explicit variable reference to avoid ambiguity if (hasSubsequentPluralOrSelect && isPluralElement(el)) { optionValue = replacePoundWithArgument(optionValue, el.value); } const newValue = hoistSelectors([ ...ast.slice(0, positionToInject), ...optionValue, ...afterElements ]); all[k] = { value: newValue }; return all; }, {}); return cloned; } function isPluralOrSelectElement(el) { return isPluralElement(el) || isSelectElement(el); } function findPluralOrSelectElement(ast) { return !!ast.find((el) => { if (isPluralOrSelectElement(el)) { return true; } if (isTagElement(el)) { return findPluralOrSelectElement(el.children); } return false; }); } /** * Hoist all selectors to the beginning of the AST & flatten the * resulting options. E.g: * "I have {count, plural, one{a dog} other{many dogs}}" * becomes "{count, plural, one{I have a dog} other{I have many dogs}}". * If there are multiple selectors, the order of which one is hoisted 1st * is non-deterministic. * The goal is to provide as many full sentences as possible since fragmented * sentences are not translator-friendly * @param ast AST */ export function hoistSelectors(ast) { for (let i = 0; i < ast.length; i++) { const el = ast[i]; if (isPluralOrSelectElement(el)) { return [hoistPluralOrSelectElement(ast, el, i)]; } if (isTagElement(el) && findPluralOrSelectElement([el])) { throw new Error("Cannot hoist plural/select within a tag element. Please put the tag element inside each plural/select option"); } } return ast; } /** * Collect all variables in an AST to Record * @param ast AST to collect variables from * @param vars Record of variable name to variable type */ function collectVariables(ast, vars = new Map()) { ast.forEach((el) => { if (isArgumentElement(el) || isDateElement(el) || isTimeElement(el) || isNumberElement(el)) { if (el.value in vars && vars.get(el.value) !== el.type) { throw new Error(`Variable ${el.value} has conflicting types`); } vars.set(el.value, el.type); } if (isPluralElement(el) || isSelectElement(el)) { vars.set(el.value, el.type); Object.keys(el.options).forEach((k) => { collectVariables(el.options[k].value, vars); }); } if (isTagElement(el)) { vars.set(el.value, el.type); collectVariables(el.children, vars); } }); } /** * Check if 2 ASTs are structurally the same. This primarily means that * they have the same variables with the same type * @param a * @param b * @returns */ export function isStructurallySame(a, b) { const aVars = new Map(); const bVars = new Map(); collectVariables(a, aVars); collectVariables(b, bVars); if (aVars.size !== bVars.size) { return { success: false, error: new Error(`Different number of variables: [${Array.from(aVars.keys()).join(", ")}] vs [${Array.from(bVars.keys()).join(", ")}]`) }; } return Array.from(aVars.entries()).reduce((result, [key, type]) => { if (!result.success) { return result; } const bType = bVars.get(key); if (bType == null) { return { success: false, error: new Error(`Missing variable ${key} in message`) }; } if (bType !== type) { return { success: false, error: new Error(`Variable ${key} has conflicting types: ${TYPE[type]} vs ${TYPE[bType]}`) }; } return result; }, { success: true }); }