Leaflet Blog in Deno Fresh

mozzius in deno

knotbin.com 5012f1f1

+11
.gitignore
··· 1 + # dotenv environment variable files 2 + .env 3 + .env.development.local 4 + .env.test.local 5 + .env.production.local 6 + .env.local 7 + 8 + # Fresh build directory 9 + _fresh/ 10 + # npm dependencies 11 + node_modules/
+6
.vscode/extensions.json
··· 1 + { 2 + "recommendations": [ 3 + "denoland.vscode-deno", 4 + "bradlc.vscode-tailwindcss" 5 + ] 6 + }
+20
.vscode/settings.json
··· 1 + { 2 + "deno.enable": true, 3 + "deno.lint": true, 4 + "editor.defaultFormatter": "denoland.vscode-deno", 5 + "[typescriptreact]": { 6 + "editor.defaultFormatter": "denoland.vscode-deno" 7 + }, 8 + "[typescript]": { 9 + "editor.defaultFormatter": "denoland.vscode-deno" 10 + }, 11 + "[javascriptreact]": { 12 + "editor.defaultFormatter": "denoland.vscode-deno" 13 + }, 14 + "[javascript]": { 15 + "editor.defaultFormatter": "denoland.vscode-deno" 16 + }, 17 + "css.customData": [ 18 + ".vscode/tailwind.json" 19 + ] 20 + }
+55
.vscode/tailwind.json
··· 1 + { 2 + "version": 1.1, 3 + "atDirectives": [ 4 + { 5 + "name": "@tailwind", 6 + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", 7 + "references": [ 8 + { 9 + "name": "Tailwind Documentation", 10 + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 + } 12 + ] 13 + }, 14 + { 15 + "name": "@apply", 16 + "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.", 17 + "references": [ 18 + { 19 + "name": "Tailwind Documentation", 20 + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 + } 22 + ] 23 + }, 24 + { 25 + "name": "@responsive", 26 + "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", 27 + "references": [ 28 + { 29 + "name": "Tailwind Documentation", 30 + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 31 + } 32 + ] 33 + }, 34 + { 35 + "name": "@screen", 36 + "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", 37 + "references": [ 38 + { 39 + "name": "Tailwind Documentation", 40 + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 41 + } 42 + ] 43 + }, 44 + { 45 + "name": "@variants", 46 + "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", 47 + "references": [ 48 + { 49 + "name": "Tailwind Documentation", 50 + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 51 + } 52 + ] 53 + } 54 + ] 55 + }
+16
README.md
··· 1 + # Fresh project 2 + 3 + Your new Fresh project is ready to go. You can follow the Fresh "Getting 4 + Started" guide here: https://fresh.deno.dev/docs/getting-started 5 + 6 + ### Usage 7 + 8 + Make sure to install Deno: https://deno.land/manual/getting_started/installation 9 + 10 + Then start the project: 11 + 12 + ``` 13 + deno task start 14 + ``` 15 + 16 + 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
··· 1 + "use client"; 2 + 3 + import { useEffect, useId, useState } from "npm:react"; 4 + 5 + const EMBED_URL = "https://embed.bsky.app"; 6 + 7 + export function BlueskyPostEmbed({ uri }: { uri: string }) { 8 + const id = useId(); 9 + const [height, setHeight] = useState(0); 10 + 11 + useEffect(() => { 12 + const abortController = new AbortController(); 13 + const { signal } = abortController; 14 + window.addEventListener( 15 + "message", 16 + (event) => { 17 + if (event.origin !== EMBED_URL) { 18 + return; 19 + } 20 + 21 + const iframeId = (event.data as { id: string }).id; 22 + if (id !== iframeId) { 23 + return; 24 + } 25 + 26 + const internalHeight = (event.data as { height: number }).height; 27 + if (internalHeight && typeof internalHeight === "number") { 28 + setHeight(internalHeight); 29 + } 30 + }, 31 + { signal }, 32 + ); 33 + 34 + return () => { 35 + abortController.abort(); 36 + }; 37 + }, [id]); 38 + 39 + const ref_url = 40 + "https://" + "knotbin.xyz/post/" + uri.split("/").pop(); 41 + 42 + const searchParams = new URLSearchParams(); 43 + searchParams.set("id", id); 44 + searchParams.set("ref_url", encodeURIComponent(ref_url)); 45 + 46 + return ( 47 + <div 48 + className="mt-6 flex max-w-[600px] w-full bluesky-embed" 49 + data-uri={uri} 50 + > 51 + <iframe 52 + className="w-full block border-none grow" 53 + style={{ height }} 54 + data-bluesky-uri={uri} 55 + src={`${EMBED_URL}/embed/${uri.slice("at://".length)}?${searchParams.toString()}`} 56 + width="100%" 57 + frameBorder="0" 58 + scrolling="no" 59 + /> 60 + </div> 61 + ); 62 + }
+42
components/footer.tsx
··· 1 + import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons"; 2 + 3 + import { env } from "../lib/env.ts"; 4 + 5 + export function Footer() { 6 + return ( 7 + <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center"> 8 + <a 9 + className="flex items-center gap-2 hover:underline hover:underline-offset-4" 10 + href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`} 11 + target="_blank" 12 + rel="noopener noreferrer" 13 + > 14 + <svg 15 + width={16} 16 + height={16} 17 + viewBox="0 0 24 24" 18 + className="fill-black dark:fill-white" 19 + > 20 + <path d={BlueskyIcon.path} /> 21 + </svg> 22 + Bluesky 23 + </a> 24 + <a 25 + className="flex items-center gap-2 hover:underline hover:underline-offset-4" 26 + href="https://github.com/mozzius" 27 + target="_blank" 28 + rel="noopener noreferrer" 29 + > 30 + <svg 31 + width={16} 32 + height={16} 33 + viewBox="0 0 24 24" 34 + className="fill-black dark:fill-white" 35 + > 36 + <path d={GithubIcon.path} /> 37 + </svg> 38 + GitHub 39 + </a> 40 + </footer> 41 + ); 42 + }
+50
components/post-info.tsx
··· 1 + import { date } from "../lib/date.ts"; 2 + import { env } from "../lib/env.ts"; 3 + 4 + import { Paragraph } from "./typography.tsx"; 5 + import type { ComponentChildren } from "preact"; 6 + 7 + export function PostInfo({ 8 + createdAt, 9 + content, 10 + includeAuthor = false, 11 + className, 12 + children, 13 + }: { 14 + createdAt?: string; 15 + content: string; 16 + includeAuthor?: boolean; 17 + className?: string; 18 + children?: ComponentChildren; 19 + }) { 20 + return ( 21 + <Paragraph className={className}> 22 + {includeAuthor && ( 23 + <> 24 + <img 25 + width={14} 26 + height={14} 27 + loading="lazy" 28 + src="../assets/me_blue_square.jpg" 29 + alt="Roscooe's profile picture" 30 + className="inline rounded-full mr-1.5 mb-0.5" 31 + /> 32 + <a 33 + href={`https://bsky.app/profile/${env.NEXT_PUBLIC_BSKY_DID}`} 34 + className="hover:underline hover:underline-offset-4" 35 + > 36 + Roscoe Rubin-Rottenberg 37 + </a>{" "} 38 + &middot;{" "} 39 + </> 40 + )} 41 + {createdAt && ( 42 + <> 43 + <time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "} 44 + &middot;{" "} 45 + </> 46 + )} 47 + {children} 48 + </Paragraph> 49 + ); 50 + }
+84
components/post-list-item.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState } from "preact/hooks"; 4 + import { ComWhtwndBlogEntry } from "npm:@atcute/client/whitewind"; 5 + 6 + import { cx } from "../lib/cx.ts"; 7 + 8 + import { PostInfo } from "./post-info.tsx"; 9 + import { Title } from "./typography.tsx"; 10 + 11 + export function PostListItem({ 12 + post, 13 + rkey, 14 + }: { 15 + post: ComWhtwndBlogEntry.Record; 16 + rkey: string; 17 + }) { 18 + const [isHovered, setIsHovered] = useState(false); 19 + const [isLeaving, setIsLeaving] = useState(false); 20 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 21 + 22 + // Clean up any timeouts on unmount 23 + useEffect(() => { 24 + return () => { 25 + if (timeoutRef.current) { 26 + clearTimeout(timeoutRef.current); 27 + } 28 + }; 29 + }, []); 30 + 31 + const handleMouseEnter = () => { 32 + if (timeoutRef.current) { 33 + clearTimeout(timeoutRef.current); 34 + } 35 + setIsLeaving(false); 36 + setIsHovered(true); 37 + }; 38 + 39 + const handleMouseLeave = () => { 40 + setIsLeaving(true); 41 + timeoutRef.current = setTimeout(() => { 42 + setIsHovered(false); 43 + setIsLeaving(false); 44 + }, 300); // Match the animation duration 45 + }; 46 + 47 + return ( 48 + <> 49 + {isHovered && ( 50 + <div 51 + className={cx( 52 + "fixed inset-0 pointer-events-none z-0 overflow-hidden flex items-center", 53 + isLeaving ? "animate-fade-out" : "animate-fade-in", 54 + )} 55 + > 56 + <div className="absolute whitespace-nowrap animate-marquee font-serif font-medium uppercase overflow-visible flex items-center justify-center leading-none"> 57 + {Array(10).fill(post.title).join(" · ")} 58 + </div> 59 + </div> 60 + )} 61 + <a 62 + href={`/post/${rkey}`} 63 + className="w-full group" 64 + onMouseEnter={handleMouseEnter} 65 + onMouseLeave={handleMouseLeave} 66 + > 67 + <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"> 68 + <div className="w-1.5 diagonal-pattern shrink-0 opacity-20 group-hover:opacity-100 transition-opacity" /> 69 + <div className="flex-1 py-2 px-4 z-10 relative"> 70 + <Title className="text-lg" level="h3"> 71 + {post.title} 72 + </Title> 73 + <PostInfo 74 + content={post.content} 75 + createdAt={post.createdAt} 76 + className="text-xs mt-1" 77 + > 78 + </PostInfo> 79 + </div> 80 + </article> 81 + </a> 82 + </> 83 + ); 84 + }
+64
components/typography.tsx
··· 1 + import { h } from "preact/src/index.d.ts"; 2 + import { cx } from "../lib/cx.ts"; 3 + 4 + export function Title({ 5 + level = "h1", 6 + className, 7 + ...props 8 + }: h.JSX.HTMLAttributes<HTMLHeadingElement> & { 9 + level?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; 10 + }) { 11 + const Tag = level; 12 + 13 + let style; 14 + switch (level) { 15 + case "h1": 16 + style = "text-4xl lg:text-5xl"; 17 + break; 18 + case "h2": 19 + style = "border-b pb-2 text-3xl"; 20 + break; 21 + case "h3": 22 + style = "text-2xl"; 23 + break; 24 + case "h4": 25 + style = "text-xl"; 26 + break; 27 + case "h5": 28 + style = "text-lg"; 29 + break; 30 + case "h6": 31 + style = "text-base"; 32 + break; 33 + } 34 + 35 + return ( 36 + <Tag 37 + className={cx( 38 + "font-serif font-bold text-balance tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0", 39 + style, 40 + className?.toString(), 41 + )} 42 + {...props} 43 + /> 44 + ); 45 + } 46 + 47 + export function Paragraph({ 48 + className, 49 + ...props 50 + }: h.JSX.HTMLAttributes<HTMLParagraphElement>) { 51 + return <p className={cx("font-sans text-pretty", className?.toString())} {...props} />; 52 + } 53 + 54 + export function Code({ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>) { 55 + return ( 56 + <code 57 + className={cx( 58 + "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", 59 + className?.toString(), 60 + )} 61 + {...props} 62 + /> 63 + ); 64 + }
+40
deno.json
··· 1 + { 2 + "lock": false, 3 + "tasks": { 4 + "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", 5 + "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", 6 + "manifest": "deno task cli manifest $(pwd)", 7 + "start": "deno run -A --watch=static/,routes/ dev.ts", 8 + "build": "deno run -A dev.ts build", 9 + "preview": "deno run -A main.ts", 10 + "update": "deno run -A -r https://fresh.deno.dev/update ." 11 + }, 12 + "lint": { 13 + "rules": { 14 + "tags": [ 15 + "fresh", 16 + "recommended" 17 + ] 18 + } 19 + }, 20 + "exclude": [ 21 + "**/_fresh/*" 22 + ], 23 + "imports": { 24 + "$fresh/": "https://deno.land/x/fresh@1.7.3/", 25 + "@deno/gfm": "jsr:@deno/gfm@^0.10.0", 26 + "preact": "https://esm.sh/preact@10.22.0", 27 + "preact/": "https://esm.sh/preact@10.22.0/", 28 + "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", 29 + "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", 30 + "tailwindcss": "npm:tailwindcss@3.4.1", 31 + "tailwindcss/": "npm:/tailwindcss@3.4.1/", 32 + "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", 33 + "$std/": "https://deno.land/std@0.216.0/" 34 + }, 35 + "compilerOptions": { 36 + "jsx": "react-jsx", 37 + "jsxImportSource": "preact" 38 + }, 39 + "nodeModulesDir": "auto" 40 + }
+8
dev.ts
··· 1 + #!/usr/bin/env -S deno run -A --watch=static/,routes/ 2 + 3 + import dev from "$fresh/dev.ts"; 4 + import config from "./fresh.config.ts"; 5 + 6 + import "$std/dotenv/load.ts"; 7 + 8 + await dev(import.meta.url, "./main.ts", config);
+6
fresh.config.ts
··· 1 + import { defineConfig } from "$fresh/server.ts"; 2 + import tailwind from "$fresh/plugins/tailwind.ts"; 3 + 4 + export default defineConfig({ 5 + plugins: [tailwind()], 6 + });
+31
fresh.gen.ts
··· 1 + // DO NOT EDIT. This file is generated by Fresh. 2 + // This file SHOULD be checked into source version control. 3 + // This file is automatically updated during development when running `dev.ts`. 4 + 5 + import * as $_404 from "./routes/_404.tsx"; 6 + import * as $_app from "./routes/_app.tsx"; 7 + import * as $api_joke from "./routes/api/joke.ts"; 8 + import * as $greet_name_ from "./routes/greet/[name].tsx"; 9 + import * as $index from "./routes/index.tsx"; 10 + import * as $post_slug_ from "./routes/post/[slug].tsx"; 11 + import * as $rss from "./routes/rss.ts"; 12 + import * as $post_list from "./islands/post-list.tsx"; 13 + import type { Manifest } from "$fresh/server.ts"; 14 + 15 + const manifest = { 16 + routes: { 17 + "./routes/_404.tsx": $_404, 18 + "./routes/_app.tsx": $_app, 19 + "./routes/api/joke.ts": $api_joke, 20 + "./routes/greet/[name].tsx": $greet_name_, 21 + "./routes/index.tsx": $index, 22 + "./routes/post/[slug].tsx": $post_slug_, 23 + "./routes/rss.ts": $rss, 24 + }, 25 + islands: { 26 + "./islands/post-list.tsx": $post_list, 27 + }, 28 + baseUrl: import.meta.url, 29 + } satisfies Manifest; 30 + 31 + export default manifest;
+32
islands/post-list.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect } from "preact/hooks"; 3 + import { PostListItem } from "../components/post-list-item.tsx"; 4 + 5 + interface PostRecord { 6 + value: any; 7 + uri: string; 8 + } 9 + 10 + export default function PostList({ posts: initialPosts }: { posts: PostRecord[] }) { 11 + const posts = useSignal(initialPosts); 12 + 13 + useEffect(() => { 14 + posts.value = initialPosts; 15 + }, [initialPosts]); 16 + 17 + return ( 18 + <> 19 + {posts.value?.map((record) => { 20 + const post = record.value; 21 + const rkey = record.uri.split("/").pop() || ""; 22 + return ( 23 + <PostListItem 24 + key={record.uri} 25 + post={post} 26 + rkey={rkey} 27 + /> 28 + ); 29 + })} 30 + </> 31 + ); 32 + }
+40
lib/api.ts
··· 1 + import { bsky } from "./bsky.ts"; 2 + import { env } from "./env.ts"; 3 + 4 + import { type ComAtprotoRepoListRecords } from "npm:@atcute/client/lexicons"; 5 + import { type ComWhtwndBlogEntry } from "npm:@atcute/whitewind"; 6 + 7 + export async function getPosts() { 8 + const posts = await bsky.get("com.atproto.repo.listRecords", { 9 + params: { 10 + repo: env.NEXT_PUBLIC_BSKY_DID, 11 + collection: "com.whtwnd.blog.entry", 12 + // todo: pagination 13 + }, 14 + }); 15 + return posts.data.records.filter( 16 + drafts, 17 + ) as (ComAtprotoRepoListRecords.Record & { 18 + value: ComWhtwndBlogEntry.Record; 19 + })[]; 20 + } 21 + 22 + function drafts(record: ComAtprotoRepoListRecords.Record) { 23 + if (Deno.env.get("NODE_ENV") === "development") return true; 24 + const post = record.value as ComWhtwndBlogEntry.Record; 25 + return post.visibility === "public"; 26 + } 27 + 28 + export async function getPost(rkey: string) { 29 + const post = await bsky.get("com.atproto.repo.getRecord", { 30 + params: { 31 + repo: env.NEXT_PUBLIC_BSKY_DID, 32 + rkey: rkey, 33 + collection: "com.whtwnd.blog.entry", 34 + }, 35 + }); 36 + 37 + return post.data as ComAtprotoRepoListRecords.Record & { 38 + value: ComWhtwndBlogEntry.Record; 39 + }; 40 + }
+9
lib/bsky.ts
··· 1 + import { CredentialManager, XRPC } from "npm:@atcute/client"; 2 + 3 + import { env } from "./env.ts"; 4 + 5 + const handler = new CredentialManager({ 6 + service: env.NEXT_PUBLIC_BSKY_PDS, 7 + fetch, 8 + }); 9 + export const bsky = new XRPC({ handler });
+1
lib/cx.ts
··· 1 + export { twMerge as cx } from "npm:tailwind-merge";
+3
lib/date.ts
··· 1 + export const { format: date } = new Intl.DateTimeFormat("en-GB", { 2 + dateStyle: "medium", 3 + });
+26
lib/env.ts
··· 1 + import { cleanEnv, str, url } from "npm:envalid"; 2 + 3 + const envVars = { 4 + NODE_ENV: "production", 5 + PLAUSIBLE_SITE_ID: "knotbin.xyz", 6 + PLAUSIBLE_DOMAIN: "https://plausible.knotbin.xyz", 7 + PLAUSIBLE_API_KEY: "", 8 + NEXT_PUBLIC_BSKY_DID: "did:plc:6hbqm2oftpotwuw7gvvrui3i", 9 + NEXT_PUBLIC_BSKY_PDS: "https://puffball.us-east.host.bsky.network", 10 + }; 11 + 12 + // Use cleanEnv to validate and parse the environment variables 13 + export const env = cleanEnv(envVars, { 14 + NODE_ENV: str({ 15 + choices: ["development", "production"], 16 + default: "production", 17 + devDefault: "development", 18 + }), 19 + PLAUSIBLE_SITE_ID: str({ default: "knotbin.xyz" }), 20 + PLAUSIBLE_DOMAIN: url({ default: "https://plausible.knotbin.xyz" }), 21 + PLAUSIBLE_API_KEY: str({ default: "" }), 22 + NEXT_PUBLIC_BSKY_DID: str({ default: "did:plc:6hbqm2oftpotwuw7gvvrui3i" }), 23 + NEXT_PUBLIC_BSKY_PDS: url({ 24 + default: "https://puffball.us-east.host.bsky.network", 25 + }), 26 + });
+21
lib/google-font.ts
··· 1 + // from https://github.com/kosei28/vercel-og-google-fonts/blob/main/src/utils/font.ts 2 + export async function loadGoogleFont(font: string, text: string) { 3 + const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( 4 + text, 5 + )}`; 6 + 7 + const css = await (await fetch(url)).text(); 8 + 9 + const resource = css.match( 10 + /src: url\((.+)\) format\('(opentype|truetype)'\)/, 11 + ); 12 + 13 + if (resource) { 14 + const res = await fetch(resource[1]); 15 + if (res.status == 200) { 16 + return await res.arrayBuffer(); 17 + } 18 + } 19 + 20 + throw new Error("failed to load font data"); 21 + }
lib/render-markdown.ts

This is a binary file and will not be displayed.

+12
main.ts
··· 1 + /// <reference lib="dom" /> 2 + /// <reference lib="dom.iterable" /> 3 + /// <reference lib="dom.asynciterable" /> 4 + /// <reference lib="deno.ns" /> 5 + 6 + import "$std/dotenv/load.ts"; 7 + 8 + import { start } from "$fresh/server.ts"; 9 + import manifest from "./fresh.gen.ts"; 10 + import config from "./fresh.config.ts"; 11 + 12 + await start(manifest, config);
+27
routes/_404.tsx
··· 1 + import { Head } from "$fresh/runtime.ts"; 2 + 3 + export default function Error404() { 4 + return ( 5 + <> 6 + <Head> 7 + <title>404 - Page not found</title> 8 + </Head> 9 + <div class="px-4 py-8 mx-auto bg-[#86efac]"> 10 + <div class="max-w-screen-md mx-auto flex flex-col items-center justify-center"> 11 + <img 12 + class="my-6" 13 + src="/logo.svg" 14 + width="128" 15 + height="128" 16 + alt="the Fresh logo: a sliced lemon dripping with juice" 17 + /> 18 + <h1 class="text-4xl font-bold">404 - Page not found</h1> 19 + <p class="my-4"> 20 + The page you were looking for doesn't exist. 21 + </p> 22 + <a href="/" class="underline">Go back home</a> 23 + </div> 24 + </div> 25 + </> 26 + ); 27 + }
+16
routes/_app.tsx
··· 1 + import { type PageProps } from "$fresh/server.ts"; 2 + export default function App({ Component }: PageProps) { 3 + return ( 4 + <html> 5 + <head> 6 + <meta charset="utf-8" /> 7 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 + <title>blog</title> 9 + <link rel="stylesheet" href="/styles.css" /> 10 + </head> 11 + <body> 12 + <Component /> 13 + </body> 14 + </html> 15 + ); 16 + }
+21
routes/api/joke.ts
··· 1 + import { FreshContext } from "$fresh/server.ts"; 2 + 3 + // Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/ 4 + const JOKES = [ 5 + "Why do Java developers often wear glasses? They can't C#.", 6 + "A SQL query walks into a bar, goes up to two tables and says “can I join you?”", 7 + "Wasn't hard to crack Forrest Gump's password. 1forrest1.", 8 + "I love pressing the F5 key. It's refreshing.", 9 + "Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”", 10 + "There are 10 types of people in the world. Those who understand binary and those who don't.", 11 + "Why are assembly programmers often wet? They work below C level.", 12 + "My favourite computer based band is the Black IPs.", 13 + "What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.", 14 + "An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.", 15 + ]; 16 + 17 + export const handler = (_req: Request, _ctx: FreshContext): Response => { 18 + const randomIndex = Math.floor(Math.random() * JOKES.length); 19 + const body = JOKES[randomIndex]; 20 + return new Response(body); 21 + };
+5
routes/greet/[name].tsx
··· 1 + import { PageProps } from "$fresh/server.ts"; 2 + 3 + export default function Greet(props: PageProps) { 4 + return <div>Hello {props.params.name}</div>; 5 + }
+31
routes/index.tsx
··· 1 + import { Footer } from "../components/footer.tsx"; 2 + import PostList from "../islands/post-list.tsx"; 3 + import { Title } from "../components/typography.tsx"; 4 + import { getPosts } from "../lib/api.ts"; 5 + 6 + export const dynamic = "force-static"; 7 + export const revalidate = 3600; // 1 hour 8 + 9 + export default async function Home() { 10 + const posts = await getPosts(); 11 + 12 + return ( 13 + <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"> 14 + <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px]"> 15 + <div className="self-center flex flex-col"> 16 + <Title level="h1" className="m-0"> 17 + knotbin 18 + </Title> 19 + <span className="font-bold text-xs opacity-50 text-right flex-1 mr-6"> 20 + looking into it 21 + </span> 22 + </div> 23 + 24 + <div className="flex flex-col gap-4 w-full"> 25 + <PostList posts={posts} /> 26 + </div> 27 + </main> 28 + <Footer /> 29 + </div> 30 + ); 31 + }
+153
routes/post/[slug].tsx
··· 1 + /** @jsxImportSource preact */ 2 + import { CSS, render } from "@deno/gfm"; 3 + import { Handlers, PageProps } from "$fresh/server.ts"; 4 + 5 + import { Footer } from "../../components/footer.tsx"; 6 + import { PostInfo } from "../../components/post-info.tsx"; 7 + import { Title } from "../../components/typography.tsx"; 8 + import { getPost } from "../../lib/api.ts"; 9 + import { Head } from "$fresh/runtime.ts"; 10 + 11 + interface Post { 12 + uri: string; 13 + value: { 14 + title: string; 15 + content: string; 16 + createdAt: string; 17 + }; 18 + } 19 + 20 + // Only override backgrounds in dark mode to make them transparent 21 + const transparentDarkModeCSS = ` 22 + @media (prefers-color-scheme: dark) { 23 + .markdown-body { 24 + color: white; 25 + background-color: transparent; 26 + } 27 + 28 + .markdown-body a { 29 + color: #58a6ff; 30 + } 31 + 32 + .markdown-body blockquote { 33 + border-left-color: #30363d; 34 + background-color: transparent; 35 + } 36 + 37 + .markdown-body pre, 38 + .markdown-body code { 39 + background-color: transparent; 40 + color: #c9d1d9; 41 + } 42 + 43 + .markdown-body table td, 44 + .markdown-body table th { 45 + border-color: #30363d; 46 + background-color: transparent; 47 + } 48 + } 49 + 50 + .font-sans { font-family: var(--font-sans); } 51 + .font-serif { font-family: var(--font-serif); } 52 + .font-mono { font-family: var(--font-mono); } 53 + 54 + .markdown-body h1 { 55 + font-family: var(--font-serif); 56 + text-transform: uppercase; 57 + font-size: 2.25rem; 58 + } 59 + 60 + .markdown-body h2 { 61 + font-family: var(--font-serif); 62 + text-transform: uppercase; 63 + font-size: 1.75rem; 64 + } 65 + 66 + .markdown-body h3 { 67 + font-family: var(--font-serif); 68 + text-transform: uppercase; 69 + font-size: 1.5rem; 70 + } 71 + 72 + .markdown-body h4 { 73 + font-family: var(--font-serif); 74 + text-transform: uppercase; 75 + font-size: 1.25rem; 76 + } 77 + 78 + .markdown-body h5 { 79 + font-family: var(--font-serif); 80 + text-transform: uppercase; 81 + font-size: 1rem; 82 + } 83 + 84 + .markdown-body h6 { 85 + font-family: var(--font-serif); 86 + text-transform: uppercase; 87 + font-size: 0.875rem; 88 + } 89 + `; 90 + 91 + export const handler: Handlers<Post> = { 92 + async GET(_req, ctx) { 93 + try { 94 + const { slug } = ctx.params; 95 + const post = await getPost(slug); 96 + return ctx.render(post); 97 + } catch (error) { 98 + console.error("Error fetching post:", error); 99 + return new Response("Post not found", { status: 404 }); 100 + } 101 + }, 102 + }; 103 + 104 + export default function BlogPage({ data: post }: PageProps<Post>) { 105 + if (!post) { 106 + return <div>Post not found</div>; 107 + } 108 + 109 + return ( 110 + <> 111 + <Head> 112 + <title>{post.value.title} — knotbin</title> 113 + <meta name="description" content="by Roscoe Rubin-Rottenberg" /> 114 + {/* Merge GFM’s default styles with our dark-mode overrides */} 115 + <style 116 + dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }} 117 + /> 118 + </Head> 119 + 120 + <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"> 121 + <link rel="alternate" href={post.uri} /> 122 + <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start w-full max-w-[600px] overflow-hidden"> 123 + <article className="w-full space-y-8"> 124 + <div className="space-y-4 w-full"> 125 + <a 126 + href="/" 127 + className="hover:underline hover:underline-offset-4 font-medium" 128 + > 129 + Back 130 + </a> 131 + <Title>{post.value.title}</Title> 132 + <PostInfo 133 + content={post.value.content} 134 + createdAt={post.value.createdAt} 135 + includeAuthor 136 + className="text-sm" 137 + /> 138 + <div className="diagonal-pattern w-full h-3" /> 139 + </div> 140 + <div className="[&>.bluesky-embed]:mt-8 [&>.bluesky-embed]:mb-0"> 141 + {/* Render GFM HTML via dangerouslySetInnerHTML */} 142 + <div 143 + class="mt-8 markdown-body" 144 + dangerouslySetInnerHTML={{ __html: render(post.value.content) }} 145 + /> 146 + </div> 147 + </article> 148 + </main> 149 + <Footer /> 150 + </div> 151 + </> 152 + ); 153 + }
+43
routes/rss.ts
··· 1 + import rehypeFormat from "npm:rehype-format"; 2 + import rehypeStringify from "npm:rehype-stringify"; 3 + import remarkParse from "npm:remark-parse"; 4 + import remarkRehype from "npm:remark-rehype"; 5 + import RSS from "npm:rss"; 6 + import { unified } from "npm:unified"; 7 + 8 + import { getPosts } from "../lib/api.ts"; 9 + 10 + export const dynamic = "force-static"; 11 + export const revalidate = 3600; // 1 hour 12 + 13 + export async function GET() { 14 + const posts = await getPosts(); 15 + 16 + const rss = new RSS({ 17 + title: "knotbin", 18 + feed_url: "https://knotbin.xyz/rss", 19 + site_url: "https://knotbin.xyz", 20 + description: "a webbed site", 21 + }); 22 + 23 + for (const post of posts) { 24 + rss.item({ 25 + title: post.value.title ?? "Untitled", 26 + description: await unified() 27 + .use(remarkParse) 28 + .use(remarkRehype) 29 + .use(rehypeFormat) 30 + .use(rehypeStringify) 31 + .process(post.value.content) 32 + .then((v) => v.toString()), 33 + url: `https://mozzius.dev/post/${post.uri.split("/").pop()}`, 34 + date: new Date(post.value.createdAt ?? Date.now()), 35 + }); 36 + } 37 + 38 + return new Response(rss.xml(), { 39 + headers: { 40 + "content-type": "application/rss+xml", 41 + }, 42 + }); 43 + }
static/favicon.ico

This is a binary file and will not be displayed.

+6
static/logo.svg
··· 1 + <svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <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"/> 3 + <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"/> 4 + <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"/> 5 + <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"/> 6 + </svg>
+145
static/styles.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'); 2 + @import url('https://fonts.googleapis.com/css2?family=Libre+Bodoni:wght@400;700&display=swap'); 3 + @font-face { 4 + font-family: 'Berkeley Mono'; 5 + src: url('/path/to/local/fonts/BerkeleyMono-Regular.woff2') format('woff2'), 6 + url('/path/to/local/fonts/BerkeleyMono-Regular.woff') format('woff'); 7 + font-weight: 400; 8 + font-style: normal; 9 + } 10 + 11 + @tailwind base; 12 + @tailwind components; 13 + @tailwind utilities; 14 + 15 + @theme inline { 16 + --color-background: var(--background); 17 + --color-foreground: var(--foreground); 18 + } 19 + 20 + :root { 21 + --font-sans: 'Inter', sans-serif; 22 + --font-serif: 'Libre Bodoni', serif; 23 + --font-mono: 'Berkeley Mono', monospace; 24 + } 25 + 26 + .font-sans { font-family: var(--font-sans); } 27 + .font-serif { font-family: var(--font-serif); } 28 + .font-mono { font-family: var(--font-mono); } 29 + 30 + /* 31 + The default border color has changed to `currentColor` in Tailwind CSS v4, 32 + so we've added these compatibility styles to make sure everything still 33 + looks the same as it did with Tailwind CSS v3. 34 + 35 + If we ever want to remove these styles, we need to add an explicit border 36 + color utility to any element that depends on these defaults. 37 + */ 38 + @layer base { 39 + *, 40 + ::after, 41 + ::before, 42 + ::backdrop, 43 + ::file-selector-button { 44 + border-color: var(--color-gray-200, currentColor); 45 + } 46 + } 47 + 48 + @utility text-balance { 49 + text-wrap: balance; 50 + } 51 + 52 + @layer utilities { 53 + :root { 54 + --background: #ffffff; 55 + --foreground: #171717; 56 + } 57 + 58 + @media (prefers-color-scheme: dark) { 59 + :root { 60 + --background: #0a0a0a; 61 + --foreground: #ededed; 62 + } 63 + } 64 + 65 + body { 66 + color: var(--foreground); 67 + background: var(--background); 68 + font-family: var(--font-sans); 69 + } 70 + 71 + @keyframes marquee { 72 + 0% { 73 + opacity: 0; 74 + transform: translateX(0px); 75 + } 76 + 2% { 77 + opacity: 0.075; 78 + } 79 + 98% { 80 + opacity: 0.075; 81 + } 82 + 100% { 83 + opacity: 0; 84 + transform: translateX(-4000px); 85 + } 86 + } 87 + 88 + @keyframes fadeIn { 89 + 0% { 90 + opacity: 0; 91 + } 92 + 100% { 93 + opacity: 1; 94 + } 95 + } 96 + 97 + @keyframes fadeOut { 98 + 0% { 99 + opacity: 1; 100 + } 101 + 100% { 102 + opacity: 0; 103 + } 104 + } 105 + 106 + .animate-marquee { 107 + animation: marquee 30s linear infinite; 108 + font-size: 100vh; 109 + line-height: 0.8; 110 + height: 100vh; 111 + display: flex; 112 + align-items: center; 113 + } 114 + 115 + .animate-fade-in { 116 + animation: fadeIn 0.3s ease-in-out forwards; 117 + } 118 + 119 + .animate-fade-out { 120 + animation: fadeOut 0.3s ease-in-out forwards; 121 + } 122 + } 123 + 124 + .diagonal-pattern { 125 + background-color: transparent; 126 + background: repeating-linear-gradient( 127 + -45deg, 128 + #000000, 129 + #000000 4px, 130 + transparent 4px, 131 + transparent 10px 132 + ); 133 + } 134 + 135 + @media (prefers-color-scheme: dark) { 136 + .diagonal-pattern { 137 + background: repeating-linear-gradient( 138 + -45deg, 139 + #ffffff, 140 + #ffffff 4px, 141 + transparent 4px, 142 + transparent 10px 143 + ); 144 + } 145 + }
+7
tailwind.config.ts
··· 1 + import { type Config } from "tailwindcss"; 2 + 3 + export default { 4 + content: [ 5 + "{routes,islands,components}/**/*.{ts,tsx,js,jsx}", 6 + ], 7 + } satisfies Config;