Add multilingual glossary and phrases for i18n, including terms, attributes, and compatibility across da, de, en, no, and sv locales.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user