diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb91560..f41f6b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,7 @@ jobs:
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
- deno-version: v2.0.0
+ deno-version: v2.1.3
- name: Check types
run: deno task check
diff --git a/components/Avatar.tsx b/components/Avatar.tsx
new file mode 100644
index 0000000..a10d95c
--- /dev/null
+++ b/components/Avatar.tsx
@@ -0,0 +1,19 @@
+import { TbBrandBluesky } from '@preact-icons/tb'
+
+export function Avatar(
+ { src, name }: { src?: string; name?: string },
+) {
+ return src
+ ? (
+
+ )
+ : (
+
+ {name?.charAt(0).toUpperCase() || }
+
+ )
+}
diff --git a/components/BskyAuthor.tsx b/components/BskyAuthor.tsx
new file mode 100644
index 0000000..b1726ab
--- /dev/null
+++ b/components/BskyAuthor.tsx
@@ -0,0 +1,35 @@
+import { formatDateTime, getTimeAgo } from '../utils/intl.ts'
+
+export function BskyAuthor({ author, indexedAt, postUri }: {
+ author: { handle: string; displayName?: string }
+ indexedAt: string
+ postUri?: string
+}) {
+ return (
+
+ )
+}
diff --git a/components/PostEmbed.tsx b/components/PostEmbed.tsx
new file mode 100644
index 0000000..844da5b
--- /dev/null
+++ b/components/PostEmbed.tsx
@@ -0,0 +1,124 @@
+import type {
+ AppBskyEmbedExternal,
+ AppBskyEmbedImages,
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
+} from '@atproto/api'
+import { Avatar } from './Avatar.tsx'
+import { BskyAuthor } from './BskyAuthor.tsx'
+import { ReactionBar } from './ReactionBar.tsx'
+import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb'
+
+// Type guard to check if the record has a text property
+const hasText = (value: unknown): value is { text: string } =>
+ typeof value === 'object' && value !== null && 'text' in value
+
+function EmbedContent({ postUri }: { postUri: string }) {
+ // Extract the post ID from the URI
+ const postId = postUri.split('/').pop()
+
+ // For AT protocol URIs, we need to extract the DID
+ const match = postUri.match(/at:\/\/([^/]+)\//)
+ const did = match ? match[1] : null
+
+ if (!postId || !did) {
+ console.warn('Invalid post URI:', postUri)
+ return null
+ }
+
+ return (
+
+ View content on
+ Bluesky
+
+ )
+}
+
+function EmbedBskyPost({ embed }: { embed: AppBskyEmbedRecord.View }) {
+ const record = embed.record as AppBskyEmbedRecord.ViewRecord
+
+ return (
+
+
+ {hasText(record.value) && (
+
{record.value.text}
+ )}
+
+ {typeof record.value === 'object' && record.value !== null &&
+ 'embed' in record.value &&
}
+
+
+
+
+
+ )
+}
+
+export function PostEmbed({ embed, postUri }: {
+ embed:
+ | AppBskyEmbedImages.View
+ | AppBskyEmbedVideo.View
+ | AppBskyEmbedExternal.View
+ | AppBskyEmbedRecord.View
+ | AppBskyEmbedRecordWithMedia.View
+ | { $type: string }
+ postUri: string
+}) {
+ if (!embed || typeof embed.$type !== 'string') return null
+
+ const type = embed.$type.replace('#view', '')
+
+ // Handle BlueSky post embeds
+ if (type === 'app.bsky.embed.record' && 'record' in embed) {
+ // Handle not found records
+ if (
+ typeof embed.record === 'object' && embed.record !== null &&
+ '$type' in embed.record &&
+ embed.record.$type === 'app.bsky.embed.record#viewNotFound'
+ ) {
+ return (
+
+ Post not found
+
+ )
+ }
+
+ // Handle list view
+ if (
+ typeof embed.record === 'object' && embed.record !== null &&
+ '$type' in embed.record &&
+ embed.record.$type === 'app.bsky.graph.defs#listView'
+ ) {
+ return
+ }
+ return
+ }
+
+ // For all other types of embeds, show a link to view on BlueSky
+ return
+}
diff --git a/components/ReactionBar.tsx b/components/ReactionBar.tsx
new file mode 100644
index 0000000..faf0d5d
--- /dev/null
+++ b/components/ReactionBar.tsx
@@ -0,0 +1,24 @@
+import { ReactionCount } from './ReactionCount.tsx'
+
+export function ReactionBar(
+ { likes, reposts, replies, postUri }: {
+ likes: number
+ reposts: number
+ replies: number
+ postUri: string
+ },
+) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/components/ReactionCount.tsx b/components/ReactionCount.tsx
new file mode 100644
index 0000000..c4967ca
--- /dev/null
+++ b/components/ReactionCount.tsx
@@ -0,0 +1,28 @@
+import TbHeart from '@preact-icons/tb/TbHeart'
+import TbMessageCircle from '@preact-icons/tb/TbMessageCircle'
+import TbRepeat from '@preact-icons/tb/TbRepeat'
+import { formatCount } from '../utils/intl.ts'
+
+export function ReactionCount(
+ { count, label }: { count: number; label: string },
+) {
+ const Icon = () => {
+ switch (label) {
+ case 'like':
+ return
+ case 'repost':
+ return
+ case 'reply':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+ {formatCount(count)}
+
+ )
+}
diff --git a/components/Reply.tsx b/components/Reply.tsx
new file mode 100644
index 0000000..516cb00
--- /dev/null
+++ b/components/Reply.tsx
@@ -0,0 +1,91 @@
+import type { AppBskyFeedDefs } from '@atproto/api'
+import { AppBskyFeedPost } from '@atproto/api'
+import { Avatar } from './Avatar.tsx'
+import { BskyAuthor } from './BskyAuthor.tsx'
+import { PostEmbed } from './PostEmbed.tsx'
+import { ReactionBar } from './ReactionBar.tsx'
+import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb'
+
+export function Reply(
+ { thread }: {
+ thread: AppBskyFeedDefs.ThreadViewPost
+ },
+) {
+ const post = thread.post
+ const replies =
+ thread.replies?.filter((r): r is AppBskyFeedDefs.ThreadViewPost =>
+ !('notFound' in r) && !('blocked' in r)
+ ).sort((a, b) =>
+ new Date(a.post.indexedAt).getTime() -
+ new Date(b.post.indexedAt).getTime()
+ ) ?? []
+
+ const hasMoreReplies = (post.replyCount ?? 0) > (replies?.length ?? 0)
+ const showThread = replies.length > 0 || hasMoreReplies
+
+ return (
+
+
+
+
+
+
+ {showThread && (
+
+ )}
+
+
+
+
+ {AppBskyFeedPost.isRecord(post.record) ? post.record.text : ''}
+
+ {post.embed &&
}
+
+
+
+
+
+
+ {showThread && (
+
+ )}
+
+ )
+}
diff --git a/deno.json b/deno.json
index fad5c83..6cd6cd8 100644
--- a/deno.json
+++ b/deno.json
@@ -4,7 +4,11 @@
"dev": "deno run -A --watch=static/,routes/ dev.ts --env-file .env ",
"build": "deno run -A dev.ts build",
"start": "deno run -A main.ts",
- "update": "deno run -A -r jsr:@fresh/update ."
+ "update": "deno run -A -r jsr:@fresh/update .",
+ "bsky-uri": {
+ "description": "Utility to retrieve the `at:` URI for a given bsky post URL. Useful to set the `site.bskyUri` in blog post's frontmatter.",
+ "command": "deno run -A utils/bsky-uri.ts"
+ }
},
"lint": {
"rules": {
@@ -18,6 +22,7 @@
"**/_fresh/*"
],
"imports": {
+ "@atproto/api": "npm:@atproto/api@^0.13.19",
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.48",
"@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7",
"@preact-icons/gr": "jsr:@preact-icons/gr@^1.0.12",
@@ -29,6 +34,8 @@
"@std/fs": "jsr:@std/fs@^1.0.6",
"@std/path": "jsr:@std/path@^1.0.8",
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.15",
+ "@jridgewell/gen-mapping": "npm:@jridgewell/gen-mapping@^0.3.5",
+ "@jridgewell/trace-mapping": "npm:@jridgewell/trace-mapping@^0.3.25",
"fresh": "jsr:@fresh/core@^2.0.0-alpha.22",
"myst-frontmatter": "npm:myst-frontmatter@^1.7.5",
"preact": "npm:preact@10.24.2",
diff --git a/deno.lock b/deno.lock
index 8ea0488..1d37dd3 100644
--- a/deno.lock
+++ b/deno.lock
@@ -3,7 +3,7 @@
"specifiers": {
"jsr:@b-fuze/deno-dom@~0.1.48": "0.1.48",
"jsr:@denosaurs/plug@1.0.3": "1.0.3",
- "jsr:@fresh/core@^2.0.0-alpha.1": "2.0.0-alpha.22",
+ "jsr:@fresh/core@^2.0.0-alpha.1": "2.0.0-alpha.25",
"jsr:@fresh/core@^2.0.0-alpha.22": "2.0.0-alpha.25",
"jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7": "0.0.1-alpha.7",
"jsr:@luca/esbuild-deno-loader@0.11": "0.11.0",
@@ -36,6 +36,7 @@
"jsr:@std/html@1": "1.0.3",
"jsr:@std/html@~0.224.2": "0.224.2",
"jsr:@std/internal@^1.0.5": "1.0.5",
+ "jsr:@std/json@1": "1.0.0",
"jsr:@std/json@^1.0.0-rc.1": "1.0.0",
"jsr:@std/json@~0.213.1": "0.213.1",
"jsr:@std/jsonc@0.213": "0.213.1",
@@ -54,6 +55,9 @@
"jsr:@std/path@~0.225.2": "0.225.2",
"jsr:@std/semver@1": "1.0.3",
"jsr:@std/semver@~0.224.3": "0.224.3",
+ "npm:@atproto/api@~0.13.19": "0.13.19",
+ "npm:@jridgewell/gen-mapping@~0.3.5": "0.3.5",
+ "npm:@jridgewell/trace-mapping@~0.3.25": "0.3.25",
"npm:@preact/signals@1.3.0": "1.3.0_preact@10.22.1",
"npm:@preact/signals@^1.2.3": "1.3.0_preact@10.22.1",
"npm:@preact/signals@^1.3.0": "1.3.0_preact@10.22.1",
@@ -296,7 +300,10 @@
]
},
"@std/jsonc@1.0.1": {
- "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda"
+ "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda",
+ "dependencies": [
+ "jsr:@std/json@1"
+ ]
},
"@std/media-types@1.0.3": {
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
@@ -339,6 +346,48 @@
"@alloc/quick-lru@5.2.0": {
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="
},
+ "@atproto/api@0.13.19": {
+ "integrity": "sha512-rLWQBZaOIk3ds1Fx9CwrdyX3X2GbdSEvVJ9mdSPNX40joiEaE1ljGMOcziFipbvZacXynozE4E0Sb1CgOhzfmA==",
+ "dependencies": [
+ "@atproto/common-web",
+ "@atproto/lexicon",
+ "@atproto/syntax",
+ "@atproto/xrpc",
+ "await-lock",
+ "multiformats",
+ "tlds",
+ "zod"
+ ]
+ },
+ "@atproto/common-web@0.3.1": {
+ "integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==",
+ "dependencies": [
+ "graphemer",
+ "multiformats",
+ "uint8arrays",
+ "zod"
+ ]
+ },
+ "@atproto/lexicon@0.4.3": {
+ "integrity": "sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg==",
+ "dependencies": [
+ "@atproto/common-web",
+ "@atproto/syntax",
+ "iso-datestring-validator",
+ "multiformats",
+ "zod"
+ ]
+ },
+ "@atproto/syntax@0.3.1": {
+ "integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw=="
+ },
+ "@atproto/xrpc@0.6.4": {
+ "integrity": "sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==",
+ "dependencies": [
+ "@atproto/lexicon",
+ "zod"
+ ]
+ },
"@esbuild/aix-ppc64@0.23.1": {
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ=="
},
@@ -605,6 +654,9 @@
"postcss-value-parser"
]
},
+ "await-lock@2.2.2": {
+ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
+ },
"bail@2.0.2": {
"integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="
},
@@ -1024,6 +1076,9 @@
"path-scurry"
]
},
+ "graphemer@1.4.0": {
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
+ },
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
@@ -1349,6 +1404,9 @@
"isexe@2.0.0": {
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
+ "iso-datestring-validator@2.2.2": {
+ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="
+ },
"jackspeak@3.4.3": {
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dependencies": [
@@ -1572,6 +1630,9 @@
"minipass@7.1.2": {
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
},
+ "multiformats@9.9.0": {
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="
+ },
"myst-cli-utils@1.0.0": {
"integrity": "sha512-6pHTGOgY63WyfYWY0tz7HhdBBOISrZ08QaAbCuk7tKHNrQnIsEhgXZwy0+gVEH8eVXehOyrEC/BmeNkhufblTA==",
"dependencies": [
@@ -2449,6 +2510,9 @@
"any-promise"
]
},
+ "tlds@1.255.0": {
+ "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw=="
+ },
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
@@ -2470,6 +2534,12 @@
"uc.micro@1.0.6": {
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
+ "uint8arrays@3.0.0": {
+ "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
+ "dependencies": [
+ "multiformats"
+ ]
+ },
"unified@10.1.2": {
"integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==",
"dependencies": [
@@ -2712,6 +2782,9 @@
"yaml@2.5.1": {
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q=="
},
+ "zod@3.23.8": {
+ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="
+ },
"zwitch@2.0.4": {
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
}
@@ -2735,6 +2808,9 @@
"jsr:@std/encoding@^1.0.5",
"jsr:@std/fs@^1.0.6",
"jsr:@std/path@^1.0.8",
+ "npm:@atproto/api@~0.13.19",
+ "npm:@jridgewell/gen-mapping@~0.3.5",
+ "npm:@jridgewell/trace-mapping@~0.3.25",
"npm:@preact/signals@^1.3.1",
"npm:@tailwindcss/typography@~0.5.15",
"npm:myst-frontmatter@^1.7.5",
diff --git a/islands/BlueSkyInteractions.tsx b/islands/BlueSkyInteractions.tsx
new file mode 100644
index 0000000..66f1d23
--- /dev/null
+++ b/islands/BlueSkyInteractions.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from 'preact/hooks'
+import type { AppBskyFeedDefs } from '@atproto/api'
+import { getPostInteractions } from '../utils/bluesky.ts'
+import { Reply } from '../components/Reply.tsx'
+import { formatCount } from '../utils/intl.ts'
+
+interface Props {
+ postUri: string
+}
+
+// deno-lint-ignore no-explicit-any
+function isThreadViewPost(data: any): data is AppBskyFeedDefs.ThreadViewPost {
+ return 'post' in data
+}
+
+export default function BlueSkyInteractions({ postUri }: Props) {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true)
+ const response = await getPostInteractions(postUri)
+ if (response?.success) {
+ if (isThreadViewPost(response.data.thread)) {
+ setData(response.data.thread)
+ } else {
+ setData(null)
+ }
+ } else {
+ setData(null)
+ }
+ setLoading(false)
+ }
+
+ fetchData()
+ }, [postUri])
+
+ if (loading && !data) {
+ return (
+
+ Loading reactions...
+
+ )
+ }
+
+ if (!data) {
+ return null
+ }
+
+ const post = data.post
+ const replies =
+ data.replies?.filter((r): r is AppBskyFeedDefs.ThreadViewPost =>
+ !('notFound' in r) && !('blocked' in r)
+ ) ?? []
+
+ return (
+
+
+
Reactions
+
+
+ Reply on Bluesky{' '}
+
+ here
+ {' '}
+ to join the conversation.
+
+
+ {replies.length > 0 && (
+
+ {replies
+ .sort((a, b) =>
+ new Date(a.post.indexedAt).getTime() -
+ new Date(b.post.indexedAt).getTime()
+ )
+ .map((reply) => )}
+
+ )}
+
+ )
+}
diff --git a/routes/posts/[slug].tsx b/routes/posts/[slug].tsx
index d974104..efd2936 100644
--- a/routes/posts/[slug].tsx
+++ b/routes/posts/[slug].tsx
@@ -10,6 +10,7 @@ import ReadTime from '../../components/ReadTime.tsx'
import DialogMessages from '../../components/DialogMessages.tsx'
import { Authors } from '../../components/Authors.tsx'
import { PostVersions } from '../../components/PostVersions.tsx'
+import BlueSkyInteractions from '../../islands/BlueSkyInteractions.tsx'
interface Data {
post: Post
@@ -106,6 +107,14 @@ export default define.page(
data-dark-theme='dark'
dangerouslySetInnerHTML={{ __html: post.content }}
/>
+ {post.frontmatter.site?.bskyUri && (
+ <>
+
+
+ >
+ )}
>
)
diff --git a/static/freshblog.css b/static/freshblog.css
index 7d1d078..0bdd8f1 100644
--- a/static/freshblog.css
+++ b/static/freshblog.css
@@ -77,7 +77,7 @@
}
.freshBlog-post-content {
- margin-bottom: 12rem;
+ margin-bottom: 4rem;
}
.freshBlog-post-content .footnotes {
@@ -138,6 +138,10 @@
margin-right: 0.5rem;
}
+.freshBlog-post {
+ margin-bottom: 8rem;
+}
+
.freshBlog-post header {
margin-bottom: 2rem;
}
diff --git a/utils/bluesky.ts b/utils/bluesky.ts
new file mode 100644
index 0000000..6905086
--- /dev/null
+++ b/utils/bluesky.ts
@@ -0,0 +1,20 @@
+import { AtpAgent } from '@atproto/api'
+import type { AppBskyFeedGetPostThread } from '@atproto/api'
+
+const agent = new AtpAgent({
+ service: 'https://public.api.bsky.app',
+})
+
+export async function getPostInteractions(
+ postUri: string,
+): Promise {
+ try {
+ return await agent.getPostThread({
+ uri: postUri,
+ depth: 3,
+ })
+ } catch (error) {
+ console.error('Error fetching BlueSky post:', error)
+ return null
+ }
+}
diff --git a/utils/bsky-uri.ts b/utils/bsky-uri.ts
new file mode 100644
index 0000000..464a098
--- /dev/null
+++ b/utils/bsky-uri.ts
@@ -0,0 +1,52 @@
+import { AtpAgent } from '@atproto/api'
+
+const agent = new AtpAgent({
+ service: 'https://public.api.bsky.app',
+})
+
+function extractPostId(url: string): { handle: string; rkey: string } | null {
+ try {
+ // Handle URLs like https://bsky.app/profile/nrako.bsky.social/post/3kh3gxwn2if2d
+ const match = url.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/)
+ if (!match) return null
+ return {
+ handle: match[1],
+ rkey: match[2],
+ }
+ } catch {
+ return null
+ }
+}
+
+async function getAtUri(url: string): Promise {
+ const parsed = extractPostId(url)
+ if (!parsed) {
+ console.error('Invalid BlueSky post URL')
+ return null
+ }
+
+ try {
+ const { data: profile } = await agent.getProfile({ actor: parsed.handle })
+ const atUri = `at://${profile.did}/app.bsky.feed.post/${parsed.rkey}`
+ return atUri
+ } catch (error) {
+ console.error('Error fetching BlueSky post:', error)
+ return null
+ }
+}
+
+// CLI entry point
+if (import.meta.main) {
+ const url = Deno.args[0]
+ if (!url) {
+ console.error('Please provide a BlueSky post URL')
+ Deno.exit(1)
+ }
+
+ const atUri = await getAtUri(url)
+ if (atUri) {
+ console.log(atUri)
+ } else {
+ Deno.exit(1)
+ }
+}
diff --git a/utils/intl.ts b/utils/intl.ts
new file mode 100644
index 0000000..154af04
--- /dev/null
+++ b/utils/intl.ts
@@ -0,0 +1,49 @@
+export function formatCount(count: number): string {
+ if (count === 0) return '0'
+
+ return new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+ }).format(count)
+}
+
+export function getTimeAgo(date: Date) {
+ const now = new Date()
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
+
+ if (diffInSeconds < 30) {
+ return 'now'
+ }
+ if (diffInSeconds < 60) {
+ return `${diffInSeconds}s`
+ }
+ const diffInMinutes = Math.floor(diffInSeconds / 60)
+ if (diffInMinutes < 60) {
+ return `${diffInMinutes}m`
+ }
+ const diffInHours = Math.floor(diffInMinutes / 60)
+ if (diffInHours < 24) {
+ return `${diffInHours}h`
+ }
+ const diffInDays = Math.floor(diffInHours / 24)
+ if (diffInDays < 30) {
+ return `${diffInDays}d`
+ }
+ const diffInMonths = Math.floor(diffInDays / 30)
+ if (diffInMonths < 12) {
+ return `${diffInMonths}mo`
+ }
+ const diffInYears = Math.floor(diffInDays / 365)
+ return `${diffInYears}y`
+}
+
+export function formatDateTime(date: Date) {
+ return new Intl.DateTimeFormat('en', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ }).format(date)
+}