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