import "server-only"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { cache } from "react"; import { type AppLocale, isSupportedLocale } from "@/i18n/locales"; import type { MessageTree } from "@/i18n/merge-messages"; import { validateCmsMessage } from "./cms-message-validation"; import { logger } from "./logger"; import { withGamedataLock, writeGamedataJsonAtomic, } from "./services/import/core/gamedata-json"; import { flattenObject, unflattenObject } from "./translations-utils"; const defaultRoot = path.join( /*turbopackIgnore: true*/ process.cwd(), "storage", "cms-translations", ); export interface CmsTranslationSnapshot { messages: Record; revision: string; } export class CmsTranslationError extends Error { constructor( public readonly code: | "conflict" | "unknownKey" | "empty" | "invalid" | "variables", public readonly key = "", ) { super(`${code}: ${key}`); } } async function bundled(locale: AppLocale): Promise> { return flattenObject((await import(`../messages/${locale}.json`)).default); } async function readOverrides(file: string): Promise> { try { const parsed: unknown = JSON.parse( await fs.readFile(/*turbopackIgnore: true*/ file, "utf8"), ); if ( !parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.values(parsed).some((v) => typeof v !== "string") ) throw new Error("Invalid CMS translation override file"); return parsed as Record; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } } function localeFile(locale: AppLocale, root: string) { if (!isSupportedLocale(locale)) throw new Error("Unsupported locale"); return path.join(root, `${locale}.json`); } export async function readCmsTranslation( locale: AppLocale, root = defaultRoot, ): Promise { const [base, source, overrides] = await Promise.all([ bundled(locale), bundled("en"), readOverrides(localeFile(locale, root)), ]); const messages = { ...base }; for (const [key, value] of Object.entries(overrides)) { // Obsolete or incompatible overrides must not break new releases. if (!Object.hasOwn(source, key) || validateCmsMessage(value, source[key])) continue; messages[key] = value; } return { messages, revision: createHash("sha256") .update(JSON.stringify([base, overrides])) .digest("hex"), }; } export async function saveCmsTranslation( locale: AppLocale, revision: string, changes: Record, root = defaultRoot, ): Promise { const file = localeFile(locale, root); await fs.mkdir(root, { recursive: true }); return withGamedataLock(file, async () => { const current = await readCmsTranslation(locale, root); if (current.revision !== revision) throw new CmsTranslationError("conflict"); const [base, source, overrides] = await Promise.all([ bundled(locale), bundled("en"), readOverrides(file), ]); for (const [key, value] of Object.entries(changes)) { if (!Object.hasOwn(source, key)) throw new CmsTranslationError("unknownKey", key); if (value === null || value === base[key]) { delete overrides[key]; continue; } const issue = validateCmsMessage(value, source[key]); if (issue) throw new CmsTranslationError(issue, key); overrides[key] = value; } await writeGamedataJsonAtomic(file, overrides); return readCmsTranslation(locale, root); }); } /** Per-request deduplication; runtime edits are visible on the next request. */ export const loadCmsMessages = cache( async (locale: AppLocale): Promise => { try { return unflattenObject( (await readCmsTranslation(locale)).messages, ) as MessageTree; } catch (error) { logger.error("CMS translation overrides could not be loaded", { locale, error, }); return (await import(`../messages/${locale}.json`)).default; } }, );