Leaflet Blog in Deno Fresh

mozzius in deno

knotbin.com 5012f1f1

+11
.gitignore
···
+
# dotenv environment variable files
+
.env
+
.env.development.local
+
.env.test.local
+
.env.production.local
+
.env.local
+
+
# Fresh build directory
+
_fresh/
+
# npm dependencies
+
node_modules/
+6
.vscode/extensions.json
···
+
{
+
"recommendations": [
+
"denoland.vscode-deno",
+
"bradlc.vscode-tailwindcss"
+
]
+
}
+20
.vscode/settings.json
···
+
{
+
"deno.enable": true,
+
"deno.lint": true,
+
"editor.defaultFormatter": "denoland.vscode-deno",
+
"[typescriptreact]": {
+
"editor.defaultFormatter": "denoland.vscode-deno"
+
},
+
"[typescript]": {
+
"editor.defaultFormatter": "denoland.vscode-deno"
+
},
+
"[javascriptreact]": {
+
"editor.defaultFormatter": "denoland.vscode-deno"
+
},
+
"[javascript]": {
+
"editor.defaultFormatter": "denoland.vscode-deno"
+
},
+
"css.customData": [
+
".vscode/tailwind.json"
+
]
+
}
+55
.vscode/tailwind.json
···
+
{
+
"version": 1.1,
+
"atDirectives": [
+
{
+
"name": "@tailwind",
+
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
+
"references": [
+
{
+
"name": "Tailwind Documentation",
+
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
+
}
+
]
+
},
+
{
+
"name": "@apply",
+
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
+
"references": [
+
{
+
"name": "Tailwind Documentation",
+
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
+
}
+
]
+
},
+
{
+
"name": "@responsive",
+
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
+
"references": [
+
{
+
"name": "Tailwind Documentation",
+
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
+
}
+
]
+
},
+
{
+
"name": "@screen",
+
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
+
"references": [
+
{
+
"name": "Tailwind Documentation",
+
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
+
}
+
]
+
},
+
{
+
"name": "@variants",
+
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
+
"references": [
+
{
+
"name": "Tailwind Documentation",
+
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
+
}
+
]
+
}
+
]
+
}
+16
README.md
···
+
# Fresh project
+
+
Your new Fresh project is ready to go. You can follow the Fresh "Getting
+
Started" guide here: https://fresh.deno.dev/docs/getting-started
+
+
### Usage
+
+
Make sure to install Deno: https://deno.land/manual/getting_started/installation
+
+
Then start the project:
+
+
```
+
deno task start
+
```
+
+
This will watch the project directory and restart as necessary.
assets/fonts/BerkeleyMono-Regular.woff2

This is a binary file and will not be displayed.

assets/me_blue_square.jpg

This is a binary file and will not be displayed.

+62
components/bluesky-embed.tsx
···
+
"use client";
+
+
import { useEffect, useId, useState } from "npm:react";
+
+
const EMBED_URL = "https://embed.bsky.app";
+
+
export function BlueskyPostEmbed({ uri }: { uri: string }) {
+
const id = useId();
+
const [height, setHeight] = useState(0);
+
+
useEffect(() => {
+
const abortController = new AbortController();
+
const { signal } = abortController;
+
window.addEventListener(
+
"message",
+
(event) => {
+
if (event.origin !== EMBED_URL) {
+
return;
+
}
+
+
const iframeId = (event.data as { id: string }).id;
+
if (id !== iframeId) {
+
return;
+
}
+
+
const internalHeight = (event.data as { height: number }).height;
+
if (internalHeight && typeof internalHeight === "number") {
+
setHeight(internalHeight);
+
}
+
},
+
{ signal },
+
);
+
+
return () => {
+
abortController.abort();
+
};
+
}, [id]);
+
+
const ref_url =
+
"https://" + "knotbin.xyz/post/" + uri.split("/").pop();
+
+
const searchParams = new URLSearchParams();
+
searchParams.set("id", id);
+
searchParams.set("ref_url", encodeURIComponent(ref_url));
+
+
return (
+
<div
+
className="mt-6 flex max-w-[600px] w-full bluesky-embed"
+
data-uri={uri}
+
>
+
<iframe
+
className="w-full block border-none grow"
+
style={{ height }}
+
data-bluesky-uri={uri}
+
src={`${EMBED_URL}/embed/${uri.slice("at://".length)}?${searchParams.toString()}`}
+
width="100%"
+
frameBorder="0"
+
scrolling="no"
+
/>
+
</div>
+
);
+
}
+42
components/footer.tsx
···
+
import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons";
+
+
import { env } from "../lib/env.ts";
+
+
export function Footer() {
+
return (
+
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
+
<a
+
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
>
+
<svg
+
width={16}
+
height={16}
+
viewBox="0 0 24 24"
+
className="fill-black dark:fill-white"
+
>
+
<path d={BlueskyIcon.path} />
+
</svg>
+
Bluesky
+
</a>
+
<a
+
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+
href="https://github.com/mozzius"
+
target="_blank"
+
rel="noopener noreferrer"
+
>
+
<svg
+
width={16}
+
height={16}
+
viewBox="0 0 24 24"
+
className="fill-black dark:fill-white"
+
>
+
<path d={GithubIcon.path} />
+
</svg>
+
GitHub
+
</a>
+
</footer>
+
);
+
}
+50
components/post-info.tsx
···
+
import { date } from "../lib/date.ts";
+
import { env } from "../lib/env.ts";
+
+
import { Paragraph } from "./typography.tsx";
+
import type { ComponentChildren } from "preact";
+
+
export function PostInfo({
+
createdAt,
+
content,
+
includeAuthor = false,
+
className,
+
children,
+
}: {
+
createdAt?: string;
+
content: string;
+
includeAuthor?: boolean;
+
className?: string;
+
children?: ComponentChildren;
+
}) {
+
return (
+
<Paragraph className={className}>
+
{includeAuthor && (
+
<>
+
<img
+
width={14}
+
height={14}
+
loading="lazy"
+
src="../assets/me_blue_square.jpg"
+
alt="Roscooe's profile picture"
+
className="inline rounded-full mr-1.5 mb-0.5"
+
/>
+
<a
+
href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`}
+
className="hover:underline hover:underline-offset-4"
+
>
+
Roscoe Rubin-Rottenberg
+
</a>{" "}
+
&middot;{" "}
+
</>
+
)}
+
{createdAt && (
+
<>
+
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "}
+
&middot;{" "}
+
</>
+
)}
+
{children}
+
</Paragraph>
+
);
+
}
+84
components/post-list-item.tsx
···
+
"use client";
+
+
import { useEffect, useRef, useState } from "preact/hooks";
+
import { ComWhtwndBlogEntry } from "npm:@atcute/client/whitewind";
+
+
import { cx } from "../lib/cx.ts";
+
+
import { PostInfo } from "./post-info.tsx";
+
import { Title } from "./typography.tsx";
+
+
export function PostListItem({
+
post,
+
rkey,
+
}: {
+
post: ComWhtwndBlogEntry.Record;
+
rkey: string;
+
}) {
+
const [isHovered, setIsHovered] = useState(false);
+
const [isLeaving, setIsLeaving] = useState(false);
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+
// Clean up any timeouts on unmount
+
useEffect(() => {
+
return () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
};
+
}, []);
+
+
const handleMouseEnter = () => {
+
if (timeoutRef.current) {
+
clearTimeout(timeoutRef.current);
+
}
+
setIsLeaving(false);
+
setIsHovered(true);
+
};
+
+
const handleMouseLeave = () => {
+
setIsLeaving(true);
+
timeoutRef.current = setTimeout(() => {
+
setIsHovered(false);
+
setIsLeaving(false);
+
}, 300); // Match the animation duration
+
};
+
+
return (
+
<>
+
{isHovered && (
+
<div
+
className={cx(
+
"fixed inset-0 pointer-events-none z-0 overflow-hidden flex items-center",
+
isLeaving ? "animate-fade-out" : "animate-fade-in",
+
)}
+
>
+
<div className="absolute whitespace-nowrap animate-marquee font-serif font-medium uppercase overflow-visible flex items-center justify-center leading-none">
+
{Array(10).fill(post.title).join(" · ")}
+
</div>
+
</div>
+
)}
+
<a
+
href={`/post/${rkey}`}
+
className="w-full group"
+
onMouseEnter={handleMouseEnter}
+
onMouseLeave={handleMouseLeave}
+
>
+
<article className="w-full flex flex-row border-b items-stretch relative transition-color backdrop-blur-sm hover:bg-slate-700/5 dark:hover:bg-slate-200/10">
+
<div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity" />
+
<div className="flex-1 py-2 px-4 z-10 relative">
+
<Title className="text-lg" level="h3">
+
{post.title}
+
</Title>
+
<PostInfo
+
content={post.content}
+
createdAt={post.createdAt}
+
className="text-xs mt-1"
+
>
+
</PostInfo>
+
</div>
+
</article>
+
</a>
+
</>
+
);
+
}
+64
components/typography.tsx
···
+
import { h } from "preact/src/index.d.ts";
+
import { cx } from "../lib/cx.ts";
+
+
export function Title({
+
level = "h1",
+
className,
+
...props
+
}: h.JSX.HTMLAttributes<HTMLHeadingElement> & {
+
level?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+
}) {
+
const Tag = level;
+
+
let style;
+
switch (level) {
+
case "h1":
+
style = "text-4xl lg:text-5xl";
+
break;
+
case "h2":
+
style = "border-b pb-2 text-3xl";
+
break;
+
case "h3":
+
style = "text-2xl";
+
break;
+
case "h4":
+
style = "text-xl";
+
break;
+
case "h5":
+
style = "text-lg";
+
break;
+
case "h6":
+
style = "text-base";
+
break;
+
}
+
+
return (
+
<Tag
+
className={cx(
+
"font-serif font-bold text-balance tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0",
+
style,
+
className?.toString(),
+
)}
+
{...props}
+
/>
+
);
+
}
+
+
export function Paragraph({
+
className,
+
...props
+
}: h.JSX.HTMLAttributes<HTMLParagraphElement>) {
+
return <p className={cx("font-sans text-pretty", className?.toString())} {...props} />;
+
}
+
+
export function Code({ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>) {
+
return (
+
<code
+
className={cx(
+
"font-mono normal-case relative rounded-sm px-[0.3rem] py-[0.2rem] bg-slate-100 text-sm dark:bg-slate-800 dark:text-slate-100",
+
className?.toString(),
+
)}
+
{...props}
+
/>
+
);
+
}
+40
deno.json
···
+
{
+
"lock": false,
+
"tasks": {
+
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
+
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
+
"manifest": "deno task cli manifest $(pwd)",
+
"start": "deno run -A --watch=static/,routes/ dev.ts",
+
"build": "deno run -A dev.ts build",
+
"preview": "deno run -A main.ts",
+
"update": "deno run -A -r https://fresh.deno.dev/update ."
+
},
+
"lint": {
+
"rules": {
+
"tags": [
+
"fresh",
+
"recommended"
+
]
+
}
+
},
+
"exclude": [
+
"**/_fresh/*"
+
],
+
"imports": {
+
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
+
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
+
"preact": "https://esm.sh/preact@10.22.0",
+
"preact/": "https://esm.sh/preact@10.22.0/",
+
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
+
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
+
"tailwindcss": "npm:tailwindcss@3.4.1",
+
"tailwindcss/": "npm:/tailwindcss@3.4.1/",
+
"tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
+
"$std/": "https://deno.land/std@0.216.0/"
+
},
+
"compilerOptions": {
+
"jsx": "react-jsx",
+
"jsxImportSource": "preact"
+
},
+
"nodeModulesDir": "auto"
+
}
+8
dev.ts
···
+
#!/usr/bin/env -S deno run -A --watch=static/,routes/
+
+
import dev from "$fresh/dev.ts";
+
import config from "./fresh.config.ts";
+
+
import "$std/dotenv/load.ts";
+
+
await dev(import.meta.url, "./main.ts", config);
+6
fresh.config.ts
···
+
import { defineConfig } from "$fresh/server.ts";
+
import tailwind from "$fresh/plugins/tailwind.ts";
+
+
export default defineConfig({
+
plugins: [tailwind()],
+
});
+31
fresh.gen.ts
···
+
// DO NOT EDIT. This file is generated by Fresh.
+
// This file SHOULD be checked into source version control.
+
// This file is automatically updated during development when running `dev.ts`.
+
+
import * as $_404 from "./routes/_404.tsx";
+
import * as $_app from "./routes/_app.tsx";
+
import * as $api_joke from "./routes/api/joke.ts";
+
import * as $greet_name_ from "./routes/greet/[name].tsx";
+
import * as $index from "./routes/index.tsx";
+
import * as $post_slug_ from "./routes/post/[slug].tsx";
+
import * as $rss from "./routes/rss.ts";
+
import * as $post_list from "./islands/post-list.tsx";
+
import type { Manifest } from "$fresh/server.ts";
+
+
const manifest = {
+
routes: {
+
"./routes/_404.tsx": $_404,
+
"./routes/_app.tsx": $_app,
+
"./routes/api/joke.ts": $api_joke,
+
"./routes/greet/[name].tsx": $greet_name_,
+
"./routes/index.tsx": $index,
+
"./routes/post/[slug].tsx": $post_slug_,
+
"./routes/rss.ts": $rss,
+
},
+
islands: {
+
"./islands/post-list.tsx": $post_list,
+
},
+
baseUrl: import.meta.url,
+
} satisfies Manifest;
+
+
export default manifest;
+32
islands/post-list.tsx
···
+
import { useSignal } from "@preact/signals";
+
import { useEffect } from "preact/hooks";
+
import { PostListItem } from "../components/post-list-item.tsx";
+
+
interface PostRecord {
+
value: any;
+
uri: string;
+
}
+
+
export default function PostList({ posts: initialPosts }: { posts: PostRecord[] }) {
+
const posts = useSignal(initialPosts);
+
+
useEffect(() => {
+
posts.value = initialPosts;
+
}, [initialPosts]);
+
+
return (
+
<>
+
{posts.value?.map((record) => {
+
const post = record.value;
+
const rkey = record.uri.split("/").pop() || "";
+
return (
+
<PostListItem
+
key={record.uri}
+
post={post}
+
rkey={rkey}
+
/>
+
);
+
})}
+
</>
+
);
+
}
+40
lib/api.ts
···
+
import { bsky } from "./bsky.ts";
+
import { env } from "./env.ts";
+
+
import { type ComAtprotoRepoListRecords } from "npm:@atcute/client/lexicons";
+
import { type ComWhtwndBlogEntry } from "npm:@atcute/whitewind";
+
+
export async function getPosts() {
+
const posts = await bsky.get("com.atproto.repo.listRecords", {
+
params: {
+
repo: env.NEXT_PUBLIC_BSKY_DID,
+
collection: "com.whtwnd.blog.entry",
+
// todo: pagination
+
},
+
});
+
return posts.data.records.filter(
+
drafts,
+
) as (ComAtprotoRepoListRecords.Record & {
+
value: ComWhtwndBlogEntry.Record;
+
})[];
+
}
+
+
function drafts(record: ComAtprotoRepoListRecords.Record) {
+
if (Deno.env.get("NODE_ENV") === "development") return true;
+
const post = record.value as ComWhtwndBlogEntry.Record;
+
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,
+
rkey: rkey,
+
collection: "com.whtwnd.blog.entry",
+
},
+
});
+
+
return post.data as ComAtprotoRepoListRecords.Record & {
+
value: ComWhtwndBlogEntry.Record;
+
};
+
}
+9
lib/bsky.ts
···
+
import { CredentialManager, XRPC } from "npm:@atcute/client";
+
+
import { env } from "./env.ts";
+
+
const handler = new CredentialManager({
+
service: env.NEXT_PUBLIC_BSKY_PDS,
+
fetch,
+
});
+
export const bsky = new XRPC({ handler });
+1
lib/cx.ts
···
+
export { twMerge as cx } from "npm:tailwind-merge";
+3
lib/date.ts
···
+
export const { format: date } = new Intl.DateTimeFormat("en-GB", {
+
dateStyle: "medium",
+
});
+26
lib/env.ts
···
+
import { cleanEnv, str, url } from "npm:envalid";
+
+
const envVars = {
+
NODE_ENV: "production",
+
PLAUSIBLE_SITE_ID: "knotbin.xyz",
+
PLAUSIBLE_DOMAIN: "https://plausible.knotbin.xyz",
+
PLAUSIBLE_API_KEY: "",
+
NEXT_PUBLIC_BSKY_DID: "did:plc:6hbqm2oftpotwuw7gvvrui3i",
+
NEXT_PUBLIC_BSKY_PDS: "https://puffball.us-east.host.bsky.network",
+
};
+
+
// Use cleanEnv to validate and parse the environment variables
+
export const env = cleanEnv(envVars, {
+
NODE_ENV: str({
+
choices: ["development", "production"],
+
default: "production",
+
devDefault: "development",
+
}),
+
PLAUSIBLE_SITE_ID: str({ default: "knotbin.xyz" }),
+
PLAUSIBLE_DOMAIN: url({ default: "https://plausible.knotbin.xyz" }),
+
PLAUSIBLE_API_KEY: str({ default: "" }),
+
NEXT_PUBLIC_BSKY_DID: str({ default: "did:plc:6hbqm2oftpotwuw7gvvrui3i" }),
+
NEXT_PUBLIC_BSKY_PDS: url({
+
default: "https://puffball.us-east.host.bsky.network",
+
}),
+
});
+21
lib/google-font.ts
···
+
// from https://github.com/kosei28/vercel-og-google-fonts/blob/main/src/utils/font.ts
+
export async function loadGoogleFont(font: string, text: string) {
+
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(
+
text,
+
)}`;
+
+
const css = await (await fetch(url)).text();
+
+
const resource = css.match(
+
/src: url\((.+)\) format\('(opentype|truetype)'\)/,
+
);
+
+
if (resource) {
+
const res = await fetch(resource[1]);
+
if (res.status == 200) {
+
return await res.arrayBuffer();
+
}
+
}
+
+
throw new Error("failed to load font data");
+
}
lib/render-markdown.ts

This is a binary file and will not be displayed.

+12
main.ts
···
+
/// <reference lib="dom" />
+
/// <reference lib="dom.iterable" />
+
/// <reference lib="dom.asynciterable" />
+
/// <reference lib="deno.ns" />
+
+
import "$std/dotenv/load.ts";
+
+
import { start } from "$fresh/server.ts";
+
import manifest from "./fresh.gen.ts";
+
import config from "./fresh.config.ts";
+
+
await start(manifest, config);
+27
routes/_404.tsx
···
+
import { Head } from "$fresh/runtime.ts";
+
+
export default function Error404() {
+
return (
+
<>
+
<Head>
+
<title>404 - Page not found</title>
+
</Head>
+
<div class="px-4 py-8 mx-auto bg-[#86efac]">
+
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
+
<img
+
class="my-6"
+
src="/logo.svg"
+
width="128"
+
height="128"
+
alt="the Fresh logo: a sliced lemon dripping with juice"
+
/>
+
<h1 class="text-4xl font-bold">404 - Page not found</h1>
+
<p class="my-4">
+
The page you were looking for doesn't exist.
+
</p>
+
<a href="/" class="underline">Go back home</a>
+
</div>
+
</div>
+
</>
+
);
+
}
+16
routes/_app.tsx
···
+
import { type PageProps } from "$fresh/server.ts";
+
export default function App({ Component }: PageProps) {
+
return (
+
<html>
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>blog</title>
+
<link rel="stylesheet" href="/styles.css" />
+
</head>
+
<body>
+
<Component />
+
</body>
+
</html>
+
);
+
}
+21
routes/api/joke.ts
···
+
import { FreshContext } from "$fresh/server.ts";
+
+
// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/
+
const JOKES = [
+
"Why do Java developers often wear glasses? They can't C#.",
+
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
+
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
+
"I love pressing the F5 key. It's refreshing.",
+
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
+
"There are 10 types of people in the world. Those who understand binary and those who don't.",
+
"Why are assembly programmers often wet? They work below C level.",
+
"My favourite computer based band is the Black IPs.",
+
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
+
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
+
];
+
+
export const handler = (_req: Request, _ctx: FreshContext): Response => {
+
const randomIndex = Math.floor(Math.random() * JOKES.length);
+
const body = JOKES[randomIndex];
+
return new Response(body);
+
};
+5
routes/greet/[name].tsx
···
+
import { PageProps } from "$fresh/server.ts";
+
+
export default function Greet(props: PageProps) {
+
return <div>Hello {props.params.name}</div>;
+
}
+31
routes/index.tsx
···
+
import { Footer } from "../components/footer.tsx";
+
import PostList from "../islands/post-list.tsx";
+
import { Title } from "../components/typography.tsx";
+
import { getPosts } from "../lib/api.ts";
+
+
export const dynamic = "force-static";
+
export const revalidate = 3600; // 1 hour
+
+
export default async function Home() {
+
const posts = await getPosts();
+
+
return (
+
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-dvh p-8 pb-20 gap-16 sm:p-20">
+
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px]">
+
<div className="self-center flex flex-col">
+
<Title level="h1" className="m-0">
+
knotbin
+
</Title>
+
<span className="font-bold text-xs opacity-50 text-right flex-1 mr-6">
+
looking into it
+
</span>
+
</div>
+
+
<div className="flex flex-col gap-4 w-full">
+
<PostList posts={posts} />
+
</div>
+
</main>
+
<Footer />
+
</div>
+
);
+
}
+153
routes/post/[slug].tsx
···
+
/** @jsxImportSource preact */
+
import { CSS, render } from "@deno/gfm";
+
import { Handlers, PageProps } from "$fresh/server.ts";
+
+
import { Footer } from "../../components/footer.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;
+
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="by Roscoe Rubin-Rottenberg" />
+
{/* Merge GFM’s default styles with our dark-mode overrides */}
+
<style
+
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
+
/>
+
</Head>
+
+
<div className="grid grid-rows-[20px_1fr_20px] justify-items-center min-h-dvh py-8 px-4 xs:px-8 pb-20 gap-16 sm:p-20">
+
<link rel="alternate" href={post.uri} />
+
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px] overflow-hidden">
+
<article className="w-full space-y-8">
+
<div className="space-y-4 w-full">
+
<a
+
href="/"
+
className="hover:underline hover:underline-offset-4 font-medium"
+
>
+
Back
+
</a>
+
<Title>{post.value.title}</Title>
+
<PostInfo
+
content={post.value.content}
+
createdAt={post.value.createdAt}
+
includeAuthor
+
className="text-sm"
+
/>
+
<div className="diagonal-pattern w-full h-3" />
+
</div>
+
<div className="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0">
+
{/* Render GFM HTML via dangerouslySetInnerHTML */}
+
<div
+
class="mt-8 markdown-body"
+
dangerouslySetInnerHTML={{ __html: render(post.value.content) }}
+
/>
+
</div>
+
</article>
+
</main>
+
<Footer />
+
</div>
+
</>
+
);
+
}
+43
routes/rss.ts
···
+
import rehypeFormat from "npm:rehype-format";
+
import rehypeStringify from "npm:rehype-stringify";
+
import remarkParse from "npm:remark-parse";
+
import remarkRehype from "npm:remark-rehype";
+
import RSS from "npm:rss";
+
import { unified } from "npm:unified";
+
+
import { getPosts } from "../lib/api.ts";
+
+
export const dynamic = "force-static";
+
export const revalidate = 3600; // 1 hour
+
+
export async function GET() {
+
const posts = await getPosts();
+
+
const rss = new RSS({
+
title: "knotbin",
+
feed_url: "https://knotbin.xyz/rss",
+
site_url: "https://knotbin.xyz",
+
description: "a webbed site",
+
});
+
+
for (const post of posts) {
+
rss.item({
+
title: post.value.title ?? "Untitled",
+
description: await unified()
+
.use(remarkParse)
+
.use(remarkRehype)
+
.use(rehypeFormat)
+
.use(rehypeStringify)
+
.process(post.value.content)
+
.then((v) => v.toString()),
+
url: `https://mozzius.dev/post/${post.uri.split("/").pop()}`,
+
date: new Date(post.value.createdAt ?? Date.now()),
+
});
+
}
+
+
return new Response(rss.xml(), {
+
headers: {
+
"content-type": "application/rss+xml",
+
},
+
});
+
}
static/favicon.ico

This is a binary file and will not be displayed.

+6
static/logo.svg
···
+
<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
+
<path d="M34.092 8.845C38.929 20.652 34.092 27 30 30.5c1 3.5-2.986 4.222-4.5 2.5-4.457 1.537-13.512 1.487-20-5C2 24.5 4.73 16.714 14 11.5c8-4.5 16-7 20.092-2.655Z" fill="#FFDB1E"/>
+
<path d="M14 11.5c6.848-4.497 15.025-6.38 18.368-3.47C37.5 12.5 21.5 22.612 15.5 25c-6.5 2.587-3 8.5-6.5 8.5-3 0-2.5-4-5.183-7.75C2.232 23.535 6.16 16.648 14 11.5Z" fill="#fff" stroke="#FFDB1E"/>
+
<path d="M28.535 8.772c4.645 1.25-.365 5.695-4.303 8.536-3.732 2.692-6.606 4.21-7.923 4.83-.366.173-1.617-2.252-1.617-1 0 .417-.7 2.238-.934 2.326-1.365.512-4.223 1.29-5.835 1.29-3.491 0-1.923-4.754 3.014-9.122.892-.789 1.478-.645 2.283-.645-.537-.773-.534-.917.403-1.546C17.79 10.64 23 8.77 25.212 8.42c.366.014.82.35.82.629.41-.14 2.095-.388 2.503-.278Z" fill="#FFE600"/>
+
<path d="M14.297 16.49c.985-.747 1.644-1.01 2.099-2.526.566.121.841-.08 1.29-.701.324.466 1.657.608 2.453.701-.715.451-1.057.852-1.452 2.106-1.464-.611-3.167-.302-4.39.42Z" fill="#fff"/>
+
</svg>
+145
static/styles.css
···
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
+
@import url('https://fonts.googleapis.com/css2?family=Libre+Bodoni:wght@400;700&display=swap');
+
@font-face {
+
font-family: 'Berkeley Mono';
+
src: url('/path/to/local/fonts/BerkeleyMono-Regular.woff2') format('woff2'),
+
url('/path/to/local/fonts/BerkeleyMono-Regular.woff') format('woff');
+
font-weight: 400;
+
font-style: normal;
+
}
+
+
@tailwind base;
+
@tailwind components;
+
@tailwind utilities;
+
+
@theme inline {
+
--color-background: var(--background);
+
--color-foreground: var(--foreground);
+
}
+
+
:root {
+
--font-sans: 'Inter', sans-serif;
+
--font-serif: 'Libre Bodoni', serif;
+
--font-mono: 'Berkeley Mono', monospace;
+
}
+
+
.font-sans { font-family: var(--font-sans); }
+
.font-serif { font-family: var(--font-serif); }
+
.font-mono { font-family: var(--font-mono); }
+
+
/*
+
The default border color has changed to `currentColor` in Tailwind CSS v4,
+
so we've added these compatibility styles to make sure everything still
+
looks the same as it did with Tailwind CSS v3.
+
+
If we ever want to remove these styles, we need to add an explicit border
+
color utility to any element that depends on these defaults.
+
*/
+
@layer base {
+
*,
+
::after,
+
::before,
+
::backdrop,
+
::file-selector-button {
+
border-color: var(--color-gray-200, currentColor);
+
}
+
}
+
+
@utility text-balance {
+
text-wrap: balance;
+
}
+
+
@layer utilities {
+
:root {
+
--background: #ffffff;
+
--foreground: #171717;
+
}
+
+
@media (prefers-color-scheme: dark) {
+
:root {
+
--background: #0a0a0a;
+
--foreground: #ededed;
+
}
+
}
+
+
body {
+
color: var(--foreground);
+
background: var(--background);
+
font-family: var(--font-sans);
+
}
+
+
@keyframes marquee {
+
0% {
+
opacity: 0;
+
transform: translateX(0px);
+
}
+
2% {
+
opacity: 0.075;
+
}
+
98% {
+
opacity: 0.075;
+
}
+
100% {
+
opacity: 0;
+
transform: translateX(-4000px);
+
}
+
}
+
+
@keyframes fadeIn {
+
0% {
+
opacity: 0;
+
}
+
100% {
+
opacity: 1;
+
}
+
}
+
+
@keyframes fadeOut {
+
0% {
+
opacity: 1;
+
}
+
100% {
+
opacity: 0;
+
}
+
}
+
+
.animate-marquee {
+
animation: marquee 30s linear infinite;
+
font-size: 100vh;
+
line-height: 0.8;
+
height: 100vh;
+
display: flex;
+
align-items: center;
+
}
+
+
.animate-fade-in {
+
animation: fadeIn 0.3s ease-in-out forwards;
+
}
+
+
.animate-fade-out {
+
animation: fadeOut 0.3s ease-in-out forwards;
+
}
+
}
+
+
.diagonal-pattern {
+
background-color: transparent;
+
background: repeating-linear-gradient(
+
-45deg,
+
#000000,
+
#000000 4px,
+
transparent 4px,
+
transparent 10px
+
);
+
}
+
+
@media (prefers-color-scheme: dark) {
+
.diagonal-pattern {
+
background: repeating-linear-gradient(
+
-45deg,
+
#ffffff,
+
#ffffff 4px,
+
transparent 4px,
+
transparent 10px
+
);
+
}
+
}
+7
tailwind.config.ts
···
+
import { type Config } from "tailwindcss";
+
+
export default {
+
content: [
+
"{routes,islands,components}/**/*.{ts,tsx,js,jsx}",
+
],
+
} satisfies Config;