// lib/util/markdown-write.mjs // Markdown frontmatter serializer + atomic markdown writer. // // Companion to lib/util/frontmatter.mjs (parser-only) and lib/util/atomic-write.mjs // (JSON-only). Together they enable the /trekrevise in-place revision loop // (v4.2): read existing artifact -> mutate frontmatter+body -> atomic write. // // Subset constraint mirrors the parser at lib/util/frontmatter.mjs: // - Scalars: string, integer, float, boolean, null // - Arrays of scalars (block-style only — no flow-style [a, b]) // - Arrays of dicts, one level deep (block-style only) // Anything outside this subset is silently dropped or quoted as a string. // // Why no js-yaml: zero-deps invariant. Templates emit only this subset. import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'node:fs'; import { splitFrontmatter, parseDocument } from './frontmatter.mjs'; const SPECIAL_CHARS = /[:#\[\]{},&*!|>'"%@`]|^\s|\s$/; function needsQuote(s) { if (s === '' || s === 'null' || s === '~' || s === 'true' || s === 'false') return true; if (s === '[]' || s === '{}') return true; if (/^-?\d+(\.\d+)?$/.test(s)) return true; if (SPECIAL_CHARS.test(s)) return true; return false; } function serializeScalar(v) { if (v === null || v === undefined) return 'null'; if (typeof v === 'boolean') return v ? 'true' : 'false'; if (typeof v === 'number') return String(v); if (typeof v === 'string') { if (needsQuote(v)) { const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); return `"${escaped}"`; } return v; } return JSON.stringify(v); } /** * Serialize a JS object to YAML frontmatter (subset only). * Returns the YAML body without --- delimiters. */ export function serializeFrontmatter(obj) { if (obj === null || obj === undefined || typeof obj !== 'object') return ''; const lines = []; for (const [key, value] of Object.entries(obj)) { if (value === undefined) continue; if (Array.isArray(value)) { if (value.length === 0) { lines.push(`${key}: []`); continue; } lines.push(`${key}:`); for (const item of value) { if (item !== null && typeof item === 'object' && !Array.isArray(item)) { // Dict in list — block style, one level deep const entries = Object.entries(item).filter(([, v]) => v !== undefined); if (entries.length === 0) { lines.push(` - {}`); continue; } const [firstK, firstV] = entries[0]; lines.push(` - ${firstK}: ${serializeScalar(firstV)}`); for (let i = 1; i < entries.length; i++) { const [k, v] = entries[i]; lines.push(` ${k}: ${serializeScalar(v)}`); } } else { lines.push(` - ${serializeScalar(item)}`); } } } else if (value !== null && typeof value === 'object') { // Single-level dict — emit as multi-line key: \n subkey: value lines.push(`${key}:`); for (const [k, v] of Object.entries(value)) { if (v === undefined) continue; lines.push(` ${k}: ${serializeScalar(v)}`); } } else { lines.push(`${key}: ${serializeScalar(value)}`); } } return lines.join('\n'); } /** * Atomically write a markdown file with frontmatter + body. * Reconstructs as: ---\n{serialized}\n---\n{body} * Single writeFileSync + renameSync for crash-safety. Body bytes preserved verbatim. * * @param {string} path - destination path * @param {object} frontmatter - object to serialize as YAML frontmatter * @param {string} body - markdown body, bytes-verbatim (no normalization) */ export function atomicWriteMarkdown(path, frontmatter, body) { const yaml = serializeFrontmatter(frontmatter); const content = `---\n${yaml}\n---\n${body}`; const tmp = path + '.tmp'; try { writeFileSync(tmp, content); renameSync(tmp, path); } catch (e) { try { unlinkSync(tmp); } catch { /* tmp already gone */ } throw e; } } /** * Read + parse + mutate + write atomically. * mutator receives { frontmatter, body }, returns new { frontmatter, body }. * * @returns {Result} from parseDocument; if invalid, no write happens. */ export function readAndUpdate(path, mutator) { const text = readFileSync(path, 'utf-8'); const doc = parseDocument(text); if (!doc.valid) return doc; const { frontmatter, body } = doc.parsed; const next = mutator({ frontmatter, body }); if (!next || typeof next !== 'object') { return { valid: false, errors: [{ code: 'MD_WRITE_MUTATOR_INVALID', message: 'mutator must return { frontmatter, body }' }], warnings: [], parsed: null }; } atomicWriteMarkdown(path, next.frontmatter || {}, next.body || ''); return { valid: true, errors: [], warnings: [], parsed: next }; }