| … | … | @@ -35,6 +35,68 @@ function inlineText (raw) { |
| 35 | 35 | |
| 36 | 36 | const children = (node, o) => (node.children || []).map((c) => toHtml(c, o)).join('') |
| 37 | 37 | |
| 38 | /** | |
| 39 | * The images in a paragraph that contains NOTHING ELSE, or null. | |
| 40 | * | |
| 41 | * ⛔ "Nothing else" is strict: any text with content, any link, any video, and this is prose with a | |
| 42 | * picture in it rather than a row of pictures. Grouping those would silently restructure someone's | |
| 43 | * writing, which is a much worse failure than not grouping. | |
| 44 | * | |
| 45 | * Whitespace between images does not count — a composer appending embeds produces exactly that. | |
| 46 | */ | |
| 47 | function imageRun (node, o) { | |
| 48 | if (!node || node.type !== 'paragraph') return null | |
| 49 | const out = [] | |
| 50 | for (const c of node.children || []) { | |
| 51 | if (c.type === 'image') { | |
| 52 | const b = blob(o, c.url || '') | |
| 53 | // ⛔ Only resolved IMAGES group. A video has controls and its own aspect; an unresolved embed | |
| 54 | // renders as nothing and would leave a hole in the grid. | |
| 55 | if (!b || !b.mimeType.startsWith('image/')) return null | |
| 56 | out.push(c) | |
| 57 | } else if (c.type === 'text' && !String(c.value || '').trim()) { | |
| 58 | continue | |
| 59 | } else { | |
| 60 | return null | |
| 61 | } | |
| 62 | } | |
| 63 | return out.length ? out : null | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Walk the top level, folding RUNS of image-only paragraphs into one lighttable. | |
| 68 | * | |
| 69 | * ⛔ A run, not a paragraph: the composer appends one embed per attachment separated by blank lines, | |
| 70 | * so three attachments are three paragraphs and grouping per-paragraph would do nothing at all — | |
| 71 | * which is exactly what it did before this. | |
| 72 | * | |
| 73 | * ⚠ A single image is left alone. One picture is not a grid, and wrapping it in one would change | |
| 74 | * how every existing post renders for no gain. | |
| 75 | */ | |
| 76 | function topLevel (node, o) { | |
| 77 | const kids = node.children || [] | |
| 78 | const out = [] | |
| 79 | let i = 0 | |
| 80 | while (i < kids.length) { | |
| 81 | const run = [] | |
| 82 | let j = i | |
| 83 | for (;;) { | |
| 84 | const imgs = imageRun(kids[j], o) | |
| 85 | if (!imgs) break | |
| 86 | run.push(...imgs) | |
| 87 | j++ | |
| 88 | } | |
| 89 | if (run.length > 1) { | |
| 90 | out.push('<div class="lighttable">' + run.map((n) => image(n, o)).join('') + '</div>') | |
| 91 | i = j | |
| 92 | } else { | |
| 93 | out.push(toHtml(kids[i], o)) | |
| 94 | i++ | |
| 95 | } | |
| 96 | } | |
| 97 | return out.join('') | |
| 98 | } | |
| 99 | ||
| 38 | 100 | /** |
| 39 | 101 | * An embedded file: `favela://blob/<oid>` (record-vocabulary.md §3.5). |
| 40 | 102 | * |
| … | … | @@ -128,7 +190,7 @@ const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) |
| 128 | 190 | function toHtml (node, o) { |
| 129 | 191 | if (!node || typeof node !== 'object') return '' |
| 130 | 192 | switch (node.type) { |
| 131 | case 'root': return children(node, o) | |
| 193 | case 'root': return topLevel(node, o) | |
| 132 | 194 | case 'paragraph': return '<p>' + children(node, o) + '</p>' |
| 133 | 195 | case 'text': return inlineText(node.value) |
| 134 | 196 | case 'strong': return '<strong>' + children(node, o) + '</strong>' |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-markdown", |
| 3 | "version": "0.1.3", | |
| 3 | "version": "0.1.4", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "license": "MIT", |
| … | … | @@ -175,3 +175,44 @@ test('⛔ embeds() uses the PARSE, so a blob URI inside a code fence is not a re |
| 175 | 175 | t.alike(embeds('no embeds here'), []) |
| 176 | 176 | t.alike(embeds(null), [], 'total — never throws') |
| 177 | 177 | }) |
| 178 | ||
| 179 | test('★ consecutive image embeds fold into ONE lighttable', (t) => { | |
| 180 | const A = 'a1'.repeat(16); const B = 'b2'.repeat(16); const C = 'c3'.repeat(16) | |
| 181 | const files = { [A]: 'image/png', [B]: 'image/jpeg', [C]: 'image/webp' } | |
| 182 | const o = { resolveBlob: (oid) => (files[oid] ? { src: '/blob/' + oid, mimeType: files[oid] } : null) } | |
| 183 | ||
| 184 | // What the composer produces: one embed per attachment, blank line between. | |
| 185 | const md = '\n\n\n\n' | |
| 186 | const html = render(md, o) | |
| 187 | t.is((html.match(/<div class="lighttable">/g) || []).length, 1, 'one grid, not three') | |
| 188 | t.is((html.match(/<img /g) || []).length, 3, 'holding all three') | |
| 189 | // ⛔ Order is preserved, which is the whole reason this is a grid and not CSS columns. | |
| 190 | t.ok(html.indexOf('/blob/' + A) < html.indexOf('/blob/' + B), 'in the order they were written') | |
| 191 | t.ok(html.indexOf('/blob/' + B) < html.indexOf('/blob/' + C)) | |
| 192 | }) | |
| 193 | ||
| 194 | test('⛔ a single image is NOT a grid, and prose with a picture in it is left alone', (t) => { | |
| 195 | const A = 'a1'.repeat(16) | |
| 196 | const o = { resolveBlob: () => ({ src: '/blob/' + A, mimeType: 'image/png' }) } | |
| 197 | ||
| 198 | t.absent(render('', o).includes('lighttable'), 'one picture is not a grid') | |
| 199 | ||
| 200 | // ⛔ Grouping a paragraph that also has words would silently restructure someone's writing — a | |
| 201 | // much worse failure than not grouping. | |
| 202 | const prose = render('look at  closely\n\n', o) | |
| 203 | t.absent(prose.includes('lighttable'), 'text alongside an image breaks the run') | |
| 204 | t.ok(prose.includes('look at'), 'and the words survive') | |
| 205 | }) | |
| 206 | ||
| 207 | test('a video or an unresolved embed breaks the run rather than leaving a hole', (t) => { | |
| 208 | const A = 'a1'.repeat(16); const V = 'd4'.repeat(16); const U = 'e5'.repeat(16) | |
| 209 | const files = { [A]: 'image/png', [V]: 'video/mp4' } | |
| 210 | const o = { resolveBlob: (oid) => (files[oid] ? { src: '/blob/' + oid, mimeType: files[oid] } : null) } | |
| 211 | ||
| 212 | const withVideo = render('\n\n', o) | |
| 213 | t.absent(withVideo.includes('lighttable'), 'a video has controls and its own aspect') | |
| 214 | t.ok(withVideo.includes('<video'), 'and still renders') | |
| 215 | ||
| 216 | const withUnknown = render('\n\n', o) | |
| 217 | t.absent(withUnknown.includes('lighttable'), 'an embed that renders as nothing would leave a hole') | |
| 218 | }) |