// parse / serialize — the canonical favela record.
//
// A record is ONE FILE: a JSON object inside a `---` fence, a blank line, then the markdown body
// (design.md §5). JSON-in-a-fence was chosen over YAML because `audience: no` parsing as `false` is
// a privacy bug, and over a sidecar because one durable human-readable artifact is the property
// worth keeping.
//
// THE ROUND-TRIP LAW IS WHY THIS FILE IS CAREFUL. library.md §10.4: `migrateInline` leans on
// DAG-CBOR being canonical so a rewrite is byte-identical, and ASSERTS it — skipping any key whose
// re-encoded oid differs rather than republishing changed content. Markdown gets no such guarantee
// for free, so `serialize(parse(x)) === x` has to be engineered: key order, indentation, the blank
// line, the trailing newline. Each is pinned below and each is a vector in test/record.test.js.
//
// The law holds "for canonical input" (§7). serialize() emits ONE canonical form, so
// serialize(parse(x)) NORMALISES anything else. That is the useful direction — a record that has
// been through the codec once is stable forever after, which is exactly what migrateInline needs.
// §3.2 — the pinned field order. Unknown keys follow, sorted, so an extension can be added without
// reordering (and therefore without changing the bytes of) any record that predates it: design.md
// §1.1's extensibility property, enforced here rather than remembered.
export const CANONICAL_KEYS = ['createdAt', 'langs', 'reply', 'reach']
const FENCE = '---'
const INDENT = 2 // matches design.md §5's example; pinned because it is part of the bytes
// Order an object's keys canonically: pinned ones first in their fixed order, then the rest sorted.
function canonicalise (obj) {
const out = {}
for (const k of CANONICAL_KEYS) if (k in obj) out[k] = obj[k]
for (const k of Object.keys(obj).sort()) if (!(k in out)) out[k] = obj[k]
return out
}
/**
* parse(md) -> { frontmatter, body, publishable, errors }
*
* TOTAL — never throws (§2). Anything unparseable degrades rather than failing.
*/
export function parse (md) {
const src = String(md == null ? '' : md).replace(/\r\n?/g, '\n') // §5.1, on the input
const errors = []
// The frontmatter is delimited by the FIRST fence pair only. A `---` later in the body is a
// thematic break and must not truncate the record — otherwise a post about markdown loses half
// of itself, silently.
if (!src.startsWith(FENCE + '\n')) {
if (src.trim() !== '') errors.push({ code: 'no-frontmatter', message: 'no leading --- fence' })
return { frontmatter: {}, body: src, publishable: false, errors }
}
const close = src.indexOf('\n' + FENCE + '\n', FENCE.length)
const closeAtEnd = src.endsWith('\n' + FENCE) ? src.length - FENCE.length - 1 : -1
const end = close >= 0 ? close : closeAtEnd
if (end < 0) {
errors.push({ code: 'unterminated-frontmatter', message: 'no closing --- fence' })
return { frontmatter: {}, body: src, publishable: false, errors }
}
const raw = src.slice(FENCE.length + 1, end)
const after = close >= 0 ? src.slice(end + FENCE.length + 2) : ''
// Exactly one blank line separates the fence from the body in canonical form; tolerate its
// absence on the way IN (serialize puts it back) so a hand-written file still reads.
const body = after.startsWith('\n') ? after.slice(1) : after
let frontmatter = {}
try {
const v = JSON.parse(raw)
if (v && typeof v === 'object' && !Array.isArray(v)) frontmatter = v
else errors.push({ code: 'frontmatter-not-object', message: 'frontmatter must be a JSON object' })
} catch (err) {
errors.push({ code: 'frontmatter-invalid-json', message: err.message })
}
// §6.2 ⛔ — a record whose frontmatter does not parse is readable, renderable and NOT publishable.
// It degrades to local-only, NEVER to Public. This is the one place a defect is a privacy bug, so
// it fails closed: no frontmatter means nothing asserts anything, including who may see it.
const publishable = errors.length === 0 && typeof frontmatter.createdAt === 'string'
if (errors.length === 0 && !publishable) {
errors.push({ code: 'no-created-at', message: 'createdAt is required to publish' })
}
return { frontmatter, body, publishable, errors }
}
/**
* serialize({ frontmatter, body }) -> md
*
* Emits the ONE canonical form. Every byte below is pinned by a test.
*/
export function serialize (record) {
const fm = canonicalise((record && record.frontmatter) || {})
let body = String((record && record.body) || '')
// Exactly one trailing newline. Pinned because "no trailing newline" and "two" are both stable
// states a text editor will produce, and either would make the law fail for reasons no author
// could see.
body = body.replace(/\n+$/, '') + '\n'
return FENCE + '\n' + JSON.stringify(fm, null, INDENT) + '\n' + FENCE + '\n\n' + body
}
export default { parse, serialize, CANONICAL_KEYS }