Files
pleno-vue/scripts/i18n-v2-globalize-templates.mjs
T

390 lines
12 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const exactLinkPattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const ensureObjectAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
parent[segments.at(-1)] = nextValue;
};
const deleteValueAtPath = (value, keyPath) => {
const segments = keyPath.split(".");
const parents = [];
let current = value;
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(current) || !(segment in current)) {
return false;
}
parents.push([current, segment]);
current = current[segment];
}
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
return false;
}
delete current[segments.at(-1)];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const [parent, segment] = parents[index];
const child = parent[segment];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete parent[segment];
} else {
break;
}
}
return true;
};
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
const mergedMessages = { ...sharedMessages };
for (const [key, value] of Object.entries(localeMessages ?? {})) {
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
} else {
mergedMessages[key] = value;
}
}
return mergedMessages;
};
const applyTextModifier = (value, modifier) => {
switch (modifier) {
case ".capitalize":
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
case ".upper":
return value.toLocaleUpperCase();
case ".lower":
return value.toLocaleLowerCase();
default:
return value;
}
};
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
if (seen.has(keyPath)) {
return undefined;
}
seen.add(keyPath);
const value = getValueAtPath(messages, keyPath);
if (typeof value !== "string") {
return undefined;
}
const exactLink = value.trim().match(exactLinkPattern);
if (exactLink) {
const target = exactLink.groups.literal ?? exactLink.groups.path;
const resolved = resolveLinkedMessage(messages, target, seen);
return resolved === undefined ? undefined : applyTextModifier(resolved, exactLink.groups.modifier);
}
return value.replace(linkTokenPattern, (token) => {
const match = token.match(exactLinkPattern);
if (!match?.groups) {
return token;
}
const target = match.groups.literal ?? match.groups.path;
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
return resolved === undefined ? token : applyTextModifier(resolved, match.groups.modifier);
});
};
const readRawLocales = () =>
Object.fromEntries(
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
);
const buildMergedLocales = (globalMessages, rawLocales) =>
Object.fromEntries(
activeLocales.map((locale) => [
locale,
mergeMessages(
mergeMessages(globalMessages.shared ?? {}, globalMessages.locales?.[locale] ?? {}),
rawLocales[locale]
),
])
);
const isCatalogPhraseKey = (keyPath) => !keyPath.startsWith("words.") && !keyPath.startsWith("templates.");
const exactLinkTarget = (value) => {
const match = value.trim().match(exactLinkPattern);
return match?.groups?.literal ?? match?.groups?.path;
};
const templateKeyFor = (keyPath) => `${templatePrefix}.${keyPath}`;
const linkedTemplateFor = (keyPath) => `@:{'${templateKeyFor(keyPath)}'}`;
const sourceValueFor = (globalMessages, rawLocales, locale, keyPath) => {
const rawValue = getValueAtPath(rawLocales[locale], keyPath);
if (typeof rawValue === "string") {
return rawValue;
}
const localeValue = getValueAtPath(globalMessages.locales?.[locale] ?? {}, keyPath);
if (typeof localeValue === "string") {
return localeValue;
}
const sharedValue = getValueAtPath(globalMessages.shared ?? {}, keyPath);
return typeof sharedValue === "string" ? sharedValue : undefined;
};
const collectCandidateKeys = (globalMessages, rawLocales) => {
const keys = new Set();
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(rawLocales[locale]).filter((entry) => isCatalogPhraseKey(entry.key))) {
keys.add(entry.key);
}
for (const entry of flattenStringEntries(globalMessages.locales?.[locale] ?? {}).filter((entry) =>
isCatalogPhraseKey(entry.key)
)) {
const target = exactLinkTarget(entry.value);
if (target !== templateKeyFor(entry.key)) {
keys.add(entry.key);
}
}
}
return [...keys].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const reorderLocaleRoot = (localeMessages) => {
const { words, templates, ...rest } = localeMessages;
return { words, templates, ...rest };
};
const pruneLocaleMap = (locales) => {
const nextLocales = {};
for (const locale of activeLocales) {
nextLocales[locale] = isPlainObject(locales?.[locale]) ? locales[locale] : {};
}
return nextLocales;
};
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
const globalMessages = cloneJson(originalGlobalMessages);
const rawLocales = cloneJson(originalRawLocales);
globalMessages.shared = globalMessages.shared ?? {};
globalMessages.locales = pruneLocaleMap(globalMessages.locales ?? {});
const originalMergedLocales = buildMergedLocales(originalGlobalMessages, originalRawLocales);
const candidateKeys = collectCandidateKeys(originalGlobalMessages, originalRawLocales);
let directSharedAliasCount = 0;
let generatedTemplateCount = 0;
let removedRawEntryCount = 0;
let removedLocaleEntryCount = 0;
for (const keyPath of candidateKeys) {
const valuesByLocale = Object.fromEntries(
activeLocales.map((locale) => {
const sourceValue = sourceValueFor(originalGlobalMessages, originalRawLocales, locale, keyPath);
if (typeof sourceValue === "string") {
return [locale, sourceValue];
}
const mergedValue = getValueAtPath(originalMergedLocales[locale], keyPath);
return [locale, typeof mergedValue === "string" ? mergedValue : undefined];
})
);
const values = activeLocales.map((locale) => valuesByLocale[locale]);
if (values.some((value) => typeof value !== "string")) {
continue;
}
const firstValue = values[0];
const allValuesIdentical = values.every((value) => value === firstValue);
const canUseDirectSharedAlias =
allValuesIdentical &&
!firstValue.includes("words.generated.") &&
!firstValue.includes(templatePrefix) &&
exactLinkTarget(firstValue) !== keyPath;
if (canUseDirectSharedAlias) {
setValueAtPath(globalMessages.shared, keyPath, firstValue);
directSharedAliasCount += 1;
} else {
setValueAtPath(globalMessages.shared, keyPath, linkedTemplateFor(keyPath));
for (const locale of activeLocales) {
setValueAtPath(rawLocales[locale], templateKeyFor(keyPath), valuesByLocale[locale]);
}
generatedTemplateCount += 1;
}
for (const locale of activeLocales) {
if (deleteValueAtPath(rawLocales[locale], keyPath)) {
removedRawEntryCount += 1;
}
if (deleteValueAtPath(globalMessages.locales[locale], keyPath)) {
removedLocaleEntryCount += 1;
}
}
}
for (const locale of activeLocales) {
rawLocales[locale] = reorderLocaleRoot(rawLocales[locale]);
globalMessages.locales[locale] = isPlainObject(globalMessages.locales[locale])
? globalMessages.locales[locale]
: {};
}
const nextMergedLocales = buildMergedLocales(globalMessages, rawLocales);
const mismatches = [];
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(originalMergedLocales[locale])) {
const expected = resolveLinkedMessage(originalMergedLocales[locale], entry.key);
const actual = resolveLinkedMessage(nextMergedLocales[locale], entry.key);
if (actual !== expected) {
mismatches.push(
`${locale}.${entry.key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
);
}
}
}
const remainingRawPhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(rawLocales[locale])
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
const remainingLocalePhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(globalMessages.locales?.[locale] ?? {})
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
return {
globalMessages,
rawLocales,
candidateCount: candidateKeys.length,
directSharedAliasCount,
generatedTemplateCount,
removedRawEntryCount,
removedLocaleEntryCount,
mismatches,
remainingRawPhraseEntries,
remainingLocalePhraseEntries,
};
};
const globalPath = path.join(localesDirectory, "global-v2.json");
const originalGlobalMessages = readJson(globalPath);
const originalRawLocales = readRawLocales();
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
console.log(
[
`candidate phrase keys: ${result.candidateCount}`,
`direct shared aliases: ${result.directSharedAliasCount}`,
`generated shared template aliases: ${result.generatedTemplateCount}`,
`removed raw locale entries: ${result.removedRawEntryCount}`,
`removed global locale entries: ${result.removedLocaleEntryCount}`,
].join("\n")
);
let hasErrors = false;
if (result.mismatches.length > 0) {
hasErrors = true;
console.error(`transformed output changed ${result.mismatches.length} strings`);
for (const mismatch of result.mismatches.slice(0, 30)) {
console.error(` ${mismatch}`);
}
}
if (result.remainingRawPhraseEntries.length > 0) {
hasErrors = true;
console.error(`raw locale phrase entries remain outside words/templates: ${result.remainingRawPhraseEntries.length}`);
for (const entry of result.remainingRawPhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (result.remainingLocalePhraseEntries.length > 0) {
hasErrors = true;
console.error(`global-v2 locale phrase entries remain: ${result.remainingLocalePhraseEntries.length}`);
for (const entry of result.remainingLocalePhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (hasErrors) {
process.exit(1);
}
if (mode === "apply") {
writeJson(globalPath, result.globalMessages);
for (const locale of activeLocales) {
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
}
}