| … | … | @@ -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. | |
| 21 | export const CANONICAL_KEYS = ['createdAt', 'langs', 'reply', 'reach'] | |
| 22 | ||
| 23 | const FENCE = '---' | |
| 24 | const 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. | |
| 27 | function 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 | */ | |
| 39 | export 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 | */ | |
| 90 | export 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 | ||
| 102 | export default { parse, serialize, CANONICAL_KEYS } |
| … | … | @@ -14,5 +14,8 @@ |
| 14 | 14 | "micromark-extension-gfm-footnote": "^2.1.0", |
| 15 | 15 | "micromark-extension-gfm-strikethrough": "^2.1.0", |
| 16 | 16 | "micromark-extension-gfm-table": "^2.1.1" |
| 17 | }, | |
| 18 | "scripts": { | |
| 19 | "test": "node test/strip.test.js && node test/record.test.js" | |
| 17 | 20 | } |
| 18 | 21 | } |
| … | … | @@ -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 | ||
| 14 | import test from 'brittle' | |
| 15 | import { parse, serialize, CANONICAL_KEYS } from '../lib/record.js' | |
| 16 | ||
| 17 | const utf8 = (s) => Buffer.from(s, 'utf8') | |
| 18 | const 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. | |
| 21 | const CANON = `--- | |
| 22 | { | |
| 23 | "createdAt": "2026-08-05T13:38:23.294Z", | |
| 24 | "langs": [ | |
| 25 | "en" | |
| 26 | ] | |
| 27 | } | |
| 28 | --- | |
| 29 | ||
| 30 | hello **world** | |
| 31 | ` | |
| 32 | ||
| 33 | test('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 | ||
| 42 | test('round-trip: serialize(parse(x)) === x for canonical input (§7, §10.4)', (t) => { | |
| 43 | bytes(t, serialize(parse(CANON)), CANON, 'byte-identical') | |
| 44 | }) | |
| 45 | ||
| 46 | test('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 | ||
| 51 | test('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 | ||
| 59 | test('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 | ||
| 66 | test('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 | ||
| 78 | test('⛔ 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 | ||
| 88 | test('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 | ||
| 94 | test('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 | ||
| 107 | test('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 | }) |