+11
.gitignore
+11
.gitignore
+6
.vscode/extensions.json
+6
.vscode/extensions.json
+20
.vscode/settings.json
+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
+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
+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
assets/fonts/BerkeleyMono-Regular.woff2
This is a binary file and will not be displayed.
assets/me_blue_square.jpg
assets/me_blue_square.jpg
This is a binary file and will not be displayed.
+62
components/bluesky-embed.tsx
+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
+
}
+50
components/post-info.tsx
+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
+
·{" "}
39
+
</>
40
+
)}
41
+
{createdAt && (
42
+
<>
43
+
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "}
44
+
·{" "}
45
+
</>
46
+
)}
47
+
{children}
48
+
</Paragraph>
49
+
);
50
+
}
+84
components/post-list-item.tsx
+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
+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
+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
+8
dev.ts
+6
fresh.config.ts
+6
fresh.config.ts
+31
fresh.gen.ts
+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
+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
+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
+9
lib/bsky.ts
+1
lib/cx.ts
+1
lib/cx.ts
···
1
+
export { twMerge as cx } from "npm:tailwind-merge";
+3
lib/date.ts
+3
lib/date.ts
+26
lib/env.ts
+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
+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
lib/render-markdown.ts
This is a binary file and will not be displayed.
+12
main.ts
+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
+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
+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
+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
+5
routes/greet/[name].tsx
+31
routes/index.tsx
+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
+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
+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
static/favicon.ico
This is a binary file and will not be displayed.
+6
static/logo.svg
+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
+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
+
}