a standard.site publication renderer for SvelteKit.
at main 489 lines 10 kB view raw view rendered
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)