/** * lib/frontend-writes.ts — the ONE write loop of the frontend primitive * scaffolders (theme, layout, ui-primitives, dashboard, auth). * * Their contracts were identical prose in five `main()` bodies nobody could * test (audit 2026-09-24, B-WARN-11): a file whose head carries `@customised` * is NEVER overwritten (skipped + reported), an identical content is a no-op * unless `force`, an i18n catalogue is DEEP-MERGED so sibling namespaces * survive, and a malformed catalogue is refused — never replaced by `{}` * (B-WARN-22). Paths are traversal-safe (`safeJoinPath`). * * Reported paths keep the shape the envelopes always had: created / modified * files ABSOLUTE, skipped files as given (relative), malformed catalogues absolute. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { safeJoinPath } from './fs.js' import { isCustomised } from './guarded-rm.js' export type FrontendWriteStrategy = 'overwrite' | 'deep-merge-json' export interface FrontendGeneratedFile { /** Relative to the web root. */ path: string content: string /** `overwrite` (default) or `deep-merge-json` (an i18n catalogue — merged into the existing file). */ strategy?: FrontendWriteStrategy } export interface FrontendWriteOptions { projectPath: string /** Rewrite even when the content is identical. Default false. */ force?: boolean } export interface FrontendWriteResult { filesCreated: string[] filesModified: string[] /** Files kept because marked `@customised` (as given in `files[].path`). */ skipped: string[] /** Existing catalogues that are not valid JSON — NOT merged, NOT overwritten (absolute). */ malformed: string[] } /** * Recursively merge `patch` on top of `base`: objects key by key, arrays and * primitives in `patch` replace those in `base` — the merge that lets a * primitive's namespace land in a locale file without wiping `auth.*`, `nav.*` * or a business catalogue. */ export function deepMerge(base: unknown, patch: unknown): unknown { if ( base !== null && typeof base === 'object' && !Array.isArray(base) && patch !== null && typeof patch === 'object' && !Array.isArray(patch) ) { const out: Record = { ...(base as Record) } for (const [k, v] of Object.entries(patch as Record)) { out[k] = deepMerge(out[k], v) } return out } return patch } export function writeFrontendFiles(files: readonly FrontendGeneratedFile[], opts: FrontendWriteOptions): FrontendWriteResult { const force = opts.force ?? false const result: FrontendWriteResult = { filesCreated: [], filesModified: [], skipped: [], malformed: [] } for (const file of files) { const abs = resolve(safeJoinPath(opts.projectPath, file.path)) const exists = existsSync(abs) if (exists && (file.strategy ?? 'overwrite') === 'deep-merge-json') { let existingJson: unknown try { existingJson = JSON.parse(readFileSync(abs, 'utf-8')) } catch { result.malformed.push(abs) continue } const out = JSON.stringify(deepMerge(existingJson, JSON.parse(file.content)), null, 2) + '\n' if (!force && readFileSync(abs, 'utf-8') === out) continue writeFileSync(abs, out, 'utf-8') result.filesModified.push(abs) continue } if (exists) { const existing = readFileSync(abs, 'utf-8') if (isCustomised(existing)) { result.skipped.push(file.path) continue } if (!force && existing === file.content) continue mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, file.content, 'utf-8') result.filesModified.push(abs) continue } mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, file.content, 'utf-8') result.filesCreated.push(abs) } return result }