a standard.site publication renderer for SvelteKit.
1# Federated Comments
2
3Display Bluesky replies as comments on your blog posts using the Comments component.
4
5## How It Works
6
71. You publish a blog post
82. You share it on Bluesky (creating an "announcement post")
93. People reply to that Bluesky post
104. The Comments component fetches those replies and displays them as comments
11
12## Quick Start
13
14### 1. Install
15
16```bash
17pnpm add svelte-standard-site
18```
19
20### 2. Add to Your Blog Post
21
22```svelte
23<script lang="ts">
24 import { Comments } from 'svelte-standard-site';
25 import type { PageData } from './$types';
26
27 const { data }: { data: PageData } = $props();
28</script>
29
30<article>
31 <h1>{data.post.title}</h1>
32 {@html data.post.content}
33</article>
34
35{#if data.post.bskyPostUri}
36 <Comments
37 bskyPostUri={data.post.bskyPostUri}
38 canonicalUrl="https://yourblog.com/posts/{data.post.slug}"
39 />
40{/if}
41```
42
43### 3. Get the AT-URI
44
45When you share your post on Bluesky:
46
471. Click on your post
482. Click the "..." menu
493. Click "Copy post link"
504. Convert to AT-URI format
51
52```
53URL: https://bsky.app/profile/you.bsky.social/post/abc123xyz
54AT-URI: at://did:plc:YOUR_DID/app.bsky.feed.post/abc123xyz
55```
56
57### 4. Store the AT-URI
58
59Add it to your post's frontmatter or database:
60
61```yaml
62---
63title: My Blog Post
64date: 2026-01-25
65bskyPostUri: at://did:plc:xxx/app.bsky.feed.post/abc123xyz
66---
67```
68
69## Component Props
70
71```svelte
72<Comments
73 bskyPostUri="at://..." // Required: AT-URI of announcement post
74 canonicalUrl="https://..." // Required: URL of your blog post
75 maxDepth={3} // Optional: Max reply nesting (default: 3)
76 title="Comments" // Optional: Section heading
77 showReplyLink={true} // Optional: Show "Reply on Bluesky" link
78 class="my-custom-class" // Optional: Additional CSS classes
79/>
80```
81
82## Workflow
83
84### Complete Example
85
861. **Write and publish your blog post**
87
88```typescript
89// scripts/publish-post.ts
90import { StandardSitePublisher } from 'svelte-standard-site/publisher';
91
92const publisher = new StandardSitePublisher({
93 identifier: 'you.bsky.social',
94 password: process.env.ATPROTO_APP_PASSWORD!
95});
96
97await publisher.login();
98
99const result = await publisher.publishDocument({
100 site: 'https://yourblog.com',
101 title: 'Understanding ATProto',
102 publishedAt: new Date().toISOString(),
103 path: '/posts/understanding-atproto'
104 // ...
105});
106```
107
1082. **Share on Bluesky**
109
110```typescript
111// Create announcement post
112const agent = publisher.getAtpAgent();
113
114const postResult = await agent.post({
115 text: `New blog post: Understanding ATProto
116
117Read it at: https://yourblog.com/posts/understanding-atproto`,
118 langs: ['en']
119});
120
121console.log('Post URI:', postResult.uri);
122// Save this: at://did:plc:xxx/app.bsky.feed.post/abc123
123```
124
1253. **Update your post with the AT-URI**
126
127```typescript
128await publisher.updateDocument(rkey, {
129 // ... all original fields
130 bskyPostRef: {
131 uri: postResult.uri,
132 cid: postResult.cid
133 }
134});
135```
136
1374. **Comments appear automatically**
138
139The Comments component fetches replies from Bluesky when users visit your post.
140
141## Programmatic Usage
142
143If you want to fetch comments in your load function instead of client-side:
144
145```typescript
146// src/routes/blog/[slug]/+page.server.ts
147import { fetchComments } from 'svelte-standard-site/comments';
148import type { PageServerLoad } from './$types';
149
150export const load: PageServerLoad = async ({ params }) => {
151 const post = await getPost(params.slug); // Your database/CMS
152
153 let comments = [];
154 if (post.bskyPostUri) {
155 comments = await fetchComments({
156 bskyPostUri: post.bskyPostUri,
157 canonicalUrl: `https://yourblog.com/blog/${params.slug}`,
158 maxDepth: 3
159 });
160 }
161
162 return {
163 post,
164 comments
165 };
166};
167```
168
169Then render them manually:
170
171```svelte
172<script lang="ts">
173 import type { PageData } from './$types';
174
175 const { data }: { data: PageData } = $props();
176</script>
177
178<div class="comments">
179 {#each data.comments as comment}
180 <div class="comment">
181 <img src={comment.author.avatar} alt={comment.author.handle} />
182 <p>{comment.text}</p>
183 </div>
184 {/each}
185</div>
186```
187
188## Functions
189
190### fetchComments
191
192```typescript
193import { fetchComments } from 'svelte-standard-site/comments';
194
195const comments = await fetchComments({
196 bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123',
197 canonicalUrl: 'https://yourblog.com/posts/my-post',
198 maxDepth: 3
199});
200
201// Returns array of Comment objects
202```
203
204### fetchMentionComments
205
206Fetch posts that mention your blog post URL (even if not replies):
207
208```typescript
209import { fetchMentionComments } from 'svelte-standard-site/comments';
210
211const mentions = await fetchMentionComments('https://yourblog.com/posts/my-post', 3);
212```
213
214### formatRelativeTime
215
216```typescript
217import { formatRelativeTime } from 'svelte-standard-site/comments';
218
219formatRelativeTime('2026-01-25T10:00:00Z');
220// "2 hours ago"
221```
222
223## Types
224
225```typescript
226interface Comment {
227 uri: string; // AT-URI of the reply
228 cid: string; // Content hash
229 author: CommentAuthor;
230 text: string; // Comment text
231 createdAt: string; // ISO date
232 likeCount: number;
233 replyCount: number;
234 replies?: Comment[]; // Nested replies
235 depth: number; // Nesting level (0 = top-level)
236}
237
238interface CommentAuthor {
239 did: string;
240 handle: string;
241 displayName?: string;
242 avatar?: string;
243}
244```
245
246## Styling
247
248The Comments component uses your site's design system classes. You can customize:
249
250```svelte
251<Comments
252 {bskyPostUri}
253 {canonicalUrl}
254 class="my-12 rounded-xl border-2 p-6"
255/>
256
257<style>
258 :global(.comments-section) {
259 /* Custom styles */
260 }
261</style>
262```
263
264## Advanced Usage
265
266### Custom Comment Renderer
267
268Build your own comment UI:
269
270```svelte
271<script lang="ts">
272 import { fetchComments, formatRelativeTime } from 'svelte-standard-site/comments';
273 import { onMount } from 'svelte';
274
275 let comments = $state([]);
276
277 onMount(async () => {
278 comments = await fetchComments({
279 bskyPostUri: 'at://...',
280 canonicalUrl: 'https://...'
281 });
282 });
283</script>
284
285<div class="comments">
286 {#each comments as comment}
287 <article>
288 <header>
289 <a href="https://bsky.app/profile/{comment.author.handle}">
290 {comment.author.displayName || comment.author.handle}
291 </a>
292 <time>{formatRelativeTime(comment.createdAt)}</time>
293 </header>
294
295 <p>{comment.text}</p>
296
297 {#if comment.replies}
298 <!-- Recursively render replies -->
299 {#each comment.replies as reply}
300 <!-- ... -->
301 {/each}
302 {/if}
303 </article>
304 {/each}
305</div>
306```
307
308### Combine with Mentions
309
310Show both replies and mentions:
311
312```typescript
313const [replies, mentions] = await Promise.all([
314 fetchComments({
315 bskyPostUri: post.bskyPostUri,
316 canonicalUrl: post.url
317 }),
318 fetchMentionComments(post.url)
319]);
320
321const allComments = [...replies, ...mentions];
322```
323
324### Filter by Language
325
326```typescript
327const comments = await fetchComments({
328 bskyPostUri,
329 canonicalUrl
330});
331
332const englishComments = comments.filter((c) => {
333 // You'd need to add language detection
334 return detectLanguage(c.text) === 'en';
335});
336```
337
338### Moderation
339
340Since these are from Bluesky, you can use their moderation tools:
341
342```typescript
343const comments = await fetchComments({
344 bskyPostUri,
345 canonicalUrl
346});
347
348// Filter out blocked users
349const moderated = comments.filter((c) => {
350 return !isUserBlocked(c.author.did);
351});
352```
353
354## Best Practices
355
3561. **Always include canonical URL** - Helps with mention detection
3572. **Set appropriate maxDepth** - Too deep can be overwhelming (3 is good)
3583. **Show "Reply on Bluesky" link** - Encourages engagement
3594. **Handle loading states** - Comments load async
3605. **Cache on server** - Fetch in load() for better performance
3616. **Respect privacy** - Remember these are public Bluesky posts
3627. **Test thoroughly** - Ensure AT-URI is correct
363
364## Troubleshooting
365
366### Comments Not Loading
367
3681. **Check the AT-URI format**
369 ```
370 ✅ at://did:plc:xxx/app.bsky.feed.post/abc123
371 ❌ https://bsky.app/profile/you.bsky.social/post/abc123
372 ```
3732. **Verify the post exists** - Visit it on bsky.app
3743. **Check console** - Look for error messages
3754. **Ensure post is public** - Private posts won't be accessible
376
377### Wrong Comments Showing
378
379- Double-check the AT-URI
380- Make sure you're using the announcement post URI, not a reply URI
381
382### Missing Nested Replies
383
384- Increase `maxDepth` prop
385- Check if replies are actually nested (some clients flatten threads)
386
387### Performance Issues
388
389- Fetch comments server-side in `load()`
390- Implement pagination for posts with many comments
391- Cache results
392
393## Static Sites
394
395For static sites (using adapter-static):
396
3971. **Pre-build comments**
398
399```typescript
400// scripts/prebuild-comments.ts
401const posts = await getAllPosts();
402
403for (const post of posts) {
404 if (post.bskyPostUri) {
405 const comments = await fetchComments({
406 bskyPostUri: post.bskyPostUri,
407 canonicalUrl: post.url
408 });
409
410 fs.writeFileSync(`static/comments/${post.slug}.json`, JSON.stringify(comments));
411 }
412}
413```
414
4152. **Load from static file**
416
417```typescript
418// +page.server.ts
419export const load = async ({ params }) => {
420 const comments = JSON.parse(fs.readFileSync(`static/comments/${params.slug}.json`, 'utf-8'));
421
422 return { comments };
423};
424```
425
4263. **Rebuild on schedule** - Use GitHub Actions or similar to rebuild daily/weekly
427
428## Examples
429
430### Basic Blog Post
431
432```svelte
433<script lang="ts">
434 import { Comments } from 'svelte-standard-site';
435
436 const post = {
437 title: 'My Post',
438 content: '...',
439 bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123'
440 };
441</script>
442
443<article>
444 <h1>{post.title}</h1>
445 {@html post.content}
446</article>
447
448<Comments
449 bskyPostUri={post.bskyPostUri}
450 canonicalUrl="https://yourblog.com/posts/my-post"
451/>
452```
453
454### With Loading State
455
456```svelte
457<script lang="ts">
458 import { Comments } from 'svelte-standard-site';
459 import { page } from '$app/stores';
460
461 const { data } = $props();
462
463 let commentsLoaded = $state(false);
464</script>
465
466<article>
467 <!-- Post content -->
468</article>
469
470{#if data.post.bskyPostUri}
471 <div class="comments-wrapper">
472 {#if !commentsLoaded}
473 <div class="loading">Loading comments...</div>
474 {/if}
475
476 <Comments
477 bskyPostUri={data.post.bskyPostUri}
478 canonicalUrl={$page.url.href}
479 on:load={() => (commentsLoaded = true)}
480 />
481 </div>
482{/if}
483```
484
485## Next Steps
486
487- [Publishing](./publishing.md)
488- [Content Transformation](./content-transformation.md)
489- [Verification](./verification.md)