Leaflet Blog in Deno Fresh

leaflet

+333 -121
+84
components/TextBlock.tsx
··· 1 + import { h } from "preact"; 2 + import { PubLeafletBlocksText } from "npm:@atcute/leaflet"; 3 + 4 + interface TextBlockProps { 5 + plaintext: string; 6 + facets?: PubLeafletBlocksText.Main["facets"]; 7 + } 8 + 9 + export function TextBlock({ plaintext, facets }: TextBlockProps) { 10 + // Only process facets if at least one facet has features 11 + if (!facets || !facets.some(f => f.features && f.features.length > 0)) { 12 + return <>{plaintext}</>; 13 + } 14 + 15 + const parts: (string | { text: string; type: string; uri?: string })[] = []; 16 + let lastIndex = 0; 17 + 18 + facets.forEach((facet) => { 19 + if (facet.index.byteStart > lastIndex) { 20 + parts.push(plaintext.slice(lastIndex, facet.index.byteStart)); 21 + } 22 + 23 + const text = plaintext.slice(facet.index.byteStart, facet.index.byteEnd); 24 + const feature = facet.features?.[0]; 25 + 26 + if (!feature) { 27 + parts.push(text); 28 + return; 29 + } 30 + 31 + if (feature.$type === "pub.leaflet.richtext.facet#bold") { 32 + parts.push({ text, type: feature.$type }); 33 + } else if (feature.$type === "pub.leaflet.richtext.facet#highlight") { 34 + parts.push({ text, type: feature.$type }); 35 + } else if (feature.$type === "pub.leaflet.richtext.facet#italic") { 36 + parts.push({ text, type: feature.$type }); 37 + } else if (feature.$type === "pub.leaflet.richtext.facet#strikethrough") { 38 + parts.push({ text, type: feature.$type }); 39 + } else if (feature.$type === "pub.leaflet.richtext.facet#underline") { 40 + parts.push({ text, type: feature.$type }); 41 + } else { 42 + parts.push({ text, type: feature.$type }); 43 + } 44 + 45 + lastIndex = facet.index.byteEnd; 46 + }); 47 + 48 + if (lastIndex < plaintext.length) { 49 + parts.push(plaintext.slice(lastIndex)); 50 + } 51 + 52 + return ( 53 + <> 54 + {parts.map((part, i) => { 55 + if (typeof part === "string") { 56 + return part; 57 + } 58 + 59 + switch (part.type) { 60 + case "pub.leaflet.richtext.facet#bold": 61 + return <strong key={i}>{part.text}</strong>; 62 + case "pub.leaflet.richtext.facet#highlight": 63 + return ( 64 + <mark 65 + key={i} 66 + className="bg-blue-100 dark:bg-blue-900 text-inherit rounded px-1" 67 + style={{ borderRadius: '0.375rem' }} 68 + > 69 + {part.text} 70 + </mark> 71 + ); 72 + case "pub.leaflet.richtext.facet#italic": 73 + return <em key={i}>{part.text}</em>; 74 + case "pub.leaflet.richtext.facet#strikethrough": 75 + return <s key={i}>{part.text}</s>; 76 + case "pub.leaflet.richtext.facet#underline": 77 + return <u key={i}>{part.text}</u>; 78 + default: 79 + return part.text; 80 + } 81 + })} 82 + </> 83 + ); 84 + }
+12 -6
components/post-list-item.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEffect, useRef, useState } from "preact/hooks"; 4 - import { ComWhtwndBlogEntry } from "npm:@atcute/whitewind"; 4 + import { type PubLeafletDocument, type PubLeafletBlocksText } from "npm:@atcute/leaflet"; 5 5 6 6 import { cx } from "../lib/cx.ts"; 7 7 ··· 12 12 post, 13 13 rkey, 14 14 }: { 15 - post: ComWhtwndBlogEntry.Main; 15 + post: PubLeafletDocument.Main; 16 16 rkey: string; 17 17 }) { 18 18 const [isHovered, setIsHovered] = useState(false); ··· 44 44 }, 300); // Match animation duration 45 45 }; 46 46 47 + // Gather all text blocks' plaintext for preview and reading time 48 + const allText = post.pages?.[0]?.blocks 49 + ?.filter(block => block.block.$type === "pub.leaflet.blocks.text") 50 + .map(block => (block.block as PubLeafletBlocksText.Main).plaintext) 51 + .join(" ") || ""; 52 + 47 53 return ( 48 54 <> 49 55 {isHovered && ( ··· 73 79 {post.title} 74 80 </Title> 75 81 <PostInfo 76 - content={post.content} 77 - createdAt={post.createdAt} 82 + content={allText} 83 + createdAt={post.publishedAt} 78 84 className="text-xs mt-1 w-full" 79 85 /> 80 86 <div className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.33,0,0.67,1)] grid-rows-[0fr] group-hover:grid-rows-[1fr] opacity-0 group-hover:opacity-100 mt-2"> 81 87 <div className="overflow-hidden"> 82 - <p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3 break-words"> 83 - {post.content.substring(0, 280)} 88 + <p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3"> 89 + {allText} 84 90 </p> 85 91 </div> 86 92 </div>
+2
fresh.gen.ts
··· 5 5 import * as $_404 from "./routes/_404.tsx"; 6 6 import * as $_app from "./routes/_app.tsx"; 7 7 import * as $about from "./routes/about.tsx"; 8 + import * as $api_atproto_images from "./routes/api/atproto_images.ts"; 8 9 import * as $index from "./routes/index.tsx"; 9 10 import * as $post_slug_ from "./routes/post/[slug].tsx"; 10 11 import * as $rss from "./routes/rss.ts"; ··· 17 18 "./routes/_404.tsx": $_404, 18 19 "./routes/_app.tsx": $_app, 19 20 "./routes/about.tsx": $about, 21 + "./routes/api/atproto_images.ts": $api_atproto_images, 20 22 "./routes/index.tsx": $index, 21 23 "./routes/post/[slug].tsx": $post_slug_, 22 24 "./routes/rss.ts": $rss,
+14 -5
islands/layout.tsx
··· 1 1 import { Footer } from "../components/footer.tsx"; 2 2 import type { ComponentChildren } from "preact"; 3 3 import { useEffect, useState } from "preact/hooks"; 4 + import { useSignal } from "@preact/signals"; 4 5 5 6 export function Layout({ children }: { children: ComponentChildren }) { 6 7 const [isScrolled, setIsScrolled] = useState(false); 7 8 const [blogHovered, setBlogHovered] = useState(false); 8 9 const [aboutHovered, setAboutHovered] = useState(false); 10 + const pathname = useSignal(""); 9 11 10 - // Get current path to determine active nav item 11 - const path = typeof window !== "undefined" ? window.location.pathname : ""; 12 12 const isActive = (href: string) => { 13 13 if (href === "/") { 14 - return path === "/" || path.startsWith("/post/"); 14 + return pathname.value === "/" || pathname.value.startsWith("/post/"); 15 15 } 16 - return path === href; 16 + return pathname.value === href; 17 17 }; 18 18 19 19 useEffect(() => { ··· 21 21 setIsScrolled(window.scrollY > 0); 22 22 }; 23 23 24 + const handlePathChange = () => { 25 + pathname.value = window.location.pathname; 26 + }; 27 + 24 28 window.addEventListener("scroll", handleScroll); 29 + window.addEventListener("popstate", handlePathChange); 25 30 handleScroll(); // Check initial scroll position 31 + handlePathChange(); // Set initial path 26 32 27 - return () => window.removeEventListener("scroll", handleScroll); 33 + return () => { 34 + window.removeEventListener("scroll", handleScroll); 35 + window.removeEventListener("popstate", handlePathChange); 36 + }; 28 37 }, []); 29 38 30 39 return (
+6 -14
lib/api.ts
··· 2 2 import { env } from "./env.ts"; 3 3 4 4 import { type ActorIdentifier } from "npm:@atcute/lexicons"; 5 - import { type ComWhtwndBlogEntry } from "@atcute/whitewind"; 6 5 import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto"; 6 + import { type PubLeafletDocument } from "npm:@atcute/leaflet"; 7 7 8 8 export async function getPosts() { 9 9 const posts = await bsky.get("com.atproto.repo.listRecords", { 10 10 params: { 11 11 repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier, 12 - collection: "com.whtwnd.blog.entry", 12 + collection: "pub.leaflet.document", 13 13 // todo: pagination 14 14 }, 15 15 }); ··· 18 18 throw new Error(posts.data.error); 19 19 } 20 20 21 - return posts.data.records.filter( 22 - drafts, 23 - ) as (ComAtprotoRepoListRecords.Record & { 24 - value: ComWhtwndBlogEntry.Main; 21 + return posts.data.records as (ComAtprotoRepoListRecords.Record & { 22 + value: PubLeafletDocument.Main; 25 23 })[]; 26 24 } 27 25 28 - function drafts(record: ComAtprotoRepoListRecords.Record) { 29 - if (Deno.env.get("NODE_ENV") === "development") return true; 30 - const post = record.value as ComWhtwndBlogEntry.Main; 31 - return post.visibility === "public"; 32 - } 33 - 34 26 export async function getPost(rkey: string) { 35 27 const post = await bsky.get("com.atproto.repo.getRecord", { 36 28 params: { 37 29 repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier, 38 30 rkey: rkey, 39 - collection: "com.whtwnd.blog.entry", 31 + collection: "pub.leaflet.document", 40 32 }, 41 33 }); 42 34 43 35 return post.data as ComAtprotoRepoListRecords.Record & { 44 - value: ComWhtwndBlogEntry.Main; 36 + value: PubLeafletDocument.Main; 45 37 }; 46 38 }
+38
routes/api/atproto_images.ts
··· 1 + import { Handlers } from "$fresh/server.ts"; 2 + import { IdResolver } from "npm:@atproto/identity"; 3 + 4 + const idResolver = new IdResolver(); 5 + 6 + export const handler: Handlers = { 7 + async GET(req) { 8 + const url = new URL(req.url); 9 + const did = url.searchParams.get("did") ?? ""; 10 + const cid = url.searchParams.get("cid") ?? ""; 11 + 12 + if (!did || !cid) { 13 + return new Response("Missing did or cid", { status: 404 }); 14 + } 15 + 16 + const identity = await idResolver.did.resolve(did); 17 + const service = identity?.service?.find((f: any) => f.id === "#atproto_pds"); 18 + if (!service) { 19 + return new Response("No PDS service found", { status: 404 }); 20 + } 21 + 22 + const blobUrl = `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 23 + const response = await fetch(blobUrl); 24 + 25 + if (!response.ok) { 26 + return new Response("Blob not found", { status: 404 }); 27 + } 28 + 29 + // Clone the response to modify headers 30 + const cachedResponse = new Response(response.body, response); 31 + cachedResponse.headers.set( 32 + "Cache-Control", 33 + "public, max-age=31536000, immutable", 34 + ); 35 + 36 + return cachedResponse; 37 + }, 38 + };
+177 -96
routes/post/[slug].tsx
··· 1 1 /** @jsxImportSource preact */ 2 - import { CSS, render } from "@deno/gfm"; 3 2 import { Handlers, PageProps } from "$fresh/server.ts"; 4 - 5 3 import { Layout } from "../../islands/layout.tsx"; 6 4 import { PostInfo } from "../../components/post-info.tsx"; 7 5 import { Title } from "../../components/typography.tsx"; 8 6 import { getPost } from "../../lib/api.ts"; 9 7 import { Head } from "$fresh/runtime.ts"; 8 + import { TextBlock } from "../../components/TextBlock.tsx"; 9 + import { 10 + PubLeafletBlocksHeader, 11 + PubLeafletBlocksImage, 12 + PubLeafletBlocksText, 13 + PubLeafletBlocksUnorderedList, 14 + PubLeafletPagesLinearDocument, 15 + } from "npm:@atcute/leaflet"; 16 + import { h } from "preact"; 10 17 11 18 interface Post { 12 19 uri: string; 13 20 value: { 14 21 title?: string; 15 - subtitle?: string; 16 - content?: string; 17 - createdAt?: string; 22 + description?: string; 23 + pages?: PubLeafletPagesLinearDocument.Main[]; 24 + publishedAt?: string; 18 25 }; 19 26 } 20 27 21 - // Only override backgrounds in dark mode to make them transparent 22 - const transparentDarkModeCSS = ` 23 - @media (prefers-color-scheme: dark) { 24 - .markdown-body { 25 - color: white; 26 - background-color: transparent; 27 - } 28 + export const handler: Handlers<Post> = { 29 + async GET(_req, ctx) { 30 + try { 31 + const { slug } = ctx.params; 32 + const post = await getPost(slug); 33 + return ctx.render(post); 34 + } catch (error) { 35 + console.error("Error fetching post:", error); 36 + return new Response("Post not found", { status: 404 }); 37 + } 38 + }, 39 + }; 40 + 41 + function Block({ 42 + block, 43 + did, 44 + isList, 45 + }: { 46 + block: PubLeafletPagesLinearDocument.Block; 47 + did: string; 48 + isList?: boolean; 49 + }) { 50 + let b = block; 28 51 29 - .markdown-body a { 30 - color: #58a6ff; 52 + // Debug log to check for duplicate rendering 53 + console.log( 54 + "Rendering block", 55 + b.block.$type, 56 + (b.block as any).plaintext || (b.block as any).text || "" 57 + ); 58 + 59 + let className = ` 60 + postBlockWrapper 61 + pt-1 62 + ${isList ? "isListItem pb-0 " : "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3"} 63 + ${b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ? "text-right" : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter" ? "text-center" : ""} 64 + `; 65 + 66 + if (b.block.$type === "pub.leaflet.blocks.unorderedList") { 67 + return ( 68 + <ul className="-ml-[1px] sm:ml-[9px] pb-2"> 69 + {b.block.children.map((child, index) => ( 70 + <ListItem 71 + item={child} 72 + did={did} 73 + key={index} 74 + className={className} 75 + /> 76 + ))} 77 + </ul> 78 + ); 31 79 } 32 80 33 - .markdown-body blockquote { 34 - border-left-color: #30363d; 35 - background-color: transparent; 81 + if (b.block.$type === "pub.leaflet.blocks.image") { 82 + const imageBlock = b.block as PubLeafletBlocksImage.Main; 83 + const image = imageBlock.image as { ref: { $link: string } }; 84 + const alt = imageBlock.alt || ""; 85 + const aspect = imageBlock.aspectRatio; 86 + let width = aspect?.width; 87 + let height = aspect?.height; 88 + // Fallback to default size if not provided 89 + if (!width) width = 600; 90 + if (!height) height = 400; 91 + return ( 92 + <img 93 + src={`/api/atproto_images?did=${did}&cid=${image.ref.$link}`} 94 + alt={alt} 95 + width={width} 96 + height={height} 97 + className={`!pt-3 sm:!pt-4 ${className}`} 98 + style={{ aspectRatio: width && height ? `${width} / ${height}` : undefined }} 99 + /> 100 + ); 36 101 } 37 102 38 - .markdown-body pre, 39 - .markdown-body code { 40 - background-color: transparent; 41 - color: #c9d1d9; 103 + if (b.block.$type === "pub.leaflet.blocks.text") { 104 + return ( 105 + <div className={` ${className}`}> 106 + <TextBlock facets={b.block.facets} plaintext={b.block.plaintext} /> 107 + </div> 108 + ); 42 109 } 43 110 44 - .markdown-body table td, 45 - .markdown-body table th { 46 - border-color: #30363d; 47 - background-color: transparent; 111 + if (b.block.$type === "pub.leaflet.blocks.header") { 112 + const header = b.block as PubLeafletBlocksHeader.Main; 113 + const level = header.level || 1; 114 + const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements; 115 + // Add heading styles based on level 116 + let headingStyle = "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap "; 117 + switch (level) { 118 + case 1: 119 + headingStyle += "text-4xl lg:text-5xl"; 120 + break; 121 + case 2: 122 + headingStyle += "text-3xl border-b pb-2 mb-6"; 123 + break; 124 + case 3: 125 + headingStyle += "text-2xl"; 126 + break; 127 + case 4: 128 + headingStyle += "text-xl"; 129 + break; 130 + case 5: 131 + headingStyle += "text-lg"; 132 + break; 133 + case 6: 134 + headingStyle += "text-base"; 135 + break; 136 + default: 137 + headingStyle += "text-2xl"; 138 + } 139 + return ( 140 + <Tag className={headingStyle + ' ' + className}> 141 + <TextBlock plaintext={header.plaintext} facets={header.facets} /> 142 + </Tag> 143 + ); 48 144 } 49 - } 50 145 51 - .font-sans { font-family: var(--font-sans); } 52 - .font-serif { font-family: var(--font-serif); } 53 - .font-mono { font-family: var(--font-mono); } 54 - 55 - .markdown-body h1 { 56 - font-family: var(--font-serif); 57 - text-transform: uppercase; 58 - font-size: 2.25rem; 59 - } 60 - 61 - .markdown-body h2 { 62 - font-family: var(--font-serif); 63 - text-transform: uppercase; 64 - font-size: 1.75rem; 65 - } 66 - 67 - .markdown-body h3 { 68 - font-family: var(--font-serif); 69 - text-transform: uppercase; 70 - font-size: 1.5rem; 71 - } 72 - 73 - .markdown-body h4 { 74 - font-family: var(--font-serif); 75 - text-transform: uppercase; 76 - font-size: 1.25rem; 146 + return null; 77 147 } 78 148 79 - .markdown-body h5 { 80 - font-family: var(--font-serif); 81 - text-transform: uppercase; 82 - font-size: 1rem; 83 - } 84 - 85 - .markdown-body h6 { 86 - font-family: var(--font-serif); 87 - text-transform: uppercase; 88 - font-size: 0.875rem; 149 + function ListItem(props: { 150 + item: PubLeafletBlocksUnorderedList.Main["children"][number]; 151 + did: string; 152 + className?: string; 153 + }) { 154 + return ( 155 + <li className={`!pb-0 flex flex-row gap-2`}> 156 + <div 157 + className={`listMarker shrink-0 mx-2 z-[1] mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`} 158 + /> 159 + <div className="flex flex-col"> 160 + <Block block={{ block: props.item.content }} did={props.did} isList /> 161 + {props.item.children?.length ? ( 162 + <ul className="-ml-[7px] sm:ml-[7px]"> 163 + {props.item.children.map((child, index) => ( 164 + <ListItem 165 + item={child} 166 + did={props.did} 167 + key={index} 168 + className={props.className} 169 + /> 170 + ))} 171 + </ul> 172 + ) : null} 173 + </div> 174 + </li> 175 + ); 89 176 } 90 - `; 91 - 92 - export const handler: Handlers<Post> = { 93 - async GET(_req, ctx) { 94 - try { 95 - const { slug } = ctx.params; 96 - const post = await getPost(slug); 97 - return ctx.render(post); 98 - } catch (error) { 99 - console.error("Error fetching post:", error); 100 - return new Response("Post not found", { status: 404 }); 101 - } 102 - }, 103 - }; 104 177 105 178 export default function BlogPage({ data: post }: PageProps<Post>) { 106 179 if (!post) { 107 180 return <div>Post not found</div>; 108 181 } 109 182 183 + const firstPage = post.value.pages?.[0]; 184 + let blocks: PubLeafletPagesLinearDocument.Block[] = []; 185 + if (firstPage?.$type === "pub.leaflet.pages.linearDocument") { 186 + blocks = firstPage.blocks || []; 187 + } 188 + // Deduplicate blocks by $type and plaintext 189 + const seen = new Set(); 190 + const uniqueBlocks = blocks.filter(b => { 191 + const key = b.block.$type + '|' + ((b.block as any).plaintext || ''); 192 + if (seen.has(key)) return false; 193 + seen.add(key); 194 + return true; 195 + }); 196 + 197 + const content = uniqueBlocks 198 + .filter(b => b.block.$type === "pub.leaflet.blocks.text") 199 + .map(b => (b.block as PubLeafletBlocksText.Main).plaintext) 200 + .join(' '); 201 + 110 202 return ( 111 203 <> 112 204 <Head> 113 205 <title>{post.value.title} — knotbin</title> 114 206 <meta 115 207 name="description" 116 - content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"} 117 - /> 118 - {/* Merge GFM's default styles with our dark-mode overrides */} 119 - <style 120 - dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }} 208 + content={post.value.description || "by Roscoe Rubin-Rottenberg"} 121 209 /> 122 210 </Head> 123 211 ··· 128 216 <article class="w-full space-y-8"> 129 217 <div class="space-y-4 w-full"> 130 218 <Title>{post.value.title || 'Untitled'}</Title> 131 - {post.value.subtitle && ( 219 + {post.value.description && ( 132 220 <p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose"> 133 - {post.value.subtitle} 221 + {post.value.description} 134 222 </p> 135 223 )} 136 224 <PostInfo 137 - content={post.value.content || ''} 138 - createdAt={post.value.createdAt || new Date().toISOString()} 225 + content={content} 226 + createdAt={post.value.publishedAt || new Date().toISOString()} 139 227 includeAuthor 140 228 className="text-sm" 141 229 /> 142 230 <div class="diagonal-pattern w-full h-3" /> 143 231 </div> 144 - <div class="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0"> 145 - <div 146 - class="mt-8 markdown-body" 147 - // replace old pds url with new one for blob urls 148 - dangerouslySetInnerHTML={{ 149 - __html: render(post.value.content || '').replace( 150 - /puffball\.us-east\.host\.bsky\.network/g, 151 - "knotbin.xyz", 152 - ), 153 - }} 154 - /> 232 + <div class="postContent flex flex-col"> 233 + {uniqueBlocks.map((block, index) => ( 234 + <Block block={block} did={post.uri.split('/')[2]} key={index} /> 235 + ))} 155 236 </div> 156 237 </article> 157 238 </div>