+103
-26
README.md
+103
-26
README.md
···
1
-
# Nuxt Minimal Starter
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
+
2
66
3
-
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
67
+
#### `app/components/BskyPost.vue`
4
68
5
-
## Setup
69
+
This component displays the post author, their avatar, the post content, and its stats beautifully.
6
70
7
-
Make sure to install dependencies:
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.
8
73
9
-
```bash
10
-
# pnpm
11
-
pnpm install
12
-
```
13
74
14
-
## Development Server
75
+
#### `app/pages/posts/[...slug].vue`
15
76
16
-
Start the development server on `http://localhost:3000`:
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.
17
79
18
-
```bash
19
-
# pnpm
20
-
pnpm dev
21
-
```
80
+
#### Others
22
81
23
-
## Production
82
+
Some other files saw modifications, to adapt to this integration addition, allowing for visual consistency.
24
83
25
-
Build the application for production:
84
+
### Advantages of the approach
26
85
27
-
```bash
28
-
# pnpm
29
-
pnpm build
30
-
```
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.
31
90
32
-
Locally preview production build:
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.
33
93
34
-
```bash
35
-
# pnpm
36
-
pnpm preview
37
-
```
94
+
### Limitations
38
95
39
-
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
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
1
<script setup lang="ts">
2
-
import { getBskyReplies, type ReplyThread } from "~/util/atproto";
2
+
import type { AppBskyFeedDefs } from "@atcute/bluesky";
3
+
import { getBskyReplies } from "~/util/atproto";
3
4
4
5
const props = defineProps({
5
6
cid: {
···
11
12
12
13
const data = ref(await getBskyReplies(cid.value));
13
14
const err = ref("");
14
-
const post = ref();
15
+
const post = ref<AppBskyFeedDefs.ThreadViewPost>();
15
16
16
17
if (data.value.$type === "app.bsky.feed.defs#blockedPost") {
17
18
err.value = "Post is blocked";
···
23
24
24
25
if (data.value.$type === "app.bsky.feed.defs#threadViewPost") {
25
26
console.log(data.value);
26
-
post.value = data.value;
27
+
post.value = data.value as AppBskyFeedDefs.ThreadViewPost;
27
28
}
28
29
</script>
29
30
···
31
32
<div class="md:w-[80%] mx-auto mt-16">
32
33
<div class="flex items-baseline flex-col md:flex-row md:gap-4 mb-2 md:mb-0">
33
34
<h3 class="font-bold text-xl">Join the conversation!</h3>
34
-
<div class="flex items-center gap-2">
35
+
<div v-if="post" class="flex items-center gap-6">
35
36
<p class="text-gray-500 text-sm" title="Replies">
36
37
<Icon name="ri:reply-line" class="-mb-[2px] mr-1" />
37
38
{{post.post.replyCount}}
38
39
</p>
39
40
<p class="text-gray-500 text-sm" title="Likes">
40
41
<Icon name="ri:heart-3-line" class="-mb-[2px] mr-1" />
41
-
<span>
42
-
{{post.post.likeCount}}
43
-
</span>
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}}
44
47
</p>
45
48
<p class="text-gray-500 text-sm" title="Bookmarks">
46
49
<Icon name="ri:bookmark-line" class="-mb-[2px] mr-1" />
···
49
52
</div>
50
53
</div>
51
54
52
-
<p class="text-gray-600 text-md mb-6">
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">
53
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.
54
63
</p>
55
64
56
-
<div v-if="err">
57
-
<div>{{ err }}</div>
58
-
</div>
59
65
60
66
<div v-if="post">
61
67
<div v-if="post.post.replyCount === 0">
···
64
70
65
71
<BskyPost
66
72
v-else
67
-
v-for="reply in post.replies"
73
+
v-for="reply in post.replies?.filter(reply => reply.$type === 'app.bsky.feed.defs#threadViewPost')"
68
74
:key="reply.post.cid"
69
75
:post="reply"
70
76
:depth="0"
+13
-1
app/util/atproto.ts
+13
-1
app/util/atproto.ts
···
5
5
import config from "@/../blog.config";
6
6
7
7
const handler = simpleFetchHandler({
8
+
// Simply hit up the Bluesky API
8
9
service: "https://public.api.bsky.app"
9
10
});
10
11
const rpc = new Client({ handler });
···
14
15
| AppBskyFeedDefs.BlockedPost
15
16
| AppBskyFeedDefs.NotFoundPost;
16
17
18
+
/**
19
+
* Fetch the first 10 replies to a post
20
+
* @param cid
21
+
* @returns
22
+
*/
17
23
export async function getBskyReplies(cid: string) {
18
24
// uri should be in format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
19
25
const uri: ResourceUri = `at://${config.authorDid}/app.bsky.feed.post/${cid}`;
···
21
27
const { ok, data } = await rpc.get("app.bsky.feed.getPostThread", {
22
28
params: {
23
29
uri,
24
-
depth: 10
30
+
depth: 6 // default
25
31
}
26
32
});
27
33
28
34
if (!ok) {
35
+
// Handle fetch errors as 'not found'. Could be cleaner, but works just fine.
29
36
console.error("Error fetching thread:", data.error);
30
37
return { $type: "app.bsky.feed.defs#notFoundPost" };
31
38
}
···
37
44
return { $type: "app.bsky.feed.defs#notFoundPost" };
38
45
}
39
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
+
*/
40
52
export function extractPostId(uri: ResourceUri) {
41
53
if (uri.includes("app.bsky.feed.post")) {
42
54
const parts = uri.split("/");
+1
package.json
+1
package.json
+10
pnpm-lock.yaml
+10
pnpm-lock.yaml
···
17
17
'@atcute/lexicons':
18
18
specifier: ^1.2.4
19
19
version: 1.2.4
20
+
'@iconify-json/bx':
21
+
specifier: ^1.2.2
22
+
version: 1.2.2
20
23
'@nuxt/content':
21
24
specifier: ^3.8.0
22
25
version: 3.8.0(@libsql/client@0.15.15)(better-sqlite3@12.4.1)(magicast@0.5.1)
···
458
461
459
462
'@iconify-json/ant-design@1.2.5':
460
463
resolution: {integrity: sha512-SYxhrx1AFq2MBcXk77AERYz2mPhLQes1F0vtvG64+dJZWyge9studXo7MiR8PPeLjRjZdWRrReRbxiwdRMf70Q==}
464
+
465
+
'@iconify-json/bx@1.2.2':
466
+
resolution: {integrity: sha512-hZVx6LMEkYckScdRdUuQWcmv8Lm2au6Cnf799TLoR6YgiAfFvaJ4M5ElwcnExvCu8ntsS7jW89r0W5LwBAfZXQ==}
461
467
462
468
'@iconify-json/ri@1.2.6':
463
469
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
···
5180
5186
optional: true
5181
5187
5182
5188
'@iconify-json/ant-design@1.2.5':
5189
+
dependencies:
5190
+
'@iconify/types': 2.0.0
5191
+
5192
+
'@iconify-json/bx@1.2.2':
5183
5193
dependencies:
5184
5194
'@iconify/types': 2.0.0
5185
5195