a standard.site publication renderer for SvelteKit.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add ATProto publishing, comments, and content tooling

Reposition library as a full ATProto longform publishing toolkit:
- Add write support via StandardSitePublisher (publish/update/delete)
- Introduce federated Bluesky comments component and utilities
- Add markdown → ATProto content transformation helpers
- Add verification helpers for .well-known and link tags
- Export schemas, collections, and new utility modules

Documentation overhaul:
- Rewrite README to focus on read + write workflows
- Add publishing, comments, verification, and transformation guides
- Clarify environment variables, security notes, and workflows
- Update examples and component docs

Developer experience:
- Add Vitest and test scripts
- Export new subpaths (publisher, content, comments, verification, schemas)
- Add Zod for schema validation
- Expand keywords to reflect federation and publishing

+4034 -397
+212
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + ## What 4 + 5 + SvelteKit library for ATProto longform publishing via the `standard.site` lexicon. Provides both read and write capabilities: display content from ATProto (Leaflet/WhiteWind), publish content TO ATProto, and aggregate federated comments. 6 + 7 + **Package:** `svelte-standard-site` 8 + 9 + ## Project Structure 10 + 11 + ``` 12 + src/lib/ 13 + client.ts # Read from ATProto (fetch documents/publications) 14 + publisher.ts # Write to ATProto (publish documents/publications) 15 + schemas.ts # Zod schemas for validation 16 + types.ts # TypeScript type definitions 17 + components/ 18 + Comments.svelte # Federated comments from Bluesky 19 + DocumentCard.svelte 20 + PublicationCard.svelte 21 + StandardSiteLayout.svelte 22 + ThemeToggle.svelte 23 + common/ # Reusable utility components 24 + document/ # Document rendering components 25 + utils/ 26 + content.ts # Markdown transformation (sidenotes, links, etc.) 27 + comments.ts # Fetch Bluesky replies 28 + verification.ts # Ownership verification helpers 29 + at-uri.ts # AT-URI parsing and conversion 30 + theme.ts # Theme utilities 31 + cache.ts # Caching layer 32 + stores/ 33 + theme.ts # Dark/light mode store 34 + styles/ 35 + base.css # Core design system 36 + themes.css # Theme definitions 37 + ``` 38 + 39 + ## Commands 40 + 41 + ```bash 42 + pnpm dev # Start dev server 43 + pnpm build # Build package 44 + pnpm test # Run tests 45 + pnpm check # Type check 46 + ``` 47 + 48 + ## Critical: TID Format 49 + 50 + Record keys for `site.standard.document` and `site.standard.publication` MUST be TIDs. Schema validation will reject anything else. 51 + 52 + **TID requirements:** 53 + - 13 characters, base32-sortable charset: `234567abcdefghijklmnopqrstuvwxyz` 54 + - First char must be `234567abcdefghij` (top bit = 0) 55 + - Regex: `/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/` 56 + 57 + See `generateTid()` in `src/lib/publisher.ts` — do not modify without reading https://atproto.com/specs/tid 58 + 59 + ## Critical: ES Modules 60 + 61 + `package.json` must have `"type": "module"`. Without this, imports break. 62 + 63 + ## Key Concepts 64 + 65 + ### Read vs Write 66 + 67 + - **SiteStandardClient** (`client.ts`): Read-only. Fetches content from ATProto. 68 + - **StandardSitePublisher** (`publisher.ts`): Write operations. Publishes content to ATProto. 69 + 70 + ### Content Transformation 71 + 72 + The `content.ts` utilities transform markdown for ATProto compatibility: 73 + - Convert HTML sidenotes → markdown blockquotes 74 + - Resolve relative links → absolute URLs 75 + - Extract plain text for search indexing 76 + - Calculate word count and reading time 77 + 78 + ### Comments System 79 + 80 + The Comments component fetches Bluesky replies and displays them as comments on blog posts. It uses the ATProto API to recursively fetch threaded conversations. 81 + 82 + ### Verification 83 + 84 + Verification helpers generate `.well-known` endpoints and `<link>` tags to prove content ownership. This allows platforms to verify that you control the content you've published. 85 + 86 + ## Testing Against Real PDS 87 + 88 + ```bash 89 + # Set your app password 90 + export ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" 91 + 92 + # Run publisher test 93 + node scripts/test-publisher.js 94 + ``` 95 + 96 + For integration testing, use `pds.rip` (throwaway test accounts). 97 + 98 + ## Design System 99 + 100 + The library uses semantic color tokens that automatically adapt to light/dark mode: 101 + 102 + - **Ink**: Text colors (ink-50 to ink-950) 103 + - **Canvas**: Background colors (canvas-50 to canvas-950) 104 + - **Primary**: Brand colors (primary-50 to primary-950) 105 + - **Secondary**: Secondary brand (secondary-50 to secondary-950) 106 + - **Accent**: Accent colors (accent-50 to accent-950) 107 + 108 + All styled using Tailwind v4 with `light-dark()` function. 109 + 110 + ## Publishing to ATProto 111 + 112 + ```typescript 113 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 114 + 115 + const publisher = new StandardSitePublisher({ 116 + identifier: 'you.bsky.social', 117 + password: process.env.ATPROTO_APP_PASSWORD!, 118 + }); 119 + 120 + await publisher.login(); 121 + 122 + await publisher.publishDocument({ 123 + site: 'https://yourblog.com', 124 + title: 'My Post', 125 + publishedAt: new Date().toISOString(), 126 + content: { 127 + $type: 'site.standard.content.markdown', 128 + text: markdownContent, 129 + version: '1.0', 130 + }, 131 + textContent: plainTextContent, 132 + }); 133 + ``` 134 + 135 + ## Reading from ATProto 136 + 137 + ```typescript 138 + import { createClient } from 'svelte-standard-site'; 139 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 140 + 141 + const config = getConfigFromEnv(); 142 + const client = createClient(config); 143 + 144 + const documents = await client.fetchAllDocuments(fetch); 145 + const publications = await client.fetchAllPublications(fetch); 146 + ``` 147 + 148 + ## Comments 149 + 150 + ```svelte 151 + <script> 152 + import { Comments } from 'svelte-standard-site'; 153 + </script> 154 + 155 + <Comments 156 + bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123" 157 + canonicalUrl="https://yourblog.com/posts/my-post" 158 + maxDepth={3} 159 + /> 160 + ``` 161 + 162 + ## Content Transformation 163 + 164 + ```typescript 165 + import { transformContent } from 'svelte-standard-site/content'; 166 + 167 + const result = transformContent(rawMarkdown, { 168 + baseUrl: 'https://yourblog.com', 169 + }); 170 + 171 + // result.markdown - cleaned for ATProto 172 + // result.textContent - plain text for search 173 + // result.wordCount 174 + // result.readingTime 175 + ``` 176 + 177 + ## Verification 178 + 179 + ```typescript 180 + // src/routes/.well-known/site.standard.publication/+server.ts 181 + import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 182 + import { text } from '@sveltejs/kit'; 183 + 184 + export function GET() { 185 + return text( 186 + generatePublicationWellKnown({ 187 + did: 'did:plc:xxx', 188 + publicationRkey: '3abc123xyz', 189 + }) 190 + ); 191 + } 192 + ``` 193 + 194 + ## Important Notes 195 + 196 + 1. **App Passwords**: Always use app passwords, never main account passwords 197 + 2. **PDS Resolution**: The publisher auto-resolves PDS from DID documents 198 + 3. **Caching**: The client has built-in caching (5-minute TTL by default) 199 + 4. **SSR**: All fetch operations support SvelteKit's `fetch` for SSR 200 + 5. **Theme Store**: Call `themeStore.init()` in `onMount()` to enable theme toggle 201 + 6. **Blob URLs**: Cover images and icons are converted from blob refs to HTTPS URLs 202 + 203 + ## External References 204 + 205 + - ATProto specs: https://atproto.com/ 206 + - standard.site: https://standard.site/ 207 + - Lexicon explorer: https://pdsls.dev/ 208 + - Bluesky: https://bsky.app/ 209 + 210 + ## License 211 + 212 + AGPL-3.0 (stricter than Astro version's MIT)
+315 -393
README.md
··· 1 1 # svelte-standard-site 2 2 3 - A comprehensive SvelteKit library for building sites powered by `site.standard.*` records from the AT Protocol. Includes a complete design system with light/dark mode support and pre-built components. 3 + 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. 4 4 5 5 Also on [Tangled](https://tangled.org/did:plc:ofrbh253gwicbkc5nktqepol/svelte-standard-site). 6 6 7 7 ## Features 8 8 9 - - 🎨 **Complete Design System** - Beautiful, accessible design language with ink, canvas, primary, secondary, and accent color palettes 10 - - 🌓 **Light/Dark Mode** - Built-in theme toggle with system preference detection and zero FOUC 11 - - 🧩 **Pre-built Components** - Ready-to-use cards, layouts, and UI elements 12 - - 🔧 **Modular Architecture** - Reusable utility components for consistent theming and formatting 9 + ### Core Functionality 10 + - ✍️ **Publishing** - Publish content TO ATProto (Bluesky, Leaflet, WhiteWind) 11 + - 📖 **Reading** - Fetch and display content FROM ATProto 12 + - 💬 **Comments** - Federated Bluesky comments on your blog 13 + - ✅ **Verification** - Prove content ownership with `.well-known` endpoints 14 + - 🔄 **Content Transformation** - Convert markdown for ATProto compatibility 15 + 16 + ### UI & Design 17 + - 🎨 **Complete Design System** - Beautiful, accessible color palettes (ink, canvas, primary, secondary, accent) 18 + - 🌓 **Light/Dark Mode** - Built-in theme toggle with system preference detection 19 + - 🧩 **Pre-built Components** - Cards, layouts, document renderers, and UI elements 20 + - 🔧 **Modular Architecture** - Reusable utility components for theming and formatting 13 21 - 🌍 **Internationalization** - Automatic locale-aware date formatting 14 - - 🔄 **Automatic PDS Resolution** - Resolves DIDs to their Personal Data Server endpoints 15 - - 📦 **Type-Safe** - Full TypeScript support with complete type definitions 16 - - 🚀 **SSR Ready** - Works seamlessly with SvelteKit's server-side rendering 17 - - 💾 **Built-in Caching** - Reduces API calls with intelligent caching 18 - - 🎯 **Customizable** - All components respect `site.standard.*` lexicons while allowing full customization 19 - - 🔗 **AT URI Support** - Parse and convert AT URIs to HTTPS URLs 20 - - ♿ **Accessible** - WCAG compliant with proper ARIA labels and keyboard navigation 22 + - ♿ **Accessible** - WCAG compliant with proper ARIA labels 21 23 22 - ## Installation 24 + ### Developer Experience 25 + - 📦 **Type-Safe** - Full TypeScript support with Zod validation 26 + - 🚀 **SSR Ready** - Works seamlessly with SvelteKit 27 + - 💾 **Built-in Caching** - Reduces API calls intelligently 28 + - 🔄 **Automatic PDS Resolution** - Resolves DIDs to PDS endpoints 29 + - 🔗 **AT URI Support** - Parse and convert AT URIs 30 + - 🧪 **Tested** - Includes test suite with Vitest 23 31 24 - ```bash 25 - pnpm add svelte-standard-site 26 - # or 27 - npm install svelte-standard-site 28 - # or 29 - yarn add svelte-standard-site 30 - ``` 32 + ## Use Cases 31 33 32 - ## Quick Start 34 + | You want to... | Use | 35 + |----------------|-----| 36 + | Show Bluesky replies as comments | `<Comments />` component | 37 + | Publish blog posts to ATProto | `StandardSitePublisher` | 38 + | Pull ATProto posts into your site | `SiteStandardClient` (reader) | 39 + | Verify you own your content | Verification helpers | 40 + | Transform markdown for ATProto | Content utilities | 33 41 34 - ### 1. Import Base Styles 42 + You can mix and match — use comments without publishing, or publish without reading, etc. 35 43 36 - In your root `+layout.svelte`: 44 + ## Installation 37 45 38 - ```svelte 39 - <script> 40 - import 'svelte-standard-site/styles/base.css'; 41 - </script> 46 + ```bash 47 + pnpm add svelte-standard-site && # THIS PACKAGE IS NOT YET PUBLISHED TO NPM 48 + pnpm add zod 42 49 ``` 43 50 44 - ### 2. Configure Environment Variables 45 - 46 - Create a `.env` file in your project root: 47 - 48 - ```env 49 - PUBLIC_ATPROTO_DID=did:plc:your-did-here 50 - # Optional: specify a custom PDS endpoint 51 - PUBLIC_ATPROTO_PDS=https://your-pds.example.com 52 - # Optional: cache TTL in milliseconds (default: 300000 = 5 minutes) 53 - PUBLIC_CACHE_TTL=300000 54 - ``` 51 + ## Quick Start 55 52 56 - ### 3. Use the Layout Component 53 + ### Reading from ATProto 57 54 58 - The simplest way to get started is with the `StandardSiteLayout` component: 55 + Display content from Leaflet, WhiteWind, or other ATProto sources: 59 56 60 57 ```svelte 58 + <!-- src/routes/+page.svelte --> 61 59 <script lang="ts"> 62 - import { StandardSiteLayout } from 'svelte-standard-site'; 60 + import { StandardSiteLayout, DocumentCard } from 'svelte-standard-site'; 61 + import type { PageData } from './$types'; 62 + 63 + const { data }: { data: PageData } = $props(); 63 64 </script> 64 65 65 - <StandardSiteLayout title="My Site" showThemeToggle={true}> 66 - <h1>Welcome to my site!</h1> 67 - <p>Powered by site.standard records.</p> 66 + <StandardSiteLayout title="My Blog"> 67 + {#each data.documents as document} 68 + <DocumentCard {document} showCover={true} /> 69 + {/each} 68 70 </StandardSiteLayout> 69 71 ``` 70 - 71 - ### 4. Fetch and Display Records 72 72 73 73 ```typescript 74 74 // src/routes/+page.server.ts 75 75 import { createClient } from 'svelte-standard-site'; 76 76 import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 77 - import type { PageServerLoad } from './$types'; 78 77 79 - export const load: PageServerLoad = async ({ fetch }) => { 80 - const config = getConfigFromEnv(); 81 - if (!config) { 82 - throw new Error('Missing configuration'); 83 - } 84 - 78 + export const load = async ({ fetch }) => { 79 + const config = getConfigFromEnv(); // Reads from env vars 85 80 const client = createClient(config); 86 - 87 - const [publications, documents] = await Promise.all([ 88 - client.fetchAllPublications(fetch), 89 - client.fetchAllDocuments(fetch) 90 - ]); 91 - 92 - return { 93 - publications, 94 - documents 95 - }; 81 + const documents = await client.fetchAllDocuments(fetch); 82 + 83 + return { documents }; 96 84 }; 97 85 ``` 98 86 99 - ```svelte 100 - <!-- src/routes/+page.svelte --> 101 - <script lang="ts"> 102 - import { StandardSiteLayout, PublicationCard, DocumentCard } from 'svelte-standard-site'; 103 - import type { PageData } from './$types'; 87 + ### Publishing to ATProto 104 88 105 - const { data }: { data: PageData } = $props(); 106 - </script> 89 + Write content FROM your blog TO the ATProto network: 107 90 108 - <StandardSiteLayout title="My Publications"> 109 - <section> 110 - <h2>Publications</h2> 111 - <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 112 - {#each data.publications as publication} 113 - <PublicationCard {publication} /> 114 - {/each} 115 - </div> 116 - </section> 91 + ```typescript 92 + // scripts/publish-post.ts 93 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 94 + import { transformContent } from 'svelte-standard-site/content'; 117 95 118 - <section> 119 - <h2>Recent Posts</h2> 120 - <div class="space-y-6"> 121 - {#each data.documents as document} 122 - <DocumentCard {document} showCover={true} /> 123 - {/each} 124 - </div> 125 - </section> 126 - </StandardSiteLayout> 127 - ``` 96 + const publisher = new StandardSitePublisher({ 97 + identifier: 'you.bsky.social', 98 + password: process.env.ATPROTO_APP_PASSWORD! // App password, not main password 99 + }); 128 100 129 - ## Components 101 + await publisher.login(); 130 102 131 - ### Core Components 103 + // Transform your markdown 104 + const transformed = transformContent(markdownContent, { 105 + baseUrl: 'https://yourblog.com' 106 + }); 132 107 133 - #### StandardSiteLayout 108 + // Publish to ATProto 109 + const result = await publisher.publishDocument({ 110 + site: 'https://yourblog.com', 111 + title: 'My Blog Post', 112 + publishedAt: new Date().toISOString(), 113 + content: { 114 + $type: 'site.standard.content.markdown', 115 + text: transformed.markdown, 116 + version: '1.0' 117 + }, 118 + textContent: transformed.textContent, 119 + tags: ['blog', 'tutorial'] 120 + }); 134 121 135 - A complete page layout with header, footer, and theme management. 122 + console.log('Published:', result.uri); 123 + ``` 124 + 125 + ### Federated Comments 126 + 127 + Display Bluesky replies as comments: 136 128 137 129 ```svelte 138 - <StandardSiteLayout title="My Site" showThemeToggle={true} class="custom-class"> 139 - {#snippet header()} 140 - <!-- Custom header --> 141 - {/snippet} 130 + <script lang="ts"> 131 + import { Comments } from 'svelte-standard-site'; 132 + </script> 142 133 143 - {#snippet footer()} 144 - <!-- Custom footer --> 145 - {/snippet} 134 + <article> 135 + <h1>{post.title}</h1> 136 + {@html post.content} 137 + </article> 146 138 147 - <!-- Main content --> 148 - </StandardSiteLayout> 139 + {#if post.bskyPostUri} 140 + <Comments 141 + bskyPostUri={post.bskyPostUri} 142 + canonicalUrl="https://yourblog.com/posts/{post.slug}" 143 + maxDepth={3} 144 + /> 145 + {/if} 149 146 ``` 150 147 151 - **Props:** 148 + ### Content Verification 152 149 153 - - `title?: string` - Site title (default: "My Site") 154 - - `showThemeToggle?: boolean` - Show theme toggle button (default: true) 155 - - `class?: string` - Additional CSS classes for main container 156 - - `header?: Snippet` - Custom header snippet (replaces default) 157 - - `footer?: Snippet` - Custom footer snippet (replaces default) 158 - - `children: Snippet` - Main content 150 + Prove you own your content: 159 151 160 - ### ThemeToggle 152 + ```typescript 153 + // src/routes/.well-known/site.standard.publication/+server.ts 154 + import { text } from '@sveltejs/kit'; 155 + import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 161 156 162 - A button component for toggling between light and dark modes. 157 + export function GET() { 158 + return text( 159 + generatePublicationWellKnown({ 160 + did: 'did:plc:your-did', 161 + publicationRkey: '3abc123xyz' 162 + }) 163 + ); 164 + } 165 + ``` 163 166 164 - ```svelte 165 - <script> 166 - import { ThemeToggle } from 'svelte-standard-site'; 167 - </script> 167 + ## Documentation 168 168 169 - <ThemeToggle class="my-custom-class" /> 170 - ``` 169 + ### Core Guides 170 + - **[Publishing Guide](./docs/publishing.md)** - Publish content TO ATProto 171 + - **[Content Transformation](./docs/content-transformation.md)** - Transform markdown for ATProto 172 + - **[Verification](./docs/verification.md)** - Prove content ownership 173 + - **[Comments](./docs/comments.md)** - Federated Bluesky comments 171 174 172 - **Props:** 175 + ### Complete Examples 176 + - **[EXAMPLES.md](./EXAMPLES.md)** - Comprehensive usage examples 177 + - **[CLAUDE.md](./CLAUDE.md)** - AI assistant context and architecture 178 + - **[CONTRIBUTING.md](./CONTRIBUTING.md)** - Contribution guidelines 173 179 174 - - `class?: string` - Additional CSS classes 180 + ## Components 175 181 176 - #### DocumentCard 182 + ### Core Components 177 183 178 - Displays a `site.standard.document` record with title, description, cover image, tags, and dates. 184 + #### StandardSiteLayout 185 + Complete page layout with header, footer, and theme management. 179 186 180 187 ```svelte 181 - <DocumentCard {document} showCover={true} href="/custom/path" class="custom-class" /> 188 + <StandardSiteLayout title="My Site" showThemeToggle={true}> 189 + <slot /> 190 + </StandardSiteLayout> 182 191 ``` 183 192 184 - **Props:** 193 + #### DocumentCard 194 + Displays a `site.standard.document` with title, description, cover, tags, and dates. 185 195 186 - - `document: AtProtoRecord<Document>` - The document record (required) 187 - - `publication?: AtProtoRecord<Publication>` - Optional publication for theme support 188 - - `showCover?: boolean` - Show cover image (default: true) 189 - - `href?: string` - Custom href override 190 - - `class?: string` - Additional CSS classes 196 + ```svelte 197 + <DocumentCard {document} showCover={true} /> 198 + ``` 191 199 192 200 #### PublicationCard 201 + Displays a `site.standard.publication` with icon, name, and description. 193 202 194 - Displays a `site.standard.publication` record with icon, name, description, and link. 203 + ```svelte 204 + <PublicationCard {publication} /> 205 + ``` 206 + 207 + #### Comments 208 + Federated Bluesky comments on your blog posts. 195 209 196 210 ```svelte 197 - <PublicationCard {publication} showExternalIcon={true} class="custom-class" /> 211 + <Comments 212 + bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123" 213 + canonicalUrl="https://yourblog.com/posts/my-post" 214 + /> 198 215 ``` 199 216 200 - **Props:** 217 + ### Utility Components 201 218 202 - - `publication: AtProtoRecord<Publication>` - The publication record (required) 203 - - `showExternalIcon?: boolean` - Show external link icon (default: true) 204 - - `class?: string` - Additional CSS classes 219 + - **DateDisplay** - Locale-aware date formatting 220 + - **TagList** - Theme-aware tag display 221 + - **ThemedContainer** - Wrap content with theme CSS variables 222 + - **ThemedText** - Text with theme-aware colors 223 + - **ThemedCard** - Base card with theme support 224 + - **ThemeToggle** - Dark/light mode toggle button 205 225 206 - ### Reusable Utility Components 226 + See [EXAMPLES.md](./EXAMPLES.md) for detailed usage. 207 227 208 - #### DateDisplay 228 + ## API Reference 209 229 210 - Consistently formats and displays dates with automatic locale detection. 230 + ### Reading (SiteStandardClient) 211 231 212 - ```svelte 213 - <DateDisplay date={document.publishedAt} /> 214 - <DateDisplay date={document.updatedAt} label="Updated " showIcon={true} locale="fr-FR" /> 232 + ```typescript 233 + import { createClient } from 'svelte-standard-site'; 234 + 235 + const client = createClient({ 236 + did: 'did:plc:xxx', 237 + pds: 'https://...', // optional 238 + cacheTTL: 300000 // optional 239 + }); 240 + 241 + // Fetch methods 242 + await client.fetchPublication(rkey, fetch); 243 + await client.fetchAllPublications(fetch); 244 + await client.fetchDocument(rkey, fetch); 245 + await client.fetchAllDocuments(fetch); 246 + await client.fetchDocumentsByPublication(pubUri, fetch); 247 + await client.fetchByAtUri(atUri, fetch); 248 + 249 + // Utilities 250 + client.clearCache(); 251 + await client.getPDS(fetch); 215 252 ``` 216 253 217 - **Props:** 254 + ### Writing (StandardSitePublisher) 218 255 219 - - `date: string` - ISO date string (required) 220 - - `label?: string` - Optional label prefix 221 - - `class?: string` - Additional CSS classes 222 - - `showIcon?: boolean` - Show update icon (default: false) 223 - - `style?: string` - Inline styles 224 - - `locale?: string` - Locale override (default: browser locale) 256 + ```typescript 257 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 225 258 226 - **Features:** 259 + const publisher = new StandardSitePublisher({ 260 + identifier: 'you.bsky.social', 261 + password: 'xxxx-xxxx-xxxx-xxxx' 262 + }); 227 263 228 - - Automatically detects user's browser locale 229 - - Supports custom locale override 230 - - Examples: "January 19, 2026" (en-US), "19 janvier 2026" (fr-FR) 264 + await publisher.login(); 231 265 232 - #### TagList 266 + // Publish operations 267 + await publisher.publishPublication({ name, url, ... }); 268 + await publisher.publishDocument({ site, title, ... }); 269 + await publisher.updateDocument(rkey, { ... }); 270 + await publisher.deleteDocument(rkey); 233 271 234 - Displays a list of tags with theme support. 272 + // List operations 273 + await publisher.listPublications(); 274 + await publisher.listDocuments(); 235 275 236 - ```svelte 237 - <TagList tags={document.tags} hasTheme={!!publication?.basicTheme} /> 276 + // Utilities 277 + publisher.getDid(); 278 + publisher.getPdsUrl(); 279 + publisher.getAtpAgent(); 238 280 ``` 239 281 240 - **Props:** 241 - 242 - - `tags: string[]` - Array of tag strings (required) 243 - - `hasTheme?: boolean` - Whether to apply custom theme (default: false) 244 - - `class?: string` - Additional CSS classes 282 + ### Content Transformation 245 283 246 - #### ThemedContainer 284 + ```typescript 285 + import { transformContent } from 'svelte-standard-site/content'; 247 286 248 - Wraps content with theme CSS variables applied. 287 + const result = transformContent(markdown, { 288 + baseUrl: 'https://yourblog.com' 289 + }); 249 290 250 - ```svelte 251 - <ThemedContainer theme={publication.basicTheme} element="article"> 252 - <!-- Content automatically inherits theme --> 253 - </ThemedContainer> 291 + // result.markdown - Cleaned markdown for ATProto 292 + // result.textContent - Plain text for search 293 + // result.wordCount - Number of words 294 + // result.readingTime - Estimated minutes 254 295 ``` 255 296 256 - **Props:** 297 + Individual functions: 298 + - `convertSidenotes(markdown)` - HTML sidenotes → markdown blockquotes 299 + - `resolveRelativeLinks(markdown, baseUrl)` - Relative → absolute URLs 300 + - `stripToPlainText(markdown)` - Extract plain text 301 + - `countWords(text)` - Count words 302 + - `calculateReadingTime(wordCount)` - Estimate reading time 257 303 258 - - `theme?: BasicTheme` - Optional theme to apply 259 - - `children: Snippet` - Content to wrap (required) 260 - - `class?: string` - Additional CSS classes 261 - - `element?: 'div' | 'article' | 'section'` - HTML element type (default: 'div') 304 + ### Comments 262 305 263 - #### ThemedText 264 - 265 - Displays text with theme-aware colors. 306 + ```typescript 307 + import { fetchComments } from 'svelte-standard-site/comments'; 266 308 267 - ```svelte 268 - <ThemedText hasTheme={!!theme} element="h1" class="text-4xl">Title</ThemedText> 269 - <ThemedText hasTheme={!!theme} opacity={70} element="p">Description</ThemedText> 309 + const comments = await fetchComments({ 310 + bskyPostUri: 'at://...', 311 + canonicalUrl: 'https://...', 312 + maxDepth: 3 313 + }); 270 314 ``` 271 315 272 - **Props:** 316 + ### Verification 273 317 274 - - `hasTheme?: boolean` - Whether to apply custom theme (default: false) 275 - - `opacity?: number` - Opacity level 0-100 (default: 100) 276 - - `variant?: 'foreground' | 'accent'` - Color variant (default: 'foreground') 277 - - `element?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'div'` - HTML element (default: 'span') 278 - - `class?: string` - Additional CSS classes 279 - - `children?: any` - Content to render 318 + ```typescript 319 + import { 320 + generatePublicationWellKnown, 321 + generateDocumentLinkTag, 322 + getDocumentAtUri, 323 + verifyPublicationWellKnown 324 + } from 'svelte-standard-site/verification'; 280 325 281 - #### ThemedCard 326 + // For .well-known endpoint 327 + generatePublicationWellKnown({ did, publicationRkey }); 282 328 283 - Base card component with theme support for building custom cards. 329 + // For <head> tag 330 + generateDocumentLinkTag({ did, documentRkey }); 284 331 285 - ```svelte 286 - <ThemedCard theme={publication.basicTheme} class="p-6"> 287 - <!-- Card content --> 288 - </ThemedCard> 332 + // Build AT-URIs 333 + getDocumentAtUri(did, rkey); 289 334 290 - <!-- With link --> 291 - <ThemedCard theme={publication.basicTheme} href="/article" class="hover:shadow-lg"> 292 - <!-- Clickable card content --> 293 - </ThemedCard> 335 + // Verify ownership 336 + await verifyPublicationWellKnown(siteUrl, did, rkey); 294 337 ``` 295 338 296 - **Props:** 297 - 298 - - `theme?: BasicTheme` - Optional theme to apply 299 - - `children: Snippet` - Card content (required) 300 - - `class?: string` - Additional CSS classes 301 - - `href?: string` - Optional link (wraps in anchor tag) 302 - 303 339 ## Design System 304 340 305 - The library uses a comprehensive color system with semantic naming: 341 + The library uses semantic color tokens that automatically adapt to light/dark mode: 306 342 307 343 - **Ink** - Text colors (`ink-50` to `ink-950`) 308 344 - **Canvas** - Background colors (`canvas-50` to `canvas-950`) ··· 310 346 - **Secondary** - Secondary brand colors (`secondary-50` to `secondary-950`) 311 347 - **Accent** - Accent colors (`accent-50` to `accent-950`) 312 348 313 - All colors automatically adapt to light/dark mode using Tailwind's `light-dark()` function. 314 - 315 - ### Example Usage 349 + All colors work with Tailwind v4's `light-dark()` function and automatically switch in dark mode. 316 350 317 351 ```svelte 318 352 <div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50"> 319 353 <h1 class="text-primary-600 dark:text-primary-400">Hello World</h1> 320 - <p class="text-ink-700 dark:text-ink-200">Supporting text</p> 321 354 </div> 322 355 ``` 323 356 324 - ## Theme Store 325 - 326 - Programmatically control the theme: 327 - 328 - ```typescript 329 - import { themeStore } from 'svelte-standard-site'; 330 - 331 - // Initialize (automatically called by ThemeToggle) 332 - themeStore.init(); 333 - 334 - // Toggle theme 335 - themeStore.toggle(); 336 - 337 - // Set specific theme 338 - themeStore.setTheme(true); // dark mode 339 - themeStore.setTheme(false); // light mode 340 - 341 - // Subscribe to changes 342 - themeStore.subscribe((state) => { 343 - console.log(state.isDark); // boolean 344 - console.log(state.mounted); // boolean 345 - }); 346 - ``` 347 - 348 - ## Theme Utilities 349 - 350 - Helper functions for working with theme colors: 351 - 352 - ```typescript 353 - import { 354 - mixThemeColor, 355 - getThemedTextColor, 356 - getThemedBackground, 357 - getThemedBorder, 358 - getThemedAccent, 359 - themeToCssVars 360 - } from 'svelte-standard-site'; 361 - 362 - // Generate color-mix CSS 363 - const semiTransparent = mixThemeColor('--theme-foreground', 50); 364 - // => 'color-mix(in srgb, var(--theme-foreground) 50%, transparent)' 365 - 366 - // Get theme-aware text color 367 - const textStyle = getThemedTextColor(hasTheme, 70); 368 - // => { color: 'color-mix(in srgb, var(--theme-foreground) 70%, transparent)' } 369 - 370 - // Get theme-aware background 371 - const bgStyle = getThemedBackground(hasTheme); 372 - // => { backgroundColor: 'var(--theme-background)' } 373 - 374 - // Get theme-aware border 375 - const borderStyle = getThemedBorder(hasTheme, 20); 376 - // => { borderColor: 'color-mix(in srgb, var(--theme-foreground) 20%, transparent)' } 377 - 378 - // Get theme-aware accent color 379 - const accentStyle = getThemedAccent(hasTheme, 15); 380 - // => { backgroundColor: '...', color: 'var(--theme-accent)' } 381 - 382 - // Convert BasicTheme to CSS vars 383 - const cssVars = themeToCssVars(publication.basicTheme); 384 - // => { '--theme-background': 'rgb(255, 245, 235)', ... } 385 - ``` 386 - 387 - **Available Functions:** 357 + ## Environment Variables 388 358 389 - - `mixThemeColor(variable: string, opacity: number, fallback?: string): string` - Generate color-mix CSS 390 - - `getThemedTextColor(hasTheme: boolean, opacity?: number): { color?: string }` - Get themed text color 391 - - `getThemedBackground(hasTheme: boolean, opacity?: number): { backgroundColor?: string }` - Get themed background 392 - - `getThemedBorder(hasTheme: boolean, opacity?: number): { borderColor?: string }` - Get themed border 393 - - `getThemedAccent(hasTheme: boolean, opacity?: number): { color?: string; backgroundColor?: string }` - Get themed accent 394 - - `themeToCssVars(theme?: BasicTheme): Record<string, string>` - Convert theme to CSS variables 395 - 396 - ## Client API 359 + ```env 360 + # Required for reading 361 + PUBLIC_ATPROTO_DID=did:plc:your-did-here 397 362 398 - ### Creating a Client 363 + # Optional 364 + PUBLIC_ATPROTO_PDS=https://your-pds.example.com 365 + PUBLIC_CACHE_TTL=300000 399 366 400 - ```typescript 401 - import { createClient } from 'svelte-standard-site'; 367 + # Required for publishing (use .env.local, never commit) 368 + ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 369 + ATPROTO_HANDLE=you.bsky.social 402 370 403 - const client = createClient({ 404 - did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 405 - pds: 'https://cortinarius.us-west.host.bsky.network', // optional 406 - cacheTTL: 300000 // optional, in milliseconds 407 - }); 371 + # Required for verification 372 + PUBLIC_PUBLICATION_RKEY=3abc123xyz 408 373 ``` 409 374 410 - ### Methods 375 + ## Testing 411 376 412 - - `fetchPublication(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication> | null>` 413 - - `fetchAllPublications(fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication>[]>` 414 - - `fetchDocument(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document> | null>` 415 - - `fetchAllDocuments(fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 416 - - `fetchDocumentsByPublication(publicationUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 417 - - `fetchByAtUri<T>(atUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<T> | null>` 418 - - `clearCache(): void` 419 - - `getPDS(fetchFn?: typeof fetch): Promise<string>` 377 + Run the test publisher script: 420 378 421 - ## Types 422 - 423 - ```typescript 424 - interface Publication { 425 - $type: 'site.standard.publication'; 426 - url: string; 427 - name: string; 428 - icon?: AtProtoBlob; 429 - description?: string; 430 - basicTheme?: BasicTheme; 431 - preferences?: PublicationPreferences; 432 - } 433 - 434 - interface Document { 435 - $type: 'site.standard.document'; 436 - site: string; // AT URI or HTTPS URL 437 - title: string; 438 - path?: string; 439 - description?: string; 440 - coverImage?: AtProtoBlob; 441 - content?: any; 442 - textContent?: string; 443 - bskyPostRef?: StrongRef; 444 - tags?: string[]; 445 - publishedAt: string; 446 - updatedAt?: string; 447 - } 448 - 449 - interface AtProtoRecord<T> { 450 - uri: string; 451 - cid: string; 452 - value: T; 453 - } 379 + ```bash 380 + ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" node scripts/test-publisher.js 454 381 ``` 455 382 456 - ## Customization 457 - 458 - ### Custom Styles 459 - 460 - Override CSS variables in your own stylesheet: 383 + Run unit tests: 461 384 462 - ```css 463 - :root { 464 - --color-primary-600: oklch(70% 0.15 280); /* Custom purple */ 465 - } 466 - 467 - [data-theme='dark'] { 468 - --color-primary-600: oklch(75% 0.15 280); 469 - } 385 + ```bash 386 + pnpm test 470 387 ``` 471 388 472 - ### Custom Layout 389 + ## Important Notes 473 390 474 - Create your own layout while using the theme system: 391 + ### Security 392 + - **Never commit app passwords** - Use environment variables 393 + - **Never use main password** - Always create app passwords at https://bsky.app/settings/app-passwords 394 + - **Validate input** - Always validate data before publishing 475 395 476 - ```svelte 477 - <script lang="ts"> 478 - import { ThemeToggle, themeStore } from 'svelte-standard-site'; 479 - import 'svelte-standard-site/styles/base.css'; 480 - import { onMount } from 'svelte'; 396 + ### TID Format 397 + Record keys (rkeys) MUST be TIDs (Timestamp Identifiers). The publisher generates these automatically. Do not manually create rkeys. 481 398 482 - onMount(() => { 483 - themeStore.init(); 484 - }); 485 - </script> 399 + ### PDS Resolution 400 + The publisher automatically resolves your PDS from your DID document. You don't need to specify it unless using a custom PDS. 486 401 487 - <div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50 min-h-screen"> 488 - <header> 489 - <nav> 490 - <a href="/">Home</a> 491 - <ThemeToggle /> 492 - </nav> 493 - </header> 402 + ### Caching 403 + The client caches responses for 5 minutes by default. Clear with `client.clearCache()` or adjust TTL in config. 494 404 495 - <main> 496 - <slot /> 497 - </main> 405 + ### SSR 406 + All fetch operations support SvelteKit's `fetch` function for proper SSR and prerendering. 498 407 499 - <footer> 500 - <!-- Your footer --> 501 - </footer> 502 - </div> 503 - ``` 408 + ## Workflows 504 409 505 - ### Extending Components 410 + ### Complete Publishing Workflow 506 411 507 - All components accept a `class` prop for customization: 412 + 1. **Create a publication** (once) 413 + 2. **Write a blog post** in markdown 414 + 3. **Transform content** for ATProto compatibility 415 + 4. **Publish to ATProto** using the publisher 416 + 5. **Share on Bluesky** to create an announcement post 417 + 6. **Add AT-URI to post** for federated comments 418 + 7. **Set up verification** with `.well-known` endpoint 508 419 509 - ```svelte 510 - <DocumentCard {document} class="border-primary-500 border-4 shadow-2xl" /> 511 - ``` 420 + See [docs/publishing.md](./docs/publishing.md) for detailed steps. 512 421 513 - ## Advanced Usage 422 + ### Adding Comments to Existing Posts 514 423 515 - ### Building a Blog 424 + 1. **Share post on Bluesky** (creates announcement post) 425 + 2. **Get AT-URI** from the Bluesky post 426 + 3. **Add to frontmatter** or database 427 + 4. **Add Comments component** to post template 428 + 5. **Comments load automatically** when users visit 516 429 517 - See the [EXAMPLES.md](./EXAMPLES.md) file for a complete blog implementation example. 430 + See [docs/comments.md](./docs/comments.md) for detailed steps. 518 431 519 - ### Using with Different Lexicons 432 + ## Troubleshooting 520 433 521 - The library is built around `site.standard.*` lexicons but can be adapted: 434 + ### "Failed to resolve handle" 435 + - Verify handle is correct 436 + - Check PDS is reachable 437 + - Ensure using app password 522 438 523 - ```typescript 524 - // Fetch any AT Proto record 525 - const customRecord = await client.fetchByAtUri<CustomType>('at://did:plc:xxx/custom.lexicon/rkey'); 526 - ``` 439 + ### "Schema validation failed" 440 + - Check data matches schema 441 + - Ensure dates are ISO 8601 442 + - Verify URLs are valid 527 443 528 - ## Accessibility 444 + ### Comments not loading 445 + - Verify AT-URI format is correct 446 + - Check post exists and is public 447 + - Look for errors in console 529 448 530 - All components follow WCAG guidelines: 449 + ### Verification 404 450 + - Ensure `.well-known` path is correct 451 + - Check hosting platform allows `.well-known` 452 + - Verify endpoint returns plain text 531 453 532 - - Proper semantic HTML 533 - - ARIA labels and roles 534 - - Keyboard navigation support 535 - - Focus visible indicators 536 - - High contrast mode support 537 - - Screen reader compatibility 454 + See documentation for more troubleshooting tips. 538 455 539 456 ## Browser Support 540 457 541 458 - Modern browsers with CSS `light-dark()` support 542 459 - Tailwind CSS v4+ required 543 460 - Svelte 5+ required 461 + - SvelteKit 2+ required 544 462 545 463 ## License 546 464 ··· 548 466 549 467 ## Contributing 550 468 551 - Contributions are welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. 469 + Contributions welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. 552 470 553 471 ## Credits 554 472 ··· 561 479 562 480 - [GitHub Repository](https://github.com/ewanc26/svelte-standard-site) 563 481 - [NPM Package](https://www.npmjs.com/package/svelte-standard-site) 564 - - [Documentation](https://github.com/ewanc26/svelte-standard-site#readme) 565 - - [AT Protocol Documentation](https://atproto.com) 566 - - [site.standard Specification](https://github.com/noeleon/site.standard) 482 + - [Documentation](./docs/) 483 + - [Examples](./EXAMPLES.md) 484 + - [AT Protocol](https://atproto.com) 485 + - [standard.site Specification](https://github.com/noeleon/site.standard) 486 + - [Bluesky](https://bsky.app) 487 + - [Leaflet](https://leaflet.pub) 488 + - [WhiteWind](https://whitewind.pages.dev)
+489
docs/comments.md
··· 1 + # Federated Comments 2 + 3 + Display Bluesky replies as comments on your blog posts using the Comments component. 4 + 5 + ## How It Works 6 + 7 + 1. You publish a blog post 8 + 2. You share it on Bluesky (creating an "announcement post") 9 + 3. People reply to that Bluesky post 10 + 4. The Comments component fetches those replies and displays them as comments 11 + 12 + ## Quick Start 13 + 14 + ### 1. Install 15 + 16 + ```bash 17 + pnpm 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 + 45 + When you share your post on Bluesky: 46 + 47 + 1. Click on your post 48 + 2. Click the "..." menu 49 + 3. Click "Copy post link" 50 + 4. Convert to AT-URI format 51 + 52 + ``` 53 + URL: https://bsky.app/profile/you.bsky.social/post/abc123xyz 54 + AT-URI: at://did:plc:YOUR_DID/app.bsky.feed.post/abc123xyz 55 + ``` 56 + 57 + ### 4. Store the AT-URI 58 + 59 + Add it to your post's frontmatter or database: 60 + 61 + ```yaml 62 + --- 63 + title: My Blog Post 64 + date: 2026-01-25 65 + bskyPostUri: 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 + 86 + 1. **Write and publish your blog post** 87 + 88 + ```typescript 89 + // scripts/publish-post.ts 90 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 91 + 92 + const publisher = new StandardSitePublisher({ 93 + identifier: 'you.bsky.social', 94 + password: process.env.ATPROTO_APP_PASSWORD! 95 + }); 96 + 97 + await publisher.login(); 98 + 99 + const 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 + 108 + 2. **Share on Bluesky** 109 + 110 + ```typescript 111 + // Create announcement post 112 + const agent = publisher.getAtpAgent(); 113 + 114 + const postResult = await agent.post({ 115 + text: `New blog post: Understanding ATProto 116 + 117 + Read it at: https://yourblog.com/posts/understanding-atproto`, 118 + langs: ['en'] 119 + }); 120 + 121 + console.log('Post URI:', postResult.uri); 122 + // Save this: at://did:plc:xxx/app.bsky.feed.post/abc123 123 + ``` 124 + 125 + 3. **Update your post with the AT-URI** 126 + 127 + ```typescript 128 + await publisher.updateDocument(rkey, { 129 + // ... all original fields 130 + bskyPostRef: { 131 + uri: postResult.uri, 132 + cid: postResult.cid 133 + } 134 + }); 135 + ``` 136 + 137 + 4. **Comments appear automatically** 138 + 139 + The Comments component fetches replies from Bluesky when users visit your post. 140 + 141 + ## Programmatic Usage 142 + 143 + If you want to fetch comments in your load function instead of client-side: 144 + 145 + ```typescript 146 + // src/routes/blog/[slug]/+page.server.ts 147 + import { fetchComments } from 'svelte-standard-site/comments'; 148 + import type { PageServerLoad } from './$types'; 149 + 150 + export 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 + 169 + Then 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 193 + import { fetchComments } from 'svelte-standard-site/comments'; 194 + 195 + const 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 + 206 + Fetch posts that mention your blog post URL (even if not replies): 207 + 208 + ```typescript 209 + import { fetchMentionComments } from 'svelte-standard-site/comments'; 210 + 211 + const mentions = await fetchMentionComments('https://yourblog.com/posts/my-post', 3); 212 + ``` 213 + 214 + ### formatRelativeTime 215 + 216 + ```typescript 217 + import { formatRelativeTime } from 'svelte-standard-site/comments'; 218 + 219 + formatRelativeTime('2026-01-25T10:00:00Z'); 220 + // "2 hours ago" 221 + ``` 222 + 223 + ## Types 224 + 225 + ```typescript 226 + interface 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 + 238 + interface CommentAuthor { 239 + did: string; 240 + handle: string; 241 + displayName?: string; 242 + avatar?: string; 243 + } 244 + ``` 245 + 246 + ## Styling 247 + 248 + The 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 + 268 + Build 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 + 310 + Show both replies and mentions: 311 + 312 + ```typescript 313 + const [replies, mentions] = await Promise.all([ 314 + fetchComments({ 315 + bskyPostUri: post.bskyPostUri, 316 + canonicalUrl: post.url 317 + }), 318 + fetchMentionComments(post.url) 319 + ]); 320 + 321 + const allComments = [...replies, ...mentions]; 322 + ``` 323 + 324 + ### Filter by Language 325 + 326 + ```typescript 327 + const comments = await fetchComments({ 328 + bskyPostUri, 329 + canonicalUrl 330 + }); 331 + 332 + const englishComments = comments.filter((c) => { 333 + // You'd need to add language detection 334 + return detectLanguage(c.text) === 'en'; 335 + }); 336 + ``` 337 + 338 + ### Moderation 339 + 340 + Since these are from Bluesky, you can use their moderation tools: 341 + 342 + ```typescript 343 + const comments = await fetchComments({ 344 + bskyPostUri, 345 + canonicalUrl 346 + }); 347 + 348 + // Filter out blocked users 349 + const moderated = comments.filter((c) => { 350 + return !isUserBlocked(c.author.did); 351 + }); 352 + ``` 353 + 354 + ## Best Practices 355 + 356 + 1. **Always include canonical URL** - Helps with mention detection 357 + 2. **Set appropriate maxDepth** - Too deep can be overwhelming (3 is good) 358 + 3. **Show "Reply on Bluesky" link** - Encourages engagement 359 + 4. **Handle loading states** - Comments load async 360 + 5. **Cache on server** - Fetch in load() for better performance 361 + 6. **Respect privacy** - Remember these are public Bluesky posts 362 + 7. **Test thoroughly** - Ensure AT-URI is correct 363 + 364 + ## Troubleshooting 365 + 366 + ### Comments Not Loading 367 + 368 + 1. **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 + ``` 373 + 2. **Verify the post exists** - Visit it on bsky.app 374 + 3. **Check console** - Look for error messages 375 + 4. **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 + 395 + For static sites (using adapter-static): 396 + 397 + 1. **Pre-build comments** 398 + 399 + ```typescript 400 + // scripts/prebuild-comments.ts 401 + const posts = await getAllPosts(); 402 + 403 + for (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 + 415 + 2. **Load from static file** 416 + 417 + ```typescript 418 + // +page.server.ts 419 + export 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 + 426 + 3. **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)
+363
docs/content-transformation.md
··· 1 + # Content Transformation 2 + 3 + Content transformation utilities convert your markdown content into formats suitable for ATProto publishing. 4 + 5 + ## Why Transform Content? 6 + 7 + When publishing to ATProto, you need to: 8 + 9 + 1. **Convert sidenotes** - HTML sidenotes → markdown blockquotes 10 + 2. **Resolve links** - Relative URLs → absolute URLs 11 + 3. **Extract plain text** - For search indexing (`textContent` field) 12 + 4. **Calculate metadata** - Word count and reading time 13 + 14 + ## Quick Start 15 + 16 + ```typescript 17 + import { transformContent } from 'svelte-standard-site/content'; 18 + 19 + const markdown = ` 20 + # My Blog Post 21 + 22 + This is [a link](/about) with some content. 23 + 24 + <div class="sidenote"> 25 + <span class="sidenote-label">Tip</span> 26 + <p>This is helpful information</p> 27 + </div> 28 + `; 29 + 30 + const result = transformContent(markdown, { 31 + baseUrl: 'https://yourblog.com' 32 + }); 33 + 34 + // result.markdown - Clean markdown for ATProto 35 + // result.textContent - Plain text for search 36 + // result.wordCount - Number of words 37 + // result.readingTime - Estimated minutes to read 38 + ``` 39 + 40 + ## Individual Functions 41 + 42 + ### Convert Sidenotes 43 + 44 + Transform HTML sidenotes into markdown blockquotes: 45 + 46 + ```typescript 47 + import { convertSidenotes, convertComplexSidenotes } from 'svelte-standard-site/content'; 48 + 49 + const input = ` 50 + <div class="sidenote sidenote--tip"> 51 + <span class="sidenote-label">Tip</span> 52 + <p>This is a helpful tip</p> 53 + </div> 54 + `; 55 + 56 + const output = convertSidenotes(input); 57 + // > **Tip:** This is a helpful tip 58 + 59 + // For complex sidenotes with multiple paragraphs: 60 + const complex = convertComplexSidenotes(input); 61 + ``` 62 + 63 + ### Resolve Relative Links 64 + 65 + Convert relative URLs to absolute: 66 + 67 + ```typescript 68 + import { resolveRelativeLinks } from 'svelte-standard-site/content'; 69 + 70 + const input = ` 71 + [About page](/about) 72 + ![Image](/images/photo.jpg) 73 + `; 74 + 75 + const output = resolveRelativeLinks(input, 'https://yourblog.com'); 76 + // [About page](https://yourblog.com/about) 77 + // ![Image](https://yourblog.com/images/photo.jpg) 78 + ``` 79 + 80 + ### Strip to Plain Text 81 + 82 + Extract plain text from markdown: 83 + 84 + ```typescript 85 + import { stripToPlainText } from 'svelte-standard-site/content'; 86 + 87 + const markdown = ` 88 + # Heading 89 + 90 + This is **bold** and *italic*. 91 + 92 + [Link](https://example.com) 93 + `; 94 + 95 + const plain = stripToPlainText(markdown); 96 + // Heading 97 + // This is bold and italic. 98 + // Link 99 + ``` 100 + 101 + ### Calculate Metadata 102 + 103 + ```typescript 104 + import { countWords, calculateReadingTime } from 'svelte-standard-site/content'; 105 + 106 + const text = 'Your blog post content here...'; 107 + const words = countWords(text); // 42 108 + const minutes = calculateReadingTime(words); // 1 (assumes 200 wpm) 109 + 110 + // Custom reading speed 111 + const slowRead = calculateReadingTime(words, 150); // Slower pace 112 + ``` 113 + 114 + ## Complete Pipeline 115 + 116 + The `transformContent` function runs all transformations: 117 + 118 + ```typescript 119 + import { transformContent } from 'svelte-standard-site/content'; 120 + 121 + const result = transformContent(rawMarkdown, { 122 + baseUrl: 'https://yourblog.com', 123 + postPath: '/blog/my-post' // Optional 124 + }); 125 + 126 + // Use in publisher 127 + await publisher.publishDocument({ 128 + site: 'https://yourblog.com', 129 + title: 'My Post', 130 + publishedAt: new Date().toISOString(), 131 + content: { 132 + $type: 'site.standard.content.markdown', 133 + text: result.markdown, 134 + version: '1.0' 135 + }, 136 + textContent: result.textContent 137 + }); 138 + ``` 139 + 140 + ## Use Cases 141 + 142 + ### Publishing from Markdown Files 143 + 144 + ```typescript 145 + import fs from 'fs'; 146 + import matter from 'gray-matter'; 147 + import { transformContent } from 'svelte-standard-site/content'; 148 + 149 + const file = fs.readFileSync('./posts/my-post.md', 'utf-8'); 150 + const { data, content } = matter(file); 151 + 152 + const transformed = transformContent(content, { 153 + baseUrl: 'https://yourblog.com' 154 + }); 155 + 156 + // Now publish... 157 + ``` 158 + 159 + ### SvelteKit Form Actions 160 + 161 + ```typescript 162 + // src/routes/admin/publish/+page.server.ts 163 + import { transformContent } from 'svelte-standard-site/content'; 164 + import type { Actions } from './$types'; 165 + 166 + export const actions = { 167 + publish: async ({ request }) => { 168 + const formData = await request.formData(); 169 + const markdown = formData.get('content') as string; 170 + 171 + const transformed = transformContent(markdown, { 172 + baseUrl: 'https://yourblog.com' 173 + }); 174 + 175 + // Publish using transformed content 176 + } 177 + } satisfies Actions; 178 + ``` 179 + 180 + ### Preview with Metadata 181 + 182 + ```svelte 183 + <script lang="ts"> 184 + import { transformContent } from 'svelte-standard-site/content'; 185 + 186 + let markdown = $state(''); 187 + let preview = $derived( 188 + transformContent(markdown, { 189 + baseUrl: 'https://yourblog.com' 190 + }) 191 + ); 192 + </script> 193 + 194 + <div> 195 + <textarea bind:value={markdown} /> 196 + 197 + <div class="stats"> 198 + <p>Words: {preview.wordCount}</p> 199 + <p>Reading time: {preview.readingTime} min</p> 200 + </div> 201 + 202 + <div class="preview"> 203 + {@html marked(preview.markdown)} 204 + </div> 205 + </div> 206 + ``` 207 + 208 + ## Advanced Examples 209 + 210 + ### Custom Sidenote Formats 211 + 212 + If you have custom sidenote HTML, create your own converter: 213 + 214 + ```typescript 215 + function convertCustomSidenotes(markdown: string): string { 216 + const regex = /<aside class="note">([\s\S]*?)<\/aside>/gi; 217 + 218 + return markdown.replace(regex, (match, content) => { 219 + const clean = content.replace(/<[^>]+>/g, '').trim(); 220 + return `\n> ${clean}\n`; 221 + }); 222 + } 223 + 224 + // Use in pipeline 225 + import { resolveRelativeLinks, stripToPlainText } from 'svelte-standard-site/content'; 226 + 227 + let transformed = convertCustomSidenotes(markdown); 228 + transformed = resolveRelativeLinks(transformed, baseUrl); 229 + const textContent = stripToPlainText(transformed); 230 + ``` 231 + 232 + ### Preserve Certain HTML 233 + 234 + If you want to keep some HTML in the markdown: 235 + 236 + ```typescript 237 + function stripToPlainTextPreserveCode(markdown: string): string { 238 + // Extract code blocks first 239 + const codeBlocks: string[] = []; 240 + let text = markdown.replace(/```[\s\S]*?```/g, (match) => { 241 + codeBlocks.push(match); 242 + return `__CODE_BLOCK_${codeBlocks.length - 1}__`; 243 + }); 244 + 245 + // Strip other markdown 246 + text = stripToPlainText(text); 247 + 248 + // Restore code blocks 249 + text = text.replace(/__CODE_BLOCK_(\d+)__/g, (_, index) => codeBlocks[parseInt(index)]); 250 + 251 + return text; 252 + } 253 + ``` 254 + 255 + ### Image Alt Text Extraction 256 + 257 + Extract all image alt text for accessibility metadata: 258 + 259 + ```typescript 260 + function extractImageAltText(markdown: string): string[] { 261 + const regex = /!\[([^\]]*)\]/g; 262 + const matches = []; 263 + let match; 264 + 265 + while ((match = regex.exec(markdown)) !== null) { 266 + if (match[1]) { 267 + matches.push(match[1]); 268 + } 269 + } 270 + 271 + return matches; 272 + } 273 + 274 + const altTexts = extractImageAltText(markdown); 275 + // ['Photo of sunset', 'Diagram showing architecture'] 276 + ``` 277 + 278 + ## Best Practices 279 + 280 + 1. **Always transform before publishing** - Don't skip transformation 281 + 2. **Include textContent** - Essential for search and accessibility 282 + 3. **Use absolute URLs** - Prevents broken links on other platforms 283 + 4. **Test transformations** - Write tests for custom sidenotes 284 + 5. **Validate output** - Ensure markdown is valid before publishing 285 + 6. **Consider locale** - If using date formatting, respect user locale 286 + 287 + ## Common Issues 288 + 289 + ### Sidenotes Not Converting 290 + 291 + Make sure the HTML structure matches exactly: 292 + 293 + ```html 294 + <!-- This works --> 295 + <div class="sidenote"> 296 + <span class="sidenote-label">Note</span> 297 + <p>Content</p> 298 + </div> 299 + 300 + <!-- This won't work (missing class) --> 301 + <div> 302 + <span>Note</span> 303 + <p>Content</p> 304 + </div> 305 + ``` 306 + 307 + ### Links Not Resolving 308 + 309 + Ensure you're passing the base URL correctly: 310 + 311 + ```typescript 312 + // ❌ Wrong - missing protocol 313 + transformContent(md, { baseUrl: 'yourblog.com' }); 314 + 315 + // ✅ Correct 316 + transformContent(md, { baseUrl: 'https://yourblog.com' }); 317 + 318 + // ✅ Also correct - trailing slash is OK 319 + transformContent(md, { baseUrl: 'https://yourblog.com/' }); 320 + ``` 321 + 322 + ### Plain Text Too Long 323 + 324 + The `textContent` field should be shorter than the markdown. If it's the same length, check that your markdown is being processed: 325 + 326 + ```typescript 327 + const result = transformContent(markdown, options); 328 + 329 + console.log('Markdown length:', result.markdown.length); 330 + console.log('Text length:', result.textContent.length); 331 + // Text should be shorter 332 + ``` 333 + 334 + ## Performance Tips 335 + 336 + For large documents, transformation can be slow. Consider: 337 + 338 + 1. **Cache results** - Don't re-transform unchanged content 339 + 2. **Transform on build** - Pre-transform content at build time 340 + 3. **Lazy load** - Transform on-demand for preview 341 + 342 + ```typescript 343 + // Cache example 344 + const cache = new Map(); 345 + 346 + function getCachedTransform(markdown: string, options: TransformOptions) { 347 + const key = `${markdown.substring(0, 100)}:${options.baseUrl}`; 348 + 349 + if (cache.has(key)) { 350 + return cache.get(key); 351 + } 352 + 353 + const result = transformContent(markdown, options); 354 + cache.set(key, result); 355 + return result; 356 + } 357 + ``` 358 + 359 + ## Next Steps 360 + 361 + - [Publishing](./publishing.md) 362 + - [Verification](./verification.md) 363 + - [Comments](./comments.md)
+304
docs/publishing.md
··· 1 + # Publishing to ATProto 2 + 3 + This guide explains how to publish content FROM your SvelteKit site TO the ATProto network (Bluesky, Leaflet, WhiteWind, etc.). 4 + 5 + ## Prerequisites 6 + 7 + 1. A Bluesky account (or any ATProto account) 8 + 2. An app password (NOT your main password) 9 + - Get one at: https://bsky.app/settings/app-passwords 10 + 3. Your DID (Decentralized Identifier) 11 + - Find it at: https://bsky.app/settings 12 + 13 + ## Quick Start 14 + 15 + ### 1. Install Dependencies 16 + 17 + ```bash 18 + pnpm add svelte-standard-site zod 19 + ``` 20 + 21 + ### 2. Create a Publication 22 + 23 + A publication represents your blog/site on ATProto. 24 + 25 + ```typescript 26 + // scripts/create-publication.ts 27 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 28 + 29 + const publisher = new StandardSitePublisher({ 30 + identifier: 'you.bsky.social', // or your DID 31 + password: process.env.ATPROTO_APP_PASSWORD! 32 + }); 33 + 34 + await publisher.login(); 35 + 36 + const result = await publisher.publishPublication({ 37 + name: 'My Awesome Blog', 38 + url: 'https://yourblog.com', 39 + description: 'Thoughts on code, life, and everything', 40 + basicTheme: { 41 + background: { r: 255, g: 245, b: 235 }, 42 + foreground: { r: 30, g: 30, b: 30 }, 43 + accent: { r: 74, g: 124, b: 155 }, 44 + accentForeground: { r: 255, g: 255, b: 255 } 45 + } 46 + }); 47 + 48 + console.log('Publication created!'); 49 + console.log('AT-URI:', result.uri); 50 + console.log('Save this rkey:', result.uri.split('/').pop()); 51 + ``` 52 + 53 + Run it: 54 + 55 + ```bash 56 + ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" node scripts/create-publication.ts 57 + ``` 58 + 59 + ### 3. Publish Documents 60 + 61 + Create a script to sync your blog posts to ATProto: 62 + 63 + ```typescript 64 + // scripts/publish-posts.ts 65 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 66 + import { transformContent } from 'svelte-standard-site/content'; 67 + import fs from 'fs'; 68 + import matter from 'gray-matter'; 69 + 70 + const publisher = new StandardSitePublisher({ 71 + identifier: 'you.bsky.social', 72 + password: process.env.ATPROTO_APP_PASSWORD! 73 + }); 74 + 75 + await publisher.login(); 76 + 77 + // Read your markdown files 78 + const files = fs.readdirSync('./content/posts'); 79 + 80 + for (const file of files) { 81 + const content = fs.readFileSync(`./content/posts/${file}`, 'utf-8'); 82 + const { data, content: markdown } = matter(content); 83 + 84 + // Transform content for ATProto 85 + const transformed = transformContent(markdown, { 86 + baseUrl: 'https://yourblog.com' 87 + }); 88 + 89 + // Publish to ATProto 90 + const result = await publisher.publishDocument({ 91 + site: 'https://yourblog.com', // or AT-URI of your publication 92 + title: data.title, 93 + description: data.description, 94 + publishedAt: data.date.toISOString(), 95 + path: `/posts/${file.replace('.md', '')}`, 96 + tags: data.tags, 97 + content: { 98 + $type: 'site.standard.content.markdown', 99 + text: transformed.markdown, 100 + version: '1.0' 101 + }, 102 + textContent: transformed.textContent 103 + }); 104 + 105 + console.log(`Published: ${data.title}`); 106 + console.log(` → ${result.uri}`); 107 + } 108 + ``` 109 + 110 + ## Advanced Usage 111 + 112 + ### Update Existing Documents 113 + 114 + ```typescript 115 + // Get the rkey from the original publish result 116 + const rkey = '3abc123xyz789'; 117 + 118 + await publisher.updateDocument(rkey, { 119 + site: 'https://yourblog.com', 120 + title: 'Updated Title', 121 + publishedAt: originalDate.toISOString(), 122 + updatedAt: new Date().toISOString(), 123 + content: { 124 + $type: 'site.standard.content.markdown', 125 + text: updatedMarkdown 126 + } 127 + }); 128 + ``` 129 + 130 + ### Delete Documents 131 + 132 + ```typescript 133 + await publisher.deleteDocument('3abc123xyz789'); 134 + ``` 135 + 136 + ### List Your Published Documents 137 + 138 + ```typescript 139 + const documents = await publisher.listDocuments(); 140 + 141 + for (const doc of documents) { 142 + console.log(`${doc.value.title} - ${doc.uri}`); 143 + } 144 + ``` 145 + 146 + ### Custom Themes 147 + 148 + ```typescript 149 + await publisher.publishPublication({ 150 + name: 'Dark Mode Blog', 151 + url: 'https://yourblog.com', 152 + basicTheme: { 153 + background: { r: 13, g: 17, b: 23 }, // Dark 154 + foreground: { r: 230, g: 237, b: 243 }, // Light text 155 + accent: { r: 136, g: 58, b: 234 }, // Purple 156 + accentForeground: { r: 255, g: 255, b: 255 } 157 + } 158 + }); 159 + ``` 160 + 161 + ### With Cover Images 162 + 163 + First, upload the image as a blob: 164 + 165 + ```typescript 166 + const agent = publisher.getAtpAgent(); 167 + 168 + const imageBuffer = fs.readFileSync('./cover.jpg'); 169 + const uploadResult = await agent.uploadBlob(imageBuffer, { 170 + encoding: 'image/jpeg' 171 + }); 172 + 173 + await publisher.publishDocument({ 174 + // ...other fields 175 + coverImage: { 176 + $type: 'blob', 177 + ref: { $link: uploadResult.data.blob.ref.$link }, 178 + mimeType: 'image/jpeg', 179 + size: imageBuffer.length 180 + } 181 + }); 182 + ``` 183 + 184 + ## SvelteKit Integration 185 + 186 + ### Create an Admin Route 187 + 188 + ```typescript 189 + // src/routes/admin/publish/+page.server.ts 190 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 191 + import { env } from '$env/dynamic/private'; 192 + import { error } from '@sveltejs/kit'; 193 + import type { PageServerLoad, Actions } from './$types'; 194 + 195 + export const load: PageServerLoad = async () => { 196 + // List existing documents 197 + const publisher = new StandardSitePublisher({ 198 + identifier: env.ATPROTO_HANDLE!, 199 + password: env.ATPROTO_APP_PASSWORD! 200 + }); 201 + 202 + await publisher.login(); 203 + const documents = await publisher.listDocuments(); 204 + 205 + return { 206 + documents 207 + }; 208 + }; 209 + 210 + export const actions = { 211 + publish: async ({ request }) => { 212 + const data = await request.formData(); 213 + const title = data.get('title') as string; 214 + const content = data.get('content') as string; 215 + 216 + const publisher = new StandardSitePublisher({ 217 + identifier: env.ATPROTO_HANDLE!, 218 + password: env.ATPROTO_APP_PASSWORD! 219 + }); 220 + 221 + await publisher.login(); 222 + 223 + const result = await publisher.publishDocument({ 224 + site: env.PUBLIC_SITE_URL!, 225 + title, 226 + publishedAt: new Date().toISOString(), 227 + content: { 228 + $type: 'site.standard.content.markdown', 229 + text: content 230 + } 231 + }); 232 + 233 + return { success: true, uri: result.uri }; 234 + } 235 + } satisfies Actions; 236 + ``` 237 + 238 + ## Important Notes 239 + 240 + ### Security 241 + 242 + 1. **Never commit app passwords** - Use environment variables 243 + 2. **Never use main password** - Always use app passwords 244 + 3. **Validate input** - Always validate data before publishing 245 + 4. **Rate limiting** - Be mindful of API rate limits 246 + 247 + ### TID Format 248 + 249 + Record keys (rkeys) MUST be TIDs (Timestamp Identifiers). The publisher generates these automatically. Do not manually create rkeys. 250 + 251 + ### PDS Resolution 252 + 253 + The publisher automatically resolves your PDS from your DID document. You don't need to specify it unless you're using a custom PDS. 254 + 255 + ### Content Types 256 + 257 + The `content` field is an open union. Different platforms support different types: 258 + 259 + - `site.standard.content.markdown` - Markdown content 260 + - `site.standard.content.html` - HTML content 261 + - Platform-specific types 262 + 263 + Always include `textContent` for search/indexing. 264 + 265 + ## Troubleshooting 266 + 267 + ### "Failed to resolve handle" 268 + 269 + - Check your handle is correct 270 + - Verify your PDS is reachable 271 + - Ensure you're using an app password 272 + 273 + ### "Schema validation failed" 274 + 275 + - Check your data matches the schema 276 + - Ensure dates are ISO 8601 format 277 + - Verify URLs are valid 278 + 279 + ### "Invalid TID" 280 + 281 + - Don't manually create rkeys 282 + - Let the publisher generate TIDs automatically 283 + 284 + ### "Authentication failed" 285 + 286 + - Verify your app password is correct 287 + - Check it hasn't been revoked 288 + - Ensure you're not using your main password 289 + 290 + ## Best Practices 291 + 292 + 1. **Use content transformation** - Always run markdown through `transformContent()` 293 + 2. **Include textContent** - Provides plain text for search 294 + 3. **Add descriptions** - Helps with discovery 295 + 4. **Use tags** - Categorize your content 296 + 5. **Set updatedAt** - Track when content changes 297 + 6. **Link Bluesky posts** - Use `bskyPostRef` for engagement 298 + 7. **Verify ownership** - Set up `.well-known` endpoints 299 + 300 + ## Next Steps 301 + 302 + - [Content Transformation](./content-transformation.md) 303 + - [Verification](./verification.md) 304 + - [Comments](./comments.md)
+412
docs/verification.md
··· 1 + # Content Verification 2 + 3 + Verification proves that you own the content you've published to ATProto. This is done through `.well-known` endpoints and `<link>` tags. 4 + 5 + ## Why Verify? 6 + 7 + Verification allows platforms like Leaflet and WhiteWind to: 8 + 9 + 1. Confirm you control the content you claim to have published 10 + 2. Prevent impersonation 11 + 3. Enable features that require ownership proof 12 + 4. Build trust in the federated ecosystem 13 + 14 + ## Quick Start 15 + 16 + ### 1. Create .well-known Endpoint 17 + 18 + Create a SvelteKit endpoint at `.well-known/site.standard.publication`: 19 + 20 + ```typescript 21 + // src/routes/.well-known/site.standard.publication/+server.ts 22 + import { text } from '@sveltejs/kit'; 23 + import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 24 + 25 + export function GET() { 26 + return text( 27 + generatePublicationWellKnown({ 28 + did: 'did:plc:your-did-here', 29 + publicationRkey: '3abc123xyz789' // From publication creation 30 + }) 31 + ); 32 + } 33 + ``` 34 + 35 + ### 2. Verify It Works 36 + 37 + ```bash 38 + curl https://yourblog.com/.well-known/site.standard.publication 39 + # Should output: at://did:plc:xxx/site.standard.publication/3abc123xyz789 40 + ``` 41 + 42 + ### 3. Add Link Tag (Optional) 43 + 44 + Add verification to individual documents: 45 + 46 + ```svelte 47 + <!-- src/routes/blog/[slug]/+page.svelte --> 48 + <script lang="ts"> 49 + import { generateDocumentLinkTag } from 'svelte-standard-site/verification'; 50 + 51 + const { data } = $props(); 52 + </script> 53 + 54 + <svelte:head> 55 + {@html generateDocumentLinkTag({ 56 + did: 'did:plc:xxx', 57 + documentRkey: data.rkey 58 + })} 59 + </svelte:head> 60 + ``` 61 + 62 + ## Functions 63 + 64 + ### generatePublicationWellKnown 65 + 66 + Generate content for the `.well-known` endpoint: 67 + 68 + ```typescript 69 + import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 70 + 71 + const content = generatePublicationWellKnown({ 72 + did: 'did:plc:xxx', 73 + publicationRkey: '3abc123xyz' 74 + }); 75 + // Returns: "at://did:plc:xxx/site.standard.publication/3abc123xyz" 76 + ``` 77 + 78 + ### generateDocumentLinkTag 79 + 80 + Generate a `<link>` tag for a specific document: 81 + 82 + ```typescript 83 + import { generateDocumentLinkTag } from 'svelte-standard-site/verification'; 84 + 85 + const tag = generateDocumentLinkTag({ 86 + did: 'did:plc:xxx', 87 + documentRkey: '3xyz789abc' 88 + }); 89 + // Returns: '<link rel="site.standard.document" href="at://...">' 90 + ``` 91 + 92 + ### generatePublicationLinkTag 93 + 94 + Generate a `<link>` tag for your publication: 95 + 96 + ```typescript 97 + import { generatePublicationLinkTag } from 'svelte-standard-site/verification'; 98 + 99 + const tag = generatePublicationLinkTag({ 100 + did: 'did:plc:xxx', 101 + publicationRkey: '3abc123xyz' 102 + }); 103 + ``` 104 + 105 + ### verifyPublicationWellKnown 106 + 107 + Programmatically verify a site's `.well-known` endpoint: 108 + 109 + ```typescript 110 + import { verifyPublicationWellKnown } from 'svelte-standard-site/verification'; 111 + 112 + const isValid = await verifyPublicationWellKnown( 113 + 'https://example.com', 114 + 'did:plc:xxx', 115 + '3abc123xyz' 116 + ); 117 + 118 + if (isValid) { 119 + console.log('Site is verified!'); 120 + } 121 + ``` 122 + 123 + ## Complete Setup 124 + 125 + ### Get Your Publication Rkey 126 + 127 + When you create a publication, save the rkey: 128 + 129 + ```typescript 130 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 131 + 132 + const publisher = new StandardSitePublisher({ 133 + identifier: 'you.bsky.social', 134 + password: process.env.ATPROTO_APP_PASSWORD! 135 + }); 136 + 137 + await publisher.login(); 138 + 139 + const result = await publisher.publishPublication({ 140 + name: 'My Blog', 141 + url: 'https://yourblog.com' 142 + }); 143 + 144 + // Save these! 145 + console.log('DID:', publisher.getDid()); 146 + console.log('Rkey:', result.uri.split('/').pop()); 147 + ``` 148 + 149 + ### Store in Environment Variables 150 + 151 + ```env 152 + # .env 153 + PUBLIC_ATPROTO_DID=did:plc:xxx 154 + PUBLIC_PUBLICATION_RKEY=3abc123xyz 155 + ``` 156 + 157 + ### Create Endpoint 158 + 159 + ```typescript 160 + // src/routes/.well-known/site.standard.publication/+server.ts 161 + import { text } from '@sveltejs/kit'; 162 + import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 163 + import { PUBLIC_ATPROTO_DID, PUBLIC_PUBLICATION_RKEY } from '$env/static/public'; 164 + 165 + export function GET() { 166 + return text( 167 + generatePublicationWellKnown({ 168 + did: PUBLIC_ATPROTO_DID, 169 + publicationRkey: PUBLIC_PUBLICATION_RKEY 170 + }), 171 + { 172 + headers: { 173 + 'Content-Type': 'text/plain', 174 + 'Cache-Control': 'public, max-age=3600' 175 + } 176 + } 177 + ); 178 + } 179 + ``` 180 + 181 + ### Add to Site Header 182 + 183 + ```svelte 184 + <!-- src/routes/+layout.svelte --> 185 + <script lang="ts"> 186 + import { generatePublicationLinkTag } from 'svelte-standard-site/verification'; 187 + import { PUBLIC_ATPROTO_DID, PUBLIC_PUBLICATION_RKEY } from '$env/static/public'; 188 + </script> 189 + 190 + <svelte:head> 191 + {@html generatePublicationLinkTag({ 192 + did: PUBLIC_ATPROTO_DID, 193 + publicationRkey: PUBLIC_PUBLICATION_RKEY 194 + })} 195 + </svelte:head> 196 + ``` 197 + 198 + ## Document Verification 199 + 200 + For individual blog posts: 201 + 202 + ### Store Document Rkeys 203 + 204 + When publishing, save the mapping: 205 + 206 + ```typescript 207 + // In your publish script 208 + const result = await publisher.publishDocument({ 209 + // ... document data 210 + }); 211 + 212 + // Save mapping: slug -> rkey 213 + const mapping = { 214 + 'my-first-post': result.uri.split('/').pop(), 215 + 'another-post': '3xyz789abc' 216 + // etc. 217 + }; 218 + 219 + fs.writeFileSync('document-rkeys.json', JSON.stringify(mapping)); 220 + ``` 221 + 222 + ### Add to Document Pages 223 + 224 + ```svelte 225 + <!-- src/routes/blog/[slug]/+page.svelte --> 226 + <script lang="ts"> 227 + import { generateDocumentLinkTag } from 'svelte-standard-site/verification'; 228 + import documentRkeys from '$lib/document-rkeys.json'; 229 + 230 + const { data } = $props(); 231 + const rkey = documentRkeys[data.slug]; 232 + </script> 233 + 234 + <svelte:head> 235 + {#if rkey} 236 + {@html generateDocumentLinkTag({ 237 + did: 'did:plc:xxx', 238 + documentRkey: rkey 239 + })} 240 + {/if} 241 + </svelte:head> 242 + ``` 243 + 244 + ## AT-URI Utilities 245 + 246 + ### Build AT-URIs 247 + 248 + ```typescript 249 + import { getDocumentAtUri, getPublicationAtUri } from 'svelte-standard-site/verification'; 250 + 251 + const docUri = getDocumentAtUri('did:plc:xxx', '3xyz789abc'); 252 + // "at://did:plc:xxx/site.standard.document/3xyz789abc" 253 + 254 + const pubUri = getPublicationAtUri('did:plc:xxx', '3abc123xyz'); 255 + // "at://did:plc:xxx/site.standard.publication/3abc123xyz" 256 + ``` 257 + 258 + ### Parse AT-URIs 259 + 260 + ```typescript 261 + import { parseAtUri } from 'svelte-standard-site/verification'; 262 + 263 + const parsed = parseAtUri('at://did:plc:xxx/site.standard.document/3xyz'); 264 + 265 + if (parsed) { 266 + console.log(parsed.did); // "did:plc:xxx" 267 + console.log(parsed.collection); // "site.standard.document" 268 + console.log(parsed.rkey); // "3xyz" 269 + } 270 + ``` 271 + 272 + ### Extract from HTML 273 + 274 + ```typescript 275 + import { extractDocumentLinkFromHtml, extractPublicationLinkFromHtml } from 'svelte-standard-site/verification'; 276 + 277 + const html = await fetch('https://example.com/post').then((r) => r.text()); 278 + 279 + const docUri = extractDocumentLinkFromHtml(html); 280 + // "at://did:plc:xxx/site.standard.document/3xyz" 281 + 282 + const pubUri = extractPublicationLinkFromHtml(html); 283 + // "at://did:plc:xxx/site.standard.publication/3abc" 284 + ``` 285 + 286 + ## Verification Flow 287 + 288 + 1. **Publish** a publication to ATProto 289 + 2. **Save** the DID and rkey 290 + 3. **Create** `.well-known` endpoint returning the AT-URI 291 + 4. **Optionally** add `<link>` tags to your HTML 292 + 5. **Platforms** fetch your `.well-known` endpoint to verify ownership 293 + 294 + ```mermaid 295 + sequenceDiagram 296 + participant You 297 + participant ATProto 298 + participant Platform 299 + 300 + You->>ATProto: Publish publication 301 + ATProto->>You: Return AT-URI 302 + You->>Your Site: Add .well-known endpoint 303 + Platform->>Your Site: Fetch .well-known 304 + Your Site->>Platform: Return AT-URI 305 + Platform->>ATProto: Verify AT-URI exists 306 + ATProto->>Platform: Confirmed 307 + Platform->>Platform: Mark as verified ✓ 308 + ``` 309 + 310 + ## Troubleshooting 311 + 312 + ### .well-known Returning 404 313 + 314 + SvelteKit requires special handling for `.well-known`: 315 + 316 + ```typescript 317 + // Option 1: Create the exact path 318 + // src/routes/.well-known/site.standard.publication/+server.ts 319 + 320 + // Option 2: Use static files 321 + // static/.well-known/site.standard.publication 322 + ``` 323 + 324 + If using static files, make sure your hosting platform allows `.well-known`. 325 + 326 + ### Wrong MIME Type 327 + 328 + Ensure you're returning `text/plain`: 329 + 330 + ```typescript 331 + export function GET() { 332 + return text(content, { 333 + headers: { 334 + 'Content-Type': 'text/plain' 335 + } 336 + }); 337 + } 338 + ``` 339 + 340 + ### CORS Issues 341 + 342 + If platforms can't access your endpoint: 343 + 344 + ```typescript 345 + export function GET() { 346 + return text(content, { 347 + headers: { 348 + 'Content-Type': 'text/plain', 349 + 'Access-Control-Allow-Origin': '*' 350 + } 351 + }); 352 + } 353 + ``` 354 + 355 + ### Verification Failing 356 + 357 + Use the verification utility to test: 358 + 359 + ```typescript 360 + const isValid = await verifyPublicationWellKnown( 361 + 'https://yourblog.com', 362 + 'did:plc:xxx', 363 + '3abc123xyz' 364 + ); 365 + 366 + if (!isValid) { 367 + // Check: 368 + // 1. .well-known endpoint is accessible 369 + // 2. Returns exact AT-URI 370 + // 3. No extra whitespace 371 + // 4. Correct MIME type 372 + } 373 + ``` 374 + 375 + ## Best Practices 376 + 377 + 1. **Use environment variables** - Don't hardcode DIDs/rkeys 378 + 2. **Add caching headers** - `.well-known` content doesn't change often 379 + 3. **Test before deploying** - Verify the endpoint works 380 + 4. **Keep rkeys secure** - Don't expose in client code unnecessarily 381 + 5. **Monitor** - Check that verification keeps working after deploys 382 + 383 + ## Hosting Platform Notes 384 + 385 + ### Vercel 386 + 387 + Works out of the box. No special configuration needed. 388 + 389 + ### Netlify 390 + 391 + Add to `netlify.toml`: 392 + 393 + ```toml 394 + [[redirects]] 395 + from = "/.well-known/site.standard.publication" 396 + to = "/.well-known/site.standard.publication/index.html" 397 + status = 200 398 + ``` 399 + 400 + ### Cloudflare Pages 401 + 402 + Works by default. Consider adding a cache rule for `.well-known`. 403 + 404 + ### GitHub Pages 405 + 406 + Static files work, but SvelteKit endpoints don't. Use the static file approach. 407 + 408 + ## Next Steps 409 + 410 + - [Publishing](./publishing.md) 411 + - [Content Transformation](./content-transformation.md) 412 + - [Comments](./comments.md)
+32 -4
package.json
··· 16 16 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 17 17 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 18 18 "format": "prettier --write .", 19 - "lint": "prettier --check ." 19 + "lint": "prettier --check .", 20 + "test": "vitest run", 21 + "test:watch": "vitest" 20 22 }, 21 23 "files": [ 22 24 "dist", ··· 34 36 "types": "./dist/index.d.ts", 35 37 "svelte": "./dist/index.js" 36 38 }, 39 + "./publisher": { 40 + "types": "./dist/publisher.d.ts", 41 + "default": "./dist/publisher.js" 42 + }, 43 + "./content": { 44 + "types": "./dist/utils/content.d.ts", 45 + "default": "./dist/utils/content.js" 46 + }, 47 + "./comments": { 48 + "types": "./dist/utils/comments.d.ts", 49 + "default": "./dist/utils/comments.js" 50 + }, 51 + "./verification": { 52 + "types": "./dist/utils/verification.d.ts", 53 + "default": "./dist/utils/verification.js" 54 + }, 55 + "./schemas": { 56 + "types": "./dist/schemas.d.ts", 57 + "default": "./dist/schemas.js" 58 + }, 37 59 "./config/env": { 38 60 "types": "./dist/config/env.d.ts", 39 61 "default": "./dist/config/env.js" ··· 56 78 "@sveltejs/vite-plugin-svelte": "^6.2.1", 57 79 "@tailwindcss/typography": "^0.5.19", 58 80 "@tailwindcss/vite": "^4.1.17", 81 + "@types/node": "^22.0.0", 59 82 "prettier": "^3.7.4", 60 83 "prettier-plugin-svelte": "^3.4.0", 61 84 "prettier-plugin-tailwindcss": "^0.7.2", ··· 64 87 "svelte-check": "^4.3.4", 65 88 "tailwindcss": "^4.1.17", 66 89 "typescript": "^5.9.3", 67 - "vite": "^7.2.6" 90 + "vite": "^7.2.6", 91 + "vitest": "^4.0.16" 68 92 }, 69 93 "keywords": [ 70 94 "svelte", ··· 79 103 "components", 80 104 "dark-mode", 81 105 "light-mode", 82 - "theme" 106 + "theme", 107 + "publishing", 108 + "federation", 109 + "comments" 83 110 ], 84 111 "repository": { 85 112 "type": "git", ··· 93 120 "@atproto/api": "^0.18.16", 94 121 "@lucide/svelte": "^0.562.0", 95 122 "katex": "^0.16.27", 96 - "shiki": "^3.21.0" 123 + "shiki": "^3.21.0", 124 + "zod": "^3.24.0" 97 125 } 98 126 }
+181
scripts/test-publisher.js
··· 1 + /** 2 + * Example script to test publishing to ATProto 3 + * 4 + * Usage: 5 + * ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" node scripts/test-publisher.js 6 + * 7 + * This script demonstrates: 8 + * 1. Creating a publisher instance 9 + * 2. Logging in 10 + * 3. Publishing a publication 11 + * 4. Publishing a document 12 + * 5. Listing your content 13 + */ 14 + 15 + import { StandardSitePublisher } from '../src/lib/publisher.js'; 16 + import { transformContent } from '../src/lib/utils/content.js'; 17 + 18 + async function main() { 19 + // Check for app password 20 + if (!process.env.ATPROTO_APP_PASSWORD) { 21 + console.error('Error: ATPROTO_APP_PASSWORD environment variable not set'); 22 + console.error(''); 23 + console.error('Get an app password at: https://bsky.app/settings/app-passwords'); 24 + console.error(''); 25 + console.error('Usage:'); 26 + console.error(' ATPROTO_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" node scripts/test-publisher.js'); 27 + process.exit(1); 28 + } 29 + 30 + console.log('🚀 Testing svelte-standard-site Publisher\n'); 31 + 32 + // Step 1: Create publisher 33 + console.log('Step 1: Creating publisher instance...'); 34 + const publisher = new StandardSitePublisher({ 35 + identifier: process.env.ATPROTO_HANDLE || 'your-handle.bsky.social', 36 + password: process.env.ATPROTO_APP_PASSWORD 37 + }); 38 + 39 + // Step 2: Login 40 + console.log('Step 2: Logging in...'); 41 + try { 42 + await publisher.login(); 43 + console.log('✅ Logged in successfully'); 44 + console.log(' DID:', publisher.getDid()); 45 + console.log(' PDS:', publisher.getPdsUrl()); 46 + } catch (error) { 47 + console.error('❌ Login failed:', error.message); 48 + process.exit(1); 49 + } 50 + 51 + // Step 3: Create a publication 52 + console.log('\nStep 3: Creating publication...'); 53 + try { 54 + const pubResult = await publisher.publishPublication({ 55 + name: 'Test Blog', 56 + url: 'https://example.com', 57 + description: 'A test publication created by svelte-standard-site', 58 + basicTheme: { 59 + background: { r: 255, g: 250, b: 240 }, 60 + foreground: { r: 30, g: 30, b: 30 }, 61 + accent: { r: 74, g: 124, b: 155 }, 62 + accentForeground: { r: 255, g: 255, b: 255 } 63 + } 64 + }); 65 + 66 + console.log('✅ Publication created'); 67 + console.log(' URI:', pubResult.uri); 68 + console.log(' CID:', pubResult.cid); 69 + console.log(' Rkey:', pubResult.uri.split('/').pop()); 70 + console.log(''); 71 + console.log(' 💡 Save the rkey for verification!'); 72 + } catch (error) { 73 + console.error('❌ Failed to create publication:', error.message); 74 + } 75 + 76 + // Step 4: Transform and publish a document 77 + console.log('\nStep 4: Publishing a test document...'); 78 + 79 + const sampleMarkdown = ` 80 + # Test Blog Post 81 + 82 + This is a test blog post published using \`svelte-standard-site\`. 83 + 84 + ## Features 85 + 86 + - **Markdown support** - Full markdown formatting 87 + - [Links](/about) are automatically resolved 88 + - Content is transformed for ATProto compatibility 89 + 90 + <div class="sidenote"> 91 + <span class="sidenote-label">Note</span> 92 + <p>This sidenote will be converted to a markdown blockquote!</p> 93 + </div> 94 + 95 + ## Code Example 96 + 97 + \`\`\`typescript 98 + import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 99 + 100 + const publisher = new StandardSitePublisher({ 101 + identifier: 'you.bsky.social', 102 + password: process.env.ATPROTO_APP_PASSWORD! 103 + }); 104 + 105 + await publisher.login(); 106 + \`\`\` 107 + 108 + Pretty cool, right? 109 + `.trim(); 110 + 111 + try { 112 + // Transform the content 113 + const transformed = transformContent(sampleMarkdown, { 114 + baseUrl: 'https://example.com' 115 + }); 116 + 117 + console.log(' Content stats:'); 118 + console.log(' - Word count:', transformed.wordCount); 119 + console.log(' - Reading time:', transformed.readingTime, 'min'); 120 + 121 + // Publish the document 122 + const docResult = await publisher.publishDocument({ 123 + site: 'https://example.com', 124 + title: 'Test Blog Post from svelte-standard-site', 125 + publishedAt: new Date().toISOString(), 126 + path: '/posts/test-post', 127 + description: 'A test document to verify publishing works', 128 + tags: ['test', 'example', 'svelte-standard-site'], 129 + content: { 130 + $type: 'site.standard.content.markdown', 131 + text: transformed.markdown, 132 + version: '1.0' 133 + }, 134 + textContent: transformed.textContent 135 + }); 136 + 137 + console.log('\n✅ Document published'); 138 + console.log(' URI:', docResult.uri); 139 + console.log(' CID:', docResult.cid); 140 + console.log(' Rkey:', docResult.uri.split('/').pop()); 141 + } catch (error) { 142 + console.error('❌ Failed to publish document:', error.message); 143 + } 144 + 145 + // Step 5: List all your content 146 + console.log('\nStep 5: Listing your published content...'); 147 + 148 + try { 149 + const [publications, documents] = await Promise.all([ 150 + publisher.listPublications(), 151 + publisher.listDocuments() 152 + ]); 153 + 154 + console.log(`\n📚 Publications (${publications.length}):`); 155 + publications.forEach((pub, i) => { 156 + console.log(` ${i + 1}. ${pub.value.name}`); 157 + console.log(` URL: ${pub.value.url}`); 158 + console.log(` URI: ${pub.uri}`); 159 + }); 160 + 161 + console.log(`\n📝 Documents (${documents.length}):`); 162 + documents.forEach((doc, i) => { 163 + console.log(` ${i + 1}. ${doc.value.title}`); 164 + console.log(` Published: ${new Date(doc.value.publishedAt).toLocaleDateString()}`); 165 + console.log(` URI: ${doc.uri}`); 166 + }); 167 + } catch (error) { 168 + console.error('❌ Failed to list content:', error.message); 169 + } 170 + 171 + console.log('\n✨ Test complete!'); 172 + console.log('\nNext steps:'); 173 + console.log('1. View your content on pdsls.dev:'); 174 + console.log(` https://pdsls.dev/at://${publisher.getDid()}/site.standard.publication`); 175 + console.log(` https://pdsls.dev/at://${publisher.getDid()}/site.standard.document`); 176 + console.log('2. Set up verification with .well-known endpoints'); 177 + console.log('3. Add Comments component to your blog'); 178 + console.log('\nSee docs/ for more information!'); 179 + } 180 + 181 + main().catch(console.error);
+155
src/lib/__tests__/content.test.ts
··· 1 + /** 2 + * Tests for content transformation utilities 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest'; 6 + import { 7 + convertSidenotes, 8 + resolveRelativeLinks, 9 + stripToPlainText, 10 + countWords, 11 + calculateReadingTime, 12 + transformContent 13 + } from '../utils/content.js'; 14 + 15 + describe('convertSidenotes', () => { 16 + it('converts simple sidenotes to blockquotes', () => { 17 + const input = ` 18 + Some text before. 19 + 20 + <div class="sidenote sidenote--tip"> 21 + <span class="sidenote-label">Tip</span> 22 + <p>This is a helpful tip.</p> 23 + </div> 24 + 25 + Some text after. 26 + `.trim(); 27 + 28 + const expected = ` 29 + Some text before. 30 + 31 + > **Tip:** This is a helpful tip. 32 + 33 + Some text after. 34 + `.trim(); 35 + 36 + expect(convertSidenotes(input)).toBe(expected); 37 + }); 38 + }); 39 + 40 + describe('resolveRelativeLinks', () => { 41 + it('converts relative links to absolute', () => { 42 + const input = ` 43 + [Link to about](/about) 44 + ![Image](/images/photo.jpg) 45 + [External link](https://example.com) should stay the same 46 + `.trim(); 47 + 48 + const expected = ` 49 + [Link to about](https://myblog.com/about) 50 + ![Image](https://myblog.com/images/photo.jpg) 51 + [External link](https://example.com) should stay the same 52 + `.trim(); 53 + 54 + expect(resolveRelativeLinks(input, 'https://myblog.com')).toBe(expected); 55 + }); 56 + 57 + it('handles trailing slash in base URL', () => { 58 + const input = '[Link](/page)'; 59 + const expected = '[Link](https://myblog.com/page)'; 60 + expect(resolveRelativeLinks(input, 'https://myblog.com/')).toBe(expected); 61 + }); 62 + }); 63 + 64 + describe('stripToPlainText', () => { 65 + it('removes markdown formatting', () => { 66 + const input = ` 67 + # Heading 68 + 69 + This is **bold** and *italic* text. 70 + 71 + [Link text](https://example.com) 72 + 73 + \`code\` 74 + 75 + \`\`\` 76 + code block 77 + \`\`\` 78 + `.trim(); 79 + 80 + const result = stripToPlainText(input); 81 + 82 + expect(result).not.toContain('#'); 83 + expect(result).not.toContain('**'); 84 + expect(result).not.toContain('*'); 85 + expect(result).not.toContain('['); 86 + expect(result).not.toContain(']'); 87 + expect(result).not.toContain('`'); 88 + expect(result).toContain('Heading'); 89 + expect(result).toContain('bold'); 90 + expect(result).toContain('italic'); 91 + expect(result).toContain('Link text'); 92 + }); 93 + 94 + it('removes images', () => { 95 + const input = '![Alt text](/image.jpg)'; 96 + const result = stripToPlainText(input); 97 + expect(result).toBe(''); 98 + }); 99 + 100 + it('preserves link text', () => { 101 + const input = '[Click here](https://example.com)'; 102 + const result = stripToPlainText(input); 103 + expect(result).toBe('Click here'); 104 + }); 105 + }); 106 + 107 + describe('countWords', () => { 108 + it('counts words correctly', () => { 109 + expect(countWords('Hello world')).toBe(2); 110 + expect(countWords('One two three four five')).toBe(5); 111 + expect(countWords(' Spaces around words ')).toBe(3); 112 + expect(countWords('')).toBe(0); 113 + }); 114 + }); 115 + 116 + describe('calculateReadingTime', () => { 117 + it('calculates reading time', () => { 118 + expect(calculateReadingTime(200)).toBe(1); // 200 words = 1 minute 119 + expect(calculateReadingTime(400)).toBe(2); // 400 words = 2 minutes 120 + expect(calculateReadingTime(100)).toBe(1); // Always at least 1 minute 121 + expect(calculateReadingTime(0)).toBe(1); // Always at least 1 minute 122 + }); 123 + }); 124 + 125 + describe('transformContent', () => { 126 + it('performs full transformation pipeline', () => { 127 + const input = ` 128 + # My Blog Post 129 + 130 + This is some **markdown** content with [relative links](/about). 131 + 132 + <div class="sidenote"> 133 + <span class="sidenote-label">Note</span> 134 + <p>Important information</p> 135 + </div> 136 + `.trim(); 137 + 138 + const result = transformContent(input, { 139 + baseUrl: 'https://myblog.com' 140 + }); 141 + 142 + // Should have transformed markdown 143 + expect(result.markdown).toContain('(https://myblog.com/about)'); 144 + expect(result.markdown).toContain('> **Note:**'); 145 + 146 + // Should have plain text version 147 + expect(result.textContent).not.toContain('['); 148 + expect(result.textContent).not.toContain('**'); 149 + expect(result.textContent).toContain('markdown'); 150 + 151 + // Should have metadata 152 + expect(result.wordCount).toBeGreaterThan(0); 153 + expect(result.readingTime).toBeGreaterThan(0); 154 + }); 155 + });
+277
src/lib/components/Comments.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Comments component for displaying Bluesky replies 4 + * 5 + * Fetches and displays threaded replies from Bluesky as comments on your blog. 6 + * 7 + * @example 8 + * ```svelte 9 + * <Comments 10 + * bskyPostUri="at://did:plc:xxx/app.bsky.feed.post/abc123" 11 + * canonicalUrl="https://yourblog.com/posts/my-post" 12 + * maxDepth={3} 13 + * /> 14 + * ``` 15 + */ 16 + 17 + import { onMount } from 'svelte'; 18 + import { fetchComments, formatRelativeTime, type Comment } from '../utils/comments.js'; 19 + import { MessageSquare, ExternalLink, Heart } from '@lucide/svelte'; 20 + 21 + interface Props { 22 + /** AT-URI of the Bluesky announcement post */ 23 + bskyPostUri: string; 24 + /** Canonical URL of your blog post */ 25 + canonicalUrl: string; 26 + /** Maximum nesting depth for replies (default: 3) */ 27 + maxDepth?: number; 28 + /** Section title (default: "Comments") */ 29 + title?: string; 30 + /** Show "Reply on Bluesky" link (default: true) */ 31 + showReplyLink?: boolean; 32 + /** Additional CSS classes */ 33 + class?: string; 34 + } 35 + 36 + let { 37 + bskyPostUri, 38 + canonicalUrl, 39 + maxDepth = 3, 40 + title = 'Comments', 41 + showReplyLink = true, 42 + class: className = '' 43 + }: Props = $props(); 44 + 45 + let comments = $state<Comment[]>([]); 46 + let loading = $state(true); 47 + let error = $state<string | null>(null); 48 + 49 + // Convert AT-URI to HTTPS URL for the reply link 50 + const bskyUrl = $derived.by(() => { 51 + const match = bskyPostUri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/); 52 + if (!match) return null; 53 + const [, did, rkey] = match; 54 + // We'll need to resolve the DID to a handle, but for now use DID 55 + return `https://bsky.app/profile/${did}/post/${rkey}`; 56 + }); 57 + 58 + onMount(async () => { 59 + try { 60 + comments = await fetchComments({ 61 + bskyPostUri, 62 + canonicalUrl, 63 + maxDepth 64 + }); 65 + } catch (err) { 66 + error = err instanceof Error ? err.message : 'Failed to load comments'; 67 + console.error('Failed to fetch comments:', err); 68 + } finally { 69 + loading = false; 70 + } 71 + }); 72 + 73 + function CommentItem(comment: Comment) { 74 + const authorUrl = `https://bsky.app/profile/${comment.author.handle}`; 75 + const postUrl = $derived.by(() => { 76 + const match = comment.uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/); 77 + if (!match) return null; 78 + return `https://bsky.app/profile/${comment.author.handle}/post/${match[2]}`; 79 + }); 80 + 81 + return { 82 + comment, 83 + authorUrl, 84 + postUrl 85 + }; 86 + } 87 + </script> 88 + 89 + <section class="comments-section {className}"> 90 + <header class="mb-6"> 91 + <h2 class="text-ink-900 dark:text-ink-50 flex items-center gap-2 text-2xl font-bold"> 92 + <MessageSquare size={24} /> 93 + {title} 94 + </h2> 95 + {#if showReplyLink && bskyUrl} 96 + <a 97 + href={bskyUrl} 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mt-2 inline-flex items-center gap-1 text-sm transition-colors" 101 + > 102 + Reply on Bluesky 103 + <ExternalLink size={14} /> 104 + </a> 105 + {/if} 106 + </header> 107 + 108 + {#if loading} 109 + <div class="text-ink-600 dark:text-ink-400 py-8 text-center"> 110 + <div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent"></div> 111 + <p class="mt-2">Loading comments...</p> 112 + </div> 113 + {:else if error} 114 + <div class="border-canvas-200 bg-canvas-100 dark:border-canvas-700 dark:bg-canvas-800 rounded-lg border p-4"> 115 + <p class="text-ink-900 dark:text-ink-50 font-medium">Failed to load comments</p> 116 + <p class="text-ink-600 dark:text-ink-400 mt-1 text-sm">{error}</p> 117 + </div> 118 + {:else if comments.length === 0} 119 + <div class="text-ink-600 dark:text-ink-400 py-8 text-center"> 120 + <MessageSquare size={48} class="mx-auto mb-2 opacity-50" /> 121 + <p>No comments yet. Be the first to comment!</p> 122 + {#if showReplyLink && bskyUrl} 123 + <a 124 + href={bskyUrl} 125 + target="_blank" 126 + rel="noopener noreferrer" 127 + class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mt-2 inline-flex items-center gap-1 transition-colors" 128 + > 129 + Start the conversation on Bluesky 130 + <ExternalLink size={14} /> 131 + </a> 132 + {/if} 133 + </div> 134 + {:else} 135 + <div class="space-y-4"> 136 + {#each comments as comment} 137 + {@const item = CommentItem(comment)} 138 + <article 139 + class="border-canvas-200 bg-canvas-50 dark:border-canvas-700 dark:bg-canvas-900 rounded-lg border p-4" 140 + style="margin-left: {comment.depth * 1.5}rem" 141 + > 142 + <header class="mb-2 flex items-start justify-between"> 143 + <div class="flex items-center gap-3"> 144 + {#if comment.author.avatar} 145 + <img 146 + src={comment.author.avatar} 147 + alt={comment.author.displayName || comment.author.handle} 148 + class="h-10 w-10 rounded-full" 149 + /> 150 + {:else} 151 + <div class="bg-primary-200 dark:bg-primary-800 flex h-10 w-10 items-center justify-center rounded-full"> 152 + <span class="text-primary-900 dark:text-primary-100 text-sm font-bold"> 153 + {(comment.author.displayName || comment.author.handle)[0].toUpperCase()} 154 + </span> 155 + </div> 156 + {/if} 157 + 158 + <div> 159 + <a 160 + href={item.authorUrl} 161 + target="_blank" 162 + rel="noopener noreferrer" 163 + class="text-ink-900 dark:text-ink-50 hover:text-primary-600 dark:hover:text-primary-400 font-medium transition-colors" 164 + > 165 + {comment.author.displayName || comment.author.handle} 166 + </a> 167 + <p class="text-ink-600 dark:text-ink-400 text-sm">@{comment.author.handle}</p> 168 + </div> 169 + </div> 170 + 171 + <time class="text-ink-600 dark:text-ink-400 text-sm" datetime={comment.createdAt}> 172 + {formatRelativeTime(comment.createdAt)} 173 + </time> 174 + </header> 175 + 176 + <p class="text-ink-900 dark:text-ink-50 mb-3 whitespace-pre-wrap">{comment.text}</p> 177 + 178 + <footer class="text-ink-600 dark:text-ink-400 flex items-center gap-4 text-sm"> 179 + {#if comment.likeCount > 0} 180 + <span class="flex items-center gap-1"> 181 + <Heart size={14} /> 182 + {comment.likeCount} 183 + </span> 184 + {/if} 185 + 186 + {#if item.postUrl} 187 + <a 188 + href={item.postUrl} 189 + target="_blank" 190 + rel="noopener noreferrer" 191 + class="hover:text-primary-600 dark:hover:text-primary-400 flex items-center gap-1 transition-colors" 192 + > 193 + View on Bluesky 194 + <ExternalLink size={12} /> 195 + </a> 196 + {/if} 197 + </footer> 198 + 199 + {#if comment.replies && comment.replies.length > 0} 200 + <div class="mt-4 space-y-4"> 201 + {#each comment.replies as reply} 202 + {@const replyItem = CommentItem(reply)} 203 + <article 204 + class="border-canvas-200 bg-canvas-100 dark:border-canvas-600 dark:bg-canvas-800 rounded-lg border p-4" 205 + > 206 + <header class="mb-2 flex items-start justify-between"> 207 + <div class="flex items-center gap-3"> 208 + {#if reply.author.avatar} 209 + <img 210 + src={reply.author.avatar} 211 + alt={reply.author.displayName || reply.author.handle} 212 + class="h-8 w-8 rounded-full" 213 + /> 214 + {:else} 215 + <div class="bg-primary-200 dark:bg-primary-800 flex h-8 w-8 items-center justify-center rounded-full"> 216 + <span class="text-primary-900 dark:text-primary-100 text-xs font-bold"> 217 + {(reply.author.displayName || reply.author.handle)[0].toUpperCase()} 218 + </span> 219 + </div> 220 + {/if} 221 + 222 + <div> 223 + <a 224 + href={replyItem.authorUrl} 225 + target="_blank" 226 + rel="noopener noreferrer" 227 + class="text-ink-900 dark:text-ink-50 hover:text-primary-600 dark:hover:text-primary-400 text-sm font-medium transition-colors" 228 + > 229 + {reply.author.displayName || reply.author.handle} 230 + </a> 231 + <p class="text-ink-600 dark:text-ink-400 text-xs">@{reply.author.handle}</p> 232 + </div> 233 + </div> 234 + 235 + <time class="text-ink-600 dark:text-ink-400 text-xs" datetime={reply.createdAt}> 236 + {formatRelativeTime(reply.createdAt)} 237 + </time> 238 + </header> 239 + 240 + <p class="text-ink-900 dark:text-ink-50 mb-2 whitespace-pre-wrap text-sm">{reply.text}</p> 241 + 242 + <footer class="text-ink-600 dark:text-ink-400 flex items-center gap-4 text-xs"> 243 + {#if reply.likeCount > 0} 244 + <span class="flex items-center gap-1"> 245 + <Heart size={12} /> 246 + {reply.likeCount} 247 + </span> 248 + {/if} 249 + 250 + {#if replyItem.postUrl} 251 + <a 252 + href={replyItem.postUrl} 253 + target="_blank" 254 + rel="noopener noreferrer" 255 + class="hover:text-primary-600 dark:hover:text-primary-400 flex items-center gap-1 transition-colors" 256 + > 257 + View on Bluesky 258 + <ExternalLink size={10} /> 259 + </a> 260 + {/if} 261 + </footer> 262 + </article> 263 + {/each} 264 + </div> 265 + {/if} 266 + </article> 267 + {/each} 268 + </div> 269 + {/if} 270 + </section> 271 + 272 + <style> 273 + .comments-section { 274 + margin-top: 3rem; 275 + margin-bottom: 3rem; 276 + } 277 + </style>
+46
src/lib/index.ts
··· 1 1 // Main exports 2 2 export { SiteStandardClient, createClient } from './client.js'; 3 + export { StandardSitePublisher } from './publisher.js'; 3 4 4 5 // Component exports 5 6 export { ··· 14 15 ThemedCard 15 16 } from './components/index.js'; 16 17 18 + // Comments component 19 + export { default as Comments } from './components/Comments.svelte'; 20 + 17 21 // Store exports 18 22 export { themeStore } from './stores/index.js'; 19 23 ··· 31 35 SiteStandardConfig 32 36 } from './types.js'; 33 37 38 + // Schema exports 39 + export type { 40 + PublisherConfig, 41 + ReaderConfig, 42 + LoaderConfig 43 + } from './schemas.js'; 44 + 45 + export { COLLECTIONS } from './schemas.js'; 46 + 34 47 // Utility exports 35 48 export { parseAtUri, atUriToHttps, buildAtUri, extractRkey, isAtUri } from './utils/at-uri.js'; 36 49 ··· 54 67 getDocumentUrl, 55 68 extractRkey as extractRkeyFromUri 56 69 } from './utils/document.js'; 70 + 71 + // Content transformation exports 72 + export { 73 + transformContent, 74 + convertSidenotes, 75 + convertComplexSidenotes, 76 + resolveRelativeLinks, 77 + stripToPlainText, 78 + countWords, 79 + calculateReadingTime 80 + } from './utils/content.js'; 81 + 82 + export type { TransformOptions, TransformResult } from './utils/content.js'; 83 + 84 + // Comments exports 85 + export { fetchComments, fetchMentionComments, formatRelativeTime } from './utils/comments.js'; 86 + 87 + export type { Comment, CommentAuthor, FetchCommentsOptions } from './utils/comments.js'; 88 + 89 + // Verification exports 90 + export { 91 + generatePublicationWellKnown, 92 + generateDocumentLinkTag, 93 + generatePublicationLinkTag, 94 + getDocumentAtUri, 95 + getPublicationAtUri, 96 + verifyPublicationWellKnown, 97 + extractDocumentLinkFromHtml, 98 + extractPublicationLinkFromHtml 99 + } from './utils/verification.js'; 100 + 101 + // Publisher types 102 + export type { PublishDocumentInput, PublishPublicationInput, PublishResult } from './publisher.js';
+475
src/lib/publisher.ts
··· 1 + /** 2 + * Publisher for standard.site documents 3 + * 4 + * Publishes documents to ATProto repositories using the standard.site lexicon, 5 + * enabling your SvelteKit site to sync content to Leaflet, WhiteWind, or any 6 + * compatible platform. 7 + * 8 + * The publisher automatically resolves the correct PDS from your DID document, 9 + * so it works with any PDS (bsky.app, Blacksky, self-hosted, etc.). 10 + * 11 + * @example 12 + * ```ts 13 + * import { StandardSitePublisher } from 'svelte-standard-site/publisher'; 14 + * 15 + * const publisher = new StandardSitePublisher({ 16 + * identifier: 'your-handle.bsky.social', 17 + * password: process.env.ATPROTO_APP_PASSWORD!, 18 + * }); 19 + * 20 + * await publisher.login(); 21 + * 22 + * await publisher.publishDocument({ 23 + * site: 'https://myblog.com', 24 + * title: 'My Blog Post', 25 + * publishedAt: new Date().toISOString(), 26 + * }); 27 + * ``` 28 + */ 29 + 30 + import { AtpAgent } from '@atproto/api'; 31 + import type { PublisherConfig, Document, Publication } from './schemas.js'; 32 + import { PublisherConfigSchema, COLLECTIONS } from './schemas.js'; 33 + 34 + /** 35 + * Resolve a handle to a DID using the public API 36 + */ 37 + async function resolveHandle(handle: string): Promise<string> { 38 + const res = await fetch( 39 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 40 + ); 41 + if (!res.ok) throw new Error(`Failed to resolve handle: ${handle}`); 42 + const data = (await res.json()) as { did: string }; 43 + return data.did; 44 + } 45 + 46 + /** 47 + * Get the PDS endpoint from a DID document 48 + */ 49 + async function getPdsFromDid(did: string): Promise<string> { 50 + let didDoc: any; 51 + 52 + if (did.startsWith('did:plc:')) { 53 + // Resolve from plc.directory 54 + const res = await fetch(`https://plc.directory/${did}`); 55 + if (!res.ok) throw new Error(`Failed to resolve DID: ${did}`); 56 + didDoc = await res.json(); 57 + } else if (did.startsWith('did:web:')) { 58 + // Resolve from the domain 59 + const domain = did.replace('did:web:', ''); 60 + const res = await fetch(`https://${domain}/.well-known/did.json`); 61 + if (!res.ok) throw new Error(`Failed to resolve DID: ${did}`); 62 + didDoc = await res.json(); 63 + } else { 64 + throw new Error(`Unsupported DID method: ${did}`); 65 + } 66 + 67 + // Find the AtprotoPersonalDataServer service 68 + const pdsService = didDoc.service?.find( 69 + (s: any) => s.type === 'AtprotoPersonalDataServer' || s.id === '#atproto_pds' 70 + ); 71 + 72 + if (!pdsService?.serviceEndpoint) { 73 + throw new Error(`No PDS found in DID document for ${did}`); 74 + } 75 + 76 + return pdsService.serviceEndpoint; 77 + } 78 + 79 + /** 80 + * Generate a TID (Timestamp Identifier) per ATProto spec 81 + * @see https://atproto.com/specs/tid 82 + * 83 + * Structure: 84 + * - 64-bit integer, big-endian 85 + * - Top bit always 0 86 + * - Next 53 bits: microseconds since UNIX epoch 87 + * - Final 10 bits: random clock identifier 88 + * - Encoded as base32-sortable (chars: 234567abcdefghijklmnopqrstuvwxyz) 89 + * - Always 13 characters 90 + */ 91 + const BASE32_SORTABLE = '234567abcdefghijklmnopqrstuvwxyz'; 92 + 93 + function generateTid(): string { 94 + const now = Date.now() * 1000; // Convert to microseconds 95 + const clockId = Math.floor(Math.random() * 1024); // 10 bits 96 + 97 + // Combine: (timestamp << 10) | clockId 98 + // Ensure top bit is 0 by masking with 0x7FFFFFFFFFFFFFFF 99 + const tid = ((BigInt(now) << 10n) | BigInt(clockId)) & 0x7fffffffffffffffn; 100 + 101 + // Encode as base32-sortable 102 + let encoded = ''; 103 + let remaining = tid; 104 + for (let i = 0; i < 13; i++) { 105 + const index = Number(remaining & 31n); 106 + encoded = BASE32_SORTABLE[index] + encoded; 107 + remaining = remaining >> 5n; 108 + } 109 + 110 + return encoded; 111 + } 112 + 113 + export interface PublishDocumentInput { 114 + /** Site/publication URI (https or at-uri) - REQUIRED */ 115 + site: string; 116 + /** Document title - REQUIRED */ 117 + title: string; 118 + /** When the document was published (ISO 8601) - REQUIRED */ 119 + publishedAt: string; 120 + /** Path to combine with site URL */ 121 + path?: string; 122 + /** Document description/excerpt */ 123 + description?: string; 124 + /** When the document was last updated (ISO 8601) */ 125 + updatedAt?: string; 126 + /** Tags/categories */ 127 + tags?: string[]; 128 + /** Plain text content for indexing */ 129 + textContent?: string; 130 + /** Platform-specific content */ 131 + content?: unknown; 132 + /** Reference to associated Bluesky post */ 133 + bskyPostRef?: { uri: string; cid: string }; 134 + /** Cover image blob */ 135 + coverImage?: { 136 + $type: 'blob'; 137 + ref: { $link: string }; 138 + mimeType: string; 139 + size: number; 140 + }; 141 + } 142 + 143 + export interface PublishPublicationInput { 144 + /** Publication name */ 145 + name: string; 146 + /** Base URL */ 147 + url: string; 148 + /** Description */ 149 + description?: string; 150 + /** Icon blob */ 151 + icon?: { 152 + $type: 'blob'; 153 + ref: { $link: string }; 154 + mimeType: string; 155 + size: number; 156 + }; 157 + /** Basic theme colors */ 158 + basicTheme?: { 159 + background: { r: number; g: number; b: number }; 160 + foreground: { r: number; g: number; b: number }; 161 + accent: { r: number; g: number; b: number }; 162 + accentForeground: { r: number; g: number; b: number }; 163 + }; 164 + /** Publication preferences */ 165 + preferences?: { 166 + showInDiscover?: boolean; 167 + }; 168 + } 169 + 170 + export interface PublishResult { 171 + uri: string; 172 + cid: string; 173 + } 174 + 175 + /** 176 + * Publisher for standard.site documents on ATProto 177 + */ 178 + export class StandardSitePublisher { 179 + private agent: AtpAgent | null = null; 180 + private config: PublisherConfig; 181 + private did: string | null = null; 182 + private pdsUrl: string | null = null; 183 + 184 + constructor(config: Partial<PublisherConfig>) { 185 + this.config = PublisherConfigSchema.parse(config); 186 + } 187 + 188 + /** 189 + * Authenticate with the PDS 190 + * Automatically resolves the correct PDS from the DID document 191 + */ 192 + async login(): Promise<void> { 193 + // Resolve handle to DID if needed 194 + let did = this.config.identifier; 195 + if (!did.startsWith('did:')) { 196 + did = await resolveHandle(this.config.identifier); 197 + } 198 + this.did = did; 199 + 200 + // Get PDS URL from DID document (unless manually overridden) 201 + if (this.config.service) { 202 + this.pdsUrl = this.config.service; 203 + } else { 204 + this.pdsUrl = await getPdsFromDid(did); 205 + } 206 + 207 + // Create agent and login 208 + this.agent = new AtpAgent({ service: this.pdsUrl }); 209 + await this.agent.login({ 210 + identifier: this.config.identifier, 211 + password: this.config.password 212 + }); 213 + } 214 + 215 + /** 216 + * Get the authenticated DID 217 + */ 218 + getDid(): string { 219 + if (!this.did) { 220 + throw new Error('Not logged in. Call login() first.'); 221 + } 222 + return this.did; 223 + } 224 + 225 + /** 226 + * Get the PDS URL being used 227 + */ 228 + getPdsUrl(): string { 229 + if (!this.pdsUrl) { 230 + throw new Error('Not logged in. Call login() first.'); 231 + } 232 + return this.pdsUrl; 233 + } 234 + 235 + private getAgent(): AtpAgent { 236 + if (!this.agent) { 237 + throw new Error('Not logged in. Call login() first.'); 238 + } 239 + return this.agent; 240 + } 241 + 242 + /** 243 + * Publish a document record 244 + */ 245 + async publishDocument(input: PublishDocumentInput): Promise<PublishResult> { 246 + const did = this.getDid(); 247 + const agent = this.getAgent(); 248 + 249 + const record: Document = { 250 + $type: 'site.standard.document', 251 + site: input.site, 252 + title: input.title, 253 + publishedAt: input.publishedAt, 254 + path: input.path, 255 + description: input.description, 256 + updatedAt: input.updatedAt, 257 + tags: input.tags, 258 + textContent: input.textContent, 259 + content: input.content, 260 + bskyPostRef: input.bskyPostRef, 261 + coverImage: input.coverImage 262 + }; 263 + 264 + // Remove undefined values 265 + const cleanRecord = Object.fromEntries( 266 + Object.entries(record).filter(([_, v]) => v !== undefined) 267 + ) as Document; 268 + 269 + // Generate TID for record key per lexicon spec (key: "tid") 270 + const rkey = generateTid(); 271 + 272 + const response = await agent.api.com.atproto.repo.createRecord({ 273 + repo: did, 274 + collection: COLLECTIONS.DOCUMENT, 275 + rkey, 276 + record: cleanRecord 277 + }); 278 + 279 + return { 280 + uri: response.data.uri, 281 + cid: response.data.cid 282 + }; 283 + } 284 + 285 + /** 286 + * Update an existing document 287 + */ 288 + async updateDocument(rkey: string, input: PublishDocumentInput): Promise<PublishResult> { 289 + const did = this.getDid(); 290 + const agent = this.getAgent(); 291 + 292 + const record: Document = { 293 + $type: 'site.standard.document', 294 + site: input.site, 295 + title: input.title, 296 + publishedAt: input.publishedAt, 297 + path: input.path, 298 + description: input.description, 299 + updatedAt: input.updatedAt ?? new Date().toISOString(), 300 + tags: input.tags, 301 + textContent: input.textContent, 302 + content: input.content, 303 + bskyPostRef: input.bskyPostRef, 304 + coverImage: input.coverImage 305 + }; 306 + 307 + const cleanRecord = Object.fromEntries( 308 + Object.entries(record).filter(([_, v]) => v !== undefined) 309 + ) as Document; 310 + 311 + const response = await agent.api.com.atproto.repo.putRecord({ 312 + repo: did, 313 + collection: COLLECTIONS.DOCUMENT, 314 + rkey, 315 + record: cleanRecord 316 + }); 317 + 318 + return { 319 + uri: response.data.uri, 320 + cid: response.data.cid 321 + }; 322 + } 323 + 324 + /** 325 + * Delete a document 326 + */ 327 + async deleteDocument(rkey: string): Promise<void> { 328 + const did = this.getDid(); 329 + const agent = this.getAgent(); 330 + 331 + await agent.api.com.atproto.repo.deleteRecord({ 332 + repo: did, 333 + collection: COLLECTIONS.DOCUMENT, 334 + rkey 335 + }); 336 + } 337 + 338 + /** 339 + * Publish a publication record 340 + */ 341 + async publishPublication(input: PublishPublicationInput): Promise<PublishResult> { 342 + const did = this.getDid(); 343 + const agent = this.getAgent(); 344 + 345 + const record: Publication = { 346 + $type: 'site.standard.publication', 347 + name: input.name, 348 + url: input.url, 349 + description: input.description, 350 + icon: input.icon, 351 + basicTheme: input.basicTheme, 352 + preferences: input.preferences 353 + }; 354 + 355 + const cleanRecord = Object.fromEntries( 356 + Object.entries(record).filter(([_, v]) => v !== undefined) 357 + ) as Publication; 358 + 359 + // Generate TID for record key per lexicon spec (key: "tid") 360 + const rkey = generateTid(); 361 + 362 + const response = await agent.api.com.atproto.repo.createRecord({ 363 + repo: did, 364 + collection: COLLECTIONS.PUBLICATION, 365 + rkey, 366 + record: cleanRecord 367 + }); 368 + 369 + return { 370 + uri: response.data.uri, 371 + cid: response.data.cid 372 + }; 373 + } 374 + 375 + /** 376 + * Update an existing publication 377 + */ 378 + async updatePublication(rkey: string, input: PublishPublicationInput): Promise<PublishResult> { 379 + const did = this.getDid(); 380 + const agent = this.getAgent(); 381 + 382 + const record: Publication = { 383 + $type: 'site.standard.publication', 384 + name: input.name, 385 + url: input.url, 386 + description: input.description, 387 + icon: input.icon, 388 + basicTheme: input.basicTheme, 389 + preferences: input.preferences 390 + }; 391 + 392 + const cleanRecord = Object.fromEntries( 393 + Object.entries(record).filter(([_, v]) => v !== undefined) 394 + ) as Publication; 395 + 396 + const response = await agent.api.com.atproto.repo.putRecord({ 397 + repo: did, 398 + collection: COLLECTIONS.PUBLICATION, 399 + rkey, 400 + record: cleanRecord 401 + }); 402 + 403 + return { 404 + uri: response.data.uri, 405 + cid: response.data.cid 406 + }; 407 + } 408 + 409 + /** 410 + * Delete a publication 411 + */ 412 + async deletePublication(rkey: string): Promise<void> { 413 + const did = this.getDid(); 414 + const agent = this.getAgent(); 415 + 416 + await agent.api.com.atproto.repo.deleteRecord({ 417 + repo: did, 418 + collection: COLLECTIONS.PUBLICATION, 419 + rkey 420 + }); 421 + } 422 + 423 + /** 424 + * Get all documents for the current account 425 + */ 426 + async listDocuments( 427 + limit = 100 428 + ): Promise<Array<{ uri: string; cid: string; value: Document }>> { 429 + const did = this.getDid(); 430 + const agent = this.getAgent(); 431 + 432 + const response = await agent.api.com.atproto.repo.listRecords({ 433 + repo: did, 434 + collection: COLLECTIONS.DOCUMENT, 435 + limit 436 + }); 437 + 438 + return response.data.records.map((r) => ({ 439 + uri: r.uri, 440 + cid: r.cid, 441 + value: r.value as Document 442 + })); 443 + } 444 + 445 + /** 446 + * Get all publications for the current account 447 + */ 448 + async listPublications( 449 + limit = 100 450 + ): Promise<Array<{ uri: string; cid: string; value: Publication }>> { 451 + const did = this.getDid(); 452 + const agent = this.getAgent(); 453 + 454 + const response = await agent.api.com.atproto.repo.listRecords({ 455 + repo: did, 456 + collection: COLLECTIONS.PUBLICATION, 457 + limit 458 + }); 459 + 460 + return response.data.records.map((r) => ({ 461 + uri: r.uri, 462 + cid: r.cid, 463 + value: r.value as Publication 464 + })); 465 + } 466 + 467 + /** 468 + * Get the underlying ATP agent for advanced operations 469 + */ 470 + getAtpAgent(): AtpAgent { 471 + return this.getAgent(); 472 + } 473 + } 474 + 475 + export type { PublisherConfig };
+132
src/lib/schemas.ts
··· 1 + /** 2 + * Zod schemas for standard.site lexicons and configuration 3 + */ 4 + 5 + import { z } from 'zod'; 6 + 7 + /** 8 + * AT Protocol collections 9 + */ 10 + export const COLLECTIONS = { 11 + DOCUMENT: 'site.standard.document', 12 + PUBLICATION: 'site.standard.publication' 13 + } as const; 14 + 15 + /** 16 + * RGB Color schema 17 + */ 18 + export const RGBColorSchema = z.object({ 19 + r: z.number().int().min(0).max(255), 20 + g: z.number().int().min(0).max(255), 21 + b: z.number().int().min(0).max(255) 22 + }); 23 + 24 + /** 25 + * Basic Theme schema 26 + */ 27 + export const BasicThemeSchema = z.object({ 28 + $type: z.literal('site.standard.theme.basic').optional(), 29 + background: RGBColorSchema, 30 + foreground: RGBColorSchema, 31 + accent: RGBColorSchema, 32 + accentForeground: RGBColorSchema 33 + }); 34 + 35 + /** 36 + * Publication Preferences schema 37 + */ 38 + export const PublicationPreferencesSchema = z.object({ 39 + showInDiscover: z.boolean().optional() 40 + }); 41 + 42 + /** 43 + * AT Protocol Blob schema 44 + */ 45 + export const AtProtoBlobSchema = z.object({ 46 + $type: z.literal('blob'), 47 + ref: z.object({ 48 + $link: z.string() 49 + }), 50 + mimeType: z.string(), 51 + size: z.number().int().positive() 52 + }); 53 + 54 + /** 55 + * Strong Reference schema 56 + */ 57 + export const StrongRefSchema = z.object({ 58 + uri: z.string(), 59 + cid: z.string() 60 + }); 61 + 62 + /** 63 + * Publication schema 64 + */ 65 + export const PublicationSchema = z.object({ 66 + $type: z.literal('site.standard.publication'), 67 + name: z.string(), 68 + url: z.string().url(), 69 + description: z.string().optional(), 70 + icon: AtProtoBlobSchema.optional(), 71 + basicTheme: BasicThemeSchema.optional(), 72 + preferences: PublicationPreferencesSchema.optional() 73 + }); 74 + 75 + /** 76 + * Document schema 77 + */ 78 + export const DocumentSchema = z.object({ 79 + $type: z.literal('site.standard.document'), 80 + site: z.string(), 81 + title: z.string(), 82 + publishedAt: z.string().datetime(), 83 + path: z.string().optional(), 84 + description: z.string().optional(), 85 + updatedAt: z.string().datetime().optional(), 86 + tags: z.array(z.string()).optional(), 87 + coverImage: AtProtoBlobSchema.optional(), 88 + textContent: z.string().optional(), 89 + content: z.unknown().optional(), 90 + bskyPostRef: StrongRefSchema.optional() 91 + }); 92 + 93 + /** 94 + * Publisher configuration schema 95 + */ 96 + export const PublisherConfigSchema = z.object({ 97 + identifier: z.string(), // handle or DID 98 + password: z.string(), 99 + service: z.string().url().optional() 100 + }); 101 + 102 + /** 103 + * Reader/Client configuration schema 104 + */ 105 + export const ReaderConfigSchema = z.object({ 106 + did: z.string(), 107 + pds: z.string().url().optional(), 108 + cacheTTL: z.number().int().positive().optional() 109 + }); 110 + 111 + /** 112 + * Loader configuration schema 113 + */ 114 + export const LoaderConfigSchema = z.object({ 115 + repo: z.string(), 116 + excludeSite: z.string().url().optional(), 117 + publication: z.string().optional(), 118 + limit: z.number().int().positive().default(100), 119 + service: z.string().url().default('https://public.api.bsky.app') 120 + }); 121 + 122 + // Type exports 123 + export type RGBColor = z.infer<typeof RGBColorSchema>; 124 + export type BasicTheme = z.infer<typeof BasicThemeSchema>; 125 + export type PublicationPreferences = z.infer<typeof PublicationPreferencesSchema>; 126 + export type AtProtoBlob = z.infer<typeof AtProtoBlobSchema>; 127 + export type StrongRef = z.infer<typeof StrongRefSchema>; 128 + export type Publication = z.infer<typeof PublicationSchema>; 129 + export type Document = z.infer<typeof DocumentSchema>; 130 + export type PublisherConfig = z.infer<typeof PublisherConfigSchema>; 131 + export type ReaderConfig = z.infer<typeof ReaderConfigSchema>; 132 + export type LoaderConfig = z.infer<typeof LoaderConfigSchema>;
+217
src/lib/utils/comments.ts
··· 1 + /** 2 + * Comments utilities for fetching Bluesky replies 3 + * 4 + * Fetches threaded replies from Bluesky to display as comments on your blog. 5 + * 6 + * @example 7 + * ```ts 8 + * import { fetchComments } from 'svelte-standard-site/comments'; 9 + * 10 + * const comments = await fetchComments({ 11 + * bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123', 12 + * canonicalUrl: 'https://yourblog.com/posts/my-post', 13 + * maxDepth: 3, 14 + * }); 15 + * ``` 16 + */ 17 + 18 + import { AtpAgent } from '@atproto/api'; 19 + 20 + export interface CommentAuthor { 21 + did: string; 22 + handle: string; 23 + displayName?: string; 24 + avatar?: string; 25 + } 26 + 27 + export interface Comment { 28 + uri: string; 29 + cid: string; 30 + author: CommentAuthor; 31 + text: string; 32 + createdAt: string; 33 + likeCount: number; 34 + replyCount: number; 35 + replies?: Comment[]; 36 + depth: number; 37 + } 38 + 39 + export interface FetchCommentsOptions { 40 + /** AT-URI of the announcement post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123) */ 41 + bskyPostUri: string; 42 + /** Optional canonical URL to search for mentions */ 43 + canonicalUrl?: string; 44 + /** Maximum depth for nested replies (default: 3) */ 45 + maxDepth?: number; 46 + /** Optional fetch function for SSR */ 47 + fetchFn?: typeof fetch; 48 + } 49 + 50 + /** 51 + * Parse an AT-URI to extract components 52 + */ 53 + function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { 54 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 55 + if (!match) return null; 56 + return { 57 + did: match[1], 58 + collection: match[2], 59 + rkey: match[3] 60 + }; 61 + } 62 + 63 + /** 64 + * Fetch a single thread of replies 65 + */ 66 + async function fetchThread( 67 + agent: AtpAgent, 68 + uri: string, 69 + maxDepth: number, 70 + currentDepth = 0 71 + ): Promise<Comment | null> { 72 + try { 73 + const response = await agent.getPostThread({ 74 + uri, 75 + depth: maxDepth - currentDepth, 76 + parentHeight: 0 77 + }); 78 + 79 + const thread = response.data.thread; 80 + 81 + if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') { 82 + return null; 83 + } 84 + 85 + const post = thread.post; 86 + 87 + // Build comment object 88 + const comment: Comment = { 89 + uri: post.uri, 90 + cid: post.cid, 91 + author: { 92 + did: post.author.did, 93 + handle: post.author.handle, 94 + displayName: post.author.displayName, 95 + avatar: post.author.avatar 96 + }, 97 + text: (post.record as any)?.text || '', 98 + createdAt: post.indexedAt, 99 + likeCount: post.likeCount || 0, 100 + replyCount: post.replyCount || 0, 101 + depth: currentDepth, 102 + replies: [] 103 + }; 104 + 105 + // Process replies if within depth limit 106 + if (thread.replies && currentDepth < maxDepth) { 107 + for (const reply of thread.replies) { 108 + if (reply.$type === 'app.bsky.feed.defs#threadViewPost') { 109 + const replyComment = await fetchThread( 110 + agent, 111 + reply.post.uri, 112 + maxDepth, 113 + currentDepth + 1 114 + ); 115 + if (replyComment) { 116 + comment.replies!.push(replyComment); 117 + } 118 + } 119 + } 120 + } 121 + 122 + return comment; 123 + } catch (error) { 124 + console.error(`Failed to fetch thread for ${uri}:`, error); 125 + return null; 126 + } 127 + } 128 + 129 + /** 130 + * Fetch comments for a blog post 131 + * 132 + * @param options - Configuration options 133 + * @returns Array of top-level comments with nested replies 134 + */ 135 + export async function fetchComments(options: FetchCommentsOptions): Promise<Comment[]> { 136 + const { bskyPostUri, canonicalUrl, maxDepth = 3 } = options; 137 + 138 + // Parse the post URI 139 + const parsed = parseAtUri(bskyPostUri); 140 + if (!parsed) { 141 + console.error('Invalid AT-URI:', bskyPostUri); 142 + return []; 143 + } 144 + 145 + try { 146 + // Create agent 147 + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 148 + 149 + // Fetch the main thread 150 + const mainComment = await fetchThread(agent, bskyPostUri, maxDepth, 0); 151 + 152 + if (!mainComment || !mainComment.replies) { 153 + return []; 154 + } 155 + 156 + // Return only the replies (not the original post) 157 + return mainComment.replies; 158 + } catch (error) { 159 + console.error('Failed to fetch comments:', error); 160 + return []; 161 + } 162 + } 163 + 164 + /** 165 + * Search for mentions of a URL and fetch those threads as comments 166 + * 167 + * This is useful if people share your blog post on Bluesky without 168 + * replying to a specific announcement post. 169 + */ 170 + export async function fetchMentionComments( 171 + canonicalUrl: string, 172 + maxDepth = 3 173 + ): Promise<Comment[]> { 174 + try { 175 + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 176 + 177 + // Search for posts mentioning the URL 178 + const searchResponse = await agent.app.bsky.feed.searchPosts({ 179 + q: canonicalUrl, 180 + limit: 25 181 + }); 182 + 183 + const comments: Comment[] = []; 184 + 185 + for (const post of searchResponse.data.posts) { 186 + const comment = await fetchThread(agent, post.uri, maxDepth, 0); 187 + if (comment) { 188 + comments.push(comment); 189 + } 190 + } 191 + 192 + return comments; 193 + } catch (error) { 194 + console.error('Failed to fetch mention comments:', error); 195 + return []; 196 + } 197 + } 198 + 199 + /** 200 + * Format a relative time string (e.g., "2 hours ago") 201 + */ 202 + export function formatRelativeTime(dateString: string): string { 203 + const date = new Date(dateString); 204 + const now = new Date(); 205 + const diffMs = now.getTime() - date.getTime(); 206 + const diffSecs = Math.floor(diffMs / 1000); 207 + const diffMins = Math.floor(diffSecs / 60); 208 + const diffHours = Math.floor(diffMins / 60); 209 + const diffDays = Math.floor(diffHours / 24); 210 + 211 + if (diffSecs < 60) return 'just now'; 212 + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`; 213 + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; 214 + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; 215 + 216 + return date.toLocaleDateString(); 217 + }
+234
src/lib/utils/content.ts
··· 1 + /** 2 + * Content transformation utilities for standard.site 3 + * 4 + * Converts blog post content into formats suitable for ATProto: 5 + * - Strips markdown to plain text for `textContent` (search/indexing) 6 + * - Converts custom sidenotes to standard markdown 7 + * - Resolves relative links to absolute URLs 8 + * 9 + * @example 10 + * ```ts 11 + * import { transformContent, stripToPlainText } from 'svelte-standard-site/content'; 12 + * 13 + * const result = transformContent(markdown, { 14 + * baseUrl: 'https://yourblog.com', 15 + * }); 16 + * 17 + * // result.markdown - cleaned markdown for ATProto 18 + * // result.textContent - plain text for search 19 + * ``` 20 + */ 21 + 22 + export interface TransformOptions { 23 + /** Base URL of your site (e.g., 'https://yourblog.com') */ 24 + baseUrl: string; 25 + /** Optional path to the current post (e.g., '/blog/my-post') */ 26 + postPath?: string; 27 + } 28 + 29 + export interface TransformResult { 30 + /** Transformed markdown suitable for ATProto */ 31 + markdown: string; 32 + /** Plain text version for textContent field */ 33 + textContent: string; 34 + /** Word count */ 35 + wordCount: number; 36 + /** Estimated reading time in minutes */ 37 + readingTime: number; 38 + } 39 + 40 + /** 41 + * Convert HTML sidenotes to markdown blockquotes 42 + * 43 + * Transforms: 44 + * ```html 45 + * <div class="sidenote sidenote--tip"> 46 + * <span class="sidenote-label">Tip</span> 47 + * <p>Content here</p> 48 + * </div> 49 + * ``` 50 + * 51 + * Into: 52 + * ```markdown 53 + * > **Tip:** Content here 54 + * ``` 55 + */ 56 + export function convertSidenotes(markdown: string): string { 57 + // Match sidenote divs with various formats 58 + const sidenoteRegex = 59 + /<div\s+class="sidenote(?:\s+sidenote--(warning|tip))?">\s*<span\s+class="sidenote-label">([^<]+)<\/span>\s*<p>([^<]+)<\/p>\s*<\/div>/gi; 60 + 61 + return markdown.replace(sidenoteRegex, (_, type, label, content) => { 62 + // Clean up the content 63 + const cleanContent = content.trim(); 64 + const cleanLabel = label.trim(); 65 + 66 + // Convert to blockquote with label 67 + return `\n> **${cleanLabel}:** ${cleanContent}\n`; 68 + }); 69 + } 70 + 71 + /** 72 + * Convert HTML sidenotes (multi-paragraph) to markdown 73 + * Handles more complex sidenote structures 74 + */ 75 + export function convertComplexSidenotes(markdown: string): string { 76 + // First pass: simple sidenotes 77 + let result = convertSidenotes(markdown); 78 + 79 + // Second pass: sidenotes with multiple paragraphs or nested content 80 + const complexSidenoteRegex = /<div\s+class="sidenote[^"]*">([\s\S]*?)<\/div>/gi; 81 + 82 + result = result.replace(complexSidenoteRegex, (match, innerContent) => { 83 + // Extract label if present 84 + const labelMatch = innerContent.match(/<span\s+class="sidenote-label">([^<]+)<\/span>/i); 85 + const label = labelMatch ? labelMatch[1].trim() : 'Note'; 86 + 87 + // Remove the label span 88 + let content = innerContent.replace(/<span\s+class="sidenote-label">[^<]+<\/span>/gi, ''); 89 + 90 + // Convert remaining HTML to plain text with basic formatting 91 + content = content 92 + .replace(/<p>/gi, '') 93 + .replace(/<\/p>/gi, '\n') 94 + .replace(/<strong>/gi, '**') 95 + .replace(/<\/strong>/gi, '**') 96 + .replace(/<em>/gi, '*') 97 + .replace(/<\/em>/gi, '*') 98 + .replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') 99 + .replace(/<[^>]+>/g, '') // Remove any remaining HTML tags 100 + .trim(); 101 + 102 + // Format as blockquote 103 + const lines = content.split('\n').filter((line: string) => line.trim()); 104 + const quotedLines = lines.map((line: string, i: number) => 105 + i === 0 ? `> **${label}:** ${line}` : `> ${line}` 106 + ); 107 + 108 + return '\n' + quotedLines.join('\n') + '\n'; 109 + }); 110 + 111 + return result; 112 + } 113 + 114 + /** 115 + * Resolve relative URLs to absolute URLs 116 + * 117 + * Converts: 118 + * - `[Link](/page)` → `[Link](https://example.com/page)` 119 + * - `![Image](/img.png)` → `![Image](https://example.com/img.png)` 120 + */ 121 + export function resolveRelativeLinks(markdown: string, baseUrl: string): string { 122 + const cleanBaseUrl = baseUrl.replace(/\/$/, ''); 123 + 124 + // Match markdown links and images with relative URLs 125 + // [text](/path) or ![alt](/path) 126 + const linkRegex = /(!?\[[^\]]*\])\((?!https?:\/\/|mailto:|#)([^)]+)\)/g; 127 + 128 + return markdown.replace(linkRegex, (_, prefix, path) => { 129 + // Ensure path starts with / 130 + const absolutePath = path.startsWith('/') ? path : `/${path}`; 131 + return `${prefix}(${cleanBaseUrl}${absolutePath})`; 132 + }); 133 + } 134 + 135 + /** 136 + * Strip markdown to plain text for indexing/search 137 + * 138 + * Removes: 139 + * - Markdown formatting (**, *, #, etc.) 140 + * - Links (keeps link text) 141 + * - Images 142 + * - Code blocks 143 + * - HTML tags 144 + */ 145 + export function stripToPlainText(markdown: string): string { 146 + let text = markdown; 147 + 148 + // Remove code blocks first (preserve nothing) 149 + text = text.replace(/```[\s\S]*?```/g, ''); 150 + text = text.replace(/`[^`]+`/g, ''); 151 + 152 + // Remove images 153 + text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ''); 154 + 155 + // Convert links to just their text 156 + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); 157 + 158 + // Remove HTML tags 159 + text = text.replace(/<[^>]+>/g, ''); 160 + 161 + // Remove heading markers 162 + text = text.replace(/^#{1,6}\s+/gm, ''); 163 + 164 + // Remove bold/italic markers 165 + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); 166 + text = text.replace(/\*([^*]+)\*/g, '$1'); 167 + text = text.replace(/__([^_]+)__/g, '$1'); 168 + text = text.replace(/_([^_]+)_/g, '$1'); 169 + 170 + // Remove blockquote markers 171 + text = text.replace(/^>\s*/gm, ''); 172 + 173 + // Remove list markers 174 + text = text.replace(/^[\s]*[-*+]\s+/gm, ''); 175 + text = text.replace(/^[\s]*\d+\.\s+/gm, ''); 176 + 177 + // Remove horizontal rules 178 + text = text.replace(/^[-*_]{3,}$/gm, ''); 179 + 180 + // Collapse multiple newlines 181 + text = text.replace(/\n{3,}/g, '\n\n'); 182 + 183 + // Trim whitespace 184 + text = text.trim(); 185 + 186 + return text; 187 + } 188 + 189 + /** 190 + * Calculate word count from text 191 + */ 192 + export function countWords(text: string): number { 193 + return text.split(/\s+/).filter((word) => word.length > 0).length; 194 + } 195 + 196 + /** 197 + * Calculate reading time in minutes (assuming 200 words per minute) 198 + */ 199 + export function calculateReadingTime(wordCount: number, wordsPerMinute = 200): number { 200 + return Math.max(1, Math.ceil(wordCount / wordsPerMinute)); 201 + } 202 + 203 + /** 204 + * Full content transformation pipeline 205 + * 206 + * Takes raw markdown (with HTML sidenotes) and produces: 207 + * - Clean markdown suitable for ATProto platforms 208 + * - Plain text for search/indexing 209 + * - Metadata (word count, reading time) 210 + */ 211 + export function transformContent(rawMarkdown: string, options: TransformOptions): TransformResult { 212 + // Step 1: Convert sidenotes from HTML to markdown blockquotes 213 + let markdown = convertComplexSidenotes(rawMarkdown); 214 + 215 + // Step 2: Resolve relative links to absolute URLs 216 + markdown = resolveRelativeLinks(markdown, options.baseUrl); 217 + 218 + // Step 3: Clean up extra whitespace 219 + markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); 220 + 221 + // Step 4: Generate plain text version 222 + const textContent = stripToPlainText(markdown); 223 + 224 + // Step 5: Calculate metadata 225 + const wordCount = countWords(textContent); 226 + const readingTime = calculateReadingTime(wordCount); 227 + 228 + return { 229 + markdown, 230 + textContent, 231 + wordCount, 232 + readingTime 233 + }; 234 + }
+180
src/lib/utils/verification.ts
··· 1 + /** 2 + * Verification utilities for proving content ownership 3 + * 4 + * Creates `.well-known` endpoints and `<link>` tags to verify that 5 + * you own the content published to ATProto. 6 + * 7 + * @example 8 + * ```ts 9 + * // SvelteKit endpoint: src/routes/.well-known/site.standard.publication/+server.ts 10 + * import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 11 + * import { text } from '@sveltejs/kit'; 12 + * 13 + * export function GET() { 14 + * return text( 15 + * generatePublicationWellKnown({ 16 + * did: 'did:plc:xxx', 17 + * publicationRkey: '3abc123xyz', 18 + * }) 19 + * ); 20 + * } 21 + * ``` 22 + */ 23 + 24 + /** 25 + * Parse an AT-URI to extract its components 26 + */ 27 + export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null { 28 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 29 + if (!match) return null; 30 + return { 31 + did: match[1], 32 + collection: match[2], 33 + rkey: match[3] 34 + }; 35 + } 36 + 37 + /** 38 + * Build an AT-URI for a document 39 + */ 40 + export function getDocumentAtUri(did: string, rkey: string): string { 41 + return `at://${did}/site.standard.document/${rkey}`; 42 + } 43 + 44 + /** 45 + * Build an AT-URI for a publication 46 + */ 47 + export function getPublicationAtUri(did: string, rkey: string): string { 48 + return `at://${did}/site.standard.publication/${rkey}`; 49 + } 50 + 51 + /** 52 + * Generate content for /.well-known/site.standard.publication endpoint 53 + * 54 + * This endpoint proves you own your publication record on ATProto. 55 + * 56 + * @example 57 + * ```ts 58 + * // src/routes/.well-known/site.standard.publication/+server.ts 59 + * import { generatePublicationWellKnown } from 'svelte-standard-site/verification'; 60 + * import { text } from '@sveltejs/kit'; 61 + * 62 + * export function GET() { 63 + * return text( 64 + * generatePublicationWellKnown({ 65 + * did: 'did:plc:xxx', 66 + * publicationRkey: '3abc123xyz', 67 + * }) 68 + * ); 69 + * } 70 + * ``` 71 + */ 72 + export function generatePublicationWellKnown(options: { 73 + did: string; 74 + publicationRkey: string; 75 + }): string { 76 + return getPublicationAtUri(options.did, options.publicationRkey); 77 + } 78 + 79 + /** 80 + * Generate a <link> tag for document verification 81 + * 82 + * Add this to your document's <head> to verify ownership. 83 + * 84 + * @example 85 + * ```svelte 86 + * <svelte:head> 87 + * {@html generateDocumentLinkTag({ 88 + * did: 'did:plc:xxx', 89 + * documentRkey: '3xyz789abc', 90 + * })} 91 + * </svelte:head> 92 + * ``` 93 + */ 94 + export function generateDocumentLinkTag(options: { did: string; documentRkey: string }): string { 95 + const atUri = getDocumentAtUri(options.did, options.documentRkey); 96 + return `<link rel="site.standard.document" href="${atUri}">`; 97 + } 98 + 99 + /** 100 + * Generate a <link> tag for publication verification 101 + * 102 + * Add this to your site's <head> to verify publication ownership. 103 + * 104 + * @example 105 + * ```svelte 106 + * <svelte:head> 107 + * {@html generatePublicationLinkTag({ 108 + * did: 'did:plc:xxx', 109 + * publicationRkey: '3abc123xyz', 110 + * })} 111 + * </svelte:head> 112 + * ``` 113 + */ 114 + export function generatePublicationLinkTag(options: { 115 + did: string; 116 + publicationRkey: string; 117 + }): string { 118 + const atUri = getPublicationAtUri(options.did, options.publicationRkey); 119 + return `<link rel="site.standard.publication" href="${atUri}">`; 120 + } 121 + 122 + /** 123 + * Verify that a well-known endpoint returns the expected AT-URI 124 + * 125 + * @example 126 + * ```ts 127 + * const isValid = await verifyPublicationWellKnown( 128 + * 'https://yourblog.com', 129 + * 'did:plc:xxx', 130 + * '3abc123xyz' 131 + * ); 132 + * ``` 133 + */ 134 + export async function verifyPublicationWellKnown( 135 + siteUrl: string, 136 + did: string, 137 + publicationRkey: string 138 + ): Promise<boolean> { 139 + try { 140 + const cleanUrl = siteUrl.replace(/\/$/, ''); 141 + const response = await fetch(`${cleanUrl}/.well-known/site.standard.publication`); 142 + 143 + if (!response.ok) return false; 144 + 145 + const content = await response.text(); 146 + const expectedUri = getPublicationAtUri(did, publicationRkey); 147 + 148 + return content.trim() === expectedUri; 149 + } catch (error) { 150 + console.error('Failed to verify publication well-known:', error); 151 + return false; 152 + } 153 + } 154 + 155 + /** 156 + * Extract AT-URI from a <link> tag in HTML 157 + * 158 + * @example 159 + * ```ts 160 + * const html = '<link rel="site.standard.document" href="at://did:plc:xxx/site.standard.document/3xyz">'; 161 + * const uri = extractDocumentLinkFromHtml(html); 162 + * // => 'at://did:plc:xxx/site.standard.document/3xyz' 163 + * ``` 164 + */ 165 + export function extractDocumentLinkFromHtml(html: string): string | null { 166 + const match = html.match( 167 + /<link\s+rel="site\.standard\.document"\s+href="(at:\/\/[^"]+)"\s*\/?>/i 168 + ); 169 + return match ? match[1] : null; 170 + } 171 + 172 + /** 173 + * Extract publication AT-URI from a <link> tag in HTML 174 + */ 175 + export function extractPublicationLinkFromHtml(html: string): string | null { 176 + const match = html.match( 177 + /<link\s+rel="site\.standard\.publication"\s+href="(at:\/\/[^"]+)"\s*\/?>/i 178 + ); 179 + return match ? match[1] : null; 180 + }
+10
vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + import { svelte } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + export default defineConfig({ 5 + plugins: [svelte()], 6 + test: { 7 + globals: true, 8 + environment: 'jsdom' 9 + } 10 + });