OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / b951400

lib: parse/serialize with the round-trip law — 10/10, 31/31
Implements record-vocabulary 3.2, design 5, library 6.2/7/10.4. Every assertion is on BYTES because the law is load-bearing: migrateInline ASSERTS byte-identity on rewrite and skips keys whose oid differs, and markdown gets no such guarantee for free. Pinned, each with a test: canonical key order (pinned keys first, unknown sorted after - so an extension never reorders a record that predates it), 2-space indent, the blank line after the fence, exactly one trailing newline. The law holds 'for canonical input' (7), so serialize NORMALISES anything else - a record through the codec once is stable forever, which is what migrateInline needs. Idempotence is its own test. 6.2 fails closed: malformed or absent frontmatter is readable and renderable but NOT publishable, degrading to local-only and never to Public. Only the FIRST fence pair delimits, so a --- in the body is not truncated. npm test now runs both files: 13 tests, 182 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b9514005ad3a1bd2f19a54f127e810e32e77ec6f
by plankatron <fleeky@gmail.com> · 2026-09-06T00:41:24.000Z

Changed files (3) +217 -0

lib/record.js +102 -0
……@@ -0,0 +1,102 @@
1// parse / serialize — the canonical favela record.
2//
3// A record is ONE FILE: a JSON object inside a `---` fence, a blank line, then the markdown body
4// (design.md §5). JSON-in-a-fence was chosen over YAML because `audience: no` parsing as `false` is
5// a privacy bug, and over a sidecar because one durable human-readable artifact is the property
6// worth keeping.
7//
8// THE ROUND-TRIP LAW IS WHY THIS FILE IS CAREFUL. library.md §10.4: `migrateInline` leans on
9// DAG-CBOR being canonical so a rewrite is byte-identical, and ASSERTS it — skipping any key whose
10// re-encoded oid differs rather than republishing changed content. Markdown gets no such guarantee
11// for free, so `serialize(parse(x)) === x` has to be engineered: key order, indentation, the blank
12// line, the trailing newline. Each is pinned below and each is a vector in test/record.test.js.
13//
14// The law holds "for canonical input" (§7). serialize() emits ONE canonical form, so
15// serialize(parse(x)) NORMALISES anything else. That is the useful direction — a record that has
16// been through the codec once is stable forever after, which is exactly what migrateInline needs.
17
18// §3.2 — the pinned field order. Unknown keys follow, sorted, so an extension can be added without
19// reordering (and therefore without changing the bytes of) any record that predates it: design.md
20// §1.1's extensibility property, enforced here rather than remembered.
21export const CANONICAL_KEYS = ['createdAt', 'langs', 'reply', 'reach']
22
23const FENCE = '---'
24const INDENT = 2 // matches design.md §5's example; pinned because it is part of the bytes
25
26// Order an object's keys canonically: pinned ones first in their fixed order, then the rest sorted.
27function canonicalise (obj) {
28 const out = {}
29 for (const k of CANONICAL_KEYS) if (k in obj) out[k] = obj[k]
30 for (const k of Object.keys(obj).sort()) if (!(k in out)) out[k] = obj[k]
31 return out
32}
33
34/**
35 * parse(md) -> { frontmatter, body, publishable, errors }
36 *
37 * TOTAL — never throws (§2). Anything unparseable degrades rather than failing.
38 */
39export function parse (md) {
40 const src = String(md == null ? '' : md).replace(/\r\n?/g, '\n') // §5.1, on the input
41 const errors = []
42
43 // The frontmatter is delimited by the FIRST fence pair only. A `---` later in the body is a
44 // thematic break and must not truncate the record — otherwise a post about markdown loses half
45 // of itself, silently.
46 if (!src.startsWith(FENCE + '\n')) {
47 if (src.trim() !== '') errors.push({ code: 'no-frontmatter', message: 'no leading --- fence' })
48 return { frontmatter: {}, body: src, publishable: false, errors }
49 }
50
51 const close = src.indexOf('\n' + FENCE + '\n', FENCE.length)
52 const closeAtEnd = src.endsWith('\n' + FENCE) ? src.length - FENCE.length - 1 : -1
53 const end = close >= 0 ? close : closeAtEnd
54 if (end < 0) {
55 errors.push({ code: 'unterminated-frontmatter', message: 'no closing --- fence' })
56 return { frontmatter: {}, body: src, publishable: false, errors }
57 }
58
59 const raw = src.slice(FENCE.length + 1, end)
60 const after = close >= 0 ? src.slice(end + FENCE.length + 2) : ''
61 // Exactly one blank line separates the fence from the body in canonical form; tolerate its
62 // absence on the way IN (serialize puts it back) so a hand-written file still reads.
63 const body = after.startsWith('\n') ? after.slice(1) : after
64
65 let frontmatter = {}
66 try {
67 const v = JSON.parse(raw)
68 if (v && typeof v === 'object' && !Array.isArray(v)) frontmatter = v
69 else errors.push({ code: 'frontmatter-not-object', message: 'frontmatter must be a JSON object' })
70 } catch (err) {
71 errors.push({ code: 'frontmatter-invalid-json', message: err.message })
72 }
73
74 // §6.2 ⛔ — a record whose frontmatter does not parse is readable, renderable and NOT publishable.
75 // It degrades to local-only, NEVER to Public. This is the one place a defect is a privacy bug, so
76 // it fails closed: no frontmatter means nothing asserts anything, including who may see it.
77 const publishable = errors.length === 0 && typeof frontmatter.createdAt === 'string'
78 if (errors.length === 0 && !publishable) {
79 errors.push({ code: 'no-created-at', message: 'createdAt is required to publish' })
80 }
81
82 return { frontmatter, body, publishable, errors }
83}
84
85/**
86 * serialize({ frontmatter, body }) -> md
87 *
88 * Emits the ONE canonical form. Every byte below is pinned by a test.
89 */
90export function serialize (record) {
91 const fm = canonicalise((record && record.frontmatter) || {})
92 let body = String((record && record.body) || '')
93
94 // Exactly one trailing newline. Pinned because "no trailing newline" and "two" are both stable
95 // states a text editor will produce, and either would make the law fail for reasons no author
96 // could see.
97 body = body.replace(/\n+$/, '') + '\n'
98
99 return FENCE + '\n' + JSON.stringify(fm, null, INDENT) + '\n' + FENCE + '\n\n' + body
100}
101
102export default { parse, serialize, CANONICAL_KEYS }
package.json +3 -0
……@@ -14,5 +14,8 @@
1414 "micromark-extension-gfm-footnote": "^2.1.0",
1515 "micromark-extension-gfm-strikethrough": "^2.1.0",
1616 "micromark-extension-gfm-table": "^2.1.1"
17 },
18 "scripts": {
19 "test": "node test/strip.test.js && node test/record.test.js"
1720 }
1821}
test/record.test.js +112 -0
……@@ -0,0 +1,112 @@
1// parse / serialize — spec/record-vocabulary.md §3.2, spec/design.md §5, spec/library.md §6.2, §7, §10.4
2//
3// The round-trip law `serialize(parse(x)) === x` is LOAD-BEARING, not a nicety (library §10.4):
4// `migrateInline` leans on DAG-CBOR being canonical so a rewrite is byte-identical, and ASSERTS it,
5// skipping any key whose re-encoded oid differs rather than republishing. Markdown has no such
6// guarantee for free — "JSON frontmatter key order, whitespace, trailing newline" must be engineered.
7// So every assertion here is on BYTES.
8//
9// Note what the law does and does not say. §7 states it "for canonical input". serialize() therefore
10// emits ONE canonical form, and serialize(parse(x)) NORMALISES non-canonical input rather than
11// preserving it. That is the useful direction: a record that has been through the codec once is
12// stable forever after, which is what migrateInline needs.
13
14import test from 'brittle'
15import { parse, serialize, CANONICAL_KEYS } from '../lib/record.js'
16
17const utf8 = (s) => Buffer.from(s, 'utf8')
18const bytes = (t, a, b, m) => t.alike(utf8(a), utf8(b), m)
19
20// The canonical shape: a JSON object in a --- fence, a blank line, the body, one trailing newline.
21const CANON = `---
22{
23 "createdAt": "2026-08-05T13:38:23.294Z",
24 "langs": [
25 "en"
26 ]
27}
28---
29
30hello **world**
31`
32
33test('parse: the canonical shape', (t) => {
34 const r = parse(CANON)
35 t.is(r.frontmatter.createdAt, '2026-08-05T13:38:23.294Z')
36 t.alike(r.frontmatter.langs, ['en'])
37 bytes(t, r.body, 'hello **world**\n', 'body is the source below the fence, verbatim')
38 t.ok(r.publishable, 'well-formed frontmatter is publishable')
39 t.alike(r.errors, [], 'errors is [] — a claim, not a silence (library §4)')
40})
41
42test('round-trip: serialize(parse(x)) === x for canonical input (§7, §10.4)', (t) => {
43 bytes(t, serialize(parse(CANON)), CANON, 'byte-identical')
44})
45
46test('round-trip is IDEMPOTENT — the second pass changes nothing', (t) => {
47 const once = serialize(parse(CANON))
48 bytes(t, serialize(parse(once)), once, 'a record through the codec once is stable forever')
49})
50
51test('serialize NORMALISES non-canonical input, and then it is stable', (t) => {
52 // Keys out of order, 4-space indent, no blank line after the fence, no trailing newline.
53 const messy = '---\n{\n "langs": ["en"],\n "createdAt": "2026-08-05T13:38:23.294Z"\n}\n---\nhello **world**'
54 const out = serialize(parse(messy))
55 bytes(t, out, CANON, 'normalised to the canonical form')
56 bytes(t, serialize(parse(out)), out, 'and stable thereafter')
57})
58
59test('canonical key ORDER is pinned, not insertion order (§10.4)', (t) => {
60 const a = serialize({ frontmatter: { langs: ['en'], createdAt: 'T' }, body: 'x\n' })
61 const b = serialize({ frontmatter: { createdAt: 'T', langs: ['en'] }, body: 'x\n' })
62 bytes(t, a, b, 'two key orders serialise identically')
63 t.is(CANONICAL_KEYS[0], 'createdAt', 'createdAt leads the canonical order')
64})
65
66test('unknown keys survive, ordered after the pinned ones', (t) => {
67 const src = serialize({
68 frontmatter: { 'net.favela.ext.zzz': 1, createdAt: 'T', 'net.favela.ext.aaa': 2 },
69 body: 'x\n'
70 })
71 const back = parse(src)
72 t.is(back.frontmatter['net.favela.ext.aaa'], 2, 'unknown keys are preserved — §1.1 extensibility')
73 t.ok(src.indexOf('"createdAt"') < src.indexOf('"net.favela.ext.aaa"'), 'pinned keys lead')
74 t.ok(src.indexOf('"net.favela.ext.aaa"') < src.indexOf('"net.favela.ext.zzz"'), 'then sorted')
75 bytes(t, serialize(parse(src)), src, 'and it round-trips')
76})
77
78test('⛔ malformed frontmatter is readable but NOT publishable (§6.2)', (t) => {
79 // JSON-in-a-fence was chosen over YAML because `audience: no` parsing as false is a privacy bug.
80 // The remaining gap was that frontmatter parsing had no totality rule. This is it.
81 const broken = '---\n{ "createdAt": oops }\n---\n\nthe body still reads\n'
82 const r = parse(broken)
83 t.absent(r.publishable, 'fails CLOSED — degrades to local-only, never to Public')
84 t.ok(r.errors.length > 0, 'and says why')
85 bytes(t, r.body, 'the body still reads\n', 'still readable and renderable')
86})
87
88test('no frontmatter at all: readable, not publishable', (t) => {
89 const r = parse('just a body\n')
90 t.absent(r.publishable, 'nothing asserts createdAt, so nothing may be published')
91 bytes(t, r.body, 'just a body\n', 'the whole source is the body')
92})
93
94test('parse is TOTAL — never throws (§2 applies to the whole codec)', (t) => {
95 const hostile = ['', '---', '---\n', '---\n---', '---\n{}\n---', '---\nnot json\n---\nbody',
96 '\n\n\n', '---\n{}\n---\n\n\n', '{ "createdAt": "T" }']
97 for (const s of hostile) {
98 try {
99 const r = parse(s)
100 t.ok(typeof r.body === 'string' && typeof r.publishable === 'boolean', 'total on ' + JSON.stringify(s))
101 } catch (err) {
102 t.fail('threw on ' + JSON.stringify(s) + ': ' + err.message)
103 }
104 }
105})
106
107test('a body containing a --- fence is not truncated', (t) => {
108 const src = '---\n{\n "createdAt": "T"\n}\n---\n\nabove\n\n---\n\nbelow\n'
109 const r = parse(src)
110 t.ok(r.body.includes('below'), 'only the FIRST fence pair delimits the frontmatter')
111 bytes(t, serialize(parse(src)), src, 'and it round-trips')
112})