OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / ec85f9c

a paragraph that renders to nothing IS nothing
image() drops a favela://blob embed when no resolver is supplied — correctly, an <img> with no usable src is noise — but the paragraph around it rendered anyway. A post whose only content was a picture produced <p>text</p><p></p>, and every consumer showed a blank line. MEASURED on ashatron: a bridge lifting embeds into AS2 attachments got exactly that on both media posts. It is not specific to that consumer — the surface hits the same shape whenever a blob fails to resolve. ⚠ One existing assertion changes: a dropped unsafe image was pinned to '<p></p>' and is now ''. The test's stated intent, 'drops the image entirely', is unchanged and now actually met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ec85f9c1684cbe10e59d93c5c2b8353c6f2dc290
by plankatron <noreply@anthropic.com> · 2026-09-08T02:14:08.000Z

Changed files (3) +41 -3

lib/render.js +8 -1
……@@ -191,7 +191,14 @@ function toHtml (node, o) {
191191 if (!node || typeof node !== 'object') return ''
192192 switch (node.type) {
193193 case 'root': return topLevel(node, o)
194 case 'paragraph': return '<p>' + children(node, o) + '</p>'
194 case 'paragraph': {
195 // ⛔ A paragraph that renders to nothing IS nothing. `image()` drops an unresolvable embed
196 // deliberately — an <img> with no usable src is noise — but the paragraph around it used to
197 // render anyway, so a post whose only content was a picture produced `<p></p>` and every
198 // consumer showed a blank line. Measured against a live feed 2026-09-07.
199 const inner = children(node, o)
200 return inner.trim() ? '<p>' + inner + '</p>' : ''
201 }
195202 case 'text': return inlineText(node.value)
196203 case 'strong': return '<strong>' + children(node, o) + '</strong>'
197204 case 'emphasis': return '<em>' + children(node, o) + '</em>'
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-markdown",
3 "version": "0.1.4",
3 "version": "0.1.5",
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 +32 -1
……@@ -78,7 +78,10 @@ test('tables render as tables', (t) => {
7878
7979test('images render, with escaped alt and an http-only src', (t) => {
8080 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')
81 // ⚠ '' rather than '<p></p>'. The intent — "drops the image entirely" — is unchanged and now
82 // fully met: an empty paragraph is not nothing, it is a blank line in every consumer that renders
83 // it, which a live feed showed on every picture post.
84 t.is(render('![x](javascript:alert(1))'), '', 'a non-http src drops the image entirely')
8285})
8386
8487test('render is TOTAL — it never throws', (t) => {
……@@ -216,3 +219,31 @@ test('a video or an unresolved embed breaks the run rather than leaving a hole',
216219 const withUnknown = render('![a](favela://blob/' + A + ')\n\n![u](favela://blob/' + U + ')', o)
217220 t.absent(withUnknown.includes('lighttable'), 'an embed that renders as nothing would leave a hole')
218221})
222
223// ⛔ A PARAGRAPH THAT RENDERS TO NOTHING IS NOTHING.
224//
225// `image()` drops a `favela://blob/…` embed entirely when no resolver is supplied — correctly: an
226// <img> with no usable src is noise. But the paragraph around it still rendered, so a post whose
227// only content was a picture produced `<p>text</p><p></p>`.
228//
229// MEASURED on ashatron 2026-09-07: a bridge lifting embeds into AS2 attachments got exactly that,
230// and a blank line would have shown in every receiving client. It is not specific to that consumer
231// — the surface hits the same shape whenever a blob fails to resolve.
232test('an empty paragraph is omitted, not emitted', (t) => {
233 const oid = 'a1'.repeat(16)
234 const html = render('look\n\n![alt](favela://blob/' + oid + ')')
235 t.absent(html.includes('<p></p>'), 'no empty paragraph: ' + html)
236 t.ok(html.includes('look'), 'and the real text survives')
237})
238
239test('⚠ a paragraph with only whitespace is also nothing', (t) => {
240 t.absent(render('a\n\n \n\nb').includes('<p></p>'))
241})
242
243test('⛔ a resolved image still renders — this must not drop real content', (t) => {
244 const oid = 'b2'.repeat(16)
245 const html = render('![cat](favela://blob/' + oid + ')', {
246 resolveBlob: () => ({ src: '/blob/' + oid, mimeType: 'image/webp' })
247 })
248 t.ok(html.includes('<img'), 'the picture is there: ' + html.slice(0, 80))
249})