Leaflet Blog in Deno Fresh

fixy

-2
.github/workflows/deploy.yml
···
project: "roscoerubin-blog-23"
entrypoint: "main.ts"
root: "."
-
-
···
project: "roscoerubin-blog-23"
entrypoint: "main.ts"
root: "."
+18 -17
components/bsky-comments/Comment.tsx
···
-
import { AppBskyFeedDefs, AppBskyFeedPost } from 'npm:@atproto/api';
type CommentProps = {
comment: AppBskyFeedDefs.ThreadViewPost;
···
if (!AppBskyFeedPost.isRecord(comment.post.record)) return null;
// filter out replies that match any of the commentFilters, by ensuring they all return false
if (filters && !filters.every((filter) => !filter(comment))) return null;
-
const styles = `
.container {
···
target="_blank"
rel="noreferrer noopener"
>
-
{author.avatar ? (
-
<img
-
src={comment.post.author.avatar}
-
alt="avatar"
-
className={avatarClassName}
-
/>
-
) : (
-
<div className={avatarClassName} />
-
)}
<p className="authorName">
-
{author.displayName ?? author.handle}{' '}
<span className="handle">@{author.handle}</span>
</p>
</a>
<a
-
href={`https://bsky.app/profile/${author.did}/post/${comment.post.uri
-
.split('/')
-
.pop()}`}
target="_blank"
rel="noreferrer noopener"
>
···
if (
!AppBskyFeedDefs.isThreadViewPost(a) ||
!AppBskyFeedDefs.isThreadViewPost(b) ||
-
!('post' in a) ||
-
!('post' in b)
) {
return 0;
}
···
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "npm:@atproto/api";
type CommentProps = {
comment: AppBskyFeedDefs.ThreadViewPost;
···
if (!AppBskyFeedPost.isRecord(comment.post.record)) return null;
// filter out replies that match any of the commentFilters, by ensuring they all return false
if (filters && !filters.every((filter) => !filter(comment))) return null;
const styles = `
.container {
···
target="_blank"
rel="noreferrer noopener"
>
+
{author.avatar
+
? (
+
<img
+
src={comment.post.author.avatar}
+
alt="avatar"
+
className={avatarClassName}
+
/>
+
)
+
: <div className={avatarClassName} />}
<p className="authorName">
+
{author.displayName ?? author.handle}{" "}
<span className="handle">@{author.handle}</span>
</p>
</a>
<a
+
href={`https://bsky.app/profile/${author.did}/post/${
+
comment.post.uri
+
.split("/")
+
.pop()
+
}`}
target="_blank"
rel="noreferrer noopener"
>
···
if (
!AppBskyFeedDefs.isThreadViewPost(a) ||
!AppBskyFeedDefs.isThreadViewPost(b) ||
+
!("post" in a) ||
+
!("post" in b)
) {
return 0;
}
+26 -18
components/bsky-comments/CommentFilters.tsx
···
-
import { AppBskyFeedPost, type AppBskyFeedDefs } from 'npm:@atproto/api';
const MinLikeCountFilter = (
-
min: number
-
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
return (comment.post.likeCount ?? 0) < min;
};
};
const MinCharacterCountFilter = (
-
min: number
-
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
const TextContainsFilter = (
-
text: string
-
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
const ExactMatchFilter = (
-
text: string
-
): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
};
-
/*
-
* This function allows you to filter out comments based on likes,
-
* characters, text, pins, or exact matches.
-
*/
export const Filters: {
-
MinLikeCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
-
MinCharacterCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
-
TextContainsFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
-
ExactMatchFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
} = {
···
TextContainsFilter,
ExactMatchFilter,
NoLikes: MinLikeCountFilter(0),
-
NoPins: ExactMatchFilter('📌'),
};
export default Filters;
···
+
import { type AppBskyFeedDefs, AppBskyFeedPost } from "npm:@atproto/api";
const MinLikeCountFilter = (
+
min: number,
+
): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
return (comment.post.likeCount ?? 0) < min;
};
};
const MinCharacterCountFilter = (
+
min: number,
+
): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
const TextContainsFilter = (
+
text: string,
+
): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
const ExactMatchFilter = (
+
text: string,
+
): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => {
return (comment: AppBskyFeedDefs.ThreadViewPost) => {
if (!AppBskyFeedPost.isRecord(comment.post.record)) {
return false;
···
};
};
+
/*
+
* This function allows you to filter out comments based on likes,
+
* characters, text, pins, or exact matches.
+
*/
export const Filters: {
+
MinLikeCountFilter: (
+
min: number,
+
) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
MinCharacterCountFilter: (
+
min: number,
+
) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
TextContainsFilter: (
+
text: string,
+
) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
+
ExactMatchFilter: (
+
text: string,
+
) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean;
} = {
···
TextContainsFilter,
ExactMatchFilter,
NoLikes: MinLikeCountFilter(0),
+
NoPins: ExactMatchFilter("📌"),
};
export default Filters;
+2 -2
components/bsky-comments/PostSummary.tsx
···
-
import { AppBskyFeedDefs } from 'npm:@atproto/api';
type PostSummaryProps = {
postUrl: string;
···
</a>
<h2 className="commentsTitle">Comments</h2>
<p className="replyText">
-
Join the conversation by{' '}
<a
className="link"
href={postUrl}
···
+
import { AppBskyFeedDefs } from "npm:@atproto/api";
type PostSummaryProps = {
postUrl: string;
···
</a>
<h2 className="commentsTitle">Comments</h2>
<p className="replyText">
+
Join the conversation by{" "}
<a
className="link"
href={postUrl}
+10 -3
components/footer.tsx
···
-
import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons";
import { useState } from "preact/hooks";
import { env } from "../lib/env.ts";
···
>
<path d={BlueskyIcon.path} />
</svg>
-
<span class="opacity-50 group-hover:opacity-100 transition-opacity">Bluesky</span>
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
<a
···
>
<path d={GithubIcon.path} />
</svg>
-
<span class="opacity-50 group-hover:opacity-100 transition-opacity">GitHub</span>
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
</footer>
···
+
import {
+
siBluesky as BlueskyIcon,
+
siGithub as GithubIcon,
+
} from "npm:simple-icons";
import { useState } from "preact/hooks";
import { env } from "../lib/env.ts";
···
>
<path d={BlueskyIcon.path} />
</svg>
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">
+
Bluesky
+
</span>
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
<a
···
>
<path d={GithubIcon.path} />
</svg>
+
<span class="opacity-50 group-hover:opacity-100 transition-opacity">
+
GitHub
+
</span>
<div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" />
</a>
</footer>
+7 -5
components/post-info.tsx
···
children?: ComponentChildren;
}) {
const readingTime = getReadingTime(content);
-
return (
<Paragraph className={className}>
{includeAuthor && (
···
)}
{createdAt && (
<>
-
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>
-
{" "}&middot;{" "}
</>
)}
-
<span >
-
<span style={{ lineHeight: 1, marginRight: '0.25rem' }}>{readingTime} min read</span>
</span>
{children}
</Paragraph>
···
children?: ComponentChildren;
}) {
const readingTime = getReadingTime(content);
+
return (
<Paragraph className={className}>
{includeAuthor && (
···
)}
{createdAt && (
<>
+
<time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "}
+
&middot;{" "}
</>
)}
+
<span>
+
<span style={{ lineHeight: 1, marginRight: "0.25rem" }}>
+
{readingTime} min read
+
</span>
</span>
{children}
</Paragraph>
+9 -2
components/typography.tsx
···
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(
···
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(
+3
deno.json
···
],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1",
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
···
"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",
···
],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
+
"@atcute/atproto": "npm:@atcute/atproto@^3.0.1",
+
"@atcute/client": "npm:@atcute/client@^4.0.1",
"@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1",
"@deno/gfm": "jsr:@deno/gfm@^0.10.0",
"@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13",
···
"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",
+
"rss": "npm:rss@^1.2.2",
"tailwindcss": "npm:tailwindcss@3.4.1",
"tailwindcss/": "npm:/tailwindcss@3.4.1/",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
+66 -40
islands/CommentSection.tsx
···
-
import { useState, useEffect } from 'preact/hooks';
-
import { AppBskyFeedDefs, type AppBskyFeedGetPostThread } from 'npm:@atproto/api';
-
import { CommentOptions } from '../components/bsky-comments/types.tsx';
-
import { PostSummary } from '../components/bsky-comments/PostSummary.tsx';
-
import { Comment } from '../components/bsky-comments/Comment.tsx';
const getAtUri = (uri: string): string => {
-
if (!uri.startsWith('at://') && uri.includes('bsky.app/profile/')) {
const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/);
if (match) {
const [, did, postId] = match;
···
};
/**
-
* This component displays a comment section for a post.
-
* It fetches the comments for a post and displays them in a threaded format.
-
*/
export const CommentSection = ({
uri: propUri,
author,
···
commentFilters,
}: CommentOptions): any => {
const [uri, setUri] = useState<string | null>(null);
-
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(null);
const [error, setError] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(5);
···
useEffect(() => {
let isSubscribed = true;
-
const initializeUri = async () => {
if (propUri) {
setUri(propUri);
···
if (author) {
try {
const currentUrl = window.location.href;
-
const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent(
-
currentUrl
-
)}&author=${author}&sort=top`;
-
const response = await fetch(apiUrl);
const data = await response.json();
···
const post = data.posts[0];
setUri(post.uri);
} else {
-
setError('No matching post found');
if (onEmpty) {
-
onEmpty({ code: 'not_found', message: 'No matching post found' });
}
}
}
} catch (err) {
if (isSubscribed) {
-
setError('Error fetching post');
if (onEmpty) {
-
onEmpty({ code: 'fetching_error', message: 'Error fetching post' });
}
}
}
···
const fetchThreadData = async () => {
if (!uri) return;
-
try {
const thread = await getPostThread(uri);
if (isSubscribed) {
···
}
} catch (err) {
if (isSubscribed) {
-
setError('Error loading comments');
if (onEmpty) {
onEmpty({
-
code: 'comment_loading_error',
-
message: 'Error loading comments',
});
}
}
···
if (!uri) return null;
if (error) {
-
return <div className="container"><style>{styles}</style><p className="errorText">{error}</p></div>;
}
if (!thread) {
-
return <div className="container"><style>{styles}</style><p className="loadingText">Loading comments...</p></div>;
}
let postUrl: string = uri;
-
if (uri.startsWith('at://')) {
-
const [, , did, _, rkey] = uri.split('/');
postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
}
···
</div>
);
}
-
// Safe sort - ensure we're working with valid objects
-
const sortedReplies = [...thread.replies].filter(reply =>
AppBskyFeedDefs.isThreadViewPost(reply)
).sort(sortByLikes);
···
);
};
-
const getPostThread = async (uri: string): Promise<AppBskyFeedDefs.ThreadViewPost> => {
const atUri = getAtUri(uri);
const params = new URLSearchParams({ uri: atUri });
const res = await fetch(
-
'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?' +
params.toString(),
{
-
method: 'GET',
headers: {
-
Accept: 'application/json',
},
-
cache: 'no-store',
-
}
);
if (!res.ok) {
console.error(await res.text());
-
throw new Error('Failed to fetch post thread');
}
const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema;
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
-
throw new Error('Could not find thread');
}
return data.thread;
···
if (
!AppBskyFeedDefs.isThreadViewPost(a) ||
!AppBskyFeedDefs.isThreadViewPost(b) ||
-
!('post' in a) ||
-
!('post' in b)
) {
return 0;
}
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
-
};
···
+
import { useEffect, useState } from "preact/hooks";
+
import {
+
AppBskyFeedDefs,
+
type AppBskyFeedGetPostThread,
+
} from "npm:@atproto/api";
+
import { CommentOptions } from "../components/bsky-comments/types.tsx";
+
import { PostSummary } from "../components/bsky-comments/PostSummary.tsx";
+
import { Comment } from "../components/bsky-comments/Comment.tsx";
const getAtUri = (uri: string): string => {
+
if (!uri.startsWith("at://") && uri.includes("bsky.app/profile/")) {
const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/);
if (match) {
const [, did, postId] = match;
···
};
/**
+
* This component displays a comment section for a post.
+
* It fetches the comments for a post and displays them in a threaded format.
+
*/
export const CommentSection = ({
uri: propUri,
author,
···
commentFilters,
}: CommentOptions): any => {
const [uri, setUri] = useState<string | null>(null);
+
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
+
null,
+
);
const [error, setError] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(5);
···
useEffect(() => {
let isSubscribed = true;
+
const initializeUri = async () => {
if (propUri) {
setUri(propUri);
···
if (author) {
try {
const currentUrl = window.location.href;
+
const apiUrl =
+
`https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${
+
encodeURIComponent(
+
currentUrl,
+
)
+
}&author=${author}&sort=top`;
+
const response = await fetch(apiUrl);
const data = await response.json();
···
const post = data.posts[0];
setUri(post.uri);
} else {
+
setError("No matching post found");
if (onEmpty) {
+
onEmpty({
+
code: "not_found",
+
message: "No matching post found",
+
});
}
}
}
} catch (err) {
if (isSubscribed) {
+
setError("Error fetching post");
if (onEmpty) {
+
onEmpty({
+
code: "fetching_error",
+
message: "Error fetching post",
+
});
}
}
}
···
const fetchThreadData = async () => {
if (!uri) return;
+
try {
const thread = await getPostThread(uri);
if (isSubscribed) {
···
}
} catch (err) {
if (isSubscribed) {
+
setError("Error loading comments");
if (onEmpty) {
onEmpty({
+
code: "comment_loading_error",
+
message: "Error loading comments",
});
}
}
···
if (!uri) return null;
if (error) {
+
return (
+
<div className="container">
+
<style>{styles}</style>
+
<p className="errorText">{error}</p>
+
</div>
+
);
}
if (!thread) {
+
return (
+
<div className="container">
+
<style>{styles}</style>
+
<p className="loadingText">Loading comments...</p>
+
</div>
+
);
}
let postUrl: string = uri;
+
if (uri.startsWith("at://")) {
+
const [, , did, _, rkey] = uri.split("/");
postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
}
···
</div>
);
}
+
// Safe sort - ensure we're working with valid objects
+
const sortedReplies = [...thread.replies].filter((reply) =>
AppBskyFeedDefs.isThreadViewPost(reply)
).sort(sortByLikes);
···
);
};
+
const getPostThread = async (
+
uri: string,
+
): Promise<AppBskyFeedDefs.ThreadViewPost> => {
const atUri = getAtUri(uri);
const params = new URLSearchParams({ uri: atUri });
const res = await fetch(
+
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" +
params.toString(),
{
+
method: "GET",
headers: {
+
Accept: "application/json",
},
+
cache: "no-store",
+
},
);
if (!res.ok) {
console.error(await res.text());
+
throw new Error("Failed to fetch post thread");
}
const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema;
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+
throw new Error("Could not find thread");
}
return data.thread;
···
if (
!AppBskyFeedDefs.isThreadViewPost(a) ||
!AppBskyFeedDefs.isThreadViewPost(b) ||
+
!("post" in a) ||
+
!("post" in b)
) {
return 0;
}
const aPost = a as AppBskyFeedDefs.ThreadViewPost;
const bPost = b as AppBskyFeedDefs.ThreadViewPost;
return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0);
+
};
+3 -3
islands/layout.tsx
···
</a>
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
<div class="text-base flex items-center gap-7">
-
<a
-
href="/"
-
class="relative group"
data-current={isActive("/")}
data-hovered={blogHovered}
onMouseEnter={() => setBlogHovered(true)}
···
</a>
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div>
<div class="text-base flex items-center gap-7">
+
<a
+
href="/"
+
class="relative group"
data-current={isActive("/")}
data-hovered={blogHovered}
onMouseEnter={() => setBlogHovered(true)}
+3 -1
islands/post-list.tsx
···
uri: string;
}
-
export default function PostList({ posts: initialPosts }: { posts: PostRecord[] }) {
const posts = useSignal(initialPosts);
useEffect(() => {
···
uri: string;
}
+
export default function PostList(
+
{ posts: initialPosts }: { posts: PostRecord[] },
+
) {
const posts = useSignal(initialPosts);
useEffect(() => {
+9 -3
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 "@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 & {
···
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",
},
···
import { bsky } from "./bsky.ts";
import { env } from "./env.ts";
+
import { type ActorIdentifier } from "npm:@atcute/lexicons";
import { type ComWhtwndBlogEntry } from "@atcute/whitewind";
+
import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto";
export async function getPosts() {
const posts = await bsky.get("com.atproto.repo.listRecords", {
params: {
+
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
collection: "com.whtwnd.blog.entry",
// todo: pagination
},
});
+
+
if ('error' in posts.data) {
+
throw new Error(posts.data.error);
+
}
+
return posts.data.records.filter(
drafts,
) as (ComAtprotoRepoListRecords.Record & {
···
export async function getPost(rkey: string) {
const post = await bsky.get("com.atproto.repo.getRecord", {
params: {
+
repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier,
rkey: rkey,
collection: "com.whtwnd.blog.entry",
},
+4 -5
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 });
···
+
import { Client, simpleFetchHandler } from "@atcute/client";
import { env } from "./env.ts";
+
const handler = simpleFetchHandler({
+
service: env.NEXT_PUBLIC_BSKY_PDS
});
+
export const bsky = new Client({ handler });
+4 -1
routes/post/[slug].tsx
···
<>
<Head>
<title>{post.value.title} — knotbin</title>
-
<meta name="description" content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"} />
{/* Merge GFM's default styles with our dark-mode overrides */}
<style
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
···
<>
<Head>
<title>{post.value.title} — knotbin</title>
+
<meta
+
name="description"
+
content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"}
+
/>
{/* Merge GFM's default styles with our dark-mode overrides */}
<style
dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
+13 -13
routes/rss.ts
···
});
for (const post of posts) {
-
const description = post.value.subtitle
? `${post.value.subtitle}\n\n${await unified()
-
.use(remarkParse)
-
.use(remarkRehype)
-
.use(rehypeFormat)
-
.use(rehypeStringify)
-
.process(post.value.content)
-
.then((v) => v.toString())}`
: await unified()
-
.use(remarkParse)
-
.use(remarkRehype)
-
.use(rehypeFormat)
-
.use(rehypeStringify)
-
.process(post.value.content)
-
.then((v) => v.toString());
rss.item({
title: post.value.title ?? "Untitled",
···
});
for (const post of posts) {
+
const description = post.value.subtitle
? `${post.value.subtitle}\n\n${await unified()
+
.use(remarkParse)
+
.use(remarkRehype)
+
.use(rehypeFormat)
+
.use(rehypeStringify)
+
.process(post.value.content)
+
.then((v) => v.toString())}`
: await unified()
+
.use(remarkParse)
+
.use(remarkRehype)
+
.use(rehypeFormat)
+
.use(rehypeStringify)
+
.process(post.value.content)
+
.then((v) => v.toString());
rss.item({
title: post.value.title ?? "Untitled",
+22 -12
static/styles.css
···
-
@import url('https://api.fonts.coollabs.io/css2?family=Inter:wght@400;700&display=swap');
-
@import url('https://api.fonts.coollabs.io/css2?family=Libre+Bodoni:ital,wght@0,400;0,700;1,400&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;
}
···
}
: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); }
-
.font-serif-italic { font-family: var(--font-serif); font-style: italic;}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
···
+
@import url("https://api.fonts.coollabs.io/css2?family=Inter:wght@400;700&display=swap");
+
@import url("https://api.fonts.coollabs.io/css2?family=Libre+Bodoni:ital,wght@0,400;0,700;1,400&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;
}
···
}
: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);
+
}
+
.font-serif-italic {
+
font-family: var(--font-serif);
+
font-style: italic;
+
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,