OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 58ead5d

render: embedded files — favela://blob/<oid>
record-vocabulary.md §3.5. A record never contains media; it names a file the overlay holds, and the bytes arrive P2P over hyper-overlay. ⛔ The library has NO OPINION about the URL scheme. `render(md, { resolveBlob })` asks the SURFACE where a blob can be read from and only escapes the answer. An earlier version of this design had the renderer resolve to an http URL and reuse the `isHttp` allowlist — that bakes a browser assumption into a pure library, and the surfaces are not all browsers. Caught by the operator asking why HTTP was being mentioned at all in a P2P stack; the answer was that it should not be. ⛔ The resolver's output is NOT run through `isHttp`. That allowlist guards AUTHOR-supplied URLs — the untrusted input it exists for — and is unchanged: `javascript:` and `data:` are still dropped, pinned by a test, because adding embeds must not widen it. ★ BLOB IS MATCHED BEFORE MENTION. `isMention` is bare `^favela://`, so it matches `favela://blob/<oid>` too — testing mentions first turns every embed into a mention span with no error, no image and nothing to debug. Mutation-tested: with the blob branch removed, two tests fail. The bytes decide the element, not the syntax: `![](…)` is an <img> for an image and a <video controls> for a video; a type that is neither renders as nothing rather than a broken image. `[](…)` is a link, or an inline <embed> for a PDF because a browser already knows how. 61/61, 2383/2383. Existing behaviour untouched — with no resolver every embed renders as nothing, which is correct for a caller that cannot serve files. ⚠ A process note: I lost this file once to `git checkout lib/render.js` while undoing a mutation test, on work that was not committed. Commit before mutating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
58ead5dd0cc8072c6c4d4e5154fd83e69d2e1bfd
by plankatron <fleeky@gmail.com> · 2026-09-07T01:14:42.000Z

Changed files (4) +157 -29

lib/dialect.js +13 -0
……@@ -36,3 +36,16 @@ export const HASHTAG_RE = /(^|\s)(#([A-Za-z0-9_À-
3636export const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
3737// §3.4 — a mention names a USER: `favela://<identity-root>`; `did:` is the legacy form.
3838export const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || ''))
39
40// record-vocabulary.md §3.5 — an embedded file: `favela://blob/<oid>`, where `<oid>` is the
41// hyper-overlay content hash. A record never contains media; it names a file the overlay holds.
42//
43// ⛔ This form ALSO matches `isMention`, which is `^favela://`. Every caller must test for a blob
44// FIRST — otherwise an embed renders as a mention span and the picture silently disappears. That
45// ordering is not a style choice, it is the difference between an image and nothing.
46export const BLOB_RE = /^favela:\/\/blob\/([0-9a-f]{8,128})$/i
47export const isBlob = (uri) => BLOB_RE.test(String(uri || ''))
48export const blobOid = (uri) => {
49 const m = BLOB_RE.exec(String(uri || ''))
50 return m ? m[1].toLowerCase() : null
51}
lib/render.js +79 -28
……@@ -16,7 +16,7 @@
1616// one of them is lossy — which is why `library.md` §5 asks the composer to say so.
1717
1818import { fromMarkdown } from 'mdast-util-from-markdown'
19import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention } from './dialect.js'
19import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention, blobOid } from './dialect.js'
2020
2121const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
2222const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
……@@ -33,10 +33,45 @@ function inlineText (raw) {
3333 return out
3434}
3535
36const children = (node) => (node.children || []).map(toHtml).join('')
36const children = (node, o) => (node.children || []).map((c) => toHtml(c, o)).join('')
3737
38function link (node) {
38/**
39 * An embedded file: `favela://blob/<oid>` (record-vocabulary.md §3.5).
40 *
41 * ⛔ The library has NO OPINION about the URL scheme. `resolveBlob(oid)` belongs to the surface —
42 * an HTTP surface returns a loopback path, a native one returns whatever it can display — and this
43 * only escapes what comes back. Baking `http` in here would make a pure library assume a browser,
44 * and the surfaces are not all browsers. The bytes themselves never move over HTTP: they arrive
45 * P2P through hyper-overlay, and the surface is only saying where it can read them from.
46 *
47 * ⛔ It is NOT run through `isHttp`. That allowlist guards AUTHOR-supplied URLs, which are the
48 * untrusted input it exists for; a resolver is the surface's own code and no author can influence
49 * what it returns.
50 *
51 * Absent resolver, unknown oid, or a type this element cannot show ⇒ nothing, the same fail-closed
52 * posture the rest of this file has.
53 */
54function blob (o, url) {
55 const oid = blobOid(url)
56 if (!oid) return null
57 const r = o && typeof o.resolveBlob === 'function' ? o.resolveBlob(oid) : null
58 if (!r || typeof r.src !== 'string' || r.src === '') return null
59 return { src: r.src, mimeType: typeof r.mimeType === 'string' ? r.mimeType : '' }
60}
61
62function link (node, o) {
3963 const url = node.url || ''
64 // ⛔ BLOB FIRST. `favela://blob/<oid>` also matches `isMention` (which is bare `^favela://`), so
65 // testing mentions first turns every embed into a mention span and the file silently vanishes.
66 const b = blob(o, url)
67 if (b) {
68 // A PDF is shown inline because a browser already knows how; anything else is a link. Either
69 // way the bytes came P2P and the surface's resolver said where to read them.
70 if (b.mimeType === 'application/pdf') {
71 return '<embed class="doc" type="application/pdf" src="' + esc(b.src) + '">'
72 }
73 return '<a class="file" href="' + esc(b.src) + '">' + children(node, o) + '</a>'
74 }
4075 // A mention is OURS to resolve, so it never becomes an href — a browser cannot follow favela://
4176 // and a surface should not be tempted to try. The target rides in a data attribute.
4277 if (isMention(url)) {
……@@ -48,15 +83,27 @@ function link (node) {
4883 return '<a href="' + esc(url) + '" rel="noopener noreferrer nofollow">' + children(node) + '</a>'
4984}
5085
51function image (node) {
86function image (node, o) {
87 const alt = esc(node.alt || '')
88 const b = blob(o, node.url || '')
89 if (b) {
90 if (b.mimeType.startsWith('image/')) return '<img src="' + esc(b.src) + '" alt="' + alt + '">'
91 // ⛔ `![]()` on a video is deliberate, not a mistake: §3.5 has the embed pipeline consuming
92 // images in source order, and a video is the same authoring gesture with different bytes.
93 if (b.mimeType.startsWith('video/')) {
94 return '<video class="embed" controls preload="metadata" src="' + esc(b.src) + '"></video>'
95 }
96 // A file that is neither is not an image, whatever the author wrote. Dropped, not guessed.
97 return ''
98 }
5299 if (!isHttp(node.url || '')) return '' // dropped entirely — an <img> with no usable src is noise
53 return '<img src="' + esc(node.url) + '" alt="' + esc(node.alt || '') + '">'
100 return '<img src="' + esc(node.url) + '" alt="' + alt + '">'
54101}
55102
56103// :::spoiler / :::cw <text> / :::details — the pinned set (library.md §5).
57function directive (node) {
104function directive (node, o) {
58105 const name = node.name || ''
59 const inner = children(node)
106 const inner = children(node, o)
60107 if (name === 'spoiler') return '<details class="spoiler"><summary>spoiler</summary>' + inner + '</details>'
61108 if (name === 'cw') {
62109 const label = (node.attributes && node.attributes.label) || textOf(node.children && node.children[0]) || 'content warning'
……@@ -70,61 +117,65 @@ function directive (node) {
70117
71118const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) => c.value || '').join('') : '')
72119
73function toHtml (node) {
120function toHtml (node, o) {
74121 if (!node || typeof node !== 'object') return ''
75122 switch (node.type) {
76 case 'root': return children(node)
77 case 'paragraph': return '<p>' + children(node) + '</p>'
123 case 'root': return children(node, o)
124 case 'paragraph': return '<p>' + children(node, o) + '</p>'
78125 case 'text': return inlineText(node.value)
79 case 'strong': return '<strong>' + children(node) + '</strong>'
80 case 'emphasis': return '<em>' + children(node) + '</em>'
81 case 'delete': return '<del>' + children(node) + '</del>'
126 case 'strong': return '<strong>' + children(node, o) + '</strong>'
127 case 'emphasis': return '<em>' + children(node, o) + '</em>'
128 case 'delete': return '<del>' + children(node, o) + '</del>'
82129 case 'inlineCode': return '<code>' + esc(node.value) + '</code>'
83130 case 'break': return '<br>'
84131 case 'thematicBreak': return '<hr>'
85 case 'link': return link(node)
86 case 'image': return image(node)
132 case 'link': return link(node, o)
133 case 'image': return image(node, o)
87134 case 'heading': {
88135 const d = Math.min(Math.max(node.depth || 1, 1), 6)
89 return '<h' + d + '>' + children(node) + '</h' + d + '>'
136 return '<h' + d + '>' + children(node, o) + '</h' + d + '>'
90137 }
91 case 'blockquote': return '<blockquote>' + children(node) + '</blockquote>'
92 case 'list': return node.ordered ? '<ol>' + children(node) + '</ol>' : '<ul>' + children(node) + '</ul>'
138 case 'blockquote': return '<blockquote>' + children(node, o) + '</blockquote>'
139 case 'list': return node.ordered ? '<ol>' + children(node, o) + '</ol>' : '<ul>' + children(node, o) + '</ul>'
93140 case 'listItem': {
94141 const inner = (node.children || []).map((c) =>
95 c.type === 'paragraph' ? children(c) : toHtml(c)).join('')
142 c.type === 'paragraph' ? children(c, o) : toHtml(c, o)).join('')
96143 return '<li>' + inner + '</li>'
97144 }
98145 case 'code':
99146 return '<pre><code' + (node.lang ? ' class="language-' + esc(node.lang) + '"' : '') + '>' +
100147 esc(node.value) + '</code></pre>'
101 case 'table': return '<table>' + children(node) + '</table>'
148 case 'table': return '<table>' + children(node, o) + '</table>'
102149 case 'tableRow': {
103150 const head = node.__head === true
104151 const cells = (node.children || []).map((c) =>
105 head ? '<th>' + children(c) + '</th>' : '<td>' + children(c) + '</td>').join('')
152 head ? '<th>' + children(c, o) + '</th>' : '<td>' + children(c, o) + '</td>').join('')
106153 return '<tr>' + cells + '</tr>'
107154 }
108 case 'tableCell': return children(node)
155 case 'tableCell': return children(node, o)
109156 case 'footnoteReference': return '<sup class="fnref">' + esc(node.identifier) + '</sup>'
110 case 'footnoteDefinition': return '<div class="fn" id="fn-' + esc(node.identifier) + '">' + children(node) + '</div>'
157 case 'footnoteDefinition': return '<div class="fn" id="fn-' + esc(node.identifier) + '">' + children(node, o) + '</div>'
111158 case 'containerDirective':
112159 case 'leafDirective':
113 case 'textDirective': return directive(node)
160 case 'textDirective': return directive(node, o)
114161 // ⛔ Raw HTML is emitted as TEXT. The dialect does not include HTML, and rendering it would hand
115162 // markup authorship to whoever wrote the record.
116163 case 'html': return esc(node.value)
117 default: return children(node)
164 default: return children(node, o)
118165 }
119166}
120167
121168/**
122 * render(source) -> html
169 * render(source, { resolveBlob }) -> html
170 *
171 * `resolveBlob(oid) -> { src, mimeType } | null` is how a surface says where a `favela://blob/<oid>`
172 * can be read from and what it is. Omit it and every embed renders as nothing, which is correct for
173 * a caller that cannot serve files.
123174 *
124175 * TOTAL — never throws. Anything unparseable degrades to escaped text, the same posture `strip` has,
125176 * because a surface that throws on one bad record shows nothing at all.
126177 */
127export function render (source) {
178export function render (source, opts = {}) {
128179 const src = String(source == null ? '' : source).replace(/\r\n?/g, '\n')
129180 let tree
130181 try {
……@@ -136,7 +187,7 @@ export function render (source) {
136187 for (const node of tree.children || []) {
137188 if (node.type === 'table' && node.children && node.children[0]) node.children[0].__head = true
138189 }
139 try { return toHtml(tree) } catch { return '<p>' + esc(src) + '</p>' }
190 try { return toHtml(tree, opts) } catch { return '<p>' + esc(src) + '</p>' }
140191}
141192
142193export default render
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-markdown",
3 "version": "0.1.0",
3 "version": "0.1.1",
44 "description": "The P2P-native markdown record layer for favela. A post is a markdown document; atproto and ActivityPub are projections derived from it on read.",
55 "type": "module",
66 "license": "MIT",
test/render.test.js +64 -0
……@@ -90,3 +90,67 @@ test('render is TOTAL — it never throws', (t) => {
9090test('unknown syntax degrades to text, per the closed dialect', (t) => {
9191 t.is(render('~single~'), '<p>~single~</p>', 'single tilde is literal (§2.1)')
9292})
93
94// ── embedded files (record-vocabulary.md §3.5) ────────────────────────────────────────────────
95//
96// `favela://blob/<oid>` names a file the overlay holds. Bytes move P2P; this library only asks the
97// SURFACE where it can be read from, and has no opinion about the answer.
98
99const OID = 'a3f9'.repeat(8)
100const BLOB = 'favela://blob/' + OID
101const resolver = (mimeType, src = '/blob/' + OID) => ({ resolveBlob: () => ({ src, mimeType }) })
102
103test('★ an image blob renders an <img> from the surface\'s own src', (t) => {
104 const html = render('![a cat](' + BLOB + ')', resolver('image/png'))
105 t.ok(/<img src="\/blob\/a3f9[a-f0-9]*" alt="a cat">/.test(html), 'src and alt both survive')
106})
107
108test('★ a video blob renders <video controls>, not a broken image', (t) => {
109 const html = render('![clip](' + BLOB + ')', resolver('video/mp4'))
110 t.ok(/<video class="embed" controls preload="metadata" src="\/blob\//.test(html))
111 t.absent(html.includes('<img'), 'not an img — the bytes decide the element, not the syntax')
112})
113
114test('a PDF link is shown inline; any other file is a link', (t) => {
115 const pdf = render('[report](' + BLOB + ')', resolver('application/pdf'))
116 t.ok(/<embed class="doc" type="application\/pdf"/.test(pdf), 'the browser already knows how')
117
118 const csv = render('[rows](' + BLOB + ')', resolver('text/csv'))
119 t.ok(/<a class="file" href="\/blob\//.test(csv))
120 t.ok(csv.includes('rows'), 'the label is kept')
121})
122
123test('⛔ a blob is matched BEFORE a mention, or the file silently vanishes', (t) => {
124 // `isMention` is bare `^favela://`, so it matches a blob URI too. Testing mentions first turns
125 // every embed into a mention span — no error, no image, nothing to debug.
126 const html = render('[pic](' + BLOB + ')', resolver('image/png'))
127 t.absent(html.includes('class="mention"'), 'not a mention')
128 t.ok(html.includes('class="file"'), 'a file')
129 // And a real mention still is one.
130 const m = render('[@alice](favela://' + 'ab'.repeat(32) + ')', resolver('image/png'))
131 t.ok(m.includes('class="mention"'), 'a mention is still a mention')
132})
133
134test('⛔ with no resolver, an embed renders as NOTHING', (t) => {
135 // Correct for a caller that cannot serve files — it must not emit a src it cannot honour.
136 t.absent(render('![x](' + BLOB + ')').includes('<img'), 'no resolver')
137 t.absent(render('![x](' + BLOB + ')', { resolveBlob: () => null }).includes('<img'), 'unknown oid')
138 t.is(render('![x](' + BLOB + ')', resolver('application/pdf')).replace(/<\/?p>/g, ''), '',
139 'and a non-image type is not forced into an <img>')
140})
141
142test('⛔ the resolver\'s output is ESCAPED', (t) => {
143 const html = render('![x](' + BLOB + ')', resolver('image/png', '"><script>x</script>'))
144 t.absent(html.includes('<script>'), 'escaped')
145 t.is((html.match(/<img /g) || []).length, 1, 'exactly one img — nothing injected another')
146})
147
148test('⛔ the author-URL allowlist is UNCHANGED by any of this', (t) => {
149 // `isHttp` guards what an AUTHOR writes, which is the untrusted input. Adding blobs must not
150 // widen it — a resolver is the surface's own code and is a different trust question entirely.
151 const o = resolver('image/png')
152 t.absent(render('![x](javascript:alert(1))', o).includes('<img'), 'javascript: still dropped')
153 t.absent(render('![x](data:image/png;base64,AAAA)', o).includes('<img'), 'data: still dropped')
154 t.absent(render('[x](javascript:alert(1))', o).includes('href'), 'and links too')
155 t.ok(render('![x](https://example.com/a.png)', o).includes('<img'), 'http still works')
156})