@bryanguffey/astro-standard-site 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +348 -0
- package/components/Comments.astro +330 -0
- package/dist/comments.d.ts +97 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +221 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +141 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +202 -0
- package/dist/content.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +181 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +207 -0
- package/dist/loader.js.map +1 -0
- package/dist/publisher.d.ts +157 -0
- package/dist/publisher.d.ts.map +1 -0
- package/dist/publisher.js +326 -0
- package/dist/publisher.js.map +1 -0
- package/dist/schemas.d.ts +1079 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +154 -0
- package/dist/schemas.js.map +1 -0
- package/dist/verification.d.ts +103 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +111 -0
- package/dist/verification.js.map +1 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# astro-standard-site
|
|
2
|
+
|
|
3
|
+
The first Astro integration for [standard.site](https://standard.site) — a unified schema for longform publishing on ATProto.
|
|
4
|
+
|
|
5
|
+
**Write once, publish everywhere.** Sync your Astro blog with any platform that supports the standard.site lexicon, like [pckt](https://pckt.blog)(**soon**), [Leaflet](https://leaflet.pub)(**soon**), and [Offprint](https://offprint.app)
|
|
6
|
+
(also **soon**).
|
|
7
|
+
|
|
8
|
+
Created with love by Bryan Guffey
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- 📤 **Publish** — Sync your Astro blog posts to ATProto
|
|
13
|
+
- 📥 **Load** — Fetch documents from any ATProto repository
|
|
14
|
+
- 💬 **Comments** — Display Bluesky replies as comments on your blog
|
|
15
|
+
- 🔄 **Transform** — Convert sidenotes, resolve relative links, extract plain text
|
|
16
|
+
- ✅ **Verify** — Generate `.well-known` endpoints and link tags per spec
|
|
17
|
+
- 📝 **Type-safe** — Full TypeScript support with Zod schemas
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install astro-standard-site
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
### 1. Publish Your Blog Posts to ATProto
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// scripts/sync-to-atproto.ts
|
|
31
|
+
import { getCollection } from 'astro:content';
|
|
32
|
+
import { StandardSitePublisher, transformPost } from 'astro-standard-site';
|
|
33
|
+
|
|
34
|
+
const publisher = new StandardSitePublisher({
|
|
35
|
+
identifier: 'your-handle.bsky.social',
|
|
36
|
+
password: process.env.ATPROTO_APP_PASSWORD!,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await publisher.login();
|
|
40
|
+
console.log('Logged in! PDS:', publisher.getPdsUrl());
|
|
41
|
+
|
|
42
|
+
// Get all blog posts
|
|
43
|
+
const posts = await getCollection('blog');
|
|
44
|
+
|
|
45
|
+
for (const post of posts) {
|
|
46
|
+
if (post.data.draft) continue;
|
|
47
|
+
|
|
48
|
+
// Transform Astro post to standard.site format
|
|
49
|
+
const doc = transformPost(post, {
|
|
50
|
+
siteUrl: 'https://yourblog.com'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Publish to ATProto
|
|
54
|
+
const result = await publisher.publishDocument(doc);
|
|
55
|
+
console.log(`Published: ${post.slug} → ${result.uri}`);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Add Comments from Bluesky
|
|
60
|
+
|
|
61
|
+
```astro
|
|
62
|
+
---
|
|
63
|
+
// src/layouts/BlogPost.astro
|
|
64
|
+
import Comments from 'astro-standard-site/components/Comments.astro';
|
|
65
|
+
|
|
66
|
+
// After publishing, save the Bluesky post URI in your frontmatter
|
|
67
|
+
const { bskyPostUri } = Astro.props.post.data;
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<article>
|
|
71
|
+
<slot />
|
|
72
|
+
</article>
|
|
73
|
+
|
|
74
|
+
<Comments
|
|
75
|
+
bskyPostUri={bskyPostUri}
|
|
76
|
+
canonicalUrl={Astro.url.href}
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3. Set Up Verification
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// src/pages/.well-known/site.standard.publication.ts
|
|
84
|
+
import type { APIRoute } from 'astro';
|
|
85
|
+
import { generatePublicationWellKnown } from 'astro-standard-site';
|
|
86
|
+
|
|
87
|
+
export const GET: APIRoute = () => {
|
|
88
|
+
return new Response(
|
|
89
|
+
generatePublicationWellKnown({
|
|
90
|
+
did: 'did:plc:your-did-here',
|
|
91
|
+
publicationRkey: 'your-publication-rkey',
|
|
92
|
+
}),
|
|
93
|
+
{ headers: { 'Content-Type': 'text/plain' } }
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## API Reference
|
|
99
|
+
|
|
100
|
+
### Publisher
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { StandardSitePublisher } from 'astro-standard-site';
|
|
104
|
+
|
|
105
|
+
const publisher = new StandardSitePublisher({
|
|
106
|
+
identifier: 'handle.bsky.social', // or DID
|
|
107
|
+
password: process.env.ATPROTO_APP_PASSWORD!,
|
|
108
|
+
// service: 'https://...' // Optional: auto-resolved from DID
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await publisher.login();
|
|
112
|
+
|
|
113
|
+
// Publish a document
|
|
114
|
+
await publisher.publishDocument({
|
|
115
|
+
site: 'https://yourblog.com', // Required
|
|
116
|
+
title: 'My Post', // Required
|
|
117
|
+
publishedAt: new Date().toISOString(), // Required
|
|
118
|
+
path: '/blog/my-post', // Optional
|
|
119
|
+
description: 'A great post', // Optional
|
|
120
|
+
tags: ['tag1', 'tag2'], // Optional
|
|
121
|
+
textContent: 'Plain text...', // Optional (for search)
|
|
122
|
+
content: { // Optional (for rendering)
|
|
123
|
+
$type: 'site.standard.content.markdown',
|
|
124
|
+
text: '# My Post\n\nFull markdown...',
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Publish a publication (your blog itself)
|
|
129
|
+
await publisher.publishPublication({
|
|
130
|
+
name: 'My Blog',
|
|
131
|
+
url: 'https://yourblog.com',
|
|
132
|
+
description: 'Thoughts and writings',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// List, update, delete
|
|
136
|
+
const docs = await publisher.listDocuments();
|
|
137
|
+
await publisher.updateDocument('rkey', { ...updatedData });
|
|
138
|
+
await publisher.deleteDocument('rkey');
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Content Transformation
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import {
|
|
145
|
+
transformPost, // Full Astro post transformation
|
|
146
|
+
transformContent, // Just the markdown body
|
|
147
|
+
stripToPlainText, // Extract plain text for textContent
|
|
148
|
+
convertSidenotes, // HTML sidenotes → markdown blockquotes
|
|
149
|
+
} from 'astro-standard-site';
|
|
150
|
+
|
|
151
|
+
// Transform an Astro blog post
|
|
152
|
+
const doc = transformPost(post, { siteUrl: 'https://yourblog.com' });
|
|
153
|
+
|
|
154
|
+
// Or transform just the content
|
|
155
|
+
const { markdown, textContent, wordCount, readingTime } = transformContent(
|
|
156
|
+
rawMarkdown,
|
|
157
|
+
{ siteUrl: 'https://yourblog.com' }
|
|
158
|
+
);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### Sidenote Conversion
|
|
162
|
+
|
|
163
|
+
Your HTML sidenotes:
|
|
164
|
+
```html
|
|
165
|
+
<div class="sidenote sidenote--tip">
|
|
166
|
+
<span class="sidenote-label">Tip</span>
|
|
167
|
+
<p>This is helpful!</p>
|
|
168
|
+
</div>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Become markdown blockquotes:
|
|
172
|
+
```markdown
|
|
173
|
+
> **Tip:** This is helpful!
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Comments
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { fetchComments, countComments } from 'astro-standard-site';
|
|
180
|
+
|
|
181
|
+
// Fetch from a Bluesky post thread
|
|
182
|
+
const comments = await fetchComments({
|
|
183
|
+
bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123',
|
|
184
|
+
canonicalUrl: 'https://yourblog.com/post', // Also searches for mentions
|
|
185
|
+
maxDepth: 3,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log(`${countComments(comments)} comments found`);
|
|
189
|
+
|
|
190
|
+
// Comments are returned as a tree structure
|
|
191
|
+
for (const comment of comments) {
|
|
192
|
+
console.log(`${comment.author.handle}: ${comment.text}`);
|
|
193
|
+
for (const reply of comment.replies || []) {
|
|
194
|
+
console.log(` ↳ ${reply.author.handle}: ${reply.text}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Loader (Fetch from ATProto)
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// src/content/config.ts
|
|
203
|
+
import { defineCollection } from 'astro:content';
|
|
204
|
+
import { standardSiteLoader } from 'astro-standard-site';
|
|
205
|
+
|
|
206
|
+
// Load documents from any ATProto account
|
|
207
|
+
const externalBlog = defineCollection({
|
|
208
|
+
loader: standardSiteLoader({
|
|
209
|
+
repo: 'someone.bsky.social',
|
|
210
|
+
// publication: 'at://...', // Optional: filter by publication
|
|
211
|
+
// limit: 50, // Optional: max documents
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
export const collections = { externalBlog };
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Verification
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import {
|
|
222
|
+
generatePublicationWellKnown,
|
|
223
|
+
generateDocumentLinkTag,
|
|
224
|
+
} from 'astro-standard-site';
|
|
225
|
+
|
|
226
|
+
// For /.well-known/site.standard.publication
|
|
227
|
+
const wellKnown = generatePublicationWellKnown({
|
|
228
|
+
did: 'did:plc:xxx',
|
|
229
|
+
publicationRkey: 'my-blog',
|
|
230
|
+
});
|
|
231
|
+
// Returns: "at://did:plc:xxx/site.standard.publication/my-blog"
|
|
232
|
+
|
|
233
|
+
// For document <head>
|
|
234
|
+
const linkTag = generateDocumentLinkTag({
|
|
235
|
+
did: 'did:plc:xxx',
|
|
236
|
+
documentRkey: 'abc123',
|
|
237
|
+
});
|
|
238
|
+
// Returns: '<link rel="site.standard.document" href="at://did:plc:xxx/site.standard.document/abc123">'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## The standard.site Lexicon
|
|
242
|
+
|
|
243
|
+
This package implements the full [standard.site](https://standard.site) specification:
|
|
244
|
+
|
|
245
|
+
### `site.standard.publication`
|
|
246
|
+
Represents your blog/publication:
|
|
247
|
+
- `url` (required) — Base URL
|
|
248
|
+
- `name` (required) — Publication name
|
|
249
|
+
- `description` — Brief description
|
|
250
|
+
- `icon` — Square image (256x256+, max 1MB)
|
|
251
|
+
- `basicTheme` — Color theme for platforms
|
|
252
|
+
- `preferences` — Platform-specific settings
|
|
253
|
+
|
|
254
|
+
### `site.standard.document`
|
|
255
|
+
Represents a blog post:
|
|
256
|
+
- `site` (required) — Publication URL or AT-URI
|
|
257
|
+
- `title` (required) — Post title
|
|
258
|
+
- `publishedAt` (required) — ISO 8601 datetime
|
|
259
|
+
- `path` — URL path (combines with site)
|
|
260
|
+
- `description` — Excerpt/summary
|
|
261
|
+
- `tags` — Array of tags
|
|
262
|
+
- `updatedAt` — Last modified datetime
|
|
263
|
+
- `coverImage` — Hero image (max 1MB)
|
|
264
|
+
- `textContent` — Plain text for search/indexing
|
|
265
|
+
- `content` — Rich content (open union, platform-specific)
|
|
266
|
+
- `bskyPostRef` — Link to Bluesky announcement post
|
|
267
|
+
|
|
268
|
+
## Workflow: Full Blog Sync
|
|
269
|
+
|
|
270
|
+
Here's a complete workflow for syncing your Astro blog:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
// scripts/sync.ts
|
|
274
|
+
import { getCollection } from 'astro:content';
|
|
275
|
+
import {
|
|
276
|
+
StandardSitePublisher,
|
|
277
|
+
transformPost,
|
|
278
|
+
} from 'astro-standard-site';
|
|
279
|
+
|
|
280
|
+
async function sync() {
|
|
281
|
+
const publisher = new StandardSitePublisher({
|
|
282
|
+
identifier: process.env.ATPROTO_IDENTIFIER!,
|
|
283
|
+
password: process.env.ATPROTO_APP_PASSWORD!,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await publisher.login();
|
|
287
|
+
const did = publisher.getDid();
|
|
288
|
+
|
|
289
|
+
// First, ensure publication exists
|
|
290
|
+
const pubs = await publisher.listPublications();
|
|
291
|
+
if (pubs.length === 0) {
|
|
292
|
+
await publisher.publishPublication({
|
|
293
|
+
name: 'My Blog',
|
|
294
|
+
url: 'https://myblog.com',
|
|
295
|
+
description: 'My thoughts and writings',
|
|
296
|
+
rkey: 'my-blog',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Get existing documents
|
|
301
|
+
const existing = await publisher.listDocuments();
|
|
302
|
+
const existingByPath = new Map(
|
|
303
|
+
existing.map(d => [d.value.path, d])
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Sync all posts
|
|
307
|
+
const posts = await getCollection('blog');
|
|
308
|
+
|
|
309
|
+
for (const post of posts) {
|
|
310
|
+
if (post.data.draft) continue;
|
|
311
|
+
|
|
312
|
+
const doc = transformPost(post, { siteUrl: 'https://myblog.com' });
|
|
313
|
+
const existingDoc = existingByPath.get(doc.path);
|
|
314
|
+
|
|
315
|
+
if (existingDoc) {
|
|
316
|
+
// Update existing
|
|
317
|
+
const rkey = existingDoc.uri.split('/').pop()!;
|
|
318
|
+
await publisher.updateDocument(rkey, doc);
|
|
319
|
+
console.log(`Updated: ${post.slug}`);
|
|
320
|
+
} else {
|
|
321
|
+
// Create new
|
|
322
|
+
const result = await publisher.publishDocument(doc);
|
|
323
|
+
console.log(`Created: ${post.slug} → ${result.uri}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
console.log('Sync complete!');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
sync().catch(console.error);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Run with:
|
|
334
|
+
```bash
|
|
335
|
+
ATPROTO_IDENTIFIER="you.bsky.social" \
|
|
336
|
+
ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
|
|
337
|
+
npx tsx scripts/sync.ts
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Resources
|
|
341
|
+
|
|
342
|
+
- [standard.site specification](https://standard.site)
|
|
343
|
+
- [ATProto documentation](https://atproto.com)
|
|
344
|
+
- [pdsls.dev](https://pdsls.dev) — Browse ATProto repositories
|
|
345
|
+
|
|
346
|
+
## License
|
|
347
|
+
|
|
348
|
+
MIT
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Comments component for displaying federated ATProto comments
|
|
4
|
+
*
|
|
5
|
+
* Fetches and displays comments from Bluesky (and future platforms)
|
|
6
|
+
* for a blog post. Comments are fetched at build time for static sites,
|
|
7
|
+
* or can be loaded client-side for dynamic updates.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```astro
|
|
11
|
+
* ---
|
|
12
|
+
* import Comments from 'astro-standard-site/components/Comments.astro';
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* <Comments
|
|
16
|
+
* bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123"
|
|
17
|
+
* canonicalUrl="https://bryanguffey.com/blog/my-post"
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Comment } from '../src/comments';
|
|
23
|
+
import { fetchComments, countComments } from '../src/comments';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** AT-URI of the Bluesky announcement post */
|
|
27
|
+
bskyPostUri?: string;
|
|
28
|
+
/** Canonical URL of the blog post */
|
|
29
|
+
canonicalUrl?: string;
|
|
30
|
+
/** Maximum depth for nested replies */
|
|
31
|
+
maxDepth?: number;
|
|
32
|
+
/** Title for the comments section */
|
|
33
|
+
title?: string;
|
|
34
|
+
/** Whether to show the "reply on Bluesky" link */
|
|
35
|
+
showReplyLink?: boolean;
|
|
36
|
+
/** Custom class for styling */
|
|
37
|
+
class?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
bskyPostUri,
|
|
42
|
+
canonicalUrl,
|
|
43
|
+
maxDepth = 3,
|
|
44
|
+
title = 'Comments',
|
|
45
|
+
showReplyLink = true,
|
|
46
|
+
class: className = '',
|
|
47
|
+
} = Astro.props;
|
|
48
|
+
|
|
49
|
+
// Fetch comments at build time
|
|
50
|
+
let comments: Comment[] = [];
|
|
51
|
+
let totalCount = 0;
|
|
52
|
+
let error: string | null = null;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
comments = await fetchComments({
|
|
56
|
+
bskyPostUri,
|
|
57
|
+
canonicalUrl,
|
|
58
|
+
maxDepth,
|
|
59
|
+
});
|
|
60
|
+
totalCount = countComments(comments);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
error = e instanceof Error ? e.message : 'Failed to load comments';
|
|
63
|
+
console.error('Comments fetch error:', e);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper to format relative time
|
|
67
|
+
function formatRelativeTime(date: Date): string {
|
|
68
|
+
const now = new Date();
|
|
69
|
+
const diffMs = now.getTime() - date.getTime();
|
|
70
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
71
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
72
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
73
|
+
|
|
74
|
+
if (diffMins < 1) return 'just now';
|
|
75
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
76
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
77
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
78
|
+
|
|
79
|
+
return date.toLocaleDateString('en-US', {
|
|
80
|
+
month: 'short',
|
|
81
|
+
day: 'numeric',
|
|
82
|
+
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get reply URL for Bluesky
|
|
87
|
+
function getReplyUrl(): string | null {
|
|
88
|
+
if (!bskyPostUri) return null;
|
|
89
|
+
|
|
90
|
+
// Parse the AT-URI to get handle and post ID
|
|
91
|
+
const match = bskyPostUri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/);
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
|
|
94
|
+
const [, did, postId] = match;
|
|
95
|
+
// For DIDs, we'd need to resolve to handle, but for now use the intent URL
|
|
96
|
+
return `https://bsky.app/intent/compose?reply=${encodeURIComponent(bskyPostUri)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const replyUrl = getReplyUrl();
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
<section class:list={['comments-section', className]}>
|
|
103
|
+
<header class="comments-header">
|
|
104
|
+
<h2 class="comments-title">
|
|
105
|
+
{title}
|
|
106
|
+
{totalCount > 0 && <span class="comments-count">({totalCount})</span>}
|
|
107
|
+
</h2>
|
|
108
|
+
|
|
109
|
+
{showReplyLink && replyUrl && (
|
|
110
|
+
<a href={replyUrl} target="_blank" rel="noopener noreferrer" class="comments-reply-link">
|
|
111
|
+
Reply on Bluesky →
|
|
112
|
+
</a>
|
|
113
|
+
)}
|
|
114
|
+
</header>
|
|
115
|
+
|
|
116
|
+
{error && (
|
|
117
|
+
<p class="comments-error">
|
|
118
|
+
Unable to load comments. <a href={bskyPostUri ? `https://bsky.app` : '#'}>View on Bluesky</a>
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{!error && comments.length === 0 && (
|
|
123
|
+
<p class="comments-empty">
|
|
124
|
+
No comments yet.
|
|
125
|
+
{replyUrl && (
|
|
126
|
+
<a href={replyUrl} target="_blank" rel="noopener noreferrer">
|
|
127
|
+
Be the first to reply on Bluesky!
|
|
128
|
+
</a>
|
|
129
|
+
)}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{!error && comments.length > 0 && (
|
|
134
|
+
<div class="comments-list">
|
|
135
|
+
{comments.map(function renderComment(comment: Comment, depth = 0) {
|
|
136
|
+
return (
|
|
137
|
+
<article class="comment" style={`--depth: ${depth}`}>
|
|
138
|
+
<header class="comment-header">
|
|
139
|
+
<a href={`https://bsky.app/profile/${comment.author.handle}`}
|
|
140
|
+
target="_blank"
|
|
141
|
+
rel="noopener noreferrer"
|
|
142
|
+
class="comment-author">
|
|
143
|
+
{comment.author.avatar && (
|
|
144
|
+
<img
|
|
145
|
+
src={comment.author.avatar}
|
|
146
|
+
alt=""
|
|
147
|
+
class="comment-avatar"
|
|
148
|
+
loading="lazy"
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
<span class="comment-author-name">
|
|
152
|
+
{comment.author.displayName || comment.author.handle}
|
|
153
|
+
</span>
|
|
154
|
+
<span class="comment-author-handle">@{comment.author.handle}</span>
|
|
155
|
+
</a>
|
|
156
|
+
|
|
157
|
+
<a href={comment.sourceUrl}
|
|
158
|
+
target="_blank"
|
|
159
|
+
rel="noopener noreferrer"
|
|
160
|
+
class="comment-time">
|
|
161
|
+
{formatRelativeTime(comment.createdAt)}
|
|
162
|
+
</a>
|
|
163
|
+
</header>
|
|
164
|
+
|
|
165
|
+
<div class="comment-body">
|
|
166
|
+
<p>{comment.text}</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{comment.likeCount !== undefined && comment.likeCount > 0 && (
|
|
170
|
+
<footer class="comment-footer">
|
|
171
|
+
<span class="comment-likes">♥ {comment.likeCount}</span>
|
|
172
|
+
</footer>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{comment.replies && comment.replies.length > 0 && (
|
|
176
|
+
<div class="comment-replies">
|
|
177
|
+
{comment.replies.map((reply: Comment) => renderComment(reply, depth + 1))}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</article>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<footer class="comments-footer">
|
|
187
|
+
<p class="comments-attribution">
|
|
188
|
+
Comments powered by <a href="https://bsky.app" target="_blank" rel="noopener noreferrer">Bluesky</a>
|
|
189
|
+
{' '}via <a href="https://standard.site" target="_blank" rel="noopener noreferrer">standard.site</a>
|
|
190
|
+
</p>
|
|
191
|
+
</footer>
|
|
192
|
+
</section>
|
|
193
|
+
|
|
194
|
+
<style>
|
|
195
|
+
.comments-section {
|
|
196
|
+
margin-top: var(--space-2xl, 3rem);
|
|
197
|
+
padding-top: var(--space-xl, 2rem);
|
|
198
|
+
border-top: 1px solid var(--color-border-soft, #30363d);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.comments-header {
|
|
202
|
+
display: flex;
|
|
203
|
+
justify-content: space-between;
|
|
204
|
+
align-items: center;
|
|
205
|
+
margin-bottom: var(--space-lg, 1.5rem);
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
gap: var(--space-md, 1rem);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.comments-title {
|
|
211
|
+
font-size: 1.5rem;
|
|
212
|
+
margin: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.comments-count {
|
|
216
|
+
font-weight: normal;
|
|
217
|
+
color: var(--color-text-muted, #7d8590);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.comments-reply-link {
|
|
221
|
+
font-size: 0.875rem;
|
|
222
|
+
color: var(--color-text-link, #7eb8da);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.comments-error,
|
|
226
|
+
.comments-empty {
|
|
227
|
+
color: var(--color-text-secondary, #a8b2bd);
|
|
228
|
+
font-style: italic;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.comments-list {
|
|
232
|
+
display: flex;
|
|
233
|
+
flex-direction: column;
|
|
234
|
+
gap: var(--space-lg, 1.5rem);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.comment {
|
|
238
|
+
padding-left: calc(var(--depth, 0) * var(--space-lg, 1.5rem));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.comment-header {
|
|
242
|
+
display: flex;
|
|
243
|
+
align-items: center;
|
|
244
|
+
gap: var(--space-md, 1rem);
|
|
245
|
+
margin-bottom: var(--space-sm, 0.5rem);
|
|
246
|
+
flex-wrap: wrap;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.comment-author {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
gap: var(--space-sm, 0.5rem);
|
|
253
|
+
text-decoration: none;
|
|
254
|
+
color: inherit;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.comment-author:hover .comment-author-name {
|
|
258
|
+
color: var(--color-text-link, #7eb8da);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.comment-avatar {
|
|
262
|
+
width: 32px;
|
|
263
|
+
height: 32px;
|
|
264
|
+
border-radius: 50%;
|
|
265
|
+
object-fit: cover;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.comment-author-name {
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
color: var(--color-text-primary, #e6edf3);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.comment-author-handle {
|
|
274
|
+
font-size: 0.875rem;
|
|
275
|
+
color: var(--color-text-muted, #7d8590);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.comment-time {
|
|
279
|
+
font-size: 0.875rem;
|
|
280
|
+
color: var(--color-text-muted, #7d8590);
|
|
281
|
+
margin-left: auto;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.comment-body {
|
|
285
|
+
color: var(--color-text-secondary, #a8b2bd);
|
|
286
|
+
line-height: 1.6;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.comment-body p {
|
|
290
|
+
margin: 0;
|
|
291
|
+
white-space: pre-wrap;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.comment-footer {
|
|
295
|
+
margin-top: var(--space-sm, 0.5rem);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.comment-likes {
|
|
299
|
+
font-size: 0.875rem;
|
|
300
|
+
color: var(--color-text-muted, #7d8590);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.comment-replies {
|
|
304
|
+
margin-top: var(--space-md, 1rem);
|
|
305
|
+
padding-left: var(--space-md, 1rem);
|
|
306
|
+
border-left: 2px solid var(--color-border-soft, #30363d);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.comments-footer {
|
|
310
|
+
margin-top: var(--space-xl, 2rem);
|
|
311
|
+
padding-top: var(--space-md, 1rem);
|
|
312
|
+
border-top: 1px solid var(--color-border-soft, #30363d);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.comments-attribution {
|
|
316
|
+
font-size: 0.75rem;
|
|
317
|
+
color: var(--color-text-muted, #7d8590);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@media (max-width: 640px) {
|
|
321
|
+
.comment-header {
|
|
322
|
+
flex-direction: column;
|
|
323
|
+
align-items: flex-start;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.comment-time {
|
|
327
|
+
margin-left: 0;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
</style>
|