diff --git a/src/blog-data.test.ts b/src/blog-data.test.ts index 5856b1f..b22f55d 100644 --- a/src/blog-data.test.ts +++ b/src/blog-data.test.ts @@ -67,3 +67,47 @@ test("renders rich Leaflet blocks through the read-only adapter", () => { assert.match(post.body, /---/); assert.match(post.body, / { + const post = parseBlogDocument( + "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/footnotes", + { + title: "Footnotes", + path: "/footnotes", + publishedAt: "2026-08-15T00:00:00.000Z", + tags: ["blog"], + content: { + $type: "pub.leaflet.content", + pages: [{ + $type: "pub.leaflet.pages.linearDocument", + id: "d918939a-047c-4d41-a328-35572f3f82e5", + blocks: [ + { block: { + $type: "pub.leaflet.blocks.text", + plaintext: "The missing note disappears[^missing].", + } }, + { block: { + $type: "pub.leaflet.blocks.text", + plaintext: "The defined note remains[^kept].", + } }, + { block: { + $type: "pub.leaflet.blocks.text", + plaintext: "[^kept]: The note body.", + } }, + { block: { + $type: "pub.leaflet.blocks.code", + language: "javascript", + plaintext: "const pattern = /[^missing]/;", + } }, + ], + }], + }, + }, + ); + + assert.match(post.body, /The missing note disappears\./); + assert.equal((post.body.match(/\[\^missing\]/g) ?? []).length, 1); + assert.match(post.body, /The defined note remains\[\^kept\]\./); + assert.match(post.body, /\[\^kept\]: The note body\./); + assert.match(post.body, /const pattern = \/\[\^missing\]\//); +}); diff --git a/src/leaflet-reader.ts b/src/leaflet-reader.ts index 4bb32ea..7ad96b8 100644 --- a/src/leaflet-reader.ts +++ b/src/leaflet-reader.ts @@ -100,9 +100,52 @@ function applyFacets(text: string, facets?: JsonObject[]): string { return parts.join(""); } -function listItemMarkdown(item: JsonObject): string { +function stripUnresolvedFootnoteReferences( + markdown: string, + definitions: ReadonlySet, +): string { + return markdown.replace( + /\[\^([A-Za-z0-9_-]+)\](?!:)/g, + (reference, label: string) => definitions.has(label) ? reference : "", + ); +} + +function footnoteDefinitions(blocks: JsonObject[]): Set { + const definitions = new Set(); + const collect = (plaintext: unknown) => { + for (const match of String(plaintext ?? "").matchAll( + /(?:^|\n)[ \t]{0,3}\[\^([A-Za-z0-9_-]+)\]:/g, + )) { + definitions.add(match[1]); + } + }; + + for (const wrapper of blocks) { + const block = wrapper.block as JsonObject | undefined; + if (!block || block.$type === "pub.leaflet.blocks.code") continue; + collect(block.plaintext); + for (const item of block.children ?? []) collect(item.content?.plaintext); + } + return definitions; +} + +function richTextMarkdown( + plaintext: unknown, + facets: JsonObject[] | undefined, + definitions: ReadonlySet, +): string { + return stripUnresolvedFootnoteReferences( + applyFacets(String(plaintext ?? ""), facets), + definitions, + ); +} + +function listItemMarkdown( + item: JsonObject, + definitions: ReadonlySet, +): string { const content = item.content ?? {}; - return applyFacets(String(content.plaintext ?? ""), content.facets); + return richTextMarkdown(content.plaintext, content.facets, definitions); } function imageUrl(did: string, cid: string, mimeType: string): string { @@ -129,6 +172,7 @@ export function leafletContentToMarkdown(content: unknown, did: string): string const lines: string[] = []; let inBlockquote = false; + const definitions = footnoteDefinitions(page.blocks as JsonObject[]); const closeBlockquote = () => { if (inBlockquote) { @@ -146,11 +190,11 @@ export function leafletContentToMarkdown(content: unknown, did: string): string switch (type) { case "pub.leaflet.blocks.text": - lines.push(applyFacets(String(block.plaintext ?? ""), block.facets), ""); + lines.push(richTextMarkdown(block.plaintext, block.facets, definitions), ""); break; case "pub.leaflet.blocks.header": lines.push( - `${"#".repeat(Math.min(6, Math.max(1, Number(block.level) || 2)))} ${applyFacets(String(block.plaintext ?? ""), block.facets)}`, + `${"#".repeat(Math.min(6, Math.max(1, Number(block.level) || 2)))} ${richTextMarkdown(block.plaintext, block.facets, definitions)}`, "", ); break; @@ -160,7 +204,7 @@ export function leafletContentToMarkdown(content: unknown, did: string): string case "pub.leaflet.blocks.blockquote": if (inBlockquote) lines.push(">"); lines.push( - applyFacets(String(block.plaintext ?? ""), block.facets) + richTextMarkdown(block.plaintext, block.facets, definitions) .split("\n") .map((line) => `> ${line}`) .join("\n"), @@ -168,13 +212,15 @@ export function leafletContentToMarkdown(content: unknown, did: string): string inBlockquote = true; break; case "pub.leaflet.blocks.unorderedList": - for (const item of block.children ?? []) lines.push(`- ${listItemMarkdown(item)}`); + for (const item of block.children ?? []) { + lines.push(`- ${listItemMarkdown(item, definitions)}`); + } lines.push(""); break; case "pub.leaflet.blocks.orderedList": { const start = Number.isFinite(block.startIndex) ? Number(block.startIndex) : 1; (block.children ?? []).forEach((item: JsonObject, index: number) => { - lines.push(`${start + index}. ${listItemMarkdown(item)}`); + lines.push(`${start + index}. ${listItemMarkdown(item, definitions)}`); }); lines.push(""); break; diff --git a/src/markdown.test.ts b/src/markdown.test.ts index 76d31cf..d7a3742 100644 --- a/src/markdown.test.ts +++ b/src/markdown.test.ts @@ -22,6 +22,17 @@ test("does not interpret ordinary currency as inline math", async () => { assert.doesNotMatch(html, / { + const html = await renderMarkdown(`A statement[^note]. + +[^note]: Supporting detail.`); + + assert.match(html, //); + assert.match(html, /
  • /); + assert.match(html, /Supporting detail\./); +}); + test("safe Markdown mode escapes arbitrary HTML, SVG, and scripts", async () => { const html = await renderMarkdown( `\n\n\n\n
    text
    `,