Leaflet Blog in Deno Fresh

leaflet

Changed files
+333 -121
components
islands
lib
routes
+84
components/TextBlock.tsx
···
···
+
import { h } from "preact";
+
import { PubLeafletBlocksText } from "npm:@atcute/leaflet";
+
+
interface TextBlockProps {
+
plaintext: string;
+
facets?: PubLeafletBlocksText.Main["facets"];
+
}
+
+
export function TextBlock({ plaintext, facets }: TextBlockProps) {
+
// Only process facets if at least one facet has features
+
if (!facets || !facets.some(f => f.features && f.features.length > 0)) {
+
return <>{plaintext}</>;
+
}
+
+
const parts: (string | { text: string; type: string; uri?: string })[] = [];
+
let lastIndex = 0;
+
+
facets.forEach((facet) => {
+
if (facet.index.byteStart > lastIndex) {
+
parts.push(plaintext.slice(lastIndex, facet.index.byteStart));
+
}
+
+
const text = plaintext.slice(facet.index.byteStart, facet.index.byteEnd);
+
const feature = facet.features?.[0];
+
+
if (!feature) {
+
parts.push(text);
+
return;
+
}
+
+
if (feature.$type === "pub.leaflet.richtext.facet#bold") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#highlight") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#italic") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#strikethrough") {
+
parts.push({ text, type: feature.$type });
+
} else if (feature.$type === "pub.leaflet.richtext.facet#underline") {
+
parts.push({ text, type: feature.$type });
+
} else {
+
parts.push({ text, type: feature.$type });
+
}
+
+
lastIndex = facet.index.byteEnd;
+
});
+
+
if (lastIndex < plaintext.length) {
+
parts.push(plaintext.slice(lastIndex));
+
}
+
+
return (
+
<>
+
{parts.map((part, i) => {
+
if (typeof part === "string") {
+
return part;
+
}
+
+
switch (part.type) {
+
case "pub.leaflet.richtext.facet#bold":
+
return <strong key={i}>{part.text}</strong>;
+
case "pub.leaflet.richtext.facet#highlight":
+
return (
+
<mark
+
key={i}
+
className="bg-blue-100 dark:bg-blue-900 text-inherit rounded px-1"
+
style={{ borderRadius: '0.375rem' }}
+
>
+
{part.text}
+
</mark>
+
);
+
case "pub.leaflet.richtext.facet#italic":
+
return <em key={i}>{part.text}</em>;
+
case "pub.leaflet.richtext.facet#strikethrough":
+
return <s key={i}>{part.text}</s>;
+
case "pub.leaflet.richtext.facet#underline":
+
return <u key={i}>{part.text}</u>;
+
default:
+
return part.text;
+
}
+
})}
+
</>
+
);
+
}
+12 -6
components/post-list-item.tsx
···
"use client";
import { useEffect, useRef, useState } from "preact/hooks";
-
import { ComWhtwndBlogEntry } from "npm:@atcute/whitewind";
import { cx } from "../lib/cx.ts";
···
post,
rkey,
}: {
-
post: ComWhtwndBlogEntry.Main;
rkey: string;
}) {
const [isHovered, setIsHovered] = useState(false);
···
}, 300); // Match animation duration
};
return (
<>
{isHovered && (
···
{post.title}
</Title>
<PostInfo
-
content={post.content}
-
createdAt={post.createdAt}
className="text-xs mt-1 w-full"
/>
<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">
<div className="overflow-hidden">
-
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3 break-words">
-
{post.content.substring(0, 280)}
</p>
</div>
</div>
···
"use client";
import { useEffect, useRef, useState } from "preact/hooks";
+
import { type PubLeafletDocument, type PubLeafletBlocksText } from "npm:@atcute/leaflet";
import { cx } from "../lib/cx.ts";
···
post,
rkey,
}: {
+
post: PubLeafletDocument.Main;
rkey: string;
}) {
const [isHovered, setIsHovered] = useState(false);
···
}, 300); // Match animation duration
};
+
// Gather all text blocks' plaintext for preview and reading time
+
const allText = post.pages?.[0]?.blocks
+
?.filter(block => block.block.$type === "pub.leaflet.blocks.text")
+
.map(block => (block.block as PubLeafletBlocksText.Main).plaintext)
+
.join(" ") || "";
+
return (
<>
{isHovered && (
···
{post.title}
</Title>
<PostInfo
+
content={allText}
+
createdAt={post.publishedAt}
className="text-xs mt-1 w-full"
/>
<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">
<div className="overflow-hidden">
+
<p className="text-sm text-slate-600 dark:text-slate-300 break-words line-clamp-3">
+
{allText}
</p>
</div>
</div>
+2
fresh.gen.ts
···
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $about from "./routes/about.tsx";
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $rss from "./routes/rss.ts";
···
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/about.tsx": $about,
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/rss.ts": $rss,
···
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $about from "./routes/about.tsx";
+
import * as $api_atproto_images from "./routes/api/atproto_images.ts";
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $rss from "./routes/rss.ts";
···
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/about.tsx": $about,
+
"./routes/api/atproto_images.ts": $api_atproto_images,
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/rss.ts": $rss,
+14 -5
islands/layout.tsx
···
import { Footer } from "../components/footer.tsx";
import type { ComponentChildren } from "preact";
import { useEffect, useState } from "preact/hooks";
export function Layout({ children }: { children: ComponentChildren }) {
const [isScrolled, setIsScrolled] = useState(false);
const [blogHovered, setBlogHovered] = useState(false);
const [aboutHovered, setAboutHovered] = useState(false);
-
// Get current path to determine active nav item
-
const path = typeof window !== "undefined" ? window.location.pathname : "";
const isActive = (href: string) => {
if (href === "/") {
-
return path === "/" || path.startsWith("/post/");
}
-
return path === href;
};
useEffect(() => {
···
setIsScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
handleScroll(); // Check initial scroll position
-
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
···
import { Footer } from "../components/footer.tsx";
import type { ComponentChildren } from "preact";
import { useEffect, useState } from "preact/hooks";
+
import { useSignal } from "@preact/signals";
export function Layout({ children }: { children: ComponentChildren }) {
const [isScrolled, setIsScrolled] = useState(false);
const [blogHovered, setBlogHovered] = useState(false);
const [aboutHovered, setAboutHovered] = useState(false);
+
const pathname = useSignal("");
const isActive = (href: string) => {
if (href === "/") {
+
return pathname.value === "/" || pathname.value.startsWith("/post/");
}
+
return pathname.value === href;
};
useEffect(() => {
···
setIsScrolled(window.scrollY > 0);
};
+
const handlePathChange = () => {
+
pathname.value = window.location.pathname;
+
};
+
window.addEventListener("scroll", handleScroll);
+
window.addEventListener("popstate", handlePathChange);
handleScroll(); // Check initial scroll position
+
handlePathChange(); // Set initial path
+
return () => {
+
window.removeEventListener("scroll", handleScroll);
+
window.removeEventListener("popstate", handlePathChange);
+
};
}, []);
return (
+6 -14
lib/api.ts
···
import { env } from "./env.ts";
import { type ActorIdentifier } from "npm:@atcute/lexicons";
-
import { type ComWhtwndBlogEntry } from "@atcute/whitewind";
import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto";
export async function getPosts() {
const posts = await bsky.get("com.atproto.repo.listRecords", {
params: {
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
-
collection: "com.whtwnd.blog.entry",
// todo: pagination
},
});
···
throw new Error(posts.data.error);
}
-
return posts.data.records.filter(
-
drafts,
-
) as (ComAtprotoRepoListRecords.Record & {
-
value: ComWhtwndBlogEntry.Main;
})[];
}
-
function drafts(record: ComAtprotoRepoListRecords.Record) {
-
if (Deno.env.get("NODE_ENV") === "development") return true;
-
const post = record.value as ComWhtwndBlogEntry.Main;
-
return post.visibility === "public";
-
}
-
export async function getPost(rkey: string) {
const post = await bsky.get("com.atproto.repo.getRecord", {
params: {
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
rkey: rkey,
-
collection: "com.whtwnd.blog.entry",
},
});
return post.data as ComAtprotoRepoListRecords.Record & {
-
value: ComWhtwndBlogEntry.Main;
};
}
···
import { env } from "./env.ts";
import { type ActorIdentifier } from "npm:@atcute/lexicons";
import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto";
+
import { type PubLeafletDocument } from "npm:@atcute/leaflet";
export async function getPosts() {
const posts = await bsky.get("com.atproto.repo.listRecords", {
params: {
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
+
collection: "pub.leaflet.document",
// todo: pagination
},
});
···
throw new Error(posts.data.error);
}
+
return posts.data.records as (ComAtprotoRepoListRecords.Record & {
+
value: PubLeafletDocument.Main;
})[];
}
export async function getPost(rkey: string) {
const post = await bsky.get("com.atproto.repo.getRecord", {
params: {
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
rkey: rkey,
+
collection: "pub.leaflet.document",
},
});
return post.data as ComAtprotoRepoListRecords.Record & {
+
value: PubLeafletDocument.Main;
};
}
+38
routes/api/atproto_images.ts
···
···
+
import { Handlers } from "$fresh/server.ts";
+
import { IdResolver } from "npm:@atproto/identity";
+
+
const idResolver = new IdResolver();
+
+
export const handler: Handlers = {
+
async GET(req) {
+
const url = new URL(req.url);
+
const did = url.searchParams.get("did") ?? "";
+
const cid = url.searchParams.get("cid") ?? "";
+
+
if (!did || !cid) {
+
return new Response("Missing did or cid", { status: 404 });
+
}
+
+
const identity = await idResolver.did.resolve(did);
+
const service = identity?.service?.find((f: any) => f.id === "#atproto_pds");
+
if (!service) {
+
return new Response("No PDS service found", { status: 404 });
+
}
+
+
const blobUrl = `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
+
const response = await fetch(blobUrl);
+
+
if (!response.ok) {
+
return new Response("Blob not found", { status: 404 });
+
}
+
+
// Clone the response to modify headers
+
const cachedResponse = new Response(response.body, response);
+
cachedResponse.headers.set(
+
"Cache-Control",
+
"public, max-age=31536000, immutable",
+
);
+
+
return cachedResponse;
+
},
+
};
+177 -96
routes/post/[slug].tsx
···
/** @jsxImportSource preact */
-
import { CSS, render } from "@deno/gfm";
import { Handlers, PageProps } from "$fresh/server.ts";
-
import { Layout } from "../../islands/layout.tsx";
import { PostInfo } from "../../components/post-info.tsx";
import { Title } from "../../components/typography.tsx";
import { getPost } from "../../lib/api.ts";
import { Head } from "$fresh/runtime.ts";
interface Post {
uri: string;
value: {
title?: string;
-
subtitle?: string;
-
content?: string;
-
createdAt?: string;
};
}
-
// Only override backgrounds in dark mode to make them transparent
-
const transparentDarkModeCSS = `
-
@media (prefers-color-scheme: dark) {
-
.markdown-body {
-
color: white;
-
background-color: transparent;
-
}
-
.markdown-body a {
-
color: #58a6ff;
}
-
.markdown-body blockquote {
-
border-left-color: #30363d;
-
background-color: transparent;
}
-
.markdown-body pre,
-
.markdown-body code {
-
background-color: transparent;
-
color: #c9d1d9;
}
-
.markdown-body table td,
-
.markdown-body table th {
-
border-color: #30363d;
-
background-color: transparent;
}
-
}
-
.font-sans { font-family: var(--font-sans); }
-
.font-serif { font-family: var(--font-serif); }
-
.font-mono { font-family: var(--font-mono); }
-
-
.markdown-body h1 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 2.25rem;
-
}
-
-
.markdown-body h2 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.75rem;
-
}
-
-
.markdown-body h3 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.5rem;
-
}
-
-
.markdown-body h4 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1.25rem;
}
-
.markdown-body h5 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 1rem;
-
}
-
-
.markdown-body h6 {
-
font-family: var(--font-serif);
-
text-transform: uppercase;
-
font-size: 0.875rem;
}
-
`;
-
-
export const handler: Handlers<Post> = {
-
async GET(_req, ctx) {
-
try {
-
const { slug } = ctx.params;
-
const post = await getPost(slug);
-
return ctx.render(post);
-
} catch (error) {
-
console.error("Error fetching post:", error);
-
return new Response("Post not found", { status: 404 });
-
}
-
},
-
};
export default function BlogPage({ data: post }: PageProps<Post>) {
if (!post) {
return <div>Post not found</div>;
}
return (
<>
<Head>
<title>{post.value.title} — knotbin</title>
<meta
name="description"
-
content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"}
-
/>
-
{/* Merge GFM's default styles with our dark-mode overrides */}
-
<style
-
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
/>
</Head>
···
<article class="w-full space-y-8">
<div class="space-y-4 w-full">
<Title>{post.value.title || 'Untitled'}</Title>
-
{post.value.subtitle && (
<p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose">
-
{post.value.subtitle}
</p>
)}
<PostInfo
-
content={post.value.content || ''}
-
createdAt={post.value.createdAt || new Date().toISOString()}
includeAuthor
className="text-sm"
/>
<div class="diagonal-pattern w-full h-3" />
</div>
-
<div class="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0">
-
<div
-
class="mt-8 markdown-body"
-
// replace old pds url with new one for blob urls
-
dangerouslySetInnerHTML={{
-
__html: render(post.value.content || '').replace(
-
/puffball\.us-east\.host\.bsky\.network/g,
-
"knotbin.xyz",
-
),
-
}}
-
/>
</div>
</article>
</div>
···
/** @jsxImportSource preact */
import { Handlers, PageProps } from "$fresh/server.ts";
import { Layout } from "../../islands/layout.tsx";
import { PostInfo } from "../../components/post-info.tsx";
import { Title } from "../../components/typography.tsx";
import { getPost } from "../../lib/api.ts";
import { Head } from "$fresh/runtime.ts";
+
import { TextBlock } from "../../components/TextBlock.tsx";
+
import {
+
PubLeafletBlocksHeader,
+
PubLeafletBlocksImage,
+
PubLeafletBlocksText,
+
PubLeafletBlocksUnorderedList,
+
PubLeafletPagesLinearDocument,
+
} from "npm:@atcute/leaflet";
+
import { h } from "preact";
interface Post {
uri: string;
value: {
title?: string;
+
description?: string;
+
pages?: PubLeafletPagesLinearDocument.Main[];
+
publishedAt?: string;
};
}
+
export const handler: Handlers<Post> = {
+
async GET(_req, ctx) {
+
try {
+
const { slug } = ctx.params;
+
const post = await getPost(slug);
+
return ctx.render(post);
+
} catch (error) {
+
console.error("Error fetching post:", error);
+
return new Response("Post not found", { status: 404 });
+
}
+
},
+
};
+
+
function Block({
+
block,
+
did,
+
isList,
+
}: {
+
block: PubLeafletPagesLinearDocument.Block;
+
did: string;
+
isList?: boolean;
+
}) {
+
let b = block;
+
// Debug log to check for duplicate rendering
+
console.log(
+
"Rendering block",
+
b.block.$type,
+
(b.block as any).plaintext || (b.block as any).text || ""
+
);
+
+
let className = `
+
postBlockWrapper
+
pt-1
+
${isList ? "isListItem pb-0 " : "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3"}
+
${b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight" ? "text-right" : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter" ? "text-center" : ""}
+
`;
+
+
if (b.block.$type === "pub.leaflet.blocks.unorderedList") {
+
return (
+
<ul className="-ml-[1px] sm:ml-[9px] pb-2">
+
{b.block.children.map((child, index) => (
+
<ListItem
+
item={child}
+
did={did}
+
key={index}
+
className={className}
+
/>
+
))}
+
</ul>
+
);
}
+
if (b.block.$type === "pub.leaflet.blocks.image") {
+
const imageBlock = b.block as PubLeafletBlocksImage.Main;
+
const image = imageBlock.image as { ref: { $link: string } };
+
const alt = imageBlock.alt || "";
+
const aspect = imageBlock.aspectRatio;
+
let width = aspect?.width;
+
let height = aspect?.height;
+
// Fallback to default size if not provided
+
if (!width) width = 600;
+
if (!height) height = 400;
+
return (
+
<img
+
src={`/api/atproto_images?did=${did}&cid=${image.ref.$link}`}
+
alt={alt}
+
width={width}
+
height={height}
+
className={`!pt-3 sm:!pt-4 ${className}`}
+
style={{ aspectRatio: width && height ? `${width} / ${height}` : undefined }}
+
/>
+
);
}
+
if (b.block.$type === "pub.leaflet.blocks.text") {
+
return (
+
<div className={` ${className}`}>
+
<TextBlock facets={b.block.facets} plaintext={b.block.plaintext} />
+
</div>
+
);
}
+
if (b.block.$type === "pub.leaflet.blocks.header") {
+
const header = b.block as PubLeafletBlocksHeader.Main;
+
const level = header.level || 1;
+
const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements;
+
// Add heading styles based on level
+
let headingStyle = "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
+
switch (level) {
+
case 1:
+
headingStyle += "text-4xl lg:text-5xl";
+
break;
+
case 2:
+
headingStyle += "text-3xl border-b pb-2 mb-6";
+
break;
+
case 3:
+
headingStyle += "text-2xl";
+
break;
+
case 4:
+
headingStyle += "text-xl";
+
break;
+
case 5:
+
headingStyle += "text-lg";
+
break;
+
case 6:
+
headingStyle += "text-base";
+
break;
+
default:
+
headingStyle += "text-2xl";
+
}
+
return (
+
<Tag className={headingStyle + ' ' + className}>
+
<TextBlock plaintext={header.plaintext} facets={header.facets} />
+
</Tag>
+
);
}
+
return null;
}
+
function ListItem(props: {
+
item: PubLeafletBlocksUnorderedList.Main["children"][number];
+
did: string;
+
className?: string;
+
}) {
+
return (
+
<li className={`!pb-0 flex flex-row gap-2`}>
+
<div
+
className={`listMarker shrink-0 mx-2 z-[1] mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`}
+
/>
+
<div className="flex flex-col">
+
<Block block={{ block: props.item.content }} did={props.did} isList />
+
{props.item.children?.length ? (
+
<ul className="-ml-[7px] sm:ml-[7px]">
+
{props.item.children.map((child, index) => (
+
<ListItem
+
item={child}
+
did={props.did}
+
key={index}
+
className={props.className}
+
/>
+
))}
+
</ul>
+
) : null}
+
</div>
+
</li>
+
);
}
export default function BlogPage({ data: post }: PageProps<Post>) {
if (!post) {
return <div>Post not found</div>;
}
+
const firstPage = post.value.pages?.[0];
+
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
+
if (firstPage?.$type === "pub.leaflet.pages.linearDocument") {
+
blocks = firstPage.blocks || [];
+
}
+
// Deduplicate blocks by $type and plaintext
+
const seen = new Set();
+
const uniqueBlocks = blocks.filter(b => {
+
const key = b.block.$type + '|' + ((b.block as any).plaintext || '');
+
if (seen.has(key)) return false;
+
seen.add(key);
+
return true;
+
});
+
+
const content = uniqueBlocks
+
.filter(b => b.block.$type === "pub.leaflet.blocks.text")
+
.map(b => (b.block as PubLeafletBlocksText.Main).plaintext)
+
.join(' ');
+
return (
<>
<Head>
<title>{post.value.title} — knotbin</title>
<meta
name="description"
+
content={post.value.description || "by Roscoe Rubin-Rottenberg"}
/>
</Head>
···
<article class="w-full space-y-8">
<div class="space-y-4 w-full">
<Title>{post.value.title || 'Untitled'}</Title>
+
{post.value.description && (
<p class="text-2xl md:text-3xl font-serif leading-relaxed max-w-prose">
+
{post.value.description}
</p>
)}
<PostInfo
+
content={content}
+
createdAt={post.value.publishedAt || new Date().toISOString()}
includeAuthor
className="text-sm"
/>
<div class="diagonal-pattern w-full h-3" />
</div>
+
<div class="postContent flex flex-col">
+
{uniqueBlocks.map((block, index) => (
+
<Block block={block} did={post.uri.split('/')[2]} key={index} />
+
))}
</div>
</article>
</div>