@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 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>