@bryanguffey/astro-standard-site
v1.0.3
Published
Astro integration for standard.site - unified ATProto longform publishing
Maintainers
Readme
astro-standard-site
Publish your Astro blog to the federated web. This package connects your blog to ATProto (the protocol behind Bluesky) using the standard.site schema, enabling:
- Cross-platform publishing — Your posts appear on Leaflet, WhiteWind, and other ATProto readers
- Federated comments — Display Bluesky replies as comments on your blog
- Verified ownership — Prove you own your content with cryptographic verification
Created with love by Bryan Guffey
Installation
npm install @bryanguffey/astro-standard-siteUse Cases
This package supports multiple workflows:
| You want to... | Use |
|----------------|-----|
| Show Bluesky replies as comments | <Comments /> component |
| Publish Astro posts to ATProto | StandardSitePublisher |
| Pull ATProto posts into Astro | standardSiteLoader |
| Verify you own your content | Verification helpers |
You can mix and match — use comments without publishing, or publish without loading, etc.
Quick Start
1. Display Bluesky Comments on Your Blog
The fastest way to get started — add federated comments to your existing posts.
Add to your blog post layout:
---
// src/layouts/BlogPost.astro
import Comments from '@bryanguffey/astro-standard-site/components/Comments.astro';
const { bskyPostUri } = Astro.props.frontmatter;
---
<article>
<slot />
</article>
{bskyPostUri && (
<Comments
bskyPostUri={bskyPostUri}
canonicalUrl={Astro.url.href}
/>
)}Add the field to your content schema:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string(),
date: z.date(),
// ... your existing fields
bskyPostUri: z.string().optional(),
}),
});
export const collections = { blog };Link a post to Bluesky:
- Publish your blog post
- Share it on Bluesky
- Copy the post's AT-URI (click ··· → "Copy post link", then convert to AT-URI format)
- Add it to your post's frontmatter:
---
title: "My First Federated Post"
date: 2026-01-15
bskyPostUri: "at://did:plc:your-did/app.bsky.feed.post/abc123def"
---- Rebuild your site — comments now appear!
Tip: To get the AT-URI from a Bluesky URL like
https://bsky.app/profile/you.bsky.social/post/abc123def, the format isat://did:plc:YOUR_DID/app.bsky.feed.post/abc123def. You can find your DID at bsky.app/settings.
Full Setup: Publish to ATProto
To publish your posts to ATProto (not just display comments), you'll need:
- A Bluesky account (or any ATProto PDS)
- An app password
Create a Publication
First, create a publication record that represents your blog:
// scripts/create-publication.ts
import { StandardSitePublisher } from '@bryanguffey/astro-standard-site';
const publisher = new StandardSitePublisher({
handle: 'you.bsky.social',
appPassword: process.env.ATPROTO_APP_PASSWORD!,
});
await publisher.login();
const result = await publisher.publishPublication({
name: 'My Awesome Blog',
url: 'https://yourblog.com',
description: 'Thoughts on code, life, and everything',
// Optional: customize your theme colors (RGB 0-255)
basicTheme: {
background: { r: 13, g: 17, b: 23 },
foreground: { r: 230, g: 237, b: 243 },
accent: { r: 74, g: 124, b: 155 },
accentForeground: { r: 255, g: 255, b: 255 },
},
});
console.log('Publication created!');
console.log('AT-URI:', result.uri);
console.log('Save this rkey for verification:', result.uri.split('/').pop());Run it once:
ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" npx tsx scripts/create-publication.tsPublish Posts
Create a sync script to publish your Astro posts:
// scripts/sync-to-atproto.ts
import { StandardSitePublisher, transformContent } from '@bryanguffey/astro-standard-site';
import { getCollection } from 'astro:content';
const publisher = new StandardSitePublisher({
handle: 'you.bsky.social',
appPassword: process.env.ATPROTO_APP_PASSWORD!,
});
await publisher.login();
const posts = await getCollection('blog');
for (const post of posts) {
// Transform content for ATProto compatibility
const transformed = transformContent(post.body, {
baseUrl: 'https://yourblog.com',
});
const result = await publisher.publishDocument({
site: 'https://yourblog.com',
path: `/blog/${post.slug}`,
title: post.data.title,
description: post.data.description,
content: {
$type: 'site.standard.content.markdown',
text: transformed.markdown,
version: '1.0',
},
textContent: transformed.textContent,
publishedAt: post.data.date.toISOString(),
tags: post.data.tags,
});
console.log(`Published: ${post.data.title}`);
console.log(` → ${result.uri}`);
}Set Up Verification
Verification lets platforms confirm you own the content. Create a well-known endpoint:
// src/pages/.well-known/site.standard.publication.ts
import type { APIRoute } from 'astro';
import { generatePublicationWellKnown } from '@bryanguffey/astro-standard-site';
export const GET: APIRoute = () => {
return new Response(
generatePublicationWellKnown({
did: 'did:plc:your-did-here', // Your DID
publicationRkey: '3abc123xyz789', // From create-publication output
}),
{ headers: { 'Content-Type': 'text/plain' } }
);
};After deploying, verify it works:
curl https://yourblog.com/.well-known/site.standard.publication
# Should output: at://did:plc:xxx/site.standard.publication/3abc123xyz789Components
<Comments />
Displays Bluesky replies as a comment section.
<Comments
bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123"
canonicalUrl="https://yourblog.com/post/my-post"
maxDepth={3}
title="Discussion"
showReplyLink={true}
class="my-custom-class"
/>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| bskyPostUri | string | — | AT-URI of the Bluesky announcement post |
| canonicalUrl | string | — | URL of your blog post (for mention search) |
| maxDepth | number | 3 | Maximum nesting depth for replies |
| title | string | "Comments" | Section heading |
| showReplyLink | boolean | true | Show "Reply on Bluesky" link |
| class | string | — | Custom CSS class |
Styling: The component uses CSS custom properties that inherit from your site's theme:
--color-border-soft
--color-text-primary
--color-text-secondary
--color-text-muted
--color-text-link
--color-bg-elevated
--space-xs, --space-sm, --space-md, --space-lg, --space-xl, --space-2xlAPI Reference
StandardSitePublisher
Handles authentication and publishing to ATProto.
import { StandardSitePublisher } from '@bryanguffey/astro-standard-site';
const publisher = new StandardSitePublisher({
handle: 'you.bsky.social', // Your handle
appPassword: 'xxxx-xxxx-xxxx', // App password (not your main password!)
// Optional: specify PDS directly (auto-resolved from DID by default)
pdsUrl: 'https://bsky.social',
});
await publisher.login();publishDocument(input)
Publish a blog post.
const result = await publisher.publishDocument({
// Required
site: 'https://yourblog.com',
title: 'My Post Title',
publishedAt: '2026-01-15T12:00:00Z',
// Recommended
path: '/blog/my-post',
description: 'A short excerpt...',
content: {
$type: 'site.standard.content.markdown',
text: '# Full markdown content...',
version: '1.0',
},
textContent: 'Plain text version for search indexing',
// Optional
updatedAt: '2026-01-16T12:00:00Z',
tags: ['astro', 'atproto'],
});
console.log(result.uri); // at://did:plc:xxx/site.standard.document/3abc...
console.log(result.cid); // Content hashpublishPublication(input)
Create or update your publication metadata.
const result = await publisher.publishPublication({
name: 'My Blog',
url: 'https://yourblog.com',
description: 'What this blog is about',
basicTheme: {
background: { r: 255, g: 255, b: 255 },
foreground: { r: 0, g: 0, b: 0 },
accent: { r: 0, g: 102, b: 204 },
accentForeground: { r: 255, g: 255, b: 255 },
},
preferences: {
showInDiscover: true,
},
});transformContent(markdown, options)
Transform markdown for ATProto compatibility.
import { transformContent } from '@bryanguffey/astro-standard-site';
const result = transformContent(markdownString, {
baseUrl: 'https://yourblog.com', // For resolving relative links
});
result.markdown; // Cleaned markdown (sidenotes converted, links resolved)
result.textContent; // Plain text for search indexing
result.wordCount; // Number of words
result.readingTime; // Estimated minutes to readWhat it does:
- Converts HTML sidenotes to markdown blockquotes
- Resolves relative links (
/about→https://yourblog.com/about) - Strips markdown to plain text for the
textContentfield - Calculates word count and reading time
standardSiteLoader(config)
Astro Content Layer loader — pull YOUR content written on other platforms (Leaflet, WhiteWind) into your Astro blog.
Primary use case: You write posts on Leaflet, and want them to appear on your Astro site — but NOT the posts you published from your Astro site to ATProto.
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { standardSiteLoader } from '@bryanguffey/astro-standard-site';
const federated = defineCollection({
loader: standardSiteLoader({
repo: 'me.bsky.social', // Your ATProto handle or DID
excludeSite: 'https://myblog.com', // Skip posts published FROM your Astro blog
}),
});
export const collections = { federated };| Option | Type | Description |
|--------|------|-------------|
| repo | string | Required. ATProto handle or DID to load from |
| excludeSite | string | Skip documents with this site URL (your blog) |
| publication | string | Only load documents from this specific site |
| limit | number | Max documents to fetch (default: 100) |
| service | string | PDS endpoint (default: public API) |
Using loaded documents:
---
// src/pages/federated/[...slug].astro
import { getCollection } from 'astro:content';
const posts = await getCollection('federated');
---
{posts.map(post => (
<article>
<h2><a href={post.data.url}>{post.data.title}</a></h2>
<time>{post.data.publishedAt.toLocaleDateString()}</time>
<p>{post.data.description}</p>
{/* For plain text display */}
{post.data.textContent && (
<div>{post.data.textContent}</div>
)}
{/* Or handle markdown content specifically */}
{post.data.content?.$type === 'site.standard.content.markdown' && (
<div set:html={marked(post.data.content.text)} />
)}
</article>
))}Loaded document fields:
| Field | Type | Description |
|-------|------|-------------|
| id | string | Record key (TID) |
| uri | string | Full AT-URI |
| title | string | Document title |
| site | string | Source publication URL |
| publishedAt | Date | Publication date |
| path | string? | Path segment |
| url | string? | Full URL (site + path) |
| description | string? | Excerpt |
| tags | string[] | Categories |
| textContent | string? | Plain text for display/search |
| content | unknown | Platform-specific content (see below) |
| _raw | Document | Full raw record |
About the content field:
The content field is an open union — different platforms use different types. Use textContent for simple display, or check content.$type for rich rendering:
// Markdown content (Leaflet, this package)
if (post.data.content?.$type === 'site.standard.content.markdown') {
const markdown = post.data.content.text;
}
// Or just use textContent for plain text
const plainText = post.data.textContent;publicationLoader(config)
Load publication metadata (blog info, not posts):
const publications = defineCollection({
loader: publicationLoader({ repo: 'someone.bsky.social' }),
});fetchComments(options)
Fetch comments programmatically (used internally by the Comments component).
import { fetchComments } from '@bryanguffey/astro-standard-site';
const comments = await fetchComments({
bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123',
canonicalUrl: 'https://yourblog.com/post/my-post',
maxDepth: 3,
});
// Returns array of Comment objects with nested repliesVerification Helpers
import {
generatePublicationWellKnown,
generateDocumentLinkTag,
getDocumentAtUri,
getPublicationAtUri,
parseAtUri,
} from '@bryanguffey/astro-standard-site';
// For /.well-known/site.standard.publication endpoint
generatePublicationWellKnown({ did: '...', publicationRkey: '...' });
// → "at://did:plc:xxx/site.standard.publication/abc123"
// For <head> tag to verify individual documents
generateDocumentLinkTag({ did: '...', documentRkey: '...' });
// → '<link rel="site.standard.document" href="at://...">'
// Build AT-URIs
getDocumentAtUri('did:plc:xxx', '3abc123');
// → "at://did:plc:xxx/site.standard.document/3abc123"
// Parse AT-URIs
parseAtUri('at://did:plc:xxx/site.standard.document/3abc123');
// → { did: 'did:plc:xxx', collection: 'site.standard.document', rkey: '3abc123' }Workflow Tips
Getting Your DID
Your DID is your permanent identifier on ATProto. Find it at:
- bsky.app/settings → scroll to "DID"
- Or:
https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=you.bsky.social
Getting AT-URIs from Bluesky URLs
Bluesky web URLs look like:
https://bsky.app/profile/you.bsky.social/post/3abc123xyzThe AT-URI format is:
at://did:plc:YOUR_DID/app.bsky.feed.post/3abc123xyzViewing Your Published Content
After publishing, view your records at:
https://pdsls.dev/at://YOUR_DID/site.standard.publicationhttps://pdsls.dev/at://YOUR_DID/site.standard.document
Comments Appear at Build Time
Comments are fetched when you build your site (static). To show new comments, rebuild and redeploy. For high-traffic sites, consider scheduled rebuilds or on-demand ISR.
Troubleshooting
"Failed to resolve handle"
- Check your handle is correct
- Verify your PDS is reachable
- Make sure you're using an app password, not your main password
"Schema validation failed" / "invalid TID"
Record keys must be TIDs (timestamp identifiers). The package generates these automatically — if you see this error, you may be using an older version or passing a custom rkey.
Comments not appearing
- Verify the
bskyPostUriis correct (AT-URI format, not web URL) - Check the Bluesky post exists and has public replies
- Rebuild your site after adding the URI
Verification endpoint returning 404
- Ensure the file is at
src/pages/.well-known/site.standard.publication.ts - The
.well-knownfolder needs to be insidepages/ - Check your hosting platform allows
.well-knownpaths
How It Works
This package implements the standard.site specification, which defines a common schema for longform content on ATProto. This means:
- Your content is portable — It lives in your ATProto repository, not locked to any platform
- Multiple readers — Leaflet, WhiteWind, and future apps can all display your posts
- Federated engagement — Comments and likes from any ATProto app appear on your blog
- Verified ownership — The
.well-knownendpoint proves you control the content
Links
- standard.site specification
- ATProto documentation
- Bluesky
- Leaflet — ATProto blog reader
- WhiteWind — Another ATProto blog platform
- pdsls.dev — Browse ATProto records
License
MIT
