chore: migrated from pds to posts app
0c9e20cf
4 file(s) · +108 −182
| 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" |
|
| 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", |
| 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">← 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> |
|
| 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", |
|