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 = /^@(?\.[\p{L}]+)?:(?:\{'(?[^']+)'\}|(?[\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]); } }