Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
178 lines
5.3 KiB
Plaintext
178 lines
5.3 KiB
Plaintext
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<string, TYPE>
|
|
* @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 });
|
|
}
|