OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 462e960

README: an API section, in the shape holepunch writes one
Measured our three READMEs against ten holepunch ones (hypercore, hyperswarm, corestore, hyperdht, protomux, b4a, compact-encoding, streamx, brittle, sodium-native). Their skeleton is near-identical every time: name, one sentence, Install, Usage, API, License. The API section IS the README — hypercore is 242 code lines to 246 prose lines, and b4a's API is 132 of its 154. This is a library with seven deep-imported modules and it had a table of export names where they would write a reference. So: every exported function now has a signature, a return shape and the sentence a caller needs, grouped by module. Also fixes an overclaim I shipped yesterday. The CLI section listed explain, lint, project and strip as though all four worked; bin/favela-markdown.js prints "not implemented — needs a projection target" for two of them. Only lint and strip are real, and the README now says so. Added Install (with the note that there is no main and no exports, so the deep-import path IS the contract) and License (MIT, which package.json already declared). Glyph count 3 -> 0. Not one ⛔/⚠/★ appears in 3,300 lines of holepunch README; at two per file they had stopped being emphasis and become texture. The two that were load-bearing — the authority is an identity root, parse never throws — say the same thing in words. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
462e9603aa66a900c88940e370240be02b8e4399
by plankatron <fleeky@gmail.com> · 2026-09-09T21:29:05.000Z

Changed files (1) +143 -65

README.md +143 -65
……@@ -1,11 +1,18 @@
11# favela-markdown
22
3**A post is a markdown file.** Not a signed CBOR record that happens to contain text — a file, with
4a JSON header and a body, that a person could open in an editor and still recognise.
3The favela record codec. A post is a markdown file — parse it, render it, strip it to plain text
4and facets, lint it, and read the `favela://` links inside it.
55
6atproto and ActivityPub are *projections* derived from it when someone reads it, not the thing
7itself. This library is the codec: parse a record, render it, strip it down to plain text, check it,
8and read the `favela://` links inside it. No storage, no network, no keys.
6No storage, no network, no keys. Every function is total: nothing here throws on bad input.
7
8## Install
9
10```sh
11npm install favela-markdown
12```
13
14There is no `main` and no `exports` — callers deep-import `favela-markdown/lib/<module>.js`, and
15that path is the contract.
916
1017## A record
1118
……@@ -26,18 +33,15 @@ JSON in the fence rather than YAML, because `audience: no` parsing as `false` is
2633rather than a curiosity. One file rather than a sidecar, because one durable human-readable
2734artifact is the property worth keeping.
2835
29## Using it
30
31There is no `main` and no `exports` — **consumers deep-import `favela-markdown/lib/*.js`**, and
32that path is the contract.
36## Usage
3337
3438```js
3539import { parse, serialize } from 'favela-markdown/lib/record.js'
3640import { render } from 'favela-markdown/lib/render.js'
3741import { strip } from 'favela-markdown/lib/strip.js'
3842
39const rec = parse(md) // { frontmatter, body, publishable, errors } — never throws
40rec.frontmatter // { createdAt: '2026-09-09T12:00:00.000Z', langs: ['en'] }
43const rec = parse(md)
44rec.frontmatter // { createdAt: '2026-09-09T12:00:00.000Z', langs: ['en'] }
4145
4246render(rec.body)
4347// <p>Hello <span class="mention" data-target="favela://aaaa…">@alice</span> — see the notes.</p>
……@@ -47,85 +51,159 @@ strip(rec.body)
4751// facets: [ { byteStart: 6, byteEnd: 12, type: 'mention', value: 'favela://aaaa…' } ] }
4852```
4953
50`parse` is total: anything unparseable degrades into `errors` rather than throwing, because a
51malformed record someone else wrote must not take down your reader.
54## API
5255
53**Round-tripping.** `serialize(parse(x)) === x` holds for *canonical* input. Anything else gets
54normalised — key order, indentation, the blank line, the trailing newline are all pinned — so a
55record that has been through the codec once is byte-stable forever after. That is the useful
56direction, and it is what makes an in-place rewrite safe.
56### `lib/record.js`
5757
58| module | what it's for |
59|---|---|
60| `lib/record.js` | `parse`, `serialize` — the file, both directions |
61| `lib/render.js` | `render`, `embeds` — HTML, and the embeds a record names |
62| `lib/strip.js` | `strip` — plain text plus facets, the projection everything else derives from |
63| `lib/uri.js` | the `favela://` scheme: `parseUri`, `recordUri`, `userUri`, `blobUri` |
64| `lib/dialect.js` | the pinned markdown extension set |
65| `lib/reach.js` | `NETWORKS`, `maxReach` — how far a record may travel |
66| `lib/lint.js` | `lint` |
58#### `parse(md)`
6759
68And a CLI for looking at one by hand: `favela-markdown explain | lint | project | strip`.
60Returns `{ frontmatter, body, publishable, errors }`. Never throws — anything unparseable degrades
61into `errors` rather than failing, because a malformed record someone else wrote must not take down
62your reader.
6963
70## The two properties that govern everything
64Only the first `---` fence pair delimits the frontmatter, so a `---` later in the body is a
65thematic break and does not truncate the record.
7166
72**Mutable** — a post is a file that can be rewritten. So the content hash changes on every edit,
73and **the rkey is the stable identity, never the hash**. Anything that addresses a post by its
74hash breaks the first time someone fixes a typo.
67#### `serialize({ frontmatter, body })`
7568
76**Extensible** — a new extension never migrates old records, because projections are derived on
77read. Unknown frontmatter keys are preserved and sorted after the pinned ones, so adding a field
78does not change the bytes of any record that predates it.
69Returns the one canonical form: pinned key order, two-space indent, one blank line after the fence,
70exactly one trailing newline.
71
72`serialize(parse(x)) === x` holds for canonical input. Anything else is normalised, so a record
73that has been through the codec once is byte-stable forever after.
74
75#### `CANONICAL_KEYS`
76
77`['createdAt', 'langs', 'reply', 'reach']` — emitted first, in this order. Unknown keys follow,
78sorted, so adding a field never changes the bytes of a record that predates it.
79
80### `lib/render.js`
81
82#### `render(source, { resolveBlob } = {})`
7983
80## The `favela://` scheme
84Returns HTML. `resolveBlob(oid)` returns `{ src, mimeType }` or `null` — how a surface says where a
85`favela://blob/<oid>` can be read from. Omit it and embeds render as nothing, which is correct for
86a caller that cannot serve files.
8187
82Three forms, told apart by shape rather than by the order you test them in:
88#### `embeds(source)`
89
90Returns `[{ oid, alt, kind }]` for every `favela://blob/<oid>` the record references, in source
91order. `kind` is the authoring gesture: `image` for `![…]()`, `link` for `[…]()`.
92
93Uses the same parser `render` does, not a regular expression — the two disagreeing about what a
94record references is how a surface fetches the wrong files.
95
96### `lib/strip.js`
97
98#### `strip(source)`
99
100Returns `{ text, facets }` — the plain-text projection every other network derives from. Facets
101carry `byteStart`/`byteEnd` offsets into the UTF-8 bytes of `text`, not character indices.
102
103Pinned by `spec/markdown-strip.md` and 36 conformance vectors.
104
105### `lib/uri.js`
106
107#### `parseUri(uri)`
108
109Returns `{ kind, root, collection, name, oid }` or `null`. Three forms, told apart by shape rather
110than by the order you test them in:
83111
84112```
85favela://blob/<oid> a blob
86favela://<root> a user
87favela://<root>/<collection>/<name> a record
113favela://blob/<oid> kind: 'blob'
114favela://<root> kind: 'user'
115favela://<root>/<collection>/<name> kind: 'record'
88116```
89117
90⛔ **The authority is an identity ROOT, never a feed key.** Both are 64 hex characters, so nothing
91in the bytes distinguishes them and a reader cannot tell which it was handed. A feed key says where
92bytes are; a root says who someone is, and only the second survives rotation. `recordUri` refuses
93anything that is not a 64-hex root, because once a URI is written into a record it cannot be
94corrected — you may not rewrite a record you did not author.
118Anything else is `null`. Deliberately permissive about the authority's shape, because it has to
119read bytes that were already written.
95120
96`parseUri` is deliberately more permissive: it has to read bytes that were already written.
121#### `isUserUri(uri)` · `isRecordUri(uri)` · `isBlobUri(uri)`
97122
98## Why this exists
123Booleans over `parseUri`.
99124
100atproto became favela's canonical P2P path by default rather than by decision. That pulled MST
101derivation, DAG-CBOR byte-exactness, lexicon field ceilings and blob CIDs sealed inside signed
102records into a network that only ever wanted to *interface* with atproto. All of that machinery is
103genuinely useful at the bridge layer. None of it belongs underneath every full peer.
125#### `recordUri({ root, collection, name })`
104126
105So a full peer needs: parse markdown, one file per record, replicate. **No MST, no DAG-CBOR, no
106byte-exact CIDs.** Those are the bridge's problem.
127Mints a record URI. **Throws unless `root` is 64 hex characters.** The authority is an identity
128root, never a feed key — both are 64 hex characters, so nothing in the bytes distinguishes them,
129and a URI written into a record can never be corrected afterwards. Refusing at mint time is the
130only place this can be enforced.
107131
108## Status
132#### `userUri(root)` · `blobUri(oid)`
133
134Mint the other two forms.
109135
110Working. `npm test` runs 77 tests — 2432 asserts, all passing — including the 36 `markdown-strip`
111conformance vectors and a fuzzer.
136### `lib/reach.js`
112137
113In use by `favela-peer` (`record`, `render`, `strip`, `reach`) and `favela-surface` (`render`,
114`uri`).
138#### `NETWORKS`
115139
116⚠ Three other projections still derive their own way and do **not** import this package:
117`favela-web`, `atproto-room/lib/signer.js` `materialize()`, and `ap-bridge/lib/as2-project.js`.
118Wiring them to this codec is outstanding work — the point of a single codec is lost while three
119consumers each keep their own.
140`['favela', 'bsky', 'fediverse']`.
141
142#### `maxReach({ roomType, verb })`
143
144Returns a `Set` of networks — the meet of what the room can express and what the verb can express.
145Always contains `favela`; there is no way to opt out of the local substrate. An unrecognised
146`roomType` or `verb` yields local-only, and matching is exact: `'PUBLIC'` is not `'public'`,
147because normalising here would let a typo widen an audience.
148
149### `lib/lint.js`
150
151#### `lint(source, { roomType } = {})`
152
153Returns `{ ok, publishable, issues, text, facets }`. Without `roomType` it makes no claim about
154reach, because the room sets the ceiling and a file on disk does not say which room it belongs to.
155
156### `lib/dialect.js`
157
158The pinned micromark extension set (`MICROMARK`), plus `isHttp`, `isMention`, `isBlob`, `blobOid`
159and the URL/wikilink/hashtag patterns. Import this rather than re-deriving what counts as a mention.
160
161## CLI
162
163```sh
164favela-markdown strip <file> # text + facets
165favela-markdown lint <file> [--room KIND] # frontmatter, reach, directives, mentions
166```
167
168`--room` is `public | followers | members | dm`. `project` and `explain` are declared but **not
169implemented** — both need a projection target.
170
171## The two properties that govern everything
172
173**Mutable** — a post is a file that can be rewritten. The content hash changes on every edit, so
174the rkey is the stable identity and the hash is not. Anything addressing a post by its hash breaks
175the first time someone fixes a typo.
176
177**Extensible** — a new extension never migrates old records, because projections are derived on
178read.
179
180## Why this exists
181
182atproto became favela's canonical P2P path by default rather than by decision. That pulled MST
183derivation, DAG-CBOR byte-exactness, lexicon field ceilings and blob CIDs sealed inside signed
184records into a network that only ever wanted to *interface* with atproto. That machinery is useful
185at the bridge layer; none of it belongs underneath every full peer.
186
187A full peer needs: parse markdown, one file per record, replicate. No MST, no DAG-CBOR, no
188byte-exact CIDs — those are the bridge's problem.
120189
121190## Specs
122191
123- `spec/design.md` — the decision and what it changes ⚠ its header still reads
124 "Status: design. Nothing built."
192- `spec/design.md` — the decision and what it changes
125193- `spec/library.md` — the surface, its guarantees, and the sync obligations it inherits
126194- `spec/record-vocabulary.md` — the `net.favela.post` vocabulary
127195- `spec/interaction.md`
128196- `spec/markdown-strip.md` + `spec/vectors/markdown-strip.json` — the strip contract, 36 vectors
129197
130The vocabulary and strip specs moved here from `favela-web/spec/` on 2026-09-02: they describe the
131record, not the web app.
198## Status
199
200Working. `npm test` runs 77 tests / 2432 asserts, including the conformance vectors and a fuzzer.
201
202Used by `favela-peer` (`record`, `render`, `strip`, `reach`) and `favela-surface` (`render`, `uri`).
203Three other projections still derive their own way and do not import this package — `favela-web`,
204`atproto-room/lib/signer.js` `materialize()`, and `ap-bridge/lib/as2-project.js`. Wiring them up is
205outstanding work.
206
207## License
208
209MIT