Files
dynamic-sites-simple/server/src/io/write-content.ts
2026-04-23 08:04:34 -05:00

84 lines
2.6 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { stringifyCanonical } from '@dynamic-sites/shared';
import { writeAuditLog } from '../db.js';
import { logger } from '../logger.js';
import { broadcastReload } from '../live-reload.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const MAX_BACKUPS = 20;
function ensureDir(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function fileHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
}
/**
* Atomic write of canonical JSON to a repo-relative path.
* Creates a pre-write backup and audit log entry.
*/
export function writeContentFile(
repoRelativePath: string,
data: unknown,
opts?: { proposalId?: string; source?: string }
): void {
const absPath = path.join(REPO_ROOT, repoRelativePath);
const canonical = stringifyCanonical(data);
// Pre-write backup
let beforeHash: string | undefined;
if (fs.existsSync(absPath)) {
const existing = fs.readFileSync(absPath, 'utf-8');
beforeHash = fileHash(existing);
// Derive a clean backup subdirectory name from the repo-relative path.
// e.g. "content/sections/hero.json" → "sections/hero" (strip leading content/ and .json)
const stripped = repoRelativePath
.replace(/^content\//, '')
.replace(/\.json$/, '');
const backupDir = path.join(REPO_ROOT, 'content', '.backups', stripped);
ensureDir(backupDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(absPath, path.join(backupDir, `${ts}.json`));
// Prune old backups
const backups = fs.readdirSync(backupDir).sort();
while (backups.length > MAX_BACKUPS) {
const oldest = backups.shift()!;
fs.unlinkSync(path.join(backupDir, oldest));
}
}
// Atomic write: temp file + rename
ensureDir(path.dirname(absPath));
const tmpPath = absPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, canonical, 'utf-8');
fs.renameSync(tmpPath, absPath);
const afterHash = fileHash(canonical);
// Audit log
writeAuditLog({
proposalId: opts?.proposalId,
repoRelativePath,
beforeHash,
afterHash,
source: opts?.source || 'http',
});
logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written');
// Notify any connected browsers to reload.
broadcastReload('content.written', {
path: repoRelativePath,
proposalId: opts?.proposalId,
source: opts?.source,
beforeHash,
afterHash,
});
}