A comprehensive SvelteKit library for ATProto longform publishing. Read AND write to the federated web with site.standard.* records. Includes a complete design system, publishing tools, federated comments, and pre-built components.
Also on Tangled.
.well-known endpoints| You want to... | Use |
|---|---|
| Show Bluesky replies as comments | <Comments /> component |
| Publish blog posts to ATProto | StandardSitePublisher |
| Pull ATProto posts into your site | SiteStandardClient (reader) |
| Verify you own your content | Verification helpers |
| Transform markdown for ATProto | Content utilities |
You can mix and match โ use comments without publishing, or publish without reading, etc.
pnpm add svelte-standard-site && # THIS PACKAGE IS NOT YET PUBLISHED TO NPM
pnpm add zod
Display content from Leaflet, WhiteWind, or other ATProto sources:
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { StandardSiteLayout, DocumentCard } from 'svelte-standard-site';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
</script>
<StandardSiteLayout title="My Blog">
{#each data.documents as document}
<DocumentCard {document} showCover={true} />
{/each}
</StandardSiteLayout>
// src/routes/+page.server.ts
import { createClient } from 'svelte-standard-site';
import { getConfigFromEnv } from 'svelte-standard-site/config/env';
export const load = async ({ fetch }) => {
const config = getConfigFromEnv(); // Reads from env vars
const client = createClient(config);
const documents = await client.fetchAllDocuments(fetch);
return { documents };
};
Write content FROM your blog TO the ATProto network:
// scripts/publish-post.ts
import { StandardSitePublisher } from 'svelte-standard-site/publisher';
import { transformContent } from 'svelte-standard-site/content';
const publisher = new StandardSitePublisher({
identifier: 'you.bsky.social',
password: process.env.ATPROTO_APP_PASSWORD! // App password, not main password
});
await publisher.login();
// Transform your markdown
const transformed = transformContent(markdownContent, {
baseUrl: 'https://yourblog.com'
});
// Publish to ATProto
const result = await publisher.publishDocument({
site: 'https://yourblog.com',
title: 'My Blog Post',
publishedAt: new Date().toISOString(),
content: {
$type: 'site.standard.content.markdown',
text: transformed.markdown,
version: '1.0'
},
textContent: transformed.textContent,
tags: ['blog', 'tutorial']
});
console.log('Published:', result.uri);
Display Bluesky replies as comments:
<script lang="ts">
import { Comments } from 'svelte-standard-site';
</script>
<article>
<h1>{post.title}</h1>
{@html post.content}
</article>
{#if post.bskyPostUri}
<Comments
bskyPostUri={post.bskyPostUri}
canonicalUrl="https://yourblog.com/posts/{post.slug}"
maxDepth={3}
/>
{/if}
Prove you own your content:
// src/routes/.well-known/site.standard.publication/+server.ts
import { text } from '@sveltejs/kit';
import { generatePublicationWellKnown } from 'svelte-standard-site/verification';
export function GET() {
return text(
generatePublicationWellKnown({
did: 'did:plc:your-did',
publicationRkey: '3abc123xyz'
})
);
}
Complete page layout with header, footer, and theme management.
<StandardSiteLayout title="My Site" showThemeToggle={true}>
<slot />
</StandardSiteLayout>
Displays a site.standard.document with title, description, cover, tags, and dates.
<DocumentCard {document} showCover={true} />
Displays a site.standard.publication with icon, name, and description.
<PublicationCard {publication} />
Federated Bluesky comments on your blog posts.
<Comments
bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123"
canonicalUrl="https://yourblog.com/posts/my-post"
/>
See EXAMPLES.md for detailed usage.
import { createClient } from 'svelte-standard-site';
const client = createClient({
did: 'did:plc:xxx',
pds: 'https://...', // optional
cacheTTL: 300000 // optional
});
// Fetch methods
await client.fetchPublication(rkey, fetch);
await client.fetchAllPublications(fetch);
await client.fetchDocument(rkey, fetch);
await client.fetchAllDocuments(fetch);
await client.fetchDocumentsByPublication(pubUri, fetch);
await client.fetchByAtUri(atUri, fetch);
// Utilities
client.clearCache();
await client.getPDS(fetch);
import { StandardSitePublisher } from 'svelte-standard-site/publisher';
const publisher = new StandardSitePublisher({
identifier: 'you.bsky.social',
password: 'xxxx-xxxx-xxxx-xxxx'
});
await publisher.login();
// Publish operations
await publisher.publishPublication({ name, url, ... });
await publisher.publishDocument({ site, title, ... });
await publisher.updateDocument(rkey, { ... });
await publisher.deleteDocument(rkey);
// List operations
await publisher.listPublications();
await publisher.listDocuments();
// Utilities
publisher.getDid();
publisher.getPdsUrl();
publisher.getAtpAgent();
import { transformContent } from 'svelte-standard-site/content';
const result = transformContent(markdown, {
baseUrl: 'https://yourblog.com'
});
// result.markdown - Cleaned markdown for ATProto
// result.textContent - Plain text for search
// result.wordCount - Number of words
// result.readingTime - Estimated minutes
Individual functions:
convertSidenotes(markdown) - HTML sidenotes โ markdown blockquotesresolveRelativeLinks(markdown, baseUrl) - Relative โ absolute URLsstripToPlainText(markdown) - Extract plain textcountWords(text) - Count wordscalculateReadingTime(wordCount) - Estimate reading timeimport { fetchComments } from 'svelte-standard-site/comments';
const comments = await fetchComments({
bskyPostUri: 'at://...',
canonicalUrl: 'https://...',
maxDepth: 3
});
import {
generatePublicationWellKnown,
generateDocumentLinkTag,
getDocumentAtUri,
verifyPublicationWellKnown
} from 'svelte-standard-site/verification';
// For .well-known endpoint
generatePublicationWellKnown({ did, publicationRkey });
// For <head> tag
generateDocumentLinkTag({ did, documentRkey });
// Build AT-URIs
getDocumentAtUri(did, rkey);
// Verify ownership
await verifyPublicationWellKnown(siteUrl, did, rkey);
The library uses semantic color tokens that automatically adapt to light/dark mode:
ink-50 to ink-950)canvas-50 to canvas-950)primary-50 to primary-950)secondary-50 to secondary-950)accent-50 to accent-950)All colors work with Tailwind v4's light-dark() function and automatically switch in dark mode.
<div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50">
<h1 class="text-primary-600 dark:text-primary-400">Hello World</h1>
</div>
# Required for reading
PUBLIC_ATPROTO_DID=did:plc:your-did-here
# Optional
PUBLIC_ATPROTO_PDS=https://your-pds.example.com
PUBLIC_CACHE_TTL=300000
# Required for publishing (use .env.local, never commit)
ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
ATPROTO_HANDLE=you.bsky.social
# Required for verification
PUBLIC_PUBLICATION_RKEY=3abc123xyz
Run the test publisher script:
ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" node scripts/test-publisher.js
Run unit tests:
pnpm test
Record keys (rkeys) MUST be TIDs (Timestamp Identifiers). The publisher generates these automatically. Do not manually create rkeys.
The publisher automatically resolves your PDS from your DID document. You don't need to specify it unless using a custom PDS.
The client caches responses for 5 minutes by default. Clear with client.clearCache() or adjust TTL in config.
All fetch operations support SvelteKit's fetch function for proper SSR and prerendering.
.well-known endpointSee docs/publishing.md for detailed steps.
See docs/comments.md for detailed steps.
.well-known path is correct.well-knownSee documentation for more troubleshooting tips.
light-dark() supportContributions welcome! Please read CONTRIBUTING.md for guidelines.