+145
-39
Diff
round #0
+103
-26
README.md
+103
-26
README.md
···
1
-
# Nuxt Minimal Starter
2
3
-
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
4
5
-
## Setup
6
7
-
Make sure to install dependencies:
8
9
-
```bash
10
-
# pnpm
11
-
pnpm install
12
-
```
13
14
-
## Development Server
15
16
-
Start the development server on `http://localhost:3000`:
17
18
-
```bash
19
-
# pnpm
20
-
pnpm dev
21
-
```
22
23
-
## Production
24
25
-
Build the application for production:
26
27
-
```bash
28
-
# pnpm
29
-
pnpm build
30
-
```
31
32
-
Locally preview production build:
33
34
-
```bash
35
-
# pnpm
36
-
pnpm preview
37
-
```
38
39
-
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
···
1
+
# finxol blog
2
+
3
+
This is the repo for finxol's blog.
4
+
5
+
All posts are in `content/`.
6
+
Configuration is in `blog.config.ts`.
7
+
8
+
## Technology stack
9
+
10
+
- Nuxt v4
11
+
- Nuxt Content
12
+
- TailwindCSS
13
+
- Deno (Deploy EA)
14
+
15
+
## Bluesky integration
16
+
17
+
Tracking PR: [#1](https://tangled.org/finxol.io/blog/pulls/1/)
18
+
19
+
Comments on this blog are directly integrated with Bluesky, the atproto-based micro-blogging social network.
20
+
21
+
This integration relies on the `@atcute/` library collection for interaction with Bluesky/atproto.
22
+
23
+
The idea was originally inspired from [natalie's blog](https://natalie.sh/posts/bluesky-comments/).
24
+
Although I ended up using mostly the same tools and strategies, I didn't follow her post to build it here.
25
+
26
+
### How it works in practice
27
+
28
+
The author of the blog writes a post and publishes it.
29
+
They can then post about it on Bluesky, find the post id, and add it to the `bskyCid` field in the post frontmatter.
30
+
Any Bluesky post below the one identified will now be displayed at the bottom of the blog post, allowing for integrated conversation about the post.
31
+
32
+
### How it works technically
33
+
34
+
The [AT Protocol](https://atproto.com/) is an open internet protocol for social applications.
35
+
All the data is decentralised and public ([for now](https://pfrazee.leaflet.pub/3lzhui2zbxk2b)).
36
+
This openness allows us to reuse and build things based on that data very easily, in a built-in way, without hacky workarounds.
37
+
38
+
This integration works in several parts:
39
+
40
+
#### `app/util/atproto.ts`
41
+
42
+
Contains the utility functions for retrieving all replies to a post, and extracting a post id from an atproto uri.
43
+
44
+
Uses `@atcute/client` to fetch using the `app.bsky.feed.getPostThread` RPC on the Bluesky public API.
45
+
Everything is strongly typed, although fetch errors are handled as `post not found` to make handling simpler in the Vue component.
46
+
47
+
#### `blog.config.ts`
48
+
49
+
The author DID is set blog-wide in the config file through `authorDid`, as it is primarily intended as a personal blog.
50
+
If need be, I can always move the DID parameter to the post frontmatter, allowing for guest authors or secondary accounts too.
51
+
52
+
#### `content.config.ts`
53
+
54
+
Since the Bluesky post CID needs to be set for each blog post independently,
55
+
I added a `bskyCid` field in the post frontmatter.
56
+
57
+
#### `app/components/BskyComments.vue`
58
+
59
+
This is the core component to display the replies.
60
+
61
+
The component simply fetches the replies by calling `getBskyReplies`, passing in the post CID passed as prop,
62
+
and displays the content using the `BskyPost` component.
63
+
64
+
The reply, like, repost, and bookmark counts of the original Bluesky post are also displayed.
65
+
66
67
+
#### `app/components/BskyPost.vue`
68
69
+
This component displays the post author, their avatar, the post content, and its stats beautifully.
70
71
+
Replies to replies are indented accordingly to visually thread replies together, using `BskyPost` recursively,
72
+
with a `MAX_DEPTH` to set a limit to the number of replies to show.
73
74
75
+
#### `app/pages/posts/[...slug].vue`
76
77
+
The actual post page only had some minor adjustments to integrate the `BskyComments` component,
78
+
using a `Suspense` boundary with a fallback to avoid blocking the rendering of the actual content.
79
80
+
#### Others
81
82
+
Some other files saw modifications, to adapt to this integration addition, allowing for visual consistency.
83
84
+
### Advantages of the approach
85
86
+
Since this blog is built with Nuxt, everything is SSRed.
87
+
This makes the Bluesky integration a wonderful progressive enhancement.
88
+
The comments will still display and show up as intended if the client has Javascript disabled,
89
+
without blocking rendering of the actual content through the use of a `Suspense` boundary.
90
91
+
Using Bluesky as a comment platform allows me to integrate conversations about my posts directly alongside them,
92
+
without bearing the load of moderation and user accounts.
93
94
+
### Limitations
95
96
+
As briefly mentioned, fetch errors are normalised to `#notFoundPost`,
97
+
this could be refined for better reporting in the UI.
98
+
99
+
This integration also only handles plain text content.
100
+
All embedded and rich media is effectively ignored for now.
101
+
102
+
## Install locally
103
+
104
+
```sh
105
+
# Install dependencies
106
+
pnpm i
107
+
108
+
# Run the development server
109
+
deno task dev
110
+
111
+
# Build for production
112
+
deno task build
113
+
114
+
# Deploy to Deno Deploy EA. Add `--prod` to deploy to production
115
+
deno deploy
116
+
```
+18
-12
app/components/BskyComments.vue
+18
-12
app/components/BskyComments.vue
···
1
<script setup lang="ts">
2
-
import { getBskyReplies, type ReplyThread } from "~/util/atproto";
3
4
const props = defineProps({
5
cid: {
···
11
12
const data = ref(await getBskyReplies(cid.value));
13
const err = ref("");
14
-
const post = ref();
15
16
if (data.value.$type === "app.bsky.feed.defs#blockedPost") {
17
err.value = "Post is blocked";
···
23
24
if (data.value.$type === "app.bsky.feed.defs#threadViewPost") {
25
console.log(data.value);
26
-
post.value = data.value;
27
}
28
</script>
29
···
31
<div class="md:w-[80%] mx-auto mt-16">
32
<div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0">
33
<h3 class="font-bold text-xl">Join the conversation!</h3>
34
-
<div class="flex items-center gap-2">
35
<p class="text-gray-500 text-sm" title="Replies">
36
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
37
{{post.post.replyCount}}
38
</p>
39
<p class="text-gray-500 text-sm" title="Likes">
40
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
41
-
<span>
42
-
{{post.post.likeCount}}
43
-
</span>
44
</p>
45
<p class="text-gray-500 text-sm" title="Bookmarks">
46
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
···
49
</div>
50
</div>
51
52
-
<p class="text-gray-600 text-md mb-6">
53
<a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion.
54
</p>
55
56
-
<div v-if="err">
57
-
<div>{{ err }}</div>
58
-
</div>
59
60
<div v-if="post">
61
<div v-if="post.post.replyCount === 0">
···
64
65
<BskyPost
66
v-else
67
-
v-for="reply in post.replies"
68
:key="reply.post.cid"
69
:post="reply"
70
:depth="0"
···
1
<script setup lang="ts">
2
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
3
+
import { getBskyReplies } from "~/util/atproto";
4
5
const props = defineProps({
6
cid: {
···
12
13
const data = ref(await getBskyReplies(cid.value));
14
const err = ref("");
15
+
const post = ref<AppBskyFeedDefs.ThreadViewPost>();
16
17
if (data.value.$type === "app.bsky.feed.defs#blockedPost") {
18
err.value = "Post is blocked";
···
24
25
if (data.value.$type === "app.bsky.feed.defs#threadViewPost") {
26
console.log(data.value);
27
+
post.value = data.value as AppBskyFeedDefs.ThreadViewPost;
28
}
29
</script>
30
···
32
<div class="md:w-[80%] mx-auto mt-16">
33
<div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0">
34
<h3 class="font-bold text-xl">Join the conversation!</h3>
35
+
<div v-if="post" class="flex items-center gap-6">
36
<p class="text-gray-500 text-sm" title="Replies">
37
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
38
{{post.post.replyCount}}
39
</p>
40
<p class="text-gray-500 text-sm" title="Likes">
41
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
42
+
{{post.post.likeCount}}
43
+
</p>
44
+
<p class="text-gray-500 text-sm" title="Reposts">
45
+
<Icon name="bx:repost" class="-mb-[2px] mr-1" />
46
+
{{post.post.repostCount}}
47
</p>
48
<p class="text-gray-500 text-sm" title="Bookmarks">
49
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
···
52
</div>
53
</div>
54
55
+
<div v-if="err">
56
+
<p class="mt-2 text-gray-700 dark:text-gray-500">
57
+
{{ err }}
58
+
</p>
59
+
</div>
60
+
61
+
<p v-if="post" class="text-gray-600 dark:text-gray-500 text-md mb-6">
62
<a class="underline" :href="`https://bsky.app/profile/${post.post.author.handle}/post/${cid}`">Reply on Bluesky</a> to take part in the discussion.
63
</p>
64
65
66
<div v-if="post">
67
<div v-if="post.post.replyCount === 0">
···
70
71
<BskyPost
72
v-else
73
+
v-for="reply in post.replies?.filter(reply => reply.$type === 'app.bsky.feed.defs#threadViewPost')"
74
:key="reply.post.cid"
75
:post="reply"
76
:depth="0"
+13
-1
app/util/atproto.ts
+13
-1
app/util/atproto.ts
···
5
import config from "@/../blog.config";
6
7
const handler = simpleFetchHandler({
8
service: "https://public.api.bsky.app"
9
});
10
const rpc = new Client({ handler });
···
14
| AppBskyFeedDefs.BlockedPost
15
| AppBskyFeedDefs.NotFoundPost;
16
17
export async function getBskyReplies(cid: string) {
18
// uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
19
const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`;
···
21
const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", {
22
params: {
23
uri,
24
-
depth: 10
25
}
26
});
27
28
if (!ok) {
29
console.error("Error fetching thread:", data.error);
30
return { $type: "app.bsky.feed.defs#notFoundPost" };
31
}
···
37
return { $type: "app.bsky.feed.defs#notFoundPost" };
38
}
39
40
export function extractPostId(uri: ResourceUri) {
41
if (uri.includes("app.bsky.feed.post")) {
42
const parts = uri.split("/");
···
5
import config from "@/../blog.config";
6
7
const handler = simpleFetchHandler({
8
+
// Simply hit up the Bluesky API
9
service: "https://public.api.bsky.app"
10
});
11
const rpc = new Client({ handler });
···
15
| AppBskyFeedDefs.BlockedPost
16
| AppBskyFeedDefs.NotFoundPost;
17
18
+
/**
19
+
* Fetch the first 10 replies to a post
20
+
* @param cid
21
+
* @returns
22
+
*/
23
export async function getBskyReplies(cid: string) {
24
// uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
25
const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`;
···
27
const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", {
28
params: {
29
uri,
30
+
depth: 6 // default
31
}
32
});
33
34
if (!ok) {
35
+
// Handle fetch errors as 'not found'. Could be cleaner, but works just fine.
36
console.error("Error fetching thread:", data.error);
37
return { $type: "app.bsky.feed.defs#notFoundPost" };
38
}
···
44
return { $type: "app.bsky.feed.defs#notFoundPost" };
45
}
46
47
+
/**
48
+
* Extract post id from an atproto uri
49
+
* @param uri The atproto uri, such as at://did:plc:user/app.bsky.feed.post/xxxxx`
50
+
* @returns The post id
51
+
*/
52
export function extractPostId(uri: ResourceUri) {
53
if (uri.includes("app.bsky.feed.post")) {
54
const parts = uri.split("/");
+1
package.json
+1
package.json
+10
pnpm-lock.yaml
+10
pnpm-lock.yaml
···
17
'@atcute/lexicons':
18
specifier: ^1.2.4
19
version: 1.2.4
20
'@nuxt/content':
21
specifier: ^3.8.0
22
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
458
459
'@iconify-json/ant-design@1.2.5':
460
resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==}
461
462
'@iconify-json/ri@1.2.6':
463
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
···
5180
optional: true
5181
5182
'@iconify-json/ant-design@1.2.5':
5183
dependencies:
5184
'@iconify/types': 2.0.0
5185
···
17
'@atcute/lexicons':
18
specifier: ^1.2.4
19
version: 1.2.4
20
+
'@iconify-json/bx':
21
+
specifier: ^1.2.2
22
+
version: 1.2.2
23
'@nuxt/content':
24
specifier: ^3.8.0
25
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
461
462
'@iconify-json/ant-design@1.2.5':
463
resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==}
464
+
465
+
'@iconify-json/bx@1.2.2':
466
+
resolution: {integrity: sha512-hZVx6LMEkYckScdRdUuQWcmv8Lm2au6Cnf799TLoR6YgiAfFvaJ4M5ElwcnExvCu8ntsS7jW89r0W5LwBAfZXQ==}
467
468
'@iconify-json/ri@1.2.6':
469
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
···
5186
optional: true
5187
5188
'@iconify-json/ant-design@1.2.5':
5189
+
dependencies:
5190
+
'@iconify/types': 2.0.0
5191
+
5192
+
'@iconify-json/bx@1.2.2':
5193
dependencies:
5194
'@iconify/types': 2.0.0
5195