390 lines
12 KiB
JavaScript
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]);
|
|
}
|
|
}
|