OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / a21700a

favela-markdown: render() — the native projection, and a shared dialect
strip() exists for bridges: it throws formatting away to fit networks that have none. render() is the opposite and is what our own surfaces show. Both now parse through lib/dialect.js — the pinned config and the lexical rules in ONE place, so the two can never disagree about what the markup means. A drift there would make the same post say different things depending on who is looking, which is the whole failure the pinned dialect exists to prevent. ⛔ This is the one function here where a defect is an XSS, so it is fail-closed the way reach.js is for privacy: every text node and attribute escaped; only http(s) may carry href or src, by allowlist, so javascript:/data:/anything-not-yet-invented degrades to its label; raw HTML in the source is emitted as escaped TEXT, because the dialect does not include HTML and rendering it would hand markup authorship to whoever wrote the record. A mention never becomes an href at all — a browser cannot follow favela://, and the target rides in a data attribute instead. ★ :::spoiler is CONCEALED here and REVEALED by strip, because atproto has no such concept. Same record, two projections, one lossy — the asymmetry library.md §5 exists to surface to a composer, now visible in the code. Also converts this package to Bare: brittle-bare, the #node:* imports map, and no node fallback. The old script was a hardcoded `node test/a && node test/b` list, which both hid new test files and ran them on a runtime we do not ship — render's tests were silently not running until this changed. One test corrected: I asserted `onmouseover=` was absent from the output. It IS present, escaped, inside the href value, and inert — the assertion would have failed correct code. Replaced with the property that matters: the quotes are escaped, there is exactly one href, and no event-handler attribute exists. 54 tests, 2,365 asserts, under Bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a21700ac54746e2425d30e059e55c5be749520d3
by plankatron <fleeky@gmail.com> · 2026-09-06T19:32:03.000Z

Changed files (6) +445 -3

lib/dialect.js +38 -0
……@@ -0,0 +1,38 @@
1// The pinned dialect, in ONE place.
2//
3// markdown-strip.md §2.1 pins CommonMark 0.31.2 plus exactly six extensions, and the set is CLOSED:
4// syntax outside it is literal text, not markup. Both `strip()` (→ text + facets, for bridges) and
5// `render()` (→ HTML, for our own surfaces) must parse identically — if they drift, the same post
6// says two different things depending on who is looking, which is the whole failure the pinned
7// dialect exists to prevent. So the config and the lexical rules live here and are imported, never
8// copied.
9
10import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'
11import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough'
12import { gfmTable } from 'micromark-extension-gfm-table'
13import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
14import { gfmFootnote } from 'micromark-extension-gfm-footnote'
15import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote'
16import { directive } from 'micromark-extension-directive'
17import { directiveFromMarkdown } from 'mdast-util-directive'
18
19export const MICROMARK = {
20 // ⛔ singleTilde:false — micromark defaults it TRUE, which would consume `~x~` as strikethrough.
21 extensions: [gfmStrikethrough({ singleTilde: false }), gfmTable(), gfmFootnote(), directive()],
22 mdastExtensions: [
23 gfmStrikethroughFromMarkdown(),
24 gfmTableFromMarkdown(),
25 gfmFootnoteFromMarkdown(),
26 directiveFromMarkdown()
27 ]
28}
29
30// §3.3 NORMATIVE — favela-web's shipped URL_RE verbatim. Changing it changes records.
31export const URL_RE = /\bhttps?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g
32export const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\][]*))?\]\]/g
33export const HASHTAG_RE = /(^|\s)(#([A-Za-z0-9_À-￿]{1,64}))/g
34
35// §3.2 — only http/https may carry a link. Everything else renders as text.
36export const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
37// §3.4 — a mention names a USER: `favela://<identity-root>`; `did:` is the legacy form.
38export const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || ''))
lib/render.js +142 -0
……@@ -0,0 +1,142 @@
1// render(markdown) -> HTML — the NATIVE projection.
2//
3// `strip()` is for bridges: it throws formatting away to fit networks that have none. This is the
4// opposite, and it is what our own surfaces show. Both parse through `dialect.js` so they can never
5// disagree about what the markup means.
6//
7// ⛔ This is the one function in the package where a defect is an XSS rather than a rendering bug,
8// so it is fail-closed in the same way `reach.js` is for privacy:
9// - every text node and every attribute value is escaped;
10// - only http/https may carry an `href` or `src` — anything else renders as its label, or is
11// dropped, and NEVER becomes a link;
12// - raw HTML in the source is emitted as escaped text, never as markup.
13//
14// ★ Note the spoiler asymmetry, which is the thesis in one node type: `:::spoiler` is REVEALED by
15// strip (atproto has no such concept) and CONCEALED here. Same record, two projections, and only
16// one of them is lossy — which is why `library.md` §5 asks the composer to say so.
17
18import { fromMarkdown } from 'mdast-util-from-markdown'
19import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention } from './dialect.js'
20
21const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
22const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
23
24// Text runs get the two LEXICAL rules the dialect owns (§3.6, §3.7) — wiki-links and hashtags are
25// not structural, so micromark never sees them and they are applied here, after escaping, so a page
26// name or a tag can never inject markup.
27function inlineText (raw) {
28 let out = esc(raw)
29 out = out.replace(WIKILINK_RE, (_m, page, label) =>
30 '<span class="wikilink" data-page="' + esc(page) + '">' + esc(label !== undefined ? label : page) + '</span>')
31 out = out.replace(HASHTAG_RE, (_m, lead, whole, tag) =>
32 lead + '<span class="hashtag" data-tag="' + esc(tag) + '">' + esc(whole) + '</span>')
33 return out
34}
35
36const children = (node) => (node.children || []).map(toHtml).join('')
37
38function link (node) {
39 const url = node.url || ''
40 // A mention is OURS to resolve, so it never becomes an href — a browser cannot follow favela://
41 // and a surface should not be tempted to try. The target rides in a data attribute.
42 if (isMention(url)) {
43 return '<span class="mention" data-target="' + esc(url) + '">' + children(node) + '</span>'
44 }
45 // §3.2 — anything not http/https degrades to its label with NO link. That covers javascript:,
46 // data:, vbscript: and every scheme nobody has invented yet, because it is an allowlist.
47 if (!isHttp(url)) return children(node)
48 return '<a href="' + esc(url) + '" rel="noopener noreferrer nofollow">' + children(node) + '</a>'
49}
50
51function image (node) {
52 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 || '') + '">'
54}
55
56// :::spoiler / :::cw <text> / :::details — the pinned set (library.md §5).
57function directive (node) {
58 const name = node.name || ''
59 const inner = children(node)
60 if (name === 'spoiler') return '<details class="spoiler"><summary>spoiler</summary>' + inner + '</details>'
61 if (name === 'cw') {
62 const label = (node.attributes && node.attributes.label) || textOf(node.children && node.children[0]) || 'content warning'
63 return '<details class="cw"><summary>' + esc(label) + '</summary>' + inner + '</details>'
64 }
65 if (name === 'details') return '<details>' + inner + '</details>'
66 // An unknown directive contributes its CONTENTS, matching strip §4.8 — the dialect is closed, so
67 // an unrecognised name is not markup and must not vanish.
68 return inner
69}
70
71const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) => c.value || '').join('') : '')
72
73function toHtml (node) {
74 if (!node || typeof node !== 'object') return ''
75 switch (node.type) {
76 case 'root': return children(node)
77 case 'paragraph': return '<p>' + children(node) + '</p>'
78 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>'
82 case 'inlineCode': return '<code>' + esc(node.value) + '</code>'
83 case 'break': return '<br>'
84 case 'thematicBreak': return '<hr>'
85 case 'link': return link(node)
86 case 'image': return image(node)
87 case 'heading': {
88 const d = Math.min(Math.max(node.depth || 1, 1), 6)
89 return '<h' + d + '>' + children(node) + '</h' + d + '>'
90 }
91 case 'blockquote': return '<blockquote>' + children(node) + '</blockquote>'
92 case 'list': return node.ordered ? '<ol>' + children(node) + '</ol>' : '<ul>' + children(node) + '</ul>'
93 case 'listItem': {
94 const inner = (node.children || []).map((c) =>
95 c.type === 'paragraph' ? children(c) : toHtml(c)).join('')
96 return '<li>' + inner + '</li>'
97 }
98 case 'code':
99 return '<pre><code' + (node.lang ? ' class="language-' + esc(node.lang) + '"' : '') + '>' +
100 esc(node.value) + '</code></pre>'
101 case 'table': return '<table>' + children(node) + '</table>'
102 case 'tableRow': {
103 const head = node.__head === true
104 const cells = (node.children || []).map((c) =>
105 head ? '<th>' + children(c) + '</th>' : '<td>' + children(c) + '</td>').join('')
106 return '<tr>' + cells + '</tr>'
107 }
108 case 'tableCell': return children(node)
109 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>'
111 case 'containerDirective':
112 case 'leafDirective':
113 case 'textDirective': return directive(node)
114 // ⛔ Raw HTML is emitted as TEXT. The dialect does not include HTML, and rendering it would hand
115 // markup authorship to whoever wrote the record.
116 case 'html': return esc(node.value)
117 default: return children(node)
118 }
119}
120
121/**
122 * render(source) -> html
123 *
124 * TOTAL — never throws. Anything unparseable degrades to escaped text, the same posture `strip` has,
125 * because a surface that throws on one bad record shows nothing at all.
126 */
127export function render (source) {
128 const src = String(source == null ? '' : source).replace(/\r\n?/g, '\n')
129 let tree
130 try {
131 tree = fromMarkdown(src, MICROMARK)
132 } catch {
133 return '<p>' + esc(src) + '</p>'
134 }
135 // mdast marks the header row only by position, so tag it before the walk.
136 for (const node of tree.children || []) {
137 if (node.type === 'table' && node.children && node.children[0]) node.children[0].__head = true
138 }
139 try { return toHtml(tree) } catch { return '<p>' + esc(src) + '</p>' }
140}
141
142export default render
package-lock.json +153 -0
……@@ -9,6 +9,9 @@
99 "version": "0.0.0",
1010 "license": "MIT",
1111 "dependencies": {
12 "bare-fs": "^4.8.1",
13 "bare-os": "^3.9.3",
14 "bare-path": "^3.1.2",
1215 "mdast-util-directive": "^3.1.0",
1316 "mdast-util-from-markdown": "^2.0.3",
1417 "mdast-util-gfm-footnote": "^2.1.0",
……@@ -18,6 +21,9 @@
1821 "micromark-extension-gfm-footnote": "^2.1.0",
1922 "micromark-extension-gfm-strikethrough": "^2.1.0",
2023 "micromark-extension-gfm-table": "^2.1.1"
24 },
25 "bin": {
26 "favela-markdown": "bin/favela-markdown.js"
2127 }
2228 },
2329 "../ap-bridge/node_modules/brittle": {
……@@ -83,6 +89,109 @@
8389 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
8490 "license": "MIT"
8591 },
92 "node_modules/b4a": {
93 "version": "1.8.1",
94 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
95 "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
96 "license": "Apache-2.0",
97 "peerDependencies": {
98 "react-native-b4a": "*"
99 },
100 "peerDependenciesMeta": {
101 "react-native-b4a": {
102 "optional": true
103 }
104 }
105 },
106 "node_modules/bare-events": {
107 "version": "2.9.2",
108 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
109 "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
110 "license": "Apache-2.0",
111 "peerDependencies": {
112 "bare-abort-controller": "*"
113 },
114 "peerDependenciesMeta": {
115 "bare-abort-controller": {
116 "optional": true
117 }
118 }
119 },
120 "node_modules/bare-fs": {
121 "version": "4.8.1",
122 "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
123 "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
124 "license": "Apache-2.0",
125 "dependencies": {
126 "bare-events": "^2.5.4",
127 "bare-path": "^3.0.0",
128 "bare-stream": "^2.6.4",
129 "bare-url": "^2.2.2",
130 "fast-fifo": "^1.3.2"
131 },
132 "engines": {
133 "bare": ">=1.28.0"
134 },
135 "peerDependencies": {
136 "bare-buffer": "*"
137 },
138 "peerDependenciesMeta": {
139 "bare-buffer": {
140 "optional": true
141 }
142 }
143 },
144 "node_modules/bare-os": {
145 "version": "3.9.3",
146 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz",
147 "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==",
148 "license": "Apache-2.0",
149 "engines": {
150 "bare": ">=1.14.0"
151 }
152 },
153 "node_modules/bare-path": {
154 "version": "3.1.2",
155 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
156 "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
157 "license": "Apache-2.0"
158 },
159 "node_modules/bare-stream": {
160 "version": "2.13.4",
161 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
162 "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
163 "license": "Apache-2.0",
164 "dependencies": {
165 "b4a": "^1.8.1",
166 "streamx": "^2.25.0",
167 "teex": "^1.0.1"
168 },
169 "peerDependencies": {
170 "bare-abort-controller": "*",
171 "bare-buffer": "*",
172 "bare-events": "*"
173 },
174 "peerDependenciesMeta": {
175 "bare-abort-controller": {
176 "optional": true
177 },
178 "bare-buffer": {
179 "optional": true
180 },
181 "bare-events": {
182 "optional": true
183 }
184 }
185 },
186 "node_modules/bare-url": {
187 "version": "2.5.4",
188 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
189 "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
190 "license": "Apache-2.0",
191 "dependencies": {
192 "bare-path": "^3.0.0"
193 }
194 },
86195 "node_modules/ccount": {
87196 "version": "2.0.1",
88197 "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
……@@ -185,6 +294,21 @@
185294 "url": "https://github.com/sponsors/wooorm"
186295 }
187296 },
297 "node_modules/events-universal": {
298 "version": "1.0.1",
299 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
300 "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
301 "license": "Apache-2.0",
302 "dependencies": {
303 "bare-events": "^2.7.0"
304 }
305 },
306 "node_modules/fast-fifo": {
307 "version": "1.3.2",
308 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
309 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
310 "license": "MIT"
311 },
188312 "node_modules/is-alphabetical": {
189313 "version": "2.0.1",
190314 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
……@@ -938,6 +1062,17 @@
9381062 "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
9391063 "license": "MIT"
9401064 },
1065 "node_modules/streamx": {
1066 "version": "2.28.1",
1067 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
1068 "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
1069 "license": "MIT",
1070 "dependencies": {
1071 "events-universal": "^1.0.0",
1072 "fast-fifo": "^1.3.2",
1073 "text-decoder": "^1.1.0"
1074 }
1075 },
9411076 "node_modules/stringify-entities": {
9421077 "version": "4.0.4",
9431078 "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
……@@ -952,6 +1087,24 @@
9521087 "url": "https://github.com/sponsors/wooorm"
9531088 }
9541089 },
1090 "node_modules/teex": {
1091 "version": "1.0.1",
1092 "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
1093 "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
1094 "license": "MIT",
1095 "dependencies": {
1096 "streamx": "^2.12.5"
1097 }
1098 },
1099 "node_modules/text-decoder": {
1100 "version": "1.2.7",
1101 "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
1102 "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
1103 "license": "Apache-2.0",
1104 "dependencies": {
1105 "b4a": "^1.6.4"
1106 }
1107 },
9551108 "node_modules/unist-util-is": {
9561109 "version": "6.0.1",
9571110 "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
package.json +19 -2
……@@ -13,12 +13,29 @@
1313 "micromark-extension-directive": "^4.0.0",
1414 "micromark-extension-gfm-footnote": "^2.1.0",
1515 "micromark-extension-gfm-strikethrough": "^2.1.0",
16 "micromark-extension-gfm-table": "^2.1.1"
16 "micromark-extension-gfm-table": "^2.1.1",
17 "bare-fs": "^4.8.1",
18 "bare-path": "^3.1.2",
19 "bare-os": "^3.9.3"
1720 },
1821 "scripts": {
19 "test": "node test/strip.test.js && node test/record.test.js && node test/reach.test.js && node test/lint.test.js && node test/strip-fuzz.test.js"
22 "test": "brittle-bare test/*.test.js"
2023 },
2124 "bin": {
2225 "favela-markdown": "bin/favela-markdown.js"
26 },
27 "imports": {
28 "#node:fs": {
29 "bare": "bare-fs",
30 "default": "fs"
31 },
32 "#node:path": {
33 "bare": "bare-path",
34 "default": "path"
35 },
36 "#node:os": {
37 "bare": "bare-os",
38 "default": "os"
39 }
2340 }
2441}
test/render.test.js +92 -0
……@@ -0,0 +1,92 @@
1// render(markdown) -> HTML — the NATIVE projection.
2//
3// strip() exists for bridges: it throws formatting away to fit networks that have none. render() is
4// the opposite and is what our own surfaces show, so it must keep everything the dialect allows.
5//
6// ⛔ It is also the one function in this package where a defect is an XSS rather than a rendering
7// bug. Every text node and every attribute is escaped, and only http/https may carry an href — the
8// same fail-closed posture reach.js has for privacy, applied to markup.
9
10import test from 'brittle'
11import { render } from '../lib/render.js'
12
13test('basic formatting survives, unlike in strip()', (t) => {
14 t.is(render('hello **world**'), '<p>hello <strong>world</strong></p>')
15 t.is(render('*em* and ~~gone~~'), '<p><em>em</em> and <del>gone</del></p>')
16 t.is(render('use `npm test`'), '<p>use <code>npm test</code></p>')
17})
18
19test('⛔ text is escaped', (t) => {
20 t.is(render('a < b & c > d'), '<p>a &lt; b &amp; c &gt; d</p>')
21 t.absent(render('<script>alert(1)</script>').includes('<script>'), 'raw HTML is not executed')
22})
23
24test('⛔ only http(s) carries an href', (t) => {
25 t.is(render('[x](https://a.com)'), '<p><a href="https://a.com" rel="noopener noreferrer nofollow">x</a></p>')
26 t.is(render('[x](javascript:alert(1))'), '<p>x</p>', 'a javascript: target renders as its label, no link')
27 t.is(render('[x](data:text/html,hi)'), '<p>x</p>')
28})
29
30test('⛔ an attribute cannot break out', (t) => {
31 const html = render('[x](https://a.com/"onmouseover="alert(1))')
32 // The substring `onmouseover=` DOES appear — inside the escaped href value, inert. Asserting its
33 // absence was wrong and would have failed correct code. The property that matters is that the
34 // quotes are escaped, so the value cannot close and start a new attribute.
35 t.ok(html.includes('&quot;onmouseover=&quot;'), 'the quotes are escaped, so nothing breaks out')
36 t.is(html.match(/href="/g).length, 1, 'exactly one href — no injected attribute')
37 t.absent(/\son\w+="/.test(html), 'no event-handler attribute exists in the output')
38})
39
40test('a mention is a link to the user, not an http link', (t) => {
41 const html = render('hi [@alice](favela://abc123)')
42 t.ok(html.includes('class="mention"'), 'marked as a mention')
43 t.ok(html.includes('data-target="favela://abc123"'), 'carries the target for the surface to resolve')
44 t.absent(html.includes('href="favela://'), 'never an href — the browser cannot follow it')
45})
46
47test('wiki-links and hashtags render as our own things', (t) => {
48 t.ok(render('see [[The Page]]').includes('class="wikilink"'))
49 t.ok(render('see [[The Page|that]]').includes('>that<'), 'the label wins')
50 const tag = render('about #p2p today')
51 t.ok(tag.includes('class="hashtag"') && tag.includes('#p2p'), 'the hash is kept, as in strip §3.7')
52})
53
54test('★ :::spoiler is CONCEALED here — the opposite of the bridge projection', (t) => {
55 const html = render(':::spoiler\nthe ending\n:::')
56 t.ok(html.includes('<details'), 'a real spoiler')
57 t.ok(html.includes('the ending'))
58 // markdown-strip.md §4.8 reveals it for bsky because atproto has no such thing. That asymmetry is
59 // the whole reason library.md §5 exists, and this is the side that gets it right.
60})
61
62test('blocks: headings, lists, quotes, code, rules', (t) => {
63 t.is(render('# Title'), '<h1>Title</h1>')
64 t.is(render('- a\n- b'), '<ul><li>a</li><li>b</li></ul>')
65 t.is(render('> quoted'), '<blockquote><p>quoted</p></blockquote>')
66 t.ok(render('```js\nconst x = 1\n```').includes('<pre><code class="language-js">'))
67 t.is(render('---'), '<hr>')
68})
69
70test('a code block escapes its contents', (t) => {
71 t.ok(render('```\n<b>not bold</b>\n```').includes('&lt;b&gt;not bold&lt;/b&gt;'))
72})
73
74test('tables render as tables', (t) => {
75 const html = render('| a | b |\n|---|---|\n| 1 | 2 |')
76 t.ok(html.includes('<table>') && html.includes('<th>a</th>') && html.includes('<td>1</td>'))
77})
78
79test('images render, with escaped alt and an http-only src', (t) => {
80 t.ok(render('![a "b"](https://x/i.png)').includes('<img src="https://x/i.png" alt="a &quot;b&quot;">'))
81 t.is(render('![x](javascript:alert(1))'), '<p></p>', 'a non-http src drops the image entirely')
82})
83
84test('render is TOTAL — it never throws', (t) => {
85 for (const bad of ['', ' ', '[', '[](', '![', '```', ':::', '|', '~~~', null, undefined, 42]) {
86 t.execution(() => render(bad))
87 }
88})
89
90test('unknown syntax degrades to text, per the closed dialect', (t) => {
91 t.is(render('~single~'), '<p>~single~</p>', 'single tilde is literal (§2.1)')
92})
test/strip.test.js +1 -1
……@@ -13,7 +13,7 @@
1313// now built by project(rec,'bsky'), the one place allowed to know atproto's names.
1414
1515import test from 'brittle'
16import { readFileSync } from 'node:fs'
16import { readFileSync } from '#node:fs'
1717import { strip } from '../lib/strip.js'
1818
1919const VECTORS = JSON.parse(