OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 9017ee9

0.1.4: a lighttable — consecutive image embeds fold into one block
⛔ A RUN of paragraphs, not one paragraph. The composer appends one embed per attachment separated by blank lines, so three attachments are three paragraphs — grouping per-paragraph would have done nothing at all, which is what it did. ⛔ "Image-only" is strict: any text with content, any link, any video, and this is prose with a picture in it rather than a row of pictures. Grouping those would silently restructure someone's writing, a much worse failure than not grouping. A video breaks the run (it has controls and its own aspect) and so does an unresolved embed, which renders as nothing and would leave a hole in the grid. ⚠ A single image is left alone — one picture is not a grid, and wrapping it would change how every existing post renders for no gain. ⚠ Two of my fixtures used non-hex oids, so BLOB_RE never matched and they were not blobs at all — the test passed for the wrong reason until the oids were hex. Third time this session I have made that exact mistake. 66/66, 2400/2400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9017ee9a08a9dbe6861bc0a523c144823899ff0d
by plankatron <fleeky@gmail.com> · 2026-09-07T01:47:05.000Z

Changed files (3) +105 -2

lib/render.js +63 -1
……@@ -35,6 +35,68 @@ function inlineText (raw) {
3535
3636const children = (node, o) => (node.children || []).map((c) => toHtml(c, o)).join('')
3737
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 */
47function 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 */
76function 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
38100/**
39101 * An embedded file: `favela://blob/<oid>` (record-vocabulary.md §3.5).
40102 *
……@@ -128,7 +190,7 @@ const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c)
128190function toHtml (node, o) {
129191 if (!node || typeof node !== 'object') return ''
130192 switch (node.type) {
131 case 'root': return children(node, o)
193 case 'root': return topLevel(node, o)
132194 case 'paragraph': return '<p>' + children(node, o) + '</p>'
133195 case 'text': return inlineText(node.value)
134196 case 'strong': return '<strong>' + children(node, o) + '</strong>'
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-markdown",
3 "version": "0.1.3",
3 "version": "0.1.4",
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 +41 -0
……@@ -175,3 +175,44 @@ test('⛔ embeds() uses the PARSE, so a blob URI inside a code fence is not a re
175175 t.alike(embeds('no embeds here'), [])
176176 t.alike(embeds(null), [], 'total — never throws')
177177})
178
179test('★ 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 = '![one](favela://blob/' + A + ')\n\n![two](favela://blob/' + B + ')\n\n![three](favela://blob/' + C + ')'
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
194test('⛔ 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('![one](favela://blob/' + A + ')', 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 ![this](favela://blob/' + A + ') closely\n\n![x](favela://blob/' + A + ')', 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
207test('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('![a](favela://blob/' + A + ')\n\n![v](favela://blob/' + V + ')', 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('![a](favela://blob/' + A + ')\n\n![u](favela://blob/' + U + ')', o)
217 t.absent(withUnknown.includes('lighttable'), 'an embed that renders as nothing would leave a hole')
218})