// Surgical line-level update for JSON5 files that preserves comments and // formatting. Used by saveClientTranslations so that the Nitro localization // files keep their headers and section markers when an admin edits a value. // // Assumptions (true for every Nitro `.json5` file in the repo): // * one entry per line, formatted as `: ,` // * keys are simple strings (single- or double-quoted, or bare) // * values are strings (single- or double-quoted) // If a changed key cannot be located via this contract, we report it back so // the caller can decide whether to fall back to a full re-serialization. export interface PatchResult { /** The new file content, with surgical edits applied. */ content: string; /** Keys that we could not patch surgically (missing or unusual format). */ unpatchedKeys: string[]; } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function escapeForQuote(value: string, quote: '"' | "'"): string { // Escape backslashes first, then the chosen quote. Newlines/tabs are // preserved as escape sequences so we don't break the JSON5 parser. let out = value.replace(/\\/g, "\\\\"); out = out.replace(new RegExp(quote, "g"), `\\${quote}`); out = out.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); return out; } /** * Apply per-key value updates to a JSON5 source string while preserving * comments and formatting. Only keys present in `original` (and with a * different value in `updated`) are touched. */ export function patchJson5( raw: string, original: Record, updated: Record, ): PatchResult { let result = raw; const unpatchedKeys: string[] = []; for (const [key, newVal] of Object.entries(updated)) { if (!(key in original)) { // Key did not exist on disk — append/merge logic is the caller's job. unpatchedKeys.push(key); continue; } if (original[key] === newVal) continue; const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`; const re = new RegExp(keyPattern); const idx = result.search(re); if (idx < 0) { unpatchedKeys.push(key); continue; } const match = re.exec(result); if (!match) { unpatchedKeys.push(key); continue; } const valueQuote = match[2] as '"' | "'"; const oldValueLen = match[3].length; const fullLen = match[0].length; // prefix keeps everything up to and including the opening value quote const prefix = match[0].slice(0, fullLen - oldValueLen - 1); const escaped = escapeForQuote(newVal, valueQuote); const replacement = prefix + escaped + valueQuote; result = result.slice(0, idx) + replacement + result.slice(idx + fullLen); } return { content: result, unpatchedKeys }; }