From f2b0272274dbd7253ce76441f00c145dd743d41e Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Mon, 1 Jun 2026 17:51:34 +0100 Subject: [PATCH] feat: add native Leaflet block rendering for blog posts Render Leaflet content blocks as Svelte components instead of converting to markdown. Handles BlobRef/CID class instances via JSON round-trip serialisation that converts them to plain POJOs before SvelteKit dehydration, then replaces blob refs with PDS URLs for image rendering. Components: LeafletBlocks (dispatcher), LeafletFacets (inline formatting), LeafletImage, LeafletCode, LeafletMath, LeafletEmbed, LeafletWebsiteCard, LeafletButton, LeafletBskyPost. Falls back to markdown rendering when blocks are unavailable. --- .../components/leaflet/LeafletBlocks.svelte | 237 ++++++++++++ .../components/leaflet/LeafletBskyPost.svelte | 30 ++ .../components/leaflet/LeafletButton.svelte | 11 + src/lib/components/leaflet/LeafletCode.svelte | 12 + .../components/leaflet/LeafletEmbed.svelte | 28 ++ .../components/leaflet/LeafletFacets.svelte | 164 +++++++++ .../components/leaflet/LeafletImage.svelte | 35 ++ src/lib/components/leaflet/LeafletMath.svelte | 11 + .../leaflet/LeafletWebsiteCard.svelte | 34 ++ src/lib/providers/serialise.ts | 120 ++++++ src/lib/styles/leaflet-blocks.css | 341 ++++++++++++++++++ .../[month]/[day]/[slug]/+page.server.ts | 27 +- .../[year]/[month]/[day]/[slug]/+page.svelte | 10 +- src/routes/layout.css | 1 + 14 files changed, 1054 insertions(+), 7 deletions(-) create mode 100644 src/lib/components/leaflet/LeafletBlocks.svelte create mode 100644 src/lib/components/leaflet/LeafletBskyPost.svelte create mode 100644 src/lib/components/leaflet/LeafletButton.svelte create mode 100644 src/lib/components/leaflet/LeafletCode.svelte create mode 100644 src/lib/components/leaflet/LeafletEmbed.svelte create mode 100644 src/lib/components/leaflet/LeafletFacets.svelte create mode 100644 src/lib/components/leaflet/LeafletImage.svelte create mode 100644 src/lib/components/leaflet/LeafletMath.svelte create mode 100644 src/lib/components/leaflet/LeafletWebsiteCard.svelte create mode 100644 src/lib/providers/serialise.ts create mode 100644 src/lib/styles/leaflet-blocks.css diff --git a/src/lib/components/leaflet/LeafletBlocks.svelte b/src/lib/components/leaflet/LeafletBlocks.svelte new file mode 100644 index 0000000..c95da64 --- /dev/null +++ b/src/lib/components/leaflet/LeafletBlocks.svelte @@ -0,0 +1,237 @@ + + +{#each blocks as wrapper, i} + {@const inner = wrapper.block} + {@const align = alignmentClass(wrapper.alignment)} + {@const $type = inner.$type as string} + + {#if $type === B("text")} +

+ +

+ + {:else if $type === B("header")} + {@const level = Math.min(Math.max((inner.level as number) ?? 1, 1), 6)} + {#if level === 1} +

+ +

+ {:else if level === 2} +

+ +

+ {:else if level === 3} +

+ +

+ {:else if level === 4} +

+ +

+ {:else if level === 5} +
+ +
+ {:else} +
+ +
+ {/if} + + {:else if $type === B("blockquote")} +
+ +
+ + {:else if $type === B("code")} + + + {:else if $type === B("math")} + + + {:else if $type === B("horizontalRule")} +
+ + {:else if $type === B("image")} + {@const ar = getAspectRatio(inner)} + + + {:else if $type === B("unorderedList")} +
    + {#each (inner.children as Record[]) ?? [] as item} + {@render listItem(item, false)} + {/each} +
+ + {:else if $type === B("orderedList")} +
    + {#each (inner.children as Record[]) ?? [] as item} + {@render listItem(item, true)} + {/each} +
+ + {:else if $type === B("iframe")} + + + {:else if $type === B("website")} + + + {:else if $type === B("button")} + + + {:else if $type === B("bskyPost")} + + + {:else if $type === B("page")} +
Embedded page
+ + {:else if $type === B("poll")} +
Poll
+ + {:else if $type === B("postsList")} +
Posts list
+ + {:else if $type === B("signup")} +
Signup form
+ + {:else if $type === B("standardSitePost")} + + + {:else} + + {/if} +{/each} + +{#snippet listItem(item: Record, ordered: boolean)} +
  • + {#if item.checked !== undefined} + + {/if} + {#if item.content} + {@const content = item.content as Record} + {@const contentType = content.$type as string} + {#if contentType === B("image")} + {@const ar = getAspectRatio(content)} + + {:else} + + {/if} + {/if} + {#if Array.isArray(item.children) && item.children.length > 0} +
      + {#each item.children as child} + {@render listItem(child, false)} + {/each} +
    + {:else if item.orderedListChildren} + {@const olc = item.orderedListChildren as Record} +
      + {#each (olc.children as Record[]) ?? [] as child} + {@render listItem(child, true)} + {/each} +
    + {/if} +
  • +{/snippet} diff --git a/src/lib/components/leaflet/LeafletBskyPost.svelte b/src/lib/components/leaflet/LeafletBskyPost.svelte new file mode 100644 index 0000000..1f035e9 --- /dev/null +++ b/src/lib/components/leaflet/LeafletBskyPost.svelte @@ -0,0 +1,30 @@ + + +
    + + Bluesky post → + +
    diff --git a/src/lib/components/leaflet/LeafletButton.svelte b/src/lib/components/leaflet/LeafletButton.svelte new file mode 100644 index 0000000..bb4d164 --- /dev/null +++ b/src/lib/components/leaflet/LeafletButton.svelte @@ -0,0 +1,11 @@ + + +
    + {text} +
    diff --git a/src/lib/components/leaflet/LeafletCode.svelte b/src/lib/components/leaflet/LeafletCode.svelte new file mode 100644 index 0000000..9f0dc63 --- /dev/null +++ b/src/lib/components/leaflet/LeafletCode.svelte @@ -0,0 +1,12 @@ + + +
    {plaintext}
    diff --git a/src/lib/components/leaflet/LeafletEmbed.svelte b/src/lib/components/leaflet/LeafletEmbed.svelte new file mode 100644 index 0000000..ba643e1 --- /dev/null +++ b/src/lib/components/leaflet/LeafletEmbed.svelte @@ -0,0 +1,28 @@ + + +
    + +
    diff --git a/src/lib/components/leaflet/LeafletFacets.svelte b/src/lib/components/leaflet/LeafletFacets.svelte new file mode 100644 index 0000000..70f4336 --- /dev/null +++ b/src/lib/components/leaflet/LeafletFacets.svelte @@ -0,0 +1,164 @@ + + +{@html html} diff --git a/src/lib/components/leaflet/LeafletImage.svelte b/src/lib/components/leaflet/LeafletImage.svelte new file mode 100644 index 0000000..82f5b63 --- /dev/null +++ b/src/lib/components/leaflet/LeafletImage.svelte @@ -0,0 +1,35 @@ + + +
    + {alt + {#if alt} +
    {alt}
    + {/if} +
    diff --git a/src/lib/components/leaflet/LeafletMath.svelte b/src/lib/components/leaflet/LeafletMath.svelte new file mode 100644 index 0000000..bbc75bd --- /dev/null +++ b/src/lib/components/leaflet/LeafletMath.svelte @@ -0,0 +1,11 @@ + + +
    {tex}
    diff --git a/src/lib/components/leaflet/LeafletWebsiteCard.svelte b/src/lib/components/leaflet/LeafletWebsiteCard.svelte new file mode 100644 index 0000000..72444bd --- /dev/null +++ b/src/lib/components/leaflet/LeafletWebsiteCard.svelte @@ -0,0 +1,34 @@ + + + + {#if previewImageSrc} +
    + +
    + {/if} +
    + {#if title} + {title} + {/if} + {#if description} +

    {description}

    + {/if} + + + {new URL(src).hostname} + +
    +
    diff --git a/src/lib/providers/serialise.ts b/src/lib/providers/serialise.ts new file mode 100644 index 0000000..225fab1 --- /dev/null +++ b/src/lib/providers/serialise.ts @@ -0,0 +1,120 @@ +/** + * Serialise Leaflet content blocks for SvelteKit server→client transfer. + * + * The raw AT Protocol record contains BlobRef objects with CID class instances + * that SvelteKit cannot dehydrate. This module does a JSON round-trip to + * convert class instances to plain POJOs, then walks the result replacing + * every image/previewImage BlobRef with a PDS blob URL stored as + * `_imageSrc` / `_previewImageSrc`. The original blob fields are removed. + */ + +const LINEAR = "pub.leaflet.pages.linearDocument"; + +type Obj = Record; + +/** A Leaflet block wrapper after BlobRef→URL serialisation. */ +export interface SerialisedBlock { + block: Obj; + alignment?: string; +} + +/** + * Build a PDS blob URL from a DID and a CID string. + */ +export function pdsBlobUrl(did: string, cid: string): string { + return `https://eurosky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; +} + +/** + * Extract CID string from a plain-object BlobRef (after JSON round-trip). + * Shape: { $type: "blob", ref: { $link: "bafy..." }, mimeType, size } + * or legacy: { ref: { $link: "bafy..." } } or { ref: "bafy..." } + */ +function extractCid(obj: Obj): string | null { + const ref = obj.ref; + if (typeof ref === "string") return ref; + if (ref && typeof ref === "object" && "$link" in (ref as Obj)) { + return (ref as Obj).$link as string; + } + return null; +} + +/** + * Walk a plain-object tree, replacing image/previewImage BlobRefs + * with PDS blob URLs. + */ +function replaceBlobs(obj: unknown, did: string): void { + if (Array.isArray(obj)) { + obj.forEach((item) => replaceBlobs(item, did)); + return; + } + if (typeof obj !== "object" || obj === null) return; + + const o = obj as Obj; + for (const [key, val] of Object.entries(o)) { + if (val && typeof val === "object" && !Array.isArray(val)) { + const v = val as Obj; + // Check if this is a BlobRef (after JSON round-trip) + if (v.$type === "blob" || ("mimeType" in v && "ref" in v)) { + const cid = extractCid(v); + if (cid) { + const url = pdsBlobUrl(did, cid); + if (key === "image") { + o._imageSrc = url; + delete o.image; + } else if (key === "previewImage") { + o._previewImageSrc = url; + delete o.previewImage; + } else { + o[key] = url; + } + } + } else { + replaceBlobs(v, did); + } + } else if (Array.isArray(val)) { + replaceBlobs(val, did); + } + } +} + +/** + * Serialise a Leaflet content record into a plain-POJO blocks array + * safe for SvelteKit dehydration. + * + * @param content The raw `pub.leaflet.content` record value + * @param did The DID that owns the record (for blob URL construction) + * @param fetchBlob Async function to fetch blob pages if needed + * @returns The blocks array with BlobRefs replaced by PDS URLs + */ +export async function serialiseBlocks( + content: unknown, + did: string, + fetchBlob: (ref: unknown) => Promise, +): Promise { + const c = content as Obj; + let pages = (c.pages as Obj[]) ?? []; + + // Handle blobPages (large content stored as a blob) + if (c.blobPages) { + try { + const bytes = await fetchBlob(c.blobPages); + pages = JSON.parse(new TextDecoder().decode(bytes)) as Obj[]; + } catch { + /* fall back to inline pages */ + } + } + + const page = pages.find((p) => p.$type === LINEAR) ?? pages[0]; + const blocks = (page?.blocks as Obj[]) ?? []; + + // JSON round-trip converts BlobRef/CID class instances to plain POJOs. + // BlobRef.toJSON() produces { $type: "blob", ref: { $link: "cid" }, ... } + // CID.toString() produces the CID string. + const cloned = JSON.parse(JSON.stringify(blocks)) as SerialisedBlock[]; + + // Walk the cloned blocks, replacing BlobRefs with PDS URLs + replaceBlobs(cloned, did); + + return cloned; +} diff --git a/src/lib/styles/leaflet-blocks.css b/src/lib/styles/leaflet-blocks.css new file mode 100644 index 0000000..3b3636c --- /dev/null +++ b/src/lib/styles/leaflet-blocks.css @@ -0,0 +1,341 @@ +@layer components { + /* ================================================================ + * leaflet-blocks.css — Native Leaflet block rendering + * + * Styles for LeafletBlocks.svelte and its child components. + * Uses the same design tokens as prose.css for visual consistency. + * ================================================================ */ + + /* ── Text blocks ─────────────────────────────────────────────── */ + + .leaflet-text { + margin-top: 0; + margin-bottom: var(--space-md); + } + + .leaflet-text:last-child { + margin-bottom: 0; + } + + /* ── Headings ───────────────────────────────────────────────── */ + + .leaflet-h1, + .leaflet-h2, + .leaflet-h3, + .leaflet-h4, + .leaflet-h5, + .leaflet-h6 { + color: var(--color-ink-950); + font-weight: 800; + letter-spacing: -0.025em; + line-height: 1.2; + margin-top: var(--space-lg); + margin-bottom: var(--space-sm); + } + + .leaflet-h1 { + font-size: var(--text-xl); + } + .leaflet-h2 { + font-size: var(--text-lg); + font-weight: 700; + } + .leaflet-h3 { + font-size: var(--text-md); + font-weight: 700; + } + .leaflet-h4 { + font-size: var(--text-md); + font-weight: 600; + opacity: 0.9; + } + .leaflet-h5 { + font-size: var(--text-sm); + font-weight: 700; + } + .leaflet-h6 { + font-size: var(--text-sm); + font-weight: 600; + opacity: 0.7; + } + + :is( + .leaflet-h1, + .leaflet-h2, + .leaflet-h3, + .leaflet-h4, + .leaflet-h5, + .leaflet-h6 + ):first-child { + margin-top: 0; + } + + /* ── Blockquote ────────────────────────────────────────────── */ + + .leaflet-blockquote { + border-left: 3px solid var(--color-primary-500); + padding: var(--space-sm) 0 var(--space-sm) var(--space-md); + margin: var(--space-md) 0; + color: var(--color-ink-700); + font-style: italic; + } + + /* ── Code blocks ────────────────────────────────────────────── */ + + .leaflet-code { + font-family: + "JetBrains Mono", ui-monospace, "Cascadia Code", "Fira Code", Menlo, + monospace; + font-size: var(--text-sm); + line-height: 1.7; + color: var(--color-ink-800); + background-color: var(--surface-raised); + border: 1px solid var(--surface-color); + border-radius: var(--radius-md); + padding: var(--space-md); + overflow-x: auto; + margin: var(--space-md) 0; + } + + /* ── Math blocks ────────────────────────────────────────────── */ + + .leaflet-math { + font-family: + "JetBrains Mono", ui-monospace, "Cascadia Code", "Fira Code", Menlo, + monospace; + font-size: var(--text-sm); + line-height: 1.7; + color: var(--color-ink-800); + background-color: var(--surface-raised); + border: 1px solid var(--surface-color); + border-radius: var(--radius-md); + padding: var(--space-md); + overflow-x: auto; + margin: var(--space-md) 0; + } + + /* ── Horizontal rule ────────────────────────────────────────── */ + + .leaflet-hr { + border: none; + border-top: 1px solid var(--surface-color); + margin: var(--space-lg) 0; + } + + /* ── Images ─────────────────────────────────────────────────── */ + + .leaflet-image { + margin: var(--space-md) 0; + } + + .leaflet-image img { + display: block; + max-width: 100%; + height: auto; + border-radius: var(--radius-md); + } + + .leaflet-image.full-bleed img { + border-radius: 0; + max-width: none; + width: 100%; + } + + .leaflet-image-alt { + margin-top: var(--space-xs); + font-size: var(--text-xs); + color: var(--color-ink-600); + text-align: center; + } + + /* ── Lists ──────────────────────────────────────────────────── */ + + .leaflet-list { + margin-top: 0; + margin-bottom: var(--space-md); + padding-left: var(--space-lg); + } + + .leaflet-list.ordered { + list-style-type: decimal; + } + + .leaflet-list.nested { + margin-top: var(--space-xs); + margin-bottom: var(--space-xs); + } + + .leaflet-list-item { + margin-bottom: var(--space-xs); + line-height: 1.7; + } + + .leaflet-list-item:last-child { + margin-bottom: 0; + } + + /* Checklist items */ + .leaflet-list-item:has(> input[type="checkbox"]) { + list-style: none; + margin-left: calc(-1 * var(--space-md)); + display: flex; + align-items: baseline; + gap: var(--space-sm); + } + + .leaflet-list-item input[type="checkbox"] { + accent-color: var(--color-primary-500); + width: 1em; + height: 1em; + flex-shrink: 0; + } + + /* Marker colours */ + .leaflet-list:not(.ordered) > .leaflet-list-item::marker { + color: var(--color-primary-500); + } + + .leaflet-list.ordered > .leaflet-list-item::marker { + color: var(--color-ink-600); + font-weight: 600; + } + + /* ── Embeds ─────────────────────────────────────────────────── */ + + .leaflet-embed { + margin: var(--space-md) 0; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--surface-color); + } + + .leaflet-embed iframe { + width: 100%; + border: none; + } + + /* ── Website card ───────────────────────────────────────────── */ + + .leaflet-website-card { + display: flex; + border: 1px solid var(--surface-color); + border-radius: var(--radius-lg); + overflow: hidden; + text-decoration: none; + color: var(--color-ink-900); + margin: var(--space-md) 0; + transition: + background-color var(--duration-fast) var(--ease-out-quart), + border-color var(--duration-fast) var(--ease-out-quart); + } + + .leaflet-website-card:hover { + background-color: var(--surface-raised); + border-color: var(--color-primary-500); + } + + .website-card-image { + width: 120px; + flex-shrink: 0; + overflow: hidden; + } + + .website-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .website-card-body { + padding: var(--space-sm) var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; + } + + .website-card-title { + font-size: var(--text-sm); + font-weight: 700; + color: var(--color-ink-950); + } + + .website-card-desc { + font-size: var(--text-xs); + color: var(--color-ink-600); + margin: 0; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .website-card-url { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: var(--text-xs); + color: var(--color-ink-500); + } + + /* ── Button ─────────────────────────────────────────────────── */ + + .leaflet-button { + margin: var(--space-md) 0; + } + + .leaflet-button a { + display: inline-block; + padding: var(--space-sm) var(--space-lg); + background-color: var(--color-primary-500); + color: var(--color-ink-950); + border-radius: var(--radius-md); + text-decoration: none; + font-weight: 600; + font-size: var(--text-sm); + transition: + background-color var(--duration-fast) var(--ease-out-quart), + color var(--duration-fast) var(--ease-out-quart); + } + + .leaflet-button a:hover { + background-color: var(--color-primary-400); + } + + /* ── Bluesky post embed ─────────────────────────────────────── */ + + .leaflet-bsky-post { + margin: var(--space-md) 0; + padding: var(--space-md); + border: 1px solid var(--surface-color); + border-radius: var(--radius-lg); + background-color: var(--surface-raised); + } + + .bsky-post-link { + color: var(--color-primary-500); + text-decoration: none; + font-size: var(--text-sm); + } + + .bsky-post-link:hover { + text-decoration: underline; + } + + /* ── Unsupported block placeholder ──────────────────────────── */ + + .leaflet-unsupported { + margin: var(--space-md) 0; + padding: var(--space-sm) var(--space-md); + border: 1px dashed var(--surface-color); + border-radius: var(--radius-md); + font-size: var(--text-xs); + color: var(--color-ink-500); + text-align: center; + } + + .leaflet-unsupported a { + color: var(--color-primary-500); + } +} diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts index 2f07646..a3e154d 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.server.ts @@ -9,6 +9,10 @@ import { error } from "@sveltejs/kit"; import { normalizeSlug } from "$lib/utils/slugify"; import { renderMarkdown } from "$lib/utils/markdown"; import { leafletProvider } from "$lib/providers/leaflet"; +import { + serialiseBlocks, + type SerialisedBlock, +} from "$lib/providers/serialise"; export const load: PageServerLoad = async ({ params, fetch }) => { const { year, month, day, slug } = params; @@ -38,31 +42,42 @@ export const load: PageServerLoad = async ({ params, fetch }) => { throw error(404, "Post not found"); } - let markdown = ""; + // Serialise Leaflet blocks — replaces BlobRef/CID instances with PDS URLs + let blocks: SerialisedBlock[] = []; + let renderedContent = ""; + if ( post.content && typeof post.content === "object" && leafletProvider.matches(post.content) ) { + // Native block rendering: serialise blocks for Svelte components + blocks = await serialiseBlocks(post.content, PUBLIC_ATPROTO_DID, fetchBlob); + + // Fallback: also render markdown for search, RSS, and non-JS contexts const result = await leafletProvider.toMarkdown(post.content, { fetchBlob, }); - markdown = result.markdown; + renderedContent = await renderMarkdown(result.markdown); } else { - markdown = + const markdown = typeof post.content === "string" ? post.content : post.textContent || ""; + renderedContent = await renderMarkdown(markdown); } - const renderedContent = await renderMarkdown(markdown); - // Fetch comments via Constellation + Slingshot const comments = await fetchComments(post.uri, fetch); + // Strip raw content before serialisation — it contains BlobRef/CID class + // instances that SvelteKit cannot dehydrate across the server boundary. + const { content: _content, ...serialisable } = post; + return { post: { - ...post, + ...serialisable, createdAt: post.publishedAt, renderedContent, + blocks, }, blog: blogPublication ? { diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte index 716e123..5a6368f 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte @@ -2,10 +2,14 @@ import ShareButtons from '$lib/components/ShareButtons.svelte'; import SiteHead from '$lib/components/SiteHead.svelte'; import TableOfContents from '$lib/components/TableOfContents.svelte'; + import LeafletBlocks from '$lib/components/leaflet/LeafletBlocks.svelte'; import { Rss, MessageCircle } from '@lucide/svelte'; import { page } from '$app/state'; let { data } = $props(); + + /** Use native block rendering when blocks are available. */ + let useBlocks = $derived(data.post.blocks && data.post.blocks.length > 0); @@ -24,7 +28,11 @@
    - {@html data.post.renderedContent} + {#if useBlocks} + + {:else} + {@html data.post.renderedContent} + {/if}
    diff --git a/src/routes/layout.css b/src/routes/layout.css index 720af64..aa6ad31 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,3 +1,4 @@ @import "tailwindcss"; @import "../lib/styles/theme.css"; @import "../lib/styles/content.css"; +@import "../lib/styles/leaflet-blocks.css"; -- 2.51.2