chore: migrated from pds to posts app 0c9e20cf
Steve Simkins · 2026-04-30 20:09 4 file(s) · +108 −182
packages/client/src/components/now/NowUpdates.astro +41 −43
1 1
---
2 2
import { createMarkdownRenderer } from "@/utils";
3 -
import { OWNER_DID, PDS_URL } from "@/data/constants";
3 +
import { POSTS_API } from "@/data/constants";
4 4
5 5
const md = await createMarkdownRenderer();
6 6
7 -
interface Document {
8 -
	uri: string;
9 -
	value: {
10 -
		title: string;
11 -
		publishedAt: string;
12 -
		path: string;
13 -
		content?: {
14 -
			markdown?: string;
15 -
		};
16 -
		textContent?: string;
17 -
		location?: string;
18 -
	};
7 +
interface Post {
8 +
	short_id: string;
9 +
	title: string;
10 +
	slug: string;
11 +
	published_date: string | null;
12 +
	meta_description: string | null;
13 +
	meta_image: string | null;
14 +
	canonical_url: string | null;
15 +
	lang: string;
16 +
	tags: string | null;
17 +
	content: string;
18 +
	created_at: string;
19 +
	updated_at: string;
19 20
}
20 21
21 -
let documents: Document[] = [];
22 +
interface PostsListResponse {
23 +
	posts: Post[];
24 +
}
25 +
26 +
let posts: Post[] = [];
22 27
let error = false;
23 28
24 29
try {
25 -
	const res = await fetch(
26 -
		`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
27 -
			new URLSearchParams({
28 -
				repo: OWNER_DID,
29 -
				collection: "site.standard.document",
30 -
			}),
31 -
	);
30 +
	const res = await fetch(`${POSTS_API}/posts`);
32 31
33 32
	if (res.ok) {
34 -
		const data = await res.json();
35 -
		documents = (data.records || [])
36 -
			.filter((doc: Document) => doc.value.path.includes("/now/"))
37 -
			.sort((a: Document, b: Document) => {
38 -
				return new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime();
39 -
			});
33 +
		const data = (await res.json()) as PostsListResponse;
34 +
		posts = (data.posts || []).slice().sort((a, b) => {
35 +
			const dateA = a.published_date ? new Date(a.published_date).getTime() : 0;
36 +
			const dateB = b.published_date ? new Date(b.published_date).getTime() : 0;
37 +
			return dateB - dateA;
38 +
		});
39 +
	} else {
40 +
		error = true;
40 41
	}
41 42
} catch (err) {
42 43
	console.error("Error fetching updates:", err);
44 45
}
45 46
---
46 47
47 -
{error && <p>Error loading recent updates. Make sure your PDS is accessible.</p>}
48 +
{error && <p>Error loading recent updates.</p>}
48 49
49 -
{!error && documents.length === 0 && <p>No recent updates found.</p>}
50 +
{!error && posts.length === 0 && <p>No recent updates found.</p>}
50 51
51 -
{!error && documents.length > 0 && (
52 +
{!error && posts.length > 0 && (
52 53
	<div class="space-y-4">
53 -
		{documents.map((record) => {
54 -
			const value = record.value;
55 -
			const path = value.path.slice(1);
56 -
			const publishedAt = new Date(value.publishedAt).toLocaleDateString();
54 +
		{posts.map((post) => {
55 +
			const publishedAt = post.published_date
56 +
				? new Date(post.published_date).toLocaleDateString()
57 +
				: "";
57 58
58 -
			let contentHTML = "";
59 -
			if (value.content && value.content.markdown) {
60 -
				contentHTML = md.render(value.content.markdown).trim()
59 +
			const contentHTML = post.content
60 +
				? md.render(post.content).trim()
61 61
					.replace(/<img\s([^>]*?)>/g, (_, attrs) => {
62 62
						const hasLoading = /loading\s*=/.test(attrs);
63 63
						const hasDecoding = /decoding\s*=/.test(attrs);
64 64
						const hasWidth = /width\s*=/.test(attrs);
65 65
						const extra = `${hasLoading ? "" : ' loading="lazy"'}${hasDecoding ? "" : ' decoding="async"'}${hasWidth ? "" : ' width="800"'}`;
66 66
						return `<img ${attrs}${extra} style="height:auto">`;
67 -
					});
68 -
			} else if (value.textContent) {
69 -
				contentHTML = `<p>${value.textContent}</p>`;
70 -
			}
67 +
					})
68 +
				: "";
71 69
72 70
			return (
73 71
				<article class="border-b pb-6 mb-6 last:border-b-0">
74 72
					<a
75 -
						href={`/${path}`}
73 +
						href={`/now/${post.slug}`}
76 74
						class="block hover:opacity-80 transition-opacity"
77 75
					>
78 -
						<h3 class="text-lg font-semibold mb-3">{value.title}</h3>
76 +
						<h3 class="text-lg font-semibold mb-3">{post.title}</h3>
79 77
					</a>
80 78
					<div
81 79
						class="prose prose-invert max-w-none mb-3"
packages/client/src/data/constants.ts +2 −0
2 2
export const OWNER_DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
3 3
export const PDS_URL = "https://andromeda.social";
4 4
5 +
export const POSTS_API = "https://posts.stevedylan.dev/api";
6 +
5 7
export const MENU_LINKS = [
6 8
  {
7 9
    title: "Home",
packages/client/src/pages/now/[slug].astro +32 −79
1 1
---
2 2
import PageLayout from "@/layouts/Base.astro";
3 -
import GuestReply from "@/components/now/GuestReply.astro";
4 -
import { OWNER_DID, PDS_URL } from "@/data/constants";
3 +
import { POSTS_API } from "@/data/constants";
5 4
import { createMarkdownRenderer } from "@/utils";
6 5
7 6
export const prerender = false;
14 13
	return Astro.redirect("/now");
15 14
}
16 15
16 +
interface PostDetail {
17 +
	short_id: string;
18 +
	title: string;
19 +
	slug: string;
20 +
	alias: string | null;
21 +
	canonical_url: string | null;
22 +
	published_date: string | null;
23 +
	meta_description: string | null;
24 +
	meta_image: string | null;
25 +
	lang: string;
26 +
	tags: string | null;
27 +
	content: string;
28 +
	created_at: string;
29 +
	updated_at: string;
30 +
}
31 +
17 32
let title = "Post";
18 -
let description = "A post from my PDS";
33 +
let description = "A post";
19 34
let contentHTML = "";
20 35
let publishedAt = "";
21 -
let atUri = "";
22 36
let errorMessage = "";
23 37
let isError = false;
24 38
25 39
try {
26 -
	let documentFound = false;
27 -
28 -
	// First, try to find a document with a matching custom path
29 -
	const listResponse = await fetch(
30 -
		`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
31 -
			new URLSearchParams({
32 -
				repo: OWNER_DID,
33 -
				collection: "site.standard.document",
34 -
				limit: "100",
35 -
			}),
36 -
	);
37 -
38 -
	if (listResponse.ok) {
39 -
		const listData = await listResponse.json();
40 -
		const matchingDoc = listData.records.find((record: any) => {
41 -
			// Check if document has a custom path that matches
42 -
			const docPath = record.value.path;
43 -
			if (docPath) {
44 -
				// Remove leading slash for comparison
45 -
				const normalizedPath = docPath.startsWith("/")
46 -
					? docPath.slice(1)
47 -
					: docPath;
48 -
				// Match against the full path including /now/
49 -
				return normalizedPath === `now/${slug}`;
50 -
			}
51 -
			return false;
52 -
		});
40 +
	const res = await fetch(`${POSTS_API}/posts/${encodeURIComponent(slug)}`);
53 41
54 -
		if (matchingDoc) {
55 -
			const doc = matchingDoc.value;
56 -
			title = doc.title || "Post";
57 -
			description = doc.content?.markdown?.slice(0, 160) || description;
58 -
			publishedAt = new Date(doc.publishedAt).toLocaleDateString();
59 -
			atUri = matchingDoc.uri;
60 -
61 -
			if (doc.content && doc.content.markdown) {
62 -
				contentHTML = md.render(doc.content.markdown);
63 -
			} else if (doc.textContent) {
64 -
				contentHTML = `<p>${doc.textContent}</p>`;
65 -
			}
66 -
			documentFound = true;
67 -
		}
68 -
	}
69 -
70 -
	// If no custom path match, try fetching by rkey
71 -
	if (!documentFound) {
72 -
		const documentResponse = await fetch(
73 -
			`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
74 -
				new URLSearchParams({
75 -
					repo: OWNER_DID,
76 -
					collection: "site.standard.document",
77 -
					rkey: slug,
78 -
				}),
79 -
		);
80 -
81 -
		if (documentResponse.ok) {
82 -
			const data = await documentResponse.json();
83 -
			const doc = data.value;
84 -
			title = doc.title || "Post";
85 -
			description = doc.content?.markdown?.slice(0, 160) || description;
86 -
			publishedAt = new Date(doc.publishedAt).toLocaleDateString();
87 -
			atUri = `at://${OWNER_DID}/site.standard.document/${slug}`;
88 -
89 -
			if (doc.content && doc.content.markdown) {
90 -
				contentHTML = md.render(doc.content.markdown);
91 -
			} else if (doc.textContent) {
92 -
				contentHTML = `<p>${doc.textContent}</p>`;
93 -
			}
94 -
			documentFound = true;
95 -
		}
96 -
	}
97 -
98 -
	if (!documentFound) {
42 +
	if (res.status === 404) {
99 43
		Astro.response.status = 404;
100 44
		isError = true;
101 45
		title = "Not Found";
102 46
		errorMessage = "The post you're looking for doesn't exist.";
47 +
	} else if (!res.ok) {
48 +
		throw new Error(`HTTP ${res.status}`);
49 +
	} else {
50 +
		const post = (await res.json()) as PostDetail;
51 +
		title = post.title || "Post";
52 +
		description =
53 +
			post.meta_description ||
54 +
			(post.content ? post.content.slice(0, 160) : description);
55 +
		publishedAt = post.published_date
56 +
			? new Date(post.published_date).toLocaleDateString()
57 +
			: "";
58 +
		contentHTML = md.render(post.content || "");
103 59
	}
104 60
} catch (err) {
105 61
	console.error("Error fetching post:", err);
112 68
const meta = {
113 69
	title,
114 70
	description,
115 -
	atUri,
116 71
};
117 72
---
118 73
131 86
				<div class="prose prose-invert max-w-none my-4">
132 87
					<Fragment set:html={contentHTML} />
133 88
				</div>
134 -
				<a href={`https://pdsls.dev/${atUri}`} target="_blank" rel="noreferrer" class="underline text-xs text-gray-400 mt-4 break-all">{atUri}</a>
135 89
				<div class="mt-12 flex items-center justify-between">
136 90
					<a class="style-link" href="/now">&larr; Now</a>
137 91
					<button
141 95
						Share
142 96
					</button>
143 97
				</div>
144 -
				<GuestReply postTitle={title} />
145 98
			</>
146 99
		)}
147 100
	</article>
packages/client/src/pages/now/rss.xml.ts +33 −60
1 1
import rss from "@astrojs/rss";
2 2
import sanitizeHtml from "sanitize-html";
3 3
import MarkdownIt from "markdown-it";
4 -
import { OWNER_DID, PDS_URL } from "@/data/constants";
4 +
import { POSTS_API } from "@/data/constants";
5 5
6 6
export const prerender = false;
7 7
11 11
	typographer: true,
12 12
});
13 13
14 -
interface DocumentRecord {
15 -
	uri: string;
16 -
	cid: string;
17 -
	value: {
18 -
		$type: string;
19 -
		title: string;
20 -
		site: string;
21 -
		path?: string;
22 -
		content?: {
23 -
			$type: string;
24 -
			markdown: string;
25 -
		};
26 -
		textContent?: string;
27 -
		publishedAt: string;
28 -
		location?: string;
29 -
	};
14 +
interface Post {
15 +
	short_id: string;
16 +
	title: string;
17 +
	slug: string;
18 +
	published_date: string | null;
19 +
	meta_description: string | null;
20 +
	meta_image: string | null;
21 +
	canonical_url: string | null;
22 +
	lang: string;
23 +
	tags: string | null;
24 +
	content: string;
25 +
	created_at: string;
26 +
	updated_at: string;
30 27
}
31 28
32 -
interface ListRecordsResponse {
33 -
	records: DocumentRecord[];
34 -
	cursor?: string;
29 +
interface PostsListResponse {
30 +
	posts: Post[];
35 31
}
36 32
37 33
export async function GET() {
38 34
	try {
39 -
		const response = await fetch(
40 -
			`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
41 -
				new URLSearchParams({
42 -
					repo: OWNER_DID,
43 -
					collection: "site.standard.document",
44 -
					limit: "50",
45 -
				}),
46 -
		);
35 +
		const response = await fetch(`${POSTS_API}/posts`);
47 36
48 37
		if (!response.ok) {
49 38
			throw new Error(`HTTP error! status: ${response.status}`);
50 39
		}
51 40
52 -
		const data = (await response.json()) as ListRecordsResponse;
41 +
		const data = (await response.json()) as PostsListResponse;
53 42
54 -
		// Only include documents from the site publication, excluding blog posts
55 -
		const filteredDocuments = data.records.filter(
56 -
			(doc) =>
57 -
				doc.value?.site ===
58 -
					"at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.publication/3mbykzswhqc2x" &&
59 -
				!doc.value?.path?.includes("/posts"),
60 -
		);
61 -
62 -
		// Sort by publishedAt descending
63 -
		filteredDocuments.sort((a, b) => {
64 -
			const dateA = new Date(a.value.publishedAt);
65 -
			const dateB = new Date(b.value.publishedAt);
66 -
			return dateB.getTime() - dateA.getTime();
43 +
		const posts = (data.posts || []).slice().sort((a, b) => {
44 +
			const dateA = a.published_date ? new Date(a.published_date).getTime() : 0;
45 +
			const dateB = b.published_date ? new Date(b.published_date).getTime() : 0;
46 +
			return dateB - dateA;
67 47
		});
68 48
69 -
		const items = filteredDocuments.map((record) => {
70 -
			const doc = record.value;
71 -
			const rkey = record.uri.split("/").pop();
72 -
73 -
			// Use custom path if available, otherwise use rkey
74 -
			const urlPath = doc.path || `/${rkey}`;
75 -
76 -
			// Always treat content as markdown and render to HTML
77 -
			const markdownContent = doc.content?.markdown || doc.title;
78 -
			const htmlContent = md.render(markdownContent);
79 -
			const description = doc.textContent || doc.title;
49 +
		const items = posts.map((post) => {
50 +
			const htmlContent = md.render(post.content || post.title);
51 +
			const description = post.meta_description || post.title;
80 52
81 53
			return {
82 -
				title: doc.title,
83 -
				description: description,
84 -
				pubDate: new Date(doc.publishedAt),
85 -
				link: urlPath,
54 +
				title: post.title,
55 +
				description,
56 +
				pubDate: post.published_date
57 +
					? new Date(post.published_date)
58 +
					: new Date(0),
59 +
				link: `/now/${post.slug}`,
86 60
				content: sanitizeHtml(htmlContent, {
87 61
					allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
88 62
				}),
90 64
		});
91 65
92 66
		return rss({
93 -
			title: "Steve Dylan - Updates",
67 +
			title: "Steve's Updates",
94 68
			description:
95 69
				"Small updates from my life that don't quite fit into a blog",
96 70
			site: process.env.SITE_URL || "https://stevedylan.dev",
99 73
	} catch (error) {
100 74
		console.error("Error generating RSS feed:", error);
101 75
102 -
		// Return an empty feed on error
103 76
		return rss({
104 -
			title: "Steve Dylan - Updates",
77 +
			title: "Steve's Updates",
105 78
			description:
106 79
				"Small updates from my life that don't quite fit into a blog",
107 80
			site: process.env.SITE_URL || "https://stevedylan.dev",