| … | … | @@ -16,7 +16,7 @@ |
| 16 | 16 | // one of them is lossy — which is why `library.md` §5 asks the composer to say so. |
| 17 | 17 | |
| 18 | 18 | import { fromMarkdown } from 'mdast-util-from-markdown' |
| 19 | | import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention } from './dialect.js' |
| 19 | import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention, blobOid } from './dialect.js' |
| 20 | 20 | |
| 21 | 21 | const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } |
| 22 | 22 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]) |
| … | … | @@ -33,10 +33,45 @@ function inlineText (raw) { |
| 33 | 33 | return out |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | | const children = (node) => (node.children || []).map(toHtml).join('') |
| 36 | const children = (node, o) => (node.children || []).map((c) => toHtml(c, o)).join('') |
| 37 | 37 | |
| 38 | | function 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 | */ |
| 54 | function 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 | |
| 62 | function link (node, o) { |
| 39 | 63 | 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 | } |
| 40 | 75 | // A mention is OURS to resolve, so it never becomes an href — a browser cannot follow favela:// |
| 41 | 76 | // and a surface should not be tempted to try. The target rides in a data attribute. |
| 42 | 77 | if (isMention(url)) { |
| … | … | @@ -48,15 +83,27 @@ function link (node) { |
| 48 | 83 | return '<a href="' + esc(url) + '" rel="noopener noreferrer nofollow">' + children(node) + '</a>' |
| 49 | 84 | } |
| 50 | 85 | |
| 51 | | function image (node) { |
| 86 | function 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 | } |
| 52 | 99 | 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 + '">' |
| 54 | 101 | } |
| 55 | 102 | |
| 56 | 103 | // :::spoiler / :::cw <text> / :::details — the pinned set (library.md §5). |
| 57 | | function directive (node) { |
| 104 | function directive (node, o) { |
| 58 | 105 | const name = node.name || '' |
| 59 | | const inner = children(node) |
| 106 | const inner = children(node, o) |
| 60 | 107 | if (name === 'spoiler') return '<details class="spoiler"><summary>spoiler</summary>' + inner + '</details>' |
| 61 | 108 | if (name === 'cw') { |
| 62 | 109 | const label = (node.attributes && node.attributes.label) || textOf(node.children && node.children[0]) || 'content warning' |
| … | … | @@ -70,61 +117,65 @@ function directive (node) { |
| 70 | 117 | |
| 71 | 118 | const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) => c.value || '').join('') : '') |
| 72 | 119 | |
| 73 | | function toHtml (node) { |
| 120 | function toHtml (node, o) { |
| 74 | 121 | if (!node || typeof node !== 'object') return '' |
| 75 | 122 | 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>' |
| 78 | 125 | 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>' |
| 82 | 129 | case 'inlineCode': return '<code>' + esc(node.value) + '</code>' |
| 83 | 130 | case 'break': return '<br>' |
| 84 | 131 | 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) |
| 87 | 134 | case 'heading': { |
| 88 | 135 | 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 + '>' |
| 90 | 137 | } |
| 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>' |
| 93 | 140 | case 'listItem': { |
| 94 | 141 | 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('') |
| 96 | 143 | return '<li>' + inner + '</li>' |
| 97 | 144 | } |
| 98 | 145 | case 'code': |
| 99 | 146 | return '<pre><code' + (node.lang ? ' class="language-' + esc(node.lang) + '"' : '') + '>' + |
| 100 | 147 | esc(node.value) + '</code></pre>' |
| 101 | | case 'table': return '<table>' + children(node) + '</table>' |
| 148 | case 'table': return '<table>' + children(node, o) + '</table>' |
| 102 | 149 | case 'tableRow': { |
| 103 | 150 | const head = node.__head === true |
| 104 | 151 | 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('') |
| 106 | 153 | return '<tr>' + cells + '</tr>' |
| 107 | 154 | } |
| 108 | | case 'tableCell': return children(node) |
| 155 | case 'tableCell': return children(node, o) |
| 109 | 156 | 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>' |
| 111 | 158 | case 'containerDirective': |
| 112 | 159 | case 'leafDirective': |
| 113 | | case 'textDirective': return directive(node) |
| 160 | case 'textDirective': return directive(node, o) |
| 114 | 161 | // ⛔ Raw HTML is emitted as TEXT. The dialect does not include HTML, and rendering it would hand |
| 115 | 162 | // markup authorship to whoever wrote the record. |
| 116 | 163 | case 'html': return esc(node.value) |
| 117 | | default: return children(node) |
| 164 | default: return children(node, o) |
| 118 | 165 | } |
| 119 | 166 | } |
| 120 | 167 | |
| 121 | 168 | /** |
| 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. |
| 123 | 174 | * |
| 124 | 175 | * TOTAL — never throws. Anything unparseable degrades to escaped text, the same posture `strip` has, |
| 125 | 176 | * because a surface that throws on one bad record shows nothing at all. |
| 126 | 177 | */ |
| 127 | | export function render (source) { |
| 178 | export function render (source, opts = {}) { |
| 128 | 179 | const src = String(source == null ? '' : source).replace(/\r\n?/g, '\n') |
| 129 | 180 | let tree |
| 130 | 181 | try { |
| … | … | @@ -136,7 +187,7 @@ export function render (source) { |
| 136 | 187 | for (const node of tree.children || []) { |
| 137 | 188 | if (node.type === 'table' && node.children && node.children[0]) node.children[0].__head = true |
| 138 | 189 | } |
| 139 | | try { return toHtml(tree) } catch { return '<p>' + esc(src) + '</p>' } |
| 190 | try { return toHtml(tree, opts) } catch { return '<p>' + esc(src) + '</p>' } |
| 140 | 191 | } |
| 141 | 192 | |
| 142 | 193 | export default render |