import { UnicodeString } from "@atproto/api"; export const LEAFLET_CONTENT_TYPE = "pub.leaflet.content" as const; interface BlobRef { $type: "blob"; ref: { $link: string }; mimeType: string; size: number; } type JsonObject = Record; function escapeAttribute(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function featureKey(feature: JsonObject): string { return JSON.stringify( Object.fromEntries(Object.entries(feature).sort(([left], [right]) => left.localeCompare(right))), ); } function featureSetKey(features: JsonObject[]): string { return features.map(featureKey).sort().join("\n"); } function normalizeFacets(unicode: UnicodeString, facets: JsonObject[]): JsonObject[] { const sorted = facets .filter((facet) => Number.isInteger(facet.index?.byteStart) && Number.isInteger(facet.index?.byteEnd) && facet.index.byteStart >= 0 && facet.index.byteEnd >= facet.index.byteStart && facet.index.byteEnd <= unicode.length ) .map((facet) => ({ ...facet, index: { ...facet.index }, features: Array.isArray(facet.features) ? [...facet.features] : [], })) .sort((left, right) => left.index.byteStart - right.index.byteStart || right.index.byteEnd - left.index.byteEnd ); for (let index = 0; index < sorted.length; index++) { const facet = sorted[index]; const selected = unicode.slice(facet.index.byteStart, facet.index.byteEnd); if (selected.trim() !== "") continue; const previous = sorted[index - 1]; const next = sorted[index + 1]; if (previous?.index.byteEnd !== facet.index.byteStart || next?.index.byteStart !== facet.index.byteEnd) { facet.features = []; continue; } const nextFeatures = new Set(next.features.map(featureKey)); facet.features = previous.features.filter((feature: JsonObject) => nextFeatures.has(featureKey(feature)) ); } const merged: JsonObject[] = []; for (const facet of sorted) { if (facet.features.length === 0 || facet.index.byteStart === facet.index.byteEnd) continue; const previous = merged.at(-1); if (previous && previous.index.byteEnd === facet.index.byteStart && featureSetKey(previous.features) === featureSetKey(facet.features)) { previous.index.byteEnd = facet.index.byteEnd; continue; } merged.push(facet); } return merged; } function applyFacets(text: string, facets?: JsonObject[]): string { if (!facets || facets.length === 0) return text; const unicode = new UnicodeString(text); const sorted = normalizeFacets(unicode, facets); const starts = new Map(); const ends = new Map(); for (const facet of sorted) { const start = facet.index.byteStart; const end = facet.index.byteEnd; starts.set(start, [...(starts.get(start) ?? []), facet]); ends.set(end, [...(ends.get(end) ?? []), facet]); } const marker = (feature: JsonObject): { open: string; close: string } | undefined => { switch (feature.$type) { case "pub.leaflet.richtext.facet#bold": return { open: "**", close: "**" }; case "pub.leaflet.richtext.facet#italic": return { open: "*", close: "*" }; case "pub.leaflet.richtext.facet#code": return { open: "`", close: "`" }; case "pub.leaflet.richtext.facet#link": return { open: "[", close: `](${feature.uri})` }; case "pub.leaflet.richtext.facet#strikethrough": return { open: "~~", close: "~~" }; default: return undefined; } }; const bytePoints = [...new Set([0, unicode.length, ...starts.keys(), ...ends.keys()])] .filter((point) => point >= 0 && point <= unicode.length) .sort((a, b) => a - b); const parts: string[] = []; for (let index = 0; index < bytePoints.length - 1; index++) { const start = bytePoints[index]; const end = bytePoints[index + 1]; const closing = [...(ends.get(start) ?? [])] .sort((a, b) => b.index.byteStart - a.index.byteStart); for (const facet of closing) { const markers = (facet.features ?? []).flatMap((feature: JsonObject) => { const value = marker(feature); return value ? [value] : []; }); parts.push(...markers.reverse().map((value: { close: string }) => value.close)); } const opening = [...(starts.get(start) ?? [])] .sort((a, b) => b.index.byteEnd - a.index.byteEnd); for (const facet of opening) { const markers = (facet.features ?? []).flatMap((feature: JsonObject) => { const value = marker(feature); return value ? [value] : []; }); parts.push(...markers.map((value: { open: string }) => value.open)); } parts.push(unicode.slice(start, end)); } const finalPoint = bytePoints.at(-1) ?? unicode.length; const closing = [...(ends.get(finalPoint) ?? [])] .sort((a, b) => b.index.byteStart - a.index.byteStart); for (const facet of closing) { const markers = (facet.features ?? []).flatMap((feature: JsonObject) => { const value = marker(feature); return value ? [value] : []; }); parts.push(...markers.reverse().map((value: { close: string }) => value.close)); } return parts.join(""); } 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 richTextMarkdown(content.plaintext, content.facets, definitions); } function imageUrl(did: string, cid: string, mimeType: string): string { const extension = mimeType === "image/png" ? "png" : mimeType === "image/webp" ? "webp" : "jpeg"; return `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@${extension}`; } export function leafletContentToMarkdown(content: unknown, did: string): string { if (!content || typeof content !== "object") { throw new Error("Leaflet content must be an object"); } const value = content as JsonObject; if (value.$type !== LEAFLET_CONTENT_TYPE || !Array.isArray(value.pages)) { throw new Error(`Unsupported content type: ${String(value.$type)}`); } if (value.pages.length !== 1) { throw new Error(`Expected one linear Leaflet page, found ${value.pages.length}`); } const page = value.pages[0] as JsonObject; if (page.$type !== "pub.leaflet.pages.linearDocument" || !Array.isArray(page.blocks)) { throw new Error(`Unsupported Leaflet page type: ${String(page.$type)}`); } const lines: string[] = []; let inBlockquote = false; const definitions = footnoteDefinitions(page.blocks as JsonObject[]); const closeBlockquote = () => { if (inBlockquote) { lines.push(""); inBlockquote = false; } }; for (const wrapper of page.blocks) { const block = (wrapper as JsonObject).block as JsonObject | undefined; if (!block) throw new Error("Leaflet block wrapper has no block"); const type = String(block.$type ?? ""); if (type !== "pub.leaflet.blocks.blockquote") closeBlockquote(); switch (type) { case "pub.leaflet.blocks.text": 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)))} ${richTextMarkdown(block.plaintext, block.facets, definitions)}`, "", ); break; case "pub.leaflet.blocks.code": lines.push(`\`\`\`${String(block.language ?? "")}`, String(block.plaintext ?? ""), "```", ""); break; case "pub.leaflet.blocks.blockquote": if (inBlockquote) lines.push(">"); lines.push( richTextMarkdown(block.plaintext, block.facets, definitions) .split("\n") .map((line) => `> ${line}`) .join("\n"), ); inBlockquote = true; break; case "pub.leaflet.blocks.unorderedList": 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, definitions)}`); }); lines.push(""); break; } case "pub.leaflet.blocks.horizontalRule": lines.push("---", ""); break; case "pub.leaflet.blocks.image": { const blob = block.image as BlobRef | undefined; const cid = blob?.ref?.$link; if (!blob || !cid) throw new Error("Leaflet image block has no blob reference"); const alt = String(block.alt ?? ""); lines.push(`![${alt}](${imageUrl(did, cid, blob.mimeType)})`, ""); break; } case "pub.leaflet.blocks.bskyPost": { const uri = String(block.postRef?.uri ?? ""); const cid = String(block.postRef?.cid ?? ""); if (!uri || !cid) throw new Error("Bluesky embed is missing a strong reference"); const [, , postDid, , postRkey] = uri.split("/"); const clientHost = String(block.clientHost ?? "bsky.app"); const url = `https://${clientHost}/profile/${postDid}/post/${postRkey}`; lines.push( ``, "", ); break; } case "pub.leaflet.blocks.website": { const preview = block.previewImage as BlobRef | undefined; const previewAttributes = preview ? ` data-preview-cid="${escapeAttribute(preview.ref.$link)}" data-preview-mime="${escapeAttribute(preview.mimeType)}" data-preview-size="${preview.size}"` : ""; lines.push( ``, "", ); break; } case "pub.leaflet.blocks.iframe": { const ratio = block.aspectRatio as { width?: number; height?: number } | undefined; lines.push( ``, "", ); break; } default: throw new Error(`Unsupported Leaflet block type: ${type || ""}`); } } closeBlockquote(); return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); }