84 lines
2.6 KiB
TypeScript
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,
|
|
});
|
|
}
|