| … | … | @@ -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 | ||
| 10 | import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough' | |
| 11 | import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough' | |
| 12 | import { gfmTable } from 'micromark-extension-gfm-table' | |
| 13 | import { gfmTableFromMarkdown } from 'mdast-util-gfm-table' | |
| 14 | import { gfmFootnote } from 'micromark-extension-gfm-footnote' | |
| 15 | import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote' | |
| 16 | import { directive } from 'micromark-extension-directive' | |
| 17 | import { directiveFromMarkdown } from 'mdast-util-directive' | |
| 18 | ||
| 19 | export 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. | |
| 31 | export const URL_RE = /\bhttps?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"]/g | |
| 32 | export const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\][]*))?\]\]/g | |
| 33 | export 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. | |
| 36 | export const isHttp = (uri) => /^https?:\/\//i.test(String(uri || '')) | |
| 37 | // §3.4 — a mention names a USER: `favela://<identity-root>`; `did:` is the legacy form. | |
| 38 | export const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || '')) |
| … | … | @@ -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 | ||
| 18 | import { fromMarkdown } from 'mdast-util-from-markdown' | |
| 19 | import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention } from './dialect.js' | |
| 20 | ||
| 21 | const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } | |
| 22 | const 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. | |
| 27 | function 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 | ||
| 36 | const children = (node) => (node.children || []).map(toHtml).join('') | |
| 37 | ||
| 38 | function 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 | ||
| 51 | function 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). | |
| 57 | function 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 | ||
| 71 | const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) => c.value || '').join('') : '') | |
| 72 | ||
| 73 | function 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 | */ | |
| 127 | export 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 | ||
| 142 | export default render |
| … | … | @@ -9,6 +9,9 @@ |
| 9 | 9 | "version": "0.0.0", |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | "bare-fs": "^4.8.1", | |
| 13 | "bare-os": "^3.9.3", | |
| 14 | "bare-path": "^3.1.2", | |
| 12 | 15 | "mdast-util-directive": "^3.1.0", |
| 13 | 16 | "mdast-util-from-markdown": "^2.0.3", |
| 14 | 17 | "mdast-util-gfm-footnote": "^2.1.0", |
| … | … | @@ -18,6 +21,9 @@ |
| 18 | 21 | "micromark-extension-gfm-footnote": "^2.1.0", |
| 19 | 22 | "micromark-extension-gfm-strikethrough": "^2.1.0", |
| 20 | 23 | "micromark-extension-gfm-table": "^2.1.1" |
| 24 | }, | |
| 25 | "bin": { | |
| 26 | "favela-markdown": "bin/favela-markdown.js" | |
| 21 | 27 | } |
| 22 | 28 | }, |
| 23 | 29 | "../ap-bridge/node_modules/brittle": { |
| … | … | @@ -83,6 +89,109 @@ |
| 83 | 89 | "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", |
| 84 | 90 | "license": "MIT" |
| 85 | 91 | }, |
| 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 | }, | |
| 86 | 195 | "node_modules/ccount": { |
| 87 | 196 | "version": "2.0.1", |
| 88 | 197 | "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", |
| … | … | @@ -185,6 +294,21 @@ |
| 185 | 294 | "url": "https://github.com/sponsors/wooorm" |
| 186 | 295 | } |
| 187 | 296 | }, |
| 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 | }, | |
| 188 | 312 | "node_modules/is-alphabetical": { |
| 189 | 313 | "version": "2.0.1", |
| 190 | 314 | "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", |
| … | … | @@ -938,6 +1062,17 @@ |
| 938 | 1062 | "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", |
| 939 | 1063 | "license": "MIT" |
| 940 | 1064 | }, |
| 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 | }, | |
| 941 | 1076 | "node_modules/stringify-entities": { |
| 942 | 1077 | "version": "4.0.4", |
| 943 | 1078 | "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", |
| … | … | @@ -952,6 +1087,24 @@ |
| 952 | 1087 | "url": "https://github.com/sponsors/wooorm" |
| 953 | 1088 | } |
| 954 | 1089 | }, |
| 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 | }, | |
| 955 | 1108 | "node_modules/unist-util-is": { |
| 956 | 1109 | "version": "6.0.1", |
| 957 | 1110 | "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", |
| … | … | @@ -13,12 +13,29 @@ |
| 13 | 13 | "micromark-extension-directive": "^4.0.0", |
| 14 | 14 | "micromark-extension-gfm-footnote": "^2.1.0", |
| 15 | 15 | "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" | |
| 17 | 20 | }, |
| 18 | 21 | "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" | |
| 20 | 23 | }, |
| 21 | 24 | "bin": { |
| 22 | 25 | "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 | } | |
| 23 | 40 | } |
| 24 | 41 | } |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import { render } from '../lib/render.js' | |
| 12 | ||
| 13 | test('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 | ||
| 19 | test('⛔ text is escaped', (t) => { | |
| 20 | t.is(render('a < b & c > d'), '<p>a < b & c > d</p>') | |
| 21 | t.absent(render('<script>alert(1)</script>').includes('<script>'), 'raw HTML is not executed') | |
| 22 | }) | |
| 23 | ||
| 24 | test('⛔ 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 | ||
| 30 | test('⛔ 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('"onmouseover="'), '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 | ||
| 40 | test('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 | ||
| 47 | test('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 | ||
| 54 | test('★ :::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 | ||
| 62 | test('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 | ||
| 70 | test('a code block escapes its contents', (t) => { | |
| 71 | t.ok(render('```\n<b>not bold</b>\n```').includes('<b>not bold</b>')) | |
| 72 | }) | |
| 73 | ||
| 74 | test('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 | ||
| 79 | test('images render, with escaped alt and an http-only src', (t) => { | |
| 80 | t.ok(render('').includes('<img src="https://x/i.png" alt="a "b"">')) | |
| 81 | t.is(render(')'), '<p></p>', 'a non-http src drops the image entirely') | |
| 82 | }) | |
| 83 | ||
| 84 | test('render is TOTAL — it never throws', (t) => { | |
| 85 | for (const bad of ['', ' ', '[', '[](', '![', '```', ':::', '|', '~~~', null, undefined, 42]) { | |
| 86 | t.execution(() => render(bad)) | |
| 87 | } | |
| 88 | }) | |
| 89 | ||
| 90 | test('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 | }) |
| … | … | @@ -13,7 +13,7 @@ |
| 13 | 13 | // now built by project(rec,'bsky'), the one place allowed to know atproto's names. |
| 14 | 14 | |
| 15 | 15 | import test from 'brittle' |
| 16 | import { readFileSync } from 'node:fs' | |
| 16 | import { readFileSync } from '#node:fs' | |
| 17 | 17 | import { strip } from '../lib/strip.js' |
| 18 | 18 | |
| 19 | 19 | const VECTORS = JSON.parse( |