a standard.site publication renderer for SvelteKit.

feat: initial release with client, components, docs, and examples

+3245 -37
+8
.env.example
··· 1 + # Required: Your AT Protocol DID 2 + PUBLIC_ATPROTO_DID=did:plc:revjuqmkvrw6fnkxppqtszpv 3 + 4 + # Optional: Custom PDS endpoint (will be auto-resolved if not provided) 5 + # PUBLIC_ATPROTO_PDS=https://cortinarius.us-west.host.bsky.network 6 + 7 + # Optional: Cache TTL in milliseconds (default: 300000 = 5 minutes) 8 + # PUBLIC_CACHE_TTL=300000
+628
API.md
··· 1 + # API Reference 2 + 3 + Complete API documentation for svelte-standard-site. 4 + 5 + ## Table of Contents 6 + 7 + - [SiteStandardClient](#sitestandardclient) 8 + - [Types](#types) 9 + - [Utility Functions](#utility-functions) 10 + - [Components](#components) 11 + - [Configuration](#configuration) 12 + 13 + ## SiteStandardClient 14 + 15 + The main client class for interacting with site.standard.\* records. 16 + 17 + ### Constructor 18 + 19 + ```typescript 20 + constructor(config: SiteStandardConfig) 21 + ``` 22 + 23 + Creates a new instance of the client. 24 + 25 + **Parameters:** 26 + 27 + - `config` (SiteStandardConfig): Configuration object 28 + 29 + **Example:** 30 + 31 + ```typescript 32 + import { createClient } from 'svelte-standard-site'; 33 + 34 + const client = createClient({ 35 + did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 36 + pds: 'https://cortinarius.us-west.host.bsky.network', // optional 37 + cacheTTL: 300000 // optional, in ms 38 + }); 39 + ``` 40 + 41 + ### Methods 42 + 43 + #### `fetchPublication` 44 + 45 + ```typescript 46 + async fetchPublication( 47 + rkey: string, 48 + fetchFn?: typeof fetch 49 + ): Promise<AtProtoRecord<Publication> | null> 50 + ``` 51 + 52 + Fetches a single publication by its record key. 53 + 54 + **Parameters:** 55 + 56 + - `rkey` (string): The record key (TID) of the publication 57 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 58 + 59 + **Returns:** Promise resolving to the publication record or null if not found 60 + 61 + **Example:** 62 + 63 + ```typescript 64 + const pub = await client.fetchPublication('3lwafzkjqm25s'); 65 + if (pub) { 66 + console.log(pub.value.name); 67 + } 68 + ``` 69 + 70 + #### `fetchAllPublications` 71 + 72 + ```typescript 73 + async fetchAllPublications( 74 + fetchFn?: typeof fetch 75 + ): Promise<AtProtoRecord<Publication>[]> 76 + ``` 77 + 78 + Fetches all publications for the configured DID with automatic pagination. 79 + 80 + **Parameters:** 81 + 82 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 83 + 84 + **Returns:** Promise resolving to an array of publication records 85 + 86 + **Example:** 87 + 88 + ```typescript 89 + const publications = await client.fetchAllPublications(fetch); 90 + console.log(`Found ${publications.length} publications`); 91 + ``` 92 + 93 + #### `fetchDocument` 94 + 95 + ```typescript 96 + async fetchDocument( 97 + rkey: string, 98 + fetchFn?: typeof fetch 99 + ): Promise<AtProtoRecord<Document> | null> 100 + ``` 101 + 102 + Fetches a single document by its record key. 103 + 104 + **Parameters:** 105 + 106 + - `rkey` (string): The record key (TID) of the document 107 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 108 + 109 + **Returns:** Promise resolving to the document record or null if not found 110 + 111 + **Example:** 112 + 113 + ```typescript 114 + const doc = await client.fetchDocument('3lxbm5kqrs2s'); 115 + if (doc) { 116 + console.log(doc.value.title); 117 + } 118 + ``` 119 + 120 + #### `fetchAllDocuments` 121 + 122 + ```typescript 123 + async fetchAllDocuments( 124 + fetchFn?: typeof fetch 125 + ): Promise<AtProtoRecord<Document>[]> 126 + ``` 127 + 128 + Fetches all documents for the configured DID with automatic pagination. Results are sorted by `publishedAt` (newest first). 129 + 130 + **Parameters:** 131 + 132 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 133 + 134 + **Returns:** Promise resolving to an array of document records 135 + 136 + **Example:** 137 + 138 + ```typescript 139 + const documents = await client.fetchAllDocuments(fetch); 140 + documents.forEach((doc) => { 141 + console.log(doc.value.title, new Date(doc.value.publishedAt)); 142 + }); 143 + ``` 144 + 145 + #### `fetchDocumentsByPublication` 146 + 147 + ```typescript 148 + async fetchDocumentsByPublication( 149 + publicationUri: string, 150 + fetchFn?: typeof fetch 151 + ): Promise<AtProtoRecord<Document>[]> 152 + ``` 153 + 154 + Fetches all documents that belong to a specific publication. 155 + 156 + **Parameters:** 157 + 158 + - `publicationUri` (string): AT URI of the publication 159 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 160 + 161 + **Returns:** Promise resolving to an array of document records 162 + 163 + **Example:** 164 + 165 + ```typescript 166 + const docs = await client.fetchDocumentsByPublication( 167 + 'at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s' 168 + ); 169 + ``` 170 + 171 + #### `fetchByAtUri` 172 + 173 + ```typescript 174 + async fetchByAtUri<T = Publication | Document>( 175 + atUri: string, 176 + fetchFn?: typeof fetch 177 + ): Promise<AtProtoRecord<T> | null> 178 + ``` 179 + 180 + Fetches a record by its full AT URI. 181 + 182 + **Parameters:** 183 + 184 + - `atUri` (string): Full AT URI of the record 185 + - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 186 + 187 + **Returns:** Promise resolving to the record or null if not found 188 + 189 + **Example:** 190 + 191 + ```typescript 192 + const record = await client.fetchByAtUri( 193 + 'at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s' 194 + ); 195 + ``` 196 + 197 + #### `clearCache` 198 + 199 + ```typescript 200 + clearCache(): void 201 + ``` 202 + 203 + Clears all cached data. 204 + 205 + **Example:** 206 + 207 + ```typescript 208 + client.clearCache(); 209 + ``` 210 + 211 + #### `getPDS` 212 + 213 + ```typescript 214 + async getPDS(fetchFn?: typeof fetch): Promise<string> 215 + ``` 216 + 217 + Gets the resolved PDS endpoint for the configured DID. 218 + 219 + **Returns:** Promise resolving to the PDS URL 220 + 221 + **Example:** 222 + 223 + ```typescript 224 + const pds = await client.getPDS(); 225 + console.log('Using PDS:', pds); 226 + ``` 227 + 228 + ## Types 229 + 230 + ### SiteStandardConfig 231 + 232 + Configuration object for the client. 233 + 234 + ```typescript 235 + interface SiteStandardConfig { 236 + /** The DID to fetch records from */ 237 + did: string; 238 + /** Optional custom PDS endpoint */ 239 + pds?: string; 240 + /** Cache TTL in milliseconds (default: 5 minutes) */ 241 + cacheTTL?: number; 242 + } 243 + ``` 244 + 245 + ### Publication 246 + 247 + Represents a site.standard.publication record. 248 + 249 + ```typescript 250 + interface Publication { 251 + $type: 'site.standard.publication'; 252 + url: string; 253 + name: string; 254 + icon?: AtProtoBlob; 255 + description?: string; 256 + basicTheme?: BasicTheme; 257 + preferences?: PublicationPreferences; 258 + } 259 + ``` 260 + 261 + ### Document 262 + 263 + Represents a site.standard.document record. 264 + 265 + ```typescript 266 + interface Document { 267 + $type: 'site.standard.document'; 268 + site: string; // AT URI or HTTPS URL 269 + title: string; 270 + path?: string; 271 + description?: string; 272 + coverImage?: AtProtoBlob; 273 + content?: any; 274 + textContent?: string; 275 + bskyPostRef?: StrongRef; 276 + tags?: string[]; 277 + publishedAt: string; // ISO 8601 datetime 278 + updatedAt?: string; // ISO 8601 datetime 279 + } 280 + ``` 281 + 282 + ### AtProtoRecord 283 + 284 + Generic wrapper for AT Protocol records. 285 + 286 + ```typescript 287 + interface AtProtoRecord<T> { 288 + uri: string; // AT URI of the record 289 + cid: string; // Content identifier 290 + value: T; // The actual record value 291 + } 292 + ``` 293 + 294 + ### AtProtoBlob 295 + 296 + Represents a blob (file) in AT Protocol. 297 + 298 + ```typescript 299 + interface AtProtoBlob { 300 + $type: 'blob'; 301 + ref: { 302 + $link: string; // CID or full URL after enhancement 303 + }; 304 + mimeType: string; 305 + size: number; 306 + } 307 + ``` 308 + 309 + ### BasicTheme 310 + 311 + Theme configuration for a publication. 312 + 313 + ```typescript 314 + interface BasicTheme { 315 + $type: 'site.standard.theme.basic'; 316 + accentColor?: string; // Hex color 317 + backgroundColor?: string; // Hex color 318 + textColor?: string; // Hex color 319 + } 320 + ``` 321 + 322 + ### StrongRef 323 + 324 + Reference to another AT Protocol record. 325 + 326 + ```typescript 327 + interface StrongRef { 328 + uri: string; // AT URI 329 + cid: string; // Content identifier 330 + } 331 + ``` 332 + 333 + ### ResolvedIdentity 334 + 335 + Result of DID resolution. 336 + 337 + ```typescript 338 + interface ResolvedIdentity { 339 + did: string; 340 + pds: string; // PDS endpoint URL 341 + handle?: string; // User handle 342 + } 343 + ``` 344 + 345 + ## Utility Functions 346 + 347 + ### AT URI Utilities 348 + 349 + #### `parseAtUri` 350 + 351 + ```typescript 352 + function parseAtUri(atUri: string): ParsedAtUri | null; 353 + ``` 354 + 355 + Parses an AT URI into its components. 356 + 357 + **Example:** 358 + 359 + ```typescript 360 + import { parseAtUri } from 'svelte-standard-site'; 361 + 362 + const parsed = parseAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 363 + // { did: 'did:plc:xxx', collection: 'site.standard.publication', rkey: 'rkey' } 364 + ``` 365 + 366 + #### `atUriToHttps` 367 + 368 + ```typescript 369 + function atUriToHttps(atUri: string, pdsEndpoint: string): string | null; 370 + ``` 371 + 372 + Converts an AT URI to an HTTPS URL for the getRecord XRPC endpoint. 373 + 374 + **Example:** 375 + 376 + ```typescript 377 + import { atUriToHttps } from 'svelte-standard-site'; 378 + 379 + const url = atUriToHttps( 380 + 'at://did:plc:xxx/site.standard.publication/rkey', 381 + 'https://pds.example.com' 382 + ); 383 + ``` 384 + 385 + #### `buildAtUri` 386 + 387 + ```typescript 388 + function buildAtUri(did: string, collection: string, rkey: string): string; 389 + ``` 390 + 391 + Constructs an AT URI from components. 392 + 393 + **Example:** 394 + 395 + ```typescript 396 + import { buildAtUri } from 'svelte-standard-site'; 397 + 398 + const uri = buildAtUri('did:plc:xxx', 'site.standard.publication', 'rkey'); 399 + // 'at://did:plc:xxx/site.standard.publication/rkey' 400 + ``` 401 + 402 + #### `extractRkey` 403 + 404 + ```typescript 405 + function extractRkey(atUri: string): string | null; 406 + ``` 407 + 408 + Extracts the record key from an AT URI. 409 + 410 + **Example:** 411 + 412 + ```typescript 413 + import { extractRkey } from 'svelte-standard-site'; 414 + 415 + const rkey = extractRkey('at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s'); 416 + // '3lwafzkjqm25s' 417 + ``` 418 + 419 + #### `isAtUri` 420 + 421 + ```typescript 422 + function isAtUri(uri: string): boolean; 423 + ``` 424 + 425 + Validates if a string is a valid AT URI. 426 + 427 + **Example:** 428 + 429 + ```typescript 430 + import { isAtUri } from 'svelte-standard-site'; 431 + 432 + console.log(isAtUri('at://did:plc:xxx/collection/rkey')); // true 433 + console.log(isAtUri('https://example.com')); // false 434 + ``` 435 + 436 + ### Agent Utilities 437 + 438 + #### `resolveIdentity` 439 + 440 + ```typescript 441 + async function resolveIdentity(did: string, fetchFn?: typeof fetch): Promise<ResolvedIdentity>; 442 + ``` 443 + 444 + Resolves a DID to its PDS endpoint using Slingshot. 445 + 446 + **Example:** 447 + 448 + ```typescript 449 + import { resolveIdentity } from 'svelte-standard-site'; 450 + 451 + const identity = await resolveIdentity('did:plc:xxx'); 452 + console.log(identity.pds); // 'https://...' 453 + ``` 454 + 455 + #### `buildPdsBlobUrl` 456 + 457 + ```typescript 458 + function buildPdsBlobUrl(pds: string, did: string, cid: string): string; 459 + ``` 460 + 461 + Constructs a blob URL for retrieving files from a PDS. 462 + 463 + **Example:** 464 + 465 + ```typescript 466 + import { buildPdsBlobUrl } from 'svelte-standard-site'; 467 + 468 + const url = buildPdsBlobUrl('https://pds.example.com', 'did:plc:xxx', 'bafyrei...'); 469 + ``` 470 + 471 + ### Cache 472 + 473 + #### `cache` 474 + 475 + Global cache instance used by the library. 476 + 477 + ```typescript 478 + const cache: Cache; 479 + ``` 480 + 481 + **Methods:** 482 + 483 + - `get<T>(key: string): T | null` - Get cached value 484 + - `set<T>(key: string, value: T, ttl?: number): void` - Set cached value 485 + - `delete(key: string): void` - Delete cached value 486 + - `clear(): void` - Clear all cached values 487 + - `setDefaultTTL(ttl: number): void` - Set default TTL 488 + 489 + **Example:** 490 + 491 + ```typescript 492 + import { cache } from 'svelte-standard-site'; 493 + 494 + // Manual cache manipulation 495 + const data = cache.get('my-key'); 496 + cache.set('my-key', { some: 'data' }, 60000); 497 + cache.clear(); 498 + ``` 499 + 500 + ## Components 501 + 502 + ### PublicationCard 503 + 504 + Displays a publication card with icon, name, description, and link. 505 + 506 + **Props:** 507 + 508 + ```typescript 509 + interface Props { 510 + publication: AtProtoRecord<Publication>; 511 + class?: string; 512 + } 513 + ``` 514 + 515 + **Example:** 516 + 517 + ```svelte 518 + <script> 519 + import { PublicationCard } from 'svelte-standard-site'; 520 + 521 + export let data; 522 + </script> 523 + 524 + <PublicationCard publication={data.publication} class="my-custom-class" /> 525 + ``` 526 + 527 + **Styling:** 528 + 529 + The component uses scoped CSS but exposes the following classes for customization: 530 + 531 + - `.publication-card` 532 + - `.publication-header` 533 + - `.publication-icon` 534 + - `.publication-info` 535 + - `.publication-name` 536 + - `.publication-description` 537 + - `.publication-link` 538 + 539 + ### DocumentCard 540 + 541 + Displays a document card with cover image, title, description, metadata, and tags. 542 + 543 + **Props:** 544 + 545 + ```typescript 546 + interface Props { 547 + document: AtProtoRecord<Document>; 548 + class?: string; 549 + showCover?: boolean; 550 + } 551 + ``` 552 + 553 + **Example:** 554 + 555 + ```svelte 556 + <script> 557 + import { DocumentCard } from 'svelte-standard-site'; 558 + 559 + export let data; 560 + </script> 561 + 562 + <DocumentCard document={data.document} showCover={true} class="my-custom-class" /> 563 + ``` 564 + 565 + **Styling:** 566 + 567 + The component uses scoped CSS but exposes the following classes: 568 + 569 + - `.document-card` 570 + - `.document-content` 571 + - `.document-cover` 572 + - `.document-body` 573 + - `.document-title` 574 + - `.document-description` 575 + - `.document-meta` 576 + - `.document-date` 577 + - `.document-updated` 578 + - `.document-tags` 579 + - `.tag` 580 + 581 + ## Configuration 582 + 583 + ### Environment Variables 584 + 585 + #### `getConfigFromEnv` 586 + 587 + ```typescript 588 + function getConfigFromEnv(): SiteStandardConfig | null; 589 + ``` 590 + 591 + Reads configuration from environment variables. 592 + 593 + **Environment Variables:** 594 + 595 + - `PUBLIC_ATPROTO_DID` (required): The DID to fetch records from 596 + - `PUBLIC_ATPROTO_PDS` (optional): Custom PDS endpoint 597 + - `PUBLIC_CACHE_TTL` (optional): Cache TTL in milliseconds 598 + 599 + **Example:** 600 + 601 + ```typescript 602 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 603 + 604 + const config = getConfigFromEnv(); 605 + if (config) { 606 + const client = createClient(config); 607 + } 608 + ``` 609 + 610 + #### `validateEnv` 611 + 612 + ```typescript 613 + function validateEnv(): void; 614 + ``` 615 + 616 + Validates that required environment variables are set. Throws an error if validation fails. 617 + 618 + **Example:** 619 + 620 + ```typescript 621 + import { validateEnv } from 'svelte-standard-site/config/env'; 622 + 623 + try { 624 + validateEnv(); 625 + } catch (error) { 626 + console.error('Missing required environment variables'); 627 + } 628 + ```
+203
CONTRIBUTING.md
··· 1 + # Contributing to svelte-standard-site 2 + 3 + Thank you for your interest in contributing! This document provides guidelines and information for contributors. 4 + 5 + ## Getting Started 6 + 7 + ### Prerequisites 8 + 9 + - Node.js 18 or higher 10 + - pnpm 8 or higher 11 + 12 + ### Setup 13 + 14 + 1. Fork and clone the repository: 15 + 16 + ```bash 17 + git clone https://github.com/your-username/svelte-standard-site.git 18 + cd svelte-standard-site 19 + ``` 20 + 21 + 2. Install dependencies: 22 + 23 + ```bash 24 + pnpm install 25 + ``` 26 + 27 + 3. Create a `.env` file: 28 + 29 + ```bash 30 + cp .env.example .env 31 + # Edit .env and add your PUBLIC_ATPROTO_DID 32 + ``` 33 + 34 + 4. Start the development server: 35 + 36 + ```bash 37 + pnpm dev 38 + ``` 39 + 40 + ## Development Workflow 41 + 42 + ### Project Structure 43 + 44 + ``` 45 + src/ 46 + ├── lib/ # Library source code 47 + │ ├── client.ts # Main client implementation 48 + │ ├── types.ts # TypeScript type definitions 49 + │ ├── index.ts # Public API exports 50 + │ ├── components/ # Reusable Svelte components 51 + │ │ ├── PublicationCard.svelte 52 + │ │ └── DocumentCard.svelte 53 + │ ├── config/ # Configuration utilities 54 + │ │ └── env.ts # Environment variable handling 55 + │ └── utils/ # Utility functions 56 + │ ├── agents.ts # AT Protocol agent utilities 57 + │ ├── at-uri.ts # AT URI parsing utilities 58 + │ └── cache.ts # Caching implementation 59 + └── routes/ # Demo/showcase pages 60 + ├── +page.svelte 61 + └── +page.server.ts 62 + ``` 63 + 64 + ### Commands 65 + 66 + - `pnpm dev` - Start development server 67 + - `pnpm build` - Build the library 68 + - `pnpm check` - Run type checking 69 + - `pnpm format` - Format code with Prettier 70 + - `pnpm lint` - Check code formatting 71 + - `pnpm prepack` - Prepare package for publishing 72 + 73 + ## Making Changes 74 + 75 + ### Code Style 76 + 77 + - We use Prettier for code formatting 78 + - Run `pnpm format` before committing 79 + - TypeScript strict mode is enabled 80 + - Follow the existing code structure and patterns 81 + 82 + ### Commit Messages 83 + 84 + We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: 85 + 86 + - `feat:` - New features 87 + - `fix:` - Bug fixes 88 + - `docs:` - Documentation changes 89 + - `style:` - Code style changes (formatting, etc.) 90 + - `refactor:` - Code refactoring 91 + - `test:` - Test additions or changes 92 + - `chore:` - Build process or tooling changes 93 + 94 + Example: 95 + 96 + ``` 97 + feat: add support for custom PDS endpoints 98 + fix: resolve caching issue with blob URLs 99 + docs: update README with new examples 100 + ``` 101 + 102 + ### Pull Request Process 103 + 104 + 1. Create a new branch: 105 + 106 + ```bash 107 + git checkout -b feat/your-feature-name 108 + ``` 109 + 110 + 2. Make your changes and commit them: 111 + 112 + ```bash 113 + git add . 114 + git commit -m "feat: add your feature" 115 + ``` 116 + 117 + 3. Push to your fork: 118 + 119 + ```bash 120 + git push origin feat/your-feature-name 121 + ``` 122 + 123 + 4. Open a Pull Request on GitHub 124 + 125 + 5. Ensure: 126 + - Code passes type checking (`pnpm check`) 127 + - Code is properly formatted (`pnpm format`) 128 + - Documentation is updated if needed 129 + - Examples are added for new features 130 + 131 + ## What to Contribute 132 + 133 + ### Good First Issues 134 + 135 + Look for issues labeled `good first issue` for beginner-friendly tasks. 136 + 137 + ### Areas for Contribution 138 + 139 + - **Bug Fixes**: Report and fix bugs 140 + - **Features**: Implement new features (discuss in an issue first) 141 + - **Documentation**: Improve or expand documentation 142 + - **Examples**: Add new usage examples 143 + - **Components**: Create new reusable components 144 + - **Tests**: Add or improve test coverage 145 + - **Performance**: Optimize existing code 146 + 147 + ## Reporting Bugs 148 + 149 + When reporting bugs, please include: 150 + 151 + 1. A clear description of the issue 152 + 2. Steps to reproduce 153 + 3. Expected behavior 154 + 4. Actual behavior 155 + 5. Environment details (Node version, OS, etc.) 156 + 6. Code samples if applicable 157 + 158 + ## Feature Requests 159 + 160 + For feature requests: 161 + 162 + 1. Check if the feature already exists or is planned 163 + 2. Open an issue describing: 164 + - The problem you're trying to solve 165 + - Your proposed solution 166 + - Any alternatives you've considered 167 + - Examples of the desired behavior 168 + 169 + ## Code of Conduct 170 + 171 + ### Our Pledge 172 + 173 + We are committed to providing a friendly, safe, and welcoming environment for all contributors. 174 + 175 + ### Expected Behavior 176 + 177 + - Be respectful and inclusive 178 + - Welcome newcomers 179 + - Accept constructive criticism gracefully 180 + - Focus on what's best for the community 181 + - Show empathy towards others 182 + 183 + ### Unacceptable Behavior 184 + 185 + - Harassment of any kind 186 + - Discriminatory language or actions 187 + - Personal attacks 188 + - Publishing others' private information 189 + - Other conduct which could reasonably be considered inappropriate 190 + 191 + ## Questions? 192 + 193 + Feel free to: 194 + 195 + - Open an issue for questions 196 + - Start a discussion in GitHub Discussions 197 + - Reach out to maintainers 198 + 199 + ## License 200 + 201 + By contributing, you agree that your contributions will be licensed under the MIT License. 202 + 203 + Thank you for contributing to svelte-standard-site! 🎉
+448
EXAMPLES.md
··· 1 + # Usage Examples 2 + 3 + This document provides comprehensive examples of using the svelte-standard-site library. 4 + 5 + ## Basic Setup 6 + 7 + ### 1. Install the Package 8 + 9 + ```bash 10 + pnpm add svelte-standard-site 11 + ``` 12 + 13 + ### 2. Configure Environment Variables 14 + 15 + Create a `.env` file: 16 + 17 + ```env 18 + PUBLIC_ATPROTO_DID=did:plc:revjuqmkvrw6fnkxppqtszpv 19 + ``` 20 + 21 + ## Example 1: Simple Blog List 22 + 23 + Fetch and display all documents as blog posts. 24 + 25 + ```typescript 26 + // src/routes/blog/+page.server.ts 27 + import { createClient } from 'svelte-standard-site'; 28 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 29 + import type { PageServerLoad } from './$types'; 30 + 31 + export const load: PageServerLoad = async ({ fetch }) => { 32 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 33 + const documents = await client.fetchAllDocuments(fetch); 34 + 35 + return { 36 + posts: documents 37 + }; 38 + }; 39 + ``` 40 + 41 + ```svelte 42 + <!-- src/routes/blog/+page.svelte --> 43 + <script lang="ts"> 44 + import { DocumentCard } from 'svelte-standard-site'; 45 + import type { PageData } from './$types'; 46 + 47 + const { data }: { data: PageData } = $props(); 48 + </script> 49 + 50 + <div class="container"> 51 + <h1>Blog Posts</h1> 52 + <div class="posts"> 53 + {#each data.posts as post} 54 + <DocumentCard document={post} /> 55 + {/each} 56 + </div> 57 + </div> 58 + ``` 59 + 60 + ## Example 2: Single Post Page 61 + 62 + Display a single document by its record key (slug). 63 + 64 + ```typescript 65 + // src/routes/blog/[slug]/+page.server.ts 66 + import { createClient } from 'svelte-standard-site'; 67 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 68 + import { error } from '@sveltejs/kit'; 69 + import type { PageServerLoad } from './$types'; 70 + 71 + export const load: PageServerLoad = async ({ params, fetch }) => { 72 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 73 + const document = await client.fetchDocument(params.slug, fetch); 74 + 75 + if (!document) { 76 + throw error(404, 'Post not found'); 77 + } 78 + 79 + return { 80 + post: document 81 + }; 82 + }; 83 + ``` 84 + 85 + ```svelte 86 + <!-- src/routes/blog/[slug]/+page.svelte --> 87 + <script lang="ts"> 88 + import type { PageData } from './$types'; 89 + 90 + const { data }: { data: PageData } = $props(); 91 + const post = data.post.value; 92 + </script> 93 + 94 + <article> 95 + {#if post.coverImage} 96 + <img src={post.coverImage.ref.$link} alt={post.title} /> 97 + {/if} 98 + 99 + <h1>{post.title}</h1> 100 + 101 + <div class="meta"> 102 + <time>{new Date(post.publishedAt).toLocaleDateString()}</time> 103 + {#if post.tags} 104 + <div class="tags"> 105 + {#each post.tags as tag} 106 + <span class="tag">{tag}</span> 107 + {/each} 108 + </div> 109 + {/if} 110 + </div> 111 + 112 + {#if post.description} 113 + <p class="description">{post.description}</p> 114 + {/if} 115 + 116 + {#if post.textContent} 117 + <div class="content"> 118 + {post.textContent} 119 + </div> 120 + {/if} 121 + </article> 122 + ``` 123 + 124 + ## Example 3: Publication-Filtered Posts 125 + 126 + Display documents from a specific publication. 127 + 128 + ```typescript 129 + // src/routes/publications/[pubkey]/+page.server.ts 130 + import { createClient, buildAtUri } from 'svelte-standard-site'; 131 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 132 + import { error } from '@sveltejs/kit'; 133 + import type { PageServerLoad } from './$types'; 134 + 135 + export const load: PageServerLoad = async ({ params, fetch }) => { 136 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 137 + 138 + // Fetch the publication 139 + const publication = await client.fetchPublication(params.pubkey, fetch); 140 + if (!publication) { 141 + throw error(404, 'Publication not found'); 142 + } 143 + 144 + // Fetch documents for this publication 145 + const publicationUri = buildAtUri(PUBLIC_ATPROTO_DID, 'site.standard.publication', params.pubkey); 146 + const documents = await client.fetchDocumentsByPublication(publicationUri, fetch); 147 + 148 + return { 149 + publication, 150 + documents 151 + }; 152 + }; 153 + ``` 154 + 155 + ```svelte 156 + <!-- src/routes/publications/[pubkey]/+page.svelte --> 157 + <script lang="ts"> 158 + import { PublicationCard, DocumentCard } from 'svelte-standard-site'; 159 + import type { PageData } from './$types'; 160 + 161 + const { data }: { data: PageData } = $props(); 162 + </script> 163 + 164 + <div class="publication-page"> 165 + <PublicationCard publication={data.publication} /> 166 + 167 + <h2>Posts in this Publication</h2> 168 + <div class="documents"> 169 + {#each data.documents as doc} 170 + <DocumentCard document={doc} /> 171 + {/each} 172 + </div> 173 + </div> 174 + ``` 175 + 176 + ## Example 4: Using AT URIs 177 + 178 + Fetch a record using its full AT URI. 179 + 180 + ```typescript 181 + import { createClient } from 'svelte-standard-site'; 182 + 183 + const client = createClient({ did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' }); 184 + 185 + // Fetch using AT URI 186 + const publication = await client.fetchByAtUri( 187 + 'at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s' 188 + ); 189 + 190 + console.log(publication?.value.name); 191 + ``` 192 + 193 + ## Example 5: Manual Configuration 194 + 195 + Configure the client without environment variables. 196 + 197 + ```typescript 198 + import { createClient } from 'svelte-standard-site'; 199 + 200 + const client = createClient({ 201 + did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 202 + pds: 'https://cortinarius.us-west.host.bsky.network', 203 + cacheTTL: 600000 // 10 minutes 204 + }); 205 + ``` 206 + 207 + ## Example 6: Custom Publication Listing 208 + 209 + Create a custom component to display publications. 210 + 211 + ```svelte 212 + <script lang="ts"> 213 + import type { AtProtoRecord, Publication } from 'svelte-standard-site'; 214 + 215 + interface Props { 216 + publications: AtProtoRecord<Publication>[]; 217 + } 218 + 219 + const { publications }: Props = $props(); 220 + </script> 221 + 222 + <div class="publications-grid"> 223 + {#each publications as pub} 224 + <a href="/pub/{pub.uri.split('/').pop()}" class="pub-card"> 225 + {#if pub.value.icon} 226 + <img src={pub.value.icon.ref.$link} alt={pub.value.name} /> 227 + {/if} 228 + <h3>{pub.value.name}</h3> 229 + <p>{pub.value.description}</p> 230 + </a> 231 + {/each} 232 + </div> 233 + 234 + <style> 235 + .publications-grid { 236 + display: grid; 237 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 238 + gap: 1.5rem; 239 + } 240 + 241 + .pub-card { 242 + padding: 1.5rem; 243 + border: 1px solid #e5e7eb; 244 + border-radius: 0.5rem; 245 + text-decoration: none; 246 + color: inherit; 247 + transition: all 0.2s; 248 + } 249 + 250 + .pub-card:hover { 251 + border-color: #3b82f6; 252 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); 253 + } 254 + 255 + .pub-card img { 256 + width: 100%; 257 + height: 150px; 258 + object-fit: cover; 259 + border-radius: 0.375rem; 260 + margin-bottom: 1rem; 261 + } 262 + 263 + .pub-card h3 { 264 + font-size: 1.25rem; 265 + font-weight: 600; 266 + margin-bottom: 0.5rem; 267 + } 268 + 269 + .pub-card p { 270 + font-size: 0.875rem; 271 + color: #6b7280; 272 + } 273 + </style> 274 + ``` 275 + 276 + ## Example 7: Search and Filter 277 + 278 + Implement search and tag filtering. 279 + 280 + ```typescript 281 + // src/routes/search/+page.server.ts 282 + import { createClient } from 'svelte-standard-site'; 283 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 284 + import type { PageServerLoad } from './$types'; 285 + 286 + export const load: PageServerLoad = async ({ url, fetch }) => { 287 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 288 + const documents = await client.fetchAllDocuments(fetch); 289 + 290 + const query = url.searchParams.get('q')?.toLowerCase() || ''; 291 + const tag = url.searchParams.get('tag') || ''; 292 + 293 + const filtered = documents.filter((doc) => { 294 + const matchesQuery = 295 + !query || 296 + doc.value.title.toLowerCase().includes(query) || 297 + doc.value.description?.toLowerCase().includes(query) || 298 + doc.value.textContent?.toLowerCase().includes(query); 299 + 300 + const matchesTag = !tag || doc.value.tags?.includes(tag); 301 + 302 + return matchesQuery && matchesTag; 303 + }); 304 + 305 + // Get all unique tags 306 + const allTags = new Set<string>(); 307 + documents.forEach((doc) => { 308 + doc.value.tags?.forEach((t) => allTags.add(t)); 309 + }); 310 + 311 + return { 312 + documents: filtered, 313 + tags: Array.from(allTags).sort(), 314 + query, 315 + selectedTag: tag 316 + }; 317 + }; 318 + ``` 319 + 320 + ## Example 8: RSS Feed 321 + 322 + Generate an RSS feed from documents. 323 + 324 + ```typescript 325 + // src/routes/rss.xml/+server.ts 326 + import { createClient } from 'svelte-standard-site'; 327 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 328 + import type { RequestHandler } from './$types'; 329 + 330 + export const GET: RequestHandler = async ({ fetch }) => { 331 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 332 + const documents = await client.fetchAllDocuments(fetch); 333 + 334 + const rss = `<?xml version="1.0" encoding="UTF-8"?> 335 + <rss version="2.0"> 336 + <channel> 337 + <title>My Blog</title> 338 + <link>https://example.com</link> 339 + <description>Blog posts from AT Protocol</description> 340 + ${documents 341 + .map( 342 + (doc) => ` 343 + <item> 344 + <title>${escapeXml(doc.value.title)}</title> 345 + <description>${escapeXml(doc.value.description || '')}</description> 346 + <pubDate>${new Date(doc.value.publishedAt).toUTCString()}</pubDate> 347 + <guid>${doc.uri}</guid> 348 + </item>` 349 + ) 350 + .join('')} 351 + </channel> 352 + </rss>`; 353 + 354 + return new Response(rss, { 355 + headers: { 356 + 'Content-Type': 'application/xml' 357 + } 358 + }); 359 + }; 360 + 361 + function escapeXml(str: string): string { 362 + return str 363 + .replace(/&/g, '&amp;') 364 + .replace(/</g, '&lt;') 365 + .replace(/>/g, '&gt;') 366 + .replace(/"/g, '&quot;') 367 + .replace(/'/g, '&apos;'); 368 + } 369 + ``` 370 + 371 + ## Example 9: Sitemap Generation 372 + 373 + Generate a sitemap from documents. 374 + 375 + ```typescript 376 + // src/routes/sitemap.xml/+server.ts 377 + import { createClient } from 'svelte-standard-site'; 378 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 379 + import type { RequestHandler } from './$types'; 380 + 381 + export const GET: RequestHandler = async ({ fetch }) => { 382 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 383 + const documents = await client.fetchAllDocuments(fetch); 384 + 385 + const sitemap = `<?xml version="1.0" encoding="UTF-8"?> 386 + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 387 + <url> 388 + <loc>https://example.com</loc> 389 + <changefreq>daily</changefreq> 390 + <priority>1.0</priority> 391 + </url> 392 + ${documents 393 + .map((doc) => { 394 + const slug = doc.uri.split('/').pop(); 395 + return ` 396 + <url> 397 + <loc>https://example.com/blog/${slug}</loc> 398 + <lastmod>${new Date(doc.value.updatedAt || doc.value.publishedAt).toISOString()}</lastmod> 399 + <changefreq>weekly</changefreq> 400 + <priority>0.8</priority> 401 + </url>`; 402 + }) 403 + .join('')} 404 + </urlset>`; 405 + 406 + return new Response(sitemap, { 407 + headers: { 408 + 'Content-Type': 'application/xml' 409 + } 410 + }); 411 + }; 412 + ``` 413 + 414 + ## Example 10: Client-Side Usage 415 + 416 + Use the client in browser context (not recommended for production due to CORS, but useful for prototyping). 417 + 418 + ```svelte 419 + <script lang="ts"> 420 + import { createClient } from 'svelte-standard-site'; 421 + import { onMount } from 'svelte'; 422 + 423 + let documents = $state([]); 424 + let loading = $state(true); 425 + 426 + onMount(async () => { 427 + const client = createClient({ did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' }); 428 + documents = await client.fetchAllDocuments(); 429 + loading = false; 430 + }); 431 + </script> 432 + 433 + {#if loading} 434 + <p>Loading...</p> 435 + {:else} 436 + {#each documents as doc} 437 + <div>{doc.value.title}</div> 438 + {/each} 439 + {/if} 440 + ``` 441 + 442 + ## Best Practices 443 + 444 + 1. **Always use SSR**: Fetch data in `+page.server.ts` or `+layout.server.ts` for better performance and SEO 445 + 2. **Pass fetch function**: Always pass SvelteKit's `fetch` to client methods for proper SSR hydration 446 + 3. **Handle errors**: Wrap client calls in try-catch and provide user-friendly error messages 447 + 4. **Use caching**: The built-in cache reduces API calls, but you can adjust TTL as needed 448 + 5. **Type safety**: Import and use TypeScript types for better developer experience
+303 -31
README.md
··· 1 - # Svelte library 1 + # svelte-standard-site 2 2 3 - Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv). 3 + A SvelteKit library for fetching and working with `site.standard.*` records from the AT Protocol. 4 4 5 - Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging). 5 + ## Features 6 6 7 - ## Creating a project 7 + - 🔄 **Automatic PDS Resolution** - Resolves DIDs to their Personal Data Server endpoints 8 + - 📦 **Type-Safe** - Full TypeScript support with complete type definitions 9 + - 🚀 **SSR Ready** - Works seamlessly with SvelteKit's server-side rendering 10 + - 💾 **Built-in Caching** - Reduces API calls with intelligent caching 11 + - 🎯 **Simple API** - Easy to use, set it and forget it configuration 12 + - 🔗 **AT URI Support** - Parse and convert AT URIs to HTTPS URLs 8 13 9 - If you're seeing this, you've probably already done this step. Congrats! 14 + ## Installation 10 15 11 - ```sh 12 - # create a new project in the current directory 13 - npx sv create 16 + ```bash 17 + pnpm add svelte-standard-site 18 + # or 19 + npm install svelte-standard-site 20 + # or 21 + yarn add svelte-standard-site 22 + ``` 14 23 15 - # create a new project in my-app 16 - npx sv create my-app 24 + ## Quick Start 25 + 26 + ### 1. Configure Environment Variables 27 + 28 + Create a `.env` file in your project root: 29 + 30 + ```env 31 + PUBLIC_ATPROTO_DID=did:plc:your-did-here 32 + # Optional: specify a custom PDS endpoint 33 + PUBLIC_ATPROTO_PDS=https://your-pds.example.com 34 + # Optional: cache TTL in milliseconds (default: 300000 = 5 minutes) 35 + PUBLIC_CACHE_TTL=300000 17 36 ``` 18 37 19 - ## Developing 38 + ### 2. Create a Client 20 39 21 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 40 + ```typescript 41 + import { createClient } from 'svelte-standard-site'; 22 42 23 - ```sh 24 - npm run dev 43 + const client = createClient({ 44 + did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' 45 + }); 46 + 47 + // Fetch a single publication 48 + const publication = await client.fetchPublication('3lwafzkjqm25s'); 49 + 50 + // Fetch all publications 51 + const publications = await client.fetchAllPublications(); 52 + 53 + // Fetch all documents 54 + const documents = await client.fetchAllDocuments(); 55 + 56 + // Fetch documents for a specific publication 57 + const pubDocs = await client.fetchDocumentsByPublication( 58 + 'at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s' 59 + ); 60 + ``` 61 + 62 + ### 3. Use in SvelteKit Load Functions 63 + 64 + ```typescript 65 + // src/routes/+page.server.ts 66 + import { createClient } from 'svelte-standard-site'; 67 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 68 + import type { PageServerLoad } from './$types'; 69 + 70 + export const load: PageServerLoad = async ({ fetch }) => { 71 + const config = getConfigFromEnv(); 72 + if (!config) { 73 + throw new Error('Missing configuration'); 74 + } 75 + 76 + const client = createClient(config); 77 + 78 + const [publications, documents] = await Promise.all([ 79 + client.fetchAllPublications(fetch), 80 + client.fetchAllDocuments(fetch) 81 + ]); 25 82 26 - # or start the server and open the app in a new browser tab 27 - npm run dev -- --open 83 + return { 84 + publications, 85 + documents 86 + }; 87 + }; 28 88 ``` 29 89 30 - Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app. 90 + ```svelte 91 + <!-- src/routes/+page.svelte --> 92 + <script lang="ts"> 93 + import type { PageData } from './$types'; 31 94 32 - ## Building 95 + const { data }: { data: PageData } = $props(); 96 + </script> 33 97 34 - To build your library: 98 + <h1>Publications</h1> 99 + {#each data.publications as pub} 100 + <article> 101 + <h2>{pub.value.name}</h2> 102 + <p>{pub.value.description}</p> 103 + <a href={pub.value.url}>Visit</a> 104 + </article> 105 + {/each} 35 106 36 - ```sh 37 - npm pack 107 + <h1>Documents</h1> 108 + {#each data.documents as doc} 109 + <article> 110 + <h2>{doc.value.title}</h2> 111 + <p>{doc.value.description}</p> 112 + <time>{new Date(doc.value.publishedAt).toLocaleDateString()}</time> 113 + </article> 114 + {/each} 38 115 ``` 39 116 40 - To create a production version of your showcase app: 117 + ## API Reference 118 + 119 + ### `SiteStandardClient` 120 + 121 + The main client for interacting with site.standard records. 122 + 123 + #### Constructor 41 124 42 - ```sh 43 - npm run build 125 + ```typescript 126 + new SiteStandardClient(config: SiteStandardConfig) 44 127 ``` 45 128 46 - You can preview the production build with `npm run preview`. 129 + #### Methods 130 + 131 + - `fetchPublication(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication> | null>` 132 + - Fetch a single publication by record key 133 + 134 + - `fetchAllPublications(fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication>[]>` 135 + - Fetch all publications for the configured DID 136 + 137 + - `fetchDocument(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document> | null>` 138 + - Fetch a single document by record key 139 + 140 + - `fetchAllDocuments(fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 141 + - Fetch all documents for the configured DID, sorted by publishedAt (newest first) 142 + 143 + - `fetchDocumentsByPublication(publicationUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 144 + - Fetch all documents belonging to a specific publication 47 145 48 - > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 146 + - `fetchByAtUri<T>(atUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<T> | null>` 147 + - Fetch any record by its AT URI 49 148 50 - ## Publishing 149 + - `clearCache(): void` 150 + - Clear all cached data 51 151 52 - Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)). 152 + - `getPDS(fetchFn?: typeof fetch): Promise<string>` 153 + - Get the resolved PDS endpoint 53 154 54 - To publish your library to [npm](https://www.npmjs.com): 155 + ### Types 55 156 56 - ```sh 57 - npm publish 157 + ```typescript 158 + interface Publication { 159 + $type: 'site.standard.publication'; 160 + url: string; 161 + name: string; 162 + icon?: AtProtoBlob; 163 + description?: string; 164 + basicTheme?: BasicTheme; 165 + preferences?: PublicationPreferences; 166 + } 167 + 168 + interface Document { 169 + $type: 'site.standard.document'; 170 + site: string; // AT URI or HTTPS URL 171 + title: string; 172 + path?: string; 173 + description?: string; 174 + coverImage?: AtProtoBlob; 175 + content?: any; 176 + textContent?: string; 177 + bskyPostRef?: StrongRef; 178 + tags?: string[]; 179 + publishedAt: string; 180 + updatedAt?: string; 181 + } 182 + 183 + interface AtProtoRecord<T> { 184 + uri: string; 185 + cid: string; 186 + value: T; 187 + } 58 188 ``` 189 + 190 + ### Utility Functions 191 + 192 + #### AT URI Utilities 193 + 194 + ```typescript 195 + import { parseAtUri, atUriToHttps, buildAtUri, isAtUri } from 'svelte-standard-site'; 196 + 197 + // Parse an AT URI 198 + const parsed = parseAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 199 + // Returns: { did: 'did:plc:xxx', collection: 'site.standard.publication', rkey: 'rkey' } 200 + 201 + // Convert AT URI to HTTPS URL 202 + const url = atUriToHttps( 203 + 'at://did:plc:xxx/site.standard.publication/rkey', 204 + 'https://pds.example.com' 205 + ); 206 + // Returns: 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=...' 207 + 208 + // Build an AT URI 209 + const uri = buildAtUri('did:plc:xxx', 'site.standard.publication', 'rkey'); 210 + // Returns: 'at://did:plc:xxx/site.standard.publication/rkey' 211 + 212 + // Validate AT URI 213 + const valid = isAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 214 + // Returns: true 215 + ``` 216 + 217 + #### PDS Resolution 218 + 219 + ```typescript 220 + import { resolveIdentity, buildPdsBlobUrl } from 'svelte-standard-site'; 221 + 222 + // Resolve a DID to its PDS 223 + const identity = await resolveIdentity('did:plc:xxx'); 224 + // Returns: { did: 'did:plc:xxx', pds: 'https://...', handle?: 'user.bsky.social' } 225 + 226 + // Build a blob URL 227 + const blobUrl = buildPdsBlobUrl('https://pds.example.com', 'did:plc:xxx', 'bafyrei...'); 228 + // Returns: 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=...&cid=...' 229 + ``` 230 + 231 + ## Configuration 232 + 233 + ### From Environment Variables 234 + 235 + ```typescript 236 + import { getConfigFromEnv, validateEnv } from 'svelte-standard-site/config/env'; 237 + 238 + // Get config (returns null if missing) 239 + const config = getConfigFromEnv(); 240 + 241 + // Validate config (throws if missing) 242 + validateEnv(); 243 + ``` 244 + 245 + ### Manual Configuration 246 + 247 + ```typescript 248 + import { createClient } from 'svelte-standard-site'; 249 + 250 + const client = createClient({ 251 + did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 252 + pds: 'https://cortinarius.us-west.host.bsky.network', // optional 253 + cacheTTL: 300000 // optional, in milliseconds 254 + }); 255 + ``` 256 + 257 + ## Caching 258 + 259 + The library includes built-in caching to reduce API calls: 260 + 261 + - Default TTL: 5 minutes (300,000ms) 262 + - Configurable via `cacheTTL` option or `PUBLIC_CACHE_TTL` env var 263 + - Cache can be cleared manually with `client.clearCache()` 264 + 265 + ## AT URI Structure 266 + 267 + AT URIs follow this format: 268 + 269 + ``` 270 + at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s 271 + └─────────┬─────────┘ └──────────┬──────────┘ └────┬────┘ 272 + DID Collection Record Key 273 + ``` 274 + 275 + The library automatically converts these to HTTPS URLs for API calls: 276 + 277 + ``` 278 + https://cortinarius.us-west.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did:plc:revjuqmkvrw6fnkxppqtszpv&collection=site.standard.publication&rkey=3lwafzkjqm25s 279 + ``` 280 + 281 + ## Example: Building a Blog 282 + 283 + ```typescript 284 + // src/routes/blog/+page.server.ts 285 + import { createClient } from 'svelte-standard-site'; 286 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 287 + 288 + export const load = async ({ fetch }) => { 289 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 290 + const documents = await client.fetchAllDocuments(fetch); 291 + 292 + return { 293 + posts: documents.map((doc) => ({ 294 + title: doc.value.title, 295 + description: doc.value.description, 296 + publishedAt: doc.value.publishedAt, 297 + slug: doc.uri.split('/').pop(), 298 + tags: doc.value.tags || [] 299 + })) 300 + }; 301 + }; 302 + ``` 303 + 304 + ```typescript 305 + // src/routes/blog/[slug]/+page.server.ts 306 + import { createClient } from 'svelte-standard-site'; 307 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 308 + import { error } from '@sveltejs/kit'; 309 + 310 + export const load = async ({ params, fetch }) => { 311 + const client = createClient({ did: PUBLIC_ATPROTO_DID }); 312 + const document = await client.fetchDocument(params.slug, fetch); 313 + 314 + if (!document) { 315 + throw error(404, 'Post not found'); 316 + } 317 + 318 + return { 319 + post: document.value 320 + }; 321 + }; 322 + ``` 323 + 324 + ## License 325 + 326 + MIT 327 + 328 + ## Contributing 329 + 330 + Contributions are welcome! Please feel free to submit a Pull Request.
+30 -2
package.json
··· 1 1 { 2 2 "name": "svelte-standard-site", 3 3 "version": "0.0.1", 4 + "description": "A SvelteKit library for fetching site.standard.* records from the AT Protocol", 5 + "license": "AGPL-3.0", 6 + "author": { 7 + "name": "Ewan Croft", 8 + "url": "https://github.com/ewanc26" 9 + }, 4 10 "scripts": { 5 11 "dev": "vite dev", 6 12 "build": "vite build && npm run prepack", ··· 27 33 ".": { 28 34 "types": "./dist/index.d.ts", 29 35 "svelte": "./dist/index.js" 36 + }, 37 + "./config/env": { 38 + "types": "./dist/config/env.d.ts", 39 + "default": "./dist/config/env.js" 30 40 } 31 41 }, 32 42 "peerDependencies": { ··· 50 60 "vite": "^7.2.6" 51 61 }, 52 62 "keywords": [ 53 - "svelte" 54 - ] 63 + "svelte", 64 + "sveltekit", 65 + "atproto", 66 + "at-protocol", 67 + "bluesky", 68 + "site-standard", 69 + "blog", 70 + "cms" 71 + ], 72 + "repository": { 73 + "type": "git", 74 + "url": "git+https://github.com/ewanc26/svelte-standard-site.git" 75 + }, 76 + "homepage": "https://github.com/ewanc26/svelte-standard-site#readme", 77 + "bugs": { 78 + "url": "https://github.com/ewanc26/svelte-standard-site/issues" 79 + }, 80 + "dependencies": { 81 + "@atproto/api": "^0.18.16" 82 + } 55 83 }
+113
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + '@atproto/api': 12 + specifier: ^0.18.16 13 + version: 0.18.16 10 14 devDependencies: 11 15 '@sveltejs/adapter-auto': 12 16 specifier: ^7.0.0 ··· 55 59 version: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) 56 60 57 61 packages: 62 + 63 + '@atproto/api@0.18.16': 64 + resolution: {integrity: sha512-tRGKSWr83pP5CQpSboePU21pE+GqLDYy1XHae4HH4hjaT0pr5V8wNgu70kbKB0B02GVUumeDRpJnlHKD+eMzLg==} 65 + 66 + '@atproto/common-web@0.4.12': 67 + resolution: {integrity: sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==} 68 + 69 + '@atproto/lex-data@0.0.8': 70 + resolution: {integrity: sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==} 71 + 72 + '@atproto/lex-json@0.0.8': 73 + resolution: {integrity: sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==} 74 + 75 + '@atproto/lexicon@0.6.0': 76 + resolution: {integrity: sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==} 77 + 78 + '@atproto/syntax@0.4.2': 79 + resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} 80 + 81 + '@atproto/xrpc@0.7.7': 82 + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} 58 83 59 84 '@esbuild/aix-ppc64@0.27.2': 60 85 resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} ··· 521 546 resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} 522 547 engines: {node: '>= 0.4'} 523 548 549 + await-lock@2.2.2: 550 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 551 + 524 552 axobject-query@4.1.0: 525 553 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 526 554 engines: {node: '>= 0.4'} ··· 595 623 is-reference@3.0.3: 596 624 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 597 625 626 + iso-datestring-validator@2.2.2: 627 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 628 + 598 629 jiti@2.6.1: 599 630 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 600 631 hasBin: true ··· 686 717 mrmime@2.0.1: 687 718 resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} 688 719 engines: {node: '>=10'} 720 + 721 + multiformats@9.9.0: 722 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 689 723 690 724 nanoid@3.3.11: 691 725 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} ··· 849 883 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 850 884 engines: {node: '>=12.0.0'} 851 885 886 + tlds@1.261.0: 887 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 888 + hasBin: true 889 + 852 890 totalist@3.0.1: 853 891 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 854 892 engines: {node: '>=6'} 855 893 894 + tslib@2.8.1: 895 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 896 + 856 897 typescript@5.9.3: 857 898 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 858 899 engines: {node: '>=14.17'} 859 900 hasBin: true 860 901 902 + uint8arrays@3.0.0: 903 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 904 + 905 + unicode-segmenter@0.14.5: 906 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 907 + 861 908 util-deprecate@1.0.2: 862 909 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 863 910 ··· 912 959 zimmerframe@1.1.4: 913 960 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 914 961 962 + zod@3.25.76: 963 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 964 + 915 965 snapshots: 916 966 967 + '@atproto/api@0.18.16': 968 + dependencies: 969 + '@atproto/common-web': 0.4.12 970 + '@atproto/lexicon': 0.6.0 971 + '@atproto/syntax': 0.4.2 972 + '@atproto/xrpc': 0.7.7 973 + await-lock: 2.2.2 974 + multiformats: 9.9.0 975 + tlds: 1.261.0 976 + zod: 3.25.76 977 + 978 + '@atproto/common-web@0.4.12': 979 + dependencies: 980 + '@atproto/lex-data': 0.0.8 981 + '@atproto/lex-json': 0.0.8 982 + zod: 3.25.76 983 + 984 + '@atproto/lex-data@0.0.8': 985 + dependencies: 986 + '@atproto/syntax': 0.4.2 987 + multiformats: 9.9.0 988 + tslib: 2.8.1 989 + uint8arrays: 3.0.0 990 + unicode-segmenter: 0.14.5 991 + 992 + '@atproto/lex-json@0.0.8': 993 + dependencies: 994 + '@atproto/lex-data': 0.0.8 995 + tslib: 2.8.1 996 + 997 + '@atproto/lexicon@0.6.0': 998 + dependencies: 999 + '@atproto/common-web': 0.4.12 1000 + '@atproto/syntax': 0.4.2 1001 + iso-datestring-validator: 2.2.2 1002 + multiformats: 9.9.0 1003 + zod: 3.25.76 1004 + 1005 + '@atproto/syntax@0.4.2': {} 1006 + 1007 + '@atproto/xrpc@0.7.7': 1008 + dependencies: 1009 + '@atproto/lexicon': 0.6.0 1010 + zod: 3.25.76 1011 + 917 1012 '@esbuild/aix-ppc64@0.27.2': 918 1013 optional: true 919 1014 ··· 1230 1325 1231 1326 aria-query@5.3.2: {} 1232 1327 1328 + await-lock@2.2.2: {} 1329 + 1233 1330 axobject-query@4.1.0: {} 1234 1331 1235 1332 chokidar@4.0.3: ··· 1306 1403 is-reference@3.0.3: 1307 1404 dependencies: 1308 1405 '@types/estree': 1.0.8 1406 + 1407 + iso-datestring-validator@2.2.2: {} 1309 1408 1310 1409 jiti@2.6.1: {} 1311 1410 ··· 1370 1469 1371 1470 mrmime@2.0.1: {} 1372 1471 1472 + multiformats@9.9.0: {} 1473 + 1373 1474 nanoid@3.3.11: {} 1374 1475 1375 1476 obug@2.1.1: {} ··· 1510 1611 fdir: 6.5.0(picomatch@4.0.3) 1511 1612 picomatch: 4.0.3 1512 1613 1614 + tlds@1.261.0: {} 1615 + 1513 1616 totalist@3.0.1: {} 1514 1617 1618 + tslib@2.8.1: {} 1619 + 1515 1620 typescript@5.9.3: {} 1621 + 1622 + uint8arrays@3.0.0: 1623 + dependencies: 1624 + multiformats: 9.9.0 1625 + 1626 + unicode-segmenter@0.14.5: {} 1516 1627 1517 1628 util-deprecate@1.0.2: {} 1518 1629 ··· 1534 1645 vite: 7.3.1(jiti@2.6.1)(lightningcss@1.30.2) 1535 1646 1536 1647 zimmerframe@1.1.4: {} 1648 + 1649 + zod@3.25.76: {}
+368
src/lib/client.ts
··· 1 + import type { 2 + SiteStandardConfig, 3 + Publication, 4 + Document, 5 + AtProtoRecord, 6 + ResolvedIdentity 7 + } from './types.js'; 8 + import { cache } from './utils/cache.js'; 9 + import { resolveIdentity, withFallback, buildPdsBlobUrl } from './utils/agents.js'; 10 + import { parseAtUri, atUriToHttps } from './utils/at-uri.js'; 11 + 12 + /** 13 + * Main client for interacting with site.standard.* records 14 + */ 15 + export class SiteStandardClient { 16 + private config: Required<SiteStandardConfig>; 17 + private pdsEndpoint: string | null = null; 18 + 19 + constructor(config: SiteStandardConfig) { 20 + this.config = { 21 + did: config.did, 22 + pds: config.pds ?? '', 23 + cacheTTL: config.cacheTTL ?? 5 * 60 * 1000 24 + }; 25 + 26 + // Set cache TTL 27 + cache.setDefaultTTL(this.config.cacheTTL); 28 + } 29 + 30 + /** 31 + * Resolve and cache PDS endpoint 32 + */ 33 + private async resolvePDS(fetchFn?: typeof fetch): Promise<string> { 34 + if (this.pdsEndpoint) return this.pdsEndpoint; 35 + 36 + if (this.config.pds) { 37 + this.pdsEndpoint = this.config.pds; 38 + return this.pdsEndpoint; 39 + } 40 + 41 + const identity = await resolveIdentity(this.config.did, fetchFn); 42 + this.pdsEndpoint = identity.pds; 43 + return this.pdsEndpoint; 44 + } 45 + 46 + /** 47 + * Convert a blob object to a URL string 48 + */ 49 + private async getBlobUrl(blob: any, fetchFn?: typeof fetch): Promise<string | undefined> { 50 + try { 51 + const cid = blob?.ref?.$link || blob?.cid; 52 + if (!cid) return undefined; 53 + 54 + const pds = await this.resolvePDS(fetchFn); 55 + return buildPdsBlobUrl(pds, this.config.did, cid); 56 + } catch (error) { 57 + console.warn('Failed to resolve blob URL:', error); 58 + return undefined; 59 + } 60 + } 61 + 62 + /** 63 + * Fetch a single publication by rkey 64 + * @param rkey - Record key for the publication 65 + * @param fetchFn - Optional fetch function for SSR 66 + * @returns Publication record or null if not found 67 + */ 68 + async fetchPublication( 69 + rkey: string, 70 + fetchFn?: typeof fetch 71 + ): Promise<AtProtoRecord<Publication> | null> { 72 + const cacheKey = `publication:${this.config.did}:${rkey}`; 73 + const cached = cache.get<AtProtoRecord<Publication>>(cacheKey); 74 + if (cached) return cached; 75 + 76 + try { 77 + const result = await withFallback( 78 + this.config.did, 79 + async (agent) => { 80 + const response = await agent.com.atproto.repo.getRecord({ 81 + repo: this.config.did, 82 + collection: 'site.standard.publication', 83 + rkey 84 + }); 85 + return response.data; 86 + }, 87 + fetchFn 88 + ); 89 + 90 + if (!result || !result.value) return null; 91 + 92 + const pubValue = result.value as any; 93 + 94 + // Build the publication object with converted blob URLs 95 + const record: AtProtoRecord<Publication> = { 96 + uri: result.uri, 97 + cid: result.cid || '', 98 + value: { 99 + $type: 'site.standard.publication', 100 + url: pubValue.url, 101 + name: pubValue.name, 102 + icon: pubValue.icon ? await this.getBlobUrl(pubValue.icon, fetchFn) : undefined, 103 + description: pubValue.description, 104 + basicTheme: pubValue.basicTheme, 105 + preferences: pubValue.preferences 106 + } 107 + }; 108 + 109 + cache.set(cacheKey, record); 110 + return record; 111 + } catch (error) { 112 + console.error(`Failed to fetch publication ${rkey}:`, error); 113 + return null; 114 + } 115 + } 116 + 117 + /** 118 + * Fetch all publications for the configured DID 119 + * @param fetchFn - Optional fetch function for SSR 120 + * @returns Array of publication records 121 + */ 122 + async fetchAllPublications(fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication>[]> { 123 + const cacheKey = `publications:${this.config.did}:all`; 124 + const cached = cache.get<AtProtoRecord<Publication>[]>(cacheKey); 125 + if (cached) return cached; 126 + 127 + try { 128 + const allRecords: AtProtoRecord<Publication>[] = []; 129 + let cursor: string | undefined; 130 + 131 + do { 132 + const records = await withFallback( 133 + this.config.did, 134 + async (agent) => { 135 + const response = await agent.com.atproto.repo.listRecords({ 136 + repo: this.config.did, 137 + collection: 'site.standard.publication', 138 + limit: 100, 139 + cursor 140 + }); 141 + cursor = response.data.cursor; 142 + return response.data.records; 143 + }, 144 + fetchFn 145 + ); 146 + 147 + // Convert each record with blob URLs 148 + for (const record of records) { 149 + const pubValue = record.value as any; 150 + const pub: AtProtoRecord<Publication> = { 151 + uri: record.uri, 152 + cid: record.cid || '', 153 + value: { 154 + $type: 'site.standard.publication', 155 + url: pubValue.url, 156 + name: pubValue.name, 157 + icon: pubValue.icon ? await this.getBlobUrl(pubValue.icon, fetchFn) : undefined, 158 + description: pubValue.description, 159 + basicTheme: pubValue.basicTheme, 160 + preferences: pubValue.preferences 161 + } 162 + }; 163 + allRecords.push(pub); 164 + } 165 + } while (cursor); 166 + 167 + cache.set(cacheKey, allRecords); 168 + return allRecords; 169 + } catch (error) { 170 + console.error('Failed to fetch publications:', error); 171 + return []; 172 + } 173 + } 174 + 175 + /** 176 + * Fetch a single document by rkey 177 + * @param rkey - Record key for the document 178 + * @param fetchFn - Optional fetch function for SSR 179 + * @returns Document record or null if not found 180 + */ 181 + async fetchDocument( 182 + rkey: string, 183 + fetchFn?: typeof fetch 184 + ): Promise<AtProtoRecord<Document> | null> { 185 + const cacheKey = `document:${this.config.did}:${rkey}`; 186 + const cached = cache.get<AtProtoRecord<Document>>(cacheKey); 187 + if (cached) return cached; 188 + 189 + try { 190 + const result = await withFallback( 191 + this.config.did, 192 + async (agent) => { 193 + const response = await agent.com.atproto.repo.getRecord({ 194 + repo: this.config.did, 195 + collection: 'site.standard.document', 196 + rkey 197 + }); 198 + return response.data; 199 + }, 200 + fetchFn 201 + ); 202 + 203 + if (!result || !result.value) return null; 204 + 205 + const docValue = result.value as any; 206 + 207 + // Build the document object with converted blob URLs 208 + const record: AtProtoRecord<Document> = { 209 + uri: result.uri, 210 + cid: result.cid || '', 211 + value: { 212 + $type: 'site.standard.document', 213 + site: docValue.site, 214 + title: docValue.title, 215 + path: docValue.path, 216 + description: docValue.description, 217 + coverImage: docValue.coverImage 218 + ? await this.getBlobUrl(docValue.coverImage, fetchFn) 219 + : undefined, 220 + content: docValue.content, 221 + textContent: docValue.textContent, 222 + bskyPostRef: docValue.bskyPostRef, 223 + tags: docValue.tags, 224 + publishedAt: docValue.publishedAt, 225 + updatedAt: docValue.updatedAt 226 + } 227 + }; 228 + 229 + cache.set(cacheKey, record); 230 + return record; 231 + } catch (error) { 232 + console.error(`Failed to fetch document ${rkey}:`, error); 233 + return null; 234 + } 235 + } 236 + 237 + /** 238 + * Fetch all documents for the configured DID 239 + * @param fetchFn - Optional fetch function for SSR 240 + * @returns Array of document records 241 + */ 242 + async fetchAllDocuments(fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]> { 243 + const cacheKey = `documents:${this.config.did}:all`; 244 + const cached = cache.get<AtProtoRecord<Document>[]>(cacheKey); 245 + if (cached) return cached; 246 + 247 + try { 248 + const allRecords: AtProtoRecord<Document>[] = []; 249 + let cursor: string | undefined; 250 + 251 + do { 252 + const records = await withFallback( 253 + this.config.did, 254 + async (agent) => { 255 + const response = await agent.com.atproto.repo.listRecords({ 256 + repo: this.config.did, 257 + collection: 'site.standard.document', 258 + limit: 100, 259 + cursor 260 + }); 261 + cursor = response.data.cursor; 262 + return response.data.records; 263 + }, 264 + fetchFn 265 + ); 266 + 267 + // Convert each record with blob URLs 268 + for (const record of records) { 269 + const docValue = record.value as any; 270 + const doc: AtProtoRecord<Document> = { 271 + uri: record.uri, 272 + cid: record.cid || '', 273 + value: { 274 + $type: 'site.standard.document', 275 + site: docValue.site, 276 + title: docValue.title, 277 + path: docValue.path, 278 + description: docValue.description, 279 + coverImage: docValue.coverImage 280 + ? await this.getBlobUrl(docValue.coverImage, fetchFn) 281 + : undefined, 282 + content: docValue.content, 283 + textContent: docValue.textContent, 284 + bskyPostRef: docValue.bskyPostRef, 285 + tags: docValue.tags, 286 + publishedAt: docValue.publishedAt, 287 + updatedAt: docValue.updatedAt 288 + } 289 + }; 290 + allRecords.push(doc); 291 + } 292 + } while (cursor); 293 + 294 + // Sort by publishedAt, newest first 295 + allRecords.sort( 296 + (a, b) => new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime() 297 + ); 298 + 299 + cache.set(cacheKey, allRecords); 300 + return allRecords; 301 + } catch (error) { 302 + console.error('Failed to fetch documents:', error); 303 + return []; 304 + } 305 + } 306 + 307 + /** 308 + * Fetch documents for a specific publication 309 + * @param publicationUri - AT URI of the publication 310 + * @param fetchFn - Optional fetch function for SSR 311 + * @returns Array of document records belonging to the publication 312 + */ 313 + async fetchDocumentsByPublication( 314 + publicationUri: string, 315 + fetchFn?: typeof fetch 316 + ): Promise<AtProtoRecord<Document>[]> { 317 + const allDocs = await this.fetchAllDocuments(fetchFn); 318 + return allDocs.filter((doc) => doc.value.site === publicationUri); 319 + } 320 + 321 + /** 322 + * Fetch a record by AT URI 323 + * @param atUri - Full AT URI (e.g., at://did:plc:xxx/site.standard.publication/rkey) 324 + * @param fetchFn - Optional fetch function for SSR 325 + * @returns Record or null if not found 326 + */ 327 + async fetchByAtUri<T = Publication | Document>( 328 + atUri: string, 329 + fetchFn?: typeof fetch 330 + ): Promise<AtProtoRecord<T> | null> { 331 + const parsed = parseAtUri(atUri); 332 + if (!parsed) { 333 + console.error('Invalid AT URI:', atUri); 334 + return null; 335 + } 336 + 337 + if (parsed.collection === 'site.standard.publication') { 338 + return this.fetchPublication(parsed.rkey, fetchFn) as Promise<AtProtoRecord<T> | null>; 339 + } else if (parsed.collection === 'site.standard.document') { 340 + return this.fetchDocument(parsed.rkey, fetchFn) as Promise<AtProtoRecord<T> | null>; 341 + } 342 + 343 + return null; 344 + } 345 + 346 + /** 347 + * Clear all cached data 348 + */ 349 + clearCache(): void { 350 + cache.clear(); 351 + } 352 + 353 + /** 354 + * Get the resolved PDS endpoint 355 + */ 356 + async getPDS(fetchFn?: typeof fetch): Promise<string> { 357 + return this.resolvePDS(fetchFn); 358 + } 359 + } 360 + 361 + /** 362 + * Create a new SiteStandardClient instance 363 + * @param config - Configuration object 364 + * @returns Configured client instance 365 + */ 366 + export function createClient(config: SiteStandardConfig): SiteStandardClient { 367 + return new SiteStandardClient(config); 368 + }
+74
src/lib/components/DocumentCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Document, AtProtoRecord } from '../types.js'; 3 + import { getDocumentSlug } from '../utils/document.js'; 4 + import { extractRkey } from '$lib/index.js'; 5 + 6 + interface Props { 7 + document: AtProtoRecord<Document>; 8 + class?: string; 9 + showCover?: boolean; 10 + } 11 + 12 + const { document, class: className = '', showCover = true }: Props = $props(); 13 + 14 + const value = $derived(document.value); 15 + 16 + // Construct the rkey-based URL: /[pub_rkey]/[doc_rkey] 17 + const pubRkey = $derived(extractRkey(value.site)); 18 + const docRkey = $derived(extractRkey(document.uri)); 19 + const href = $derived(`/${pubRkey}/${docRkey}`); 20 + 21 + function formatDate(dateString: string): string { 22 + return new Date(dateString).toLocaleDateString('en-US', { 23 + year: 'numeric', 24 + month: 'long', 25 + day: 'numeric' 26 + }); 27 + } 28 + </script> 29 + 30 + <a {href} class="group block"> 31 + <article 32 + class="flex gap-6 rounded-xl border border-gray-200 bg-white p-6 transition-all duration-200 hover:border-blue-300 hover:shadow-md {className}" 33 + > 34 + {#if showCover && value.coverImage} 35 + <img 36 + src={value.coverImage} 37 + alt="{value.title} cover" 38 + class="h-48 w-32 shrink-0 rounded-lg object-cover shadow-sm" 39 + /> 40 + {/if} 41 + 42 + <div class="flex-1"> 43 + <h3 class="mb-2 text-2xl font-bold text-gray-900 transition-colors group-hover:text-blue-600"> 44 + {value.title} 45 + </h3> 46 + 47 + {#if value.description} 48 + <p class="mb-4 line-clamp-3 text-sm leading-relaxed text-gray-600"> 49 + {value.description} 50 + </p> 51 + {/if} 52 + 53 + <div class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> 54 + <time class="font-medium text-gray-700">{formatDate(value.publishedAt)}</time> 55 + {#if value.updatedAt} 56 + <span class="flex items-center gap-1"> 57 + <span class="h-1 w-1 rounded-full bg-gray-300"></span> 58 + Updated {formatDate(value.updatedAt)} 59 + </span> 60 + {/if} 61 + </div> 62 + 63 + {#if value.tags && value.tags.length > 0} 64 + <div class="flex flex-wrap gap-2"> 65 + {#each value.tags as tag} 66 + <span class="rounded-lg bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700"> 67 + #{tag} 68 + </span> 69 + {/each} 70 + </div> 71 + {/if} 72 + </div> 73 + </article> 74 + </a>
+50
src/lib/components/PublicationCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Publication, AtProtoRecord } from '../types.js'; 3 + import { getThemeVars } from '../utils/theme.js'; 4 + 5 + interface Props { 6 + publication: AtProtoRecord<Publication>; 7 + class?: string; 8 + } 9 + 10 + const { publication, class: className = '' }: Props = $props(); 11 + 12 + // Access value reactively through a getter 13 + const value = $derived(publication.value); 14 + const themeVars = $derived(value.basicTheme ? getThemeVars(value.basicTheme) : {}); 15 + </script> 16 + 17 + <article 18 + class="rounded-lg border border-gray-200 bg-white p-6 transition-all hover:shadow-lg {className}" 19 + style={Object.entries(themeVars) 20 + .map(([k, v]) => `${k}:${v}`) 21 + .join(';')} 22 + > 23 + <div class="mb-4 flex gap-4"> 24 + {#if value.icon} 25 + <img 26 + src={value.icon} 27 + alt="{value.name} icon" 28 + class="size-16 flex-shrink-0 rounded-lg object-cover" 29 + /> 30 + {/if} 31 + <div class="flex-1"> 32 + <h3 class="mb-2 text-xl font-semibold text-gray-900">{value.name}</h3> 33 + {#if value.description} 34 + <p class="text-sm text-gray-600">{value.description}</p> 35 + {/if} 36 + </div> 37 + </div> 38 + <a 39 + href={value.url} 40 + target="_blank" 41 + rel="noopener noreferrer" 42 + class="inline-flex items-center gap-1 font-medium transition-opacity hover:opacity-80" 43 + style:color={value.basicTheme ? `var(--theme-accent)` : undefined} 44 + > 45 + Visit Site 46 + <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 47 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 48 + </svg> 49 + </a> 50 + </article>
+2
src/lib/components/index.ts
··· 1 + export { default as PublicationCard } from './PublicationCard.svelte'; 2 + export { default as DocumentCard } from './DocumentCard.svelte';
+31
src/lib/config/env.ts
··· 1 + import { env } from '$env/dynamic/public'; 2 + import type { SiteStandardConfig } from '../types.js'; 3 + 4 + /** 5 + * Get configuration from environment variables 6 + * @returns Configuration object or null if required vars are missing 7 + */ 8 + export function getConfigFromEnv(): SiteStandardConfig | null { 9 + const did = env.PUBLIC_ATPROTO_DID; 10 + 11 + if (!did) { 12 + console.error('Missing required environment variable: PUBLIC_ATPROTO_DID'); 13 + return null; 14 + } 15 + 16 + return { 17 + did, 18 + pds: env.PUBLIC_ATPROTO_PDS || undefined, 19 + cacheTTL: env.PUBLIC_CACHE_TTL ? parseInt(env.PUBLIC_CACHE_TTL) : undefined 20 + }; 21 + } 22 + 23 + /** 24 + * Validate that required environment variables are set 25 + * @throws Error if required variables are missing 26 + */ 27 + export function validateEnv(): void { 28 + if (!env.PUBLIC_ATPROTO_DID) { 29 + throw new Error('Missing required environment variable: PUBLIC_ATPROTO_DID'); 30 + } 31 + }
+31 -1
src/lib/index.ts
··· 1 - // Reexport your entry components here 1 + // Main exports 2 + export { SiteStandardClient, createClient } from './client.js'; 3 + 4 + // Type exports 5 + export type { 6 + AtProtoBlob, 7 + StrongRef, 8 + RGBColor, 9 + BasicTheme, 10 + PublicationPreferences, 11 + Publication, 12 + Document, 13 + AtProtoRecord, 14 + ResolvedIdentity, 15 + SiteStandardConfig 16 + } from './types.js'; 17 + 18 + // Utility exports 19 + export { parseAtUri, atUriToHttps, buildAtUri, extractRkey, isAtUri } from './utils/at-uri.js'; 20 + 21 + export { resolveIdentity, buildPdsBlobUrl } from './utils/agents.js'; 22 + 23 + export { cache } from './utils/cache.js'; 24 + 25 + export { rgbToCSS, rgbToHex, getThemeVars } from './utils/theme.js'; 26 + 27 + export { 28 + getDocumentSlug, 29 + getDocumentUrl, 30 + extractRkey as extractRkeyFromUri 31 + } from './utils/document.js';
+111
src/lib/types.ts
··· 1 + /** 2 + * Core types for site.standard.* lexicons 3 + */ 4 + 5 + /** 6 + * AT Protocol blob reference 7 + */ 8 + export interface AtProtoBlob { 9 + $type: 'blob'; 10 + ref: { 11 + $link: string; 12 + }; 13 + mimeType: string; 14 + size: number; 15 + } 16 + 17 + /** 18 + * Strong reference to another AT Protocol record 19 + */ 20 + export interface StrongRef { 21 + uri: string; 22 + cid: string; 23 + } 24 + 25 + /** 26 + * RGB Color 27 + */ 28 + export interface RGBColor { 29 + r: number; // 0-255 30 + g: number; // 0-255 31 + b: number; // 0-255 32 + } 33 + 34 + /** 35 + * Basic theme for publications 36 + */ 37 + export interface BasicTheme { 38 + $type?: 'site.standard.theme.basic'; 39 + background: RGBColor; 40 + foreground: RGBColor; 41 + accent: RGBColor; 42 + accentForeground: RGBColor; 43 + } 44 + 45 + /** 46 + * Publication preferences 47 + */ 48 + export interface PublicationPreferences { 49 + showInDiscover?: boolean; 50 + } 51 + 52 + /** 53 + * Site Standard Publication record 54 + */ 55 + export interface Publication { 56 + $type: 'site.standard.publication'; 57 + url: string; 58 + name: string; 59 + icon?: string; // Blob URL converted to string 60 + description?: string; 61 + basicTheme?: BasicTheme; 62 + preferences?: PublicationPreferences; 63 + } 64 + 65 + /** 66 + * Site Standard Document record 67 + */ 68 + export interface Document { 69 + $type: 'site.standard.document'; 70 + site: string; // AT URI or HTTPS URL 71 + title: string; 72 + path?: string; 73 + description?: string; 74 + coverImage?: string; // Blob URL converted to string 75 + content?: any; // Open union 76 + textContent?: string; 77 + bskyPostRef?: StrongRef; 78 + tags?: string[]; 79 + publishedAt: string; 80 + updatedAt?: string; 81 + } 82 + 83 + /** 84 + * AT Protocol record response 85 + */ 86 + export interface AtProtoRecord<T = any> { 87 + uri: string; 88 + cid: string; 89 + value: T; 90 + } 91 + 92 + /** 93 + * Resolved identity from PDS resolution 94 + */ 95 + export interface ResolvedIdentity { 96 + did: string; 97 + pds: string; 98 + handle?: string; 99 + } 100 + 101 + /** 102 + * Configuration for the library 103 + */ 104 + export interface SiteStandardConfig { 105 + /** The DID to fetch records from */ 106 + did: string; 107 + /** Optional custom PDS endpoint */ 108 + pds?: string; 109 + /** Cache TTL in milliseconds (default: 5 minutes) */ 110 + cacheTTL?: number; 111 + }
+124
src/lib/utils/agents.ts
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import type { ResolvedIdentity } from '../types.js'; 3 + import { cache } from './cache.js'; 4 + 5 + /** 6 + * Creates an AtpAgent with optional fetch function injection 7 + */ 8 + export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { 9 + const wrappedFetch = fetchFn 10 + ? async (url: URL | RequestInfo, init?: RequestInit) => { 11 + const urlStr = url instanceof URL ? url.toString() : url; 12 + const response = await fetchFn(urlStr, init); 13 + 14 + const headers = new Headers(response.headers); 15 + if (!headers.has('content-type')) { 16 + headers.set('content-type', 'application/json'); 17 + } 18 + 19 + return new Response(response.body, { 20 + status: response.status, 21 + statusText: response.statusText, 22 + headers 23 + }); 24 + } 25 + : undefined; 26 + 27 + return new AtpAgent({ 28 + service, 29 + ...(wrappedFetch && { fetch: wrappedFetch }) 30 + }); 31 + } 32 + 33 + /** 34 + * Resolves a DID to find its PDS endpoint using Slingshot 35 + * @param did - DID to resolve 36 + * @param fetchFn - Optional fetch function for SSR 37 + * @returns Resolved identity with PDS endpoint 38 + */ 39 + export async function resolveIdentity( 40 + did: string, 41 + fetchFn?: typeof fetch 42 + ): Promise<ResolvedIdentity> { 43 + const cacheKey = `identity:${did}`; 44 + const cached = cache.get<ResolvedIdentity>(cacheKey); 45 + if (cached) return cached; 46 + 47 + const _fetch = fetchFn ?? globalThis.fetch; 48 + 49 + const response = await _fetch( 50 + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}` 51 + ); 52 + 53 + if (!response.ok) { 54 + throw new Error(`Failed to resolve DID: ${response.status} ${response.statusText}`); 55 + } 56 + 57 + const rawText = await response.text(); 58 + const data = JSON.parse(rawText); 59 + 60 + if (!data.did || !data.pds) { 61 + throw new Error('Invalid response from identity resolver'); 62 + } 63 + 64 + cache.set(cacheKey, data); 65 + return data; 66 + } 67 + 68 + /** 69 + * Gets or creates a PDS-specific agent 70 + * @param did - DID to resolve PDS for 71 + * @param fetchFn - Optional fetch function for SSR 72 + * @returns AtpAgent configured for the user's PDS 73 + */ 74 + export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> { 75 + const resolved = await resolveIdentity(did, fetchFn); 76 + return createAgent(resolved.pds, fetchFn); 77 + } 78 + 79 + /** 80 + * Executes a function with automatic fallback 81 + * @param did - The DID to resolve 82 + * @param operation - The operation to execute 83 + * @param fetchFn - Optional fetch function for SSR 84 + */ 85 + export async function withFallback<T>( 86 + did: string, 87 + operation: (agent: AtpAgent) => Promise<T>, 88 + fetchFn?: typeof fetch 89 + ): Promise<T> { 90 + const agents = [ 91 + () => getPDSAgent(did, fetchFn), 92 + () => 93 + Promise.resolve( 94 + fetchFn 95 + ? createAgent('https://public.api.bsky.app', fetchFn) 96 + : createAgent('https://public.api.bsky.app') 97 + ) 98 + ]; 99 + 100 + let lastError: any; 101 + 102 + for (const getAgent of agents) { 103 + try { 104 + const agent = await getAgent(); 105 + return await operation(agent); 106 + } catch (error) { 107 + lastError = error; 108 + } 109 + } 110 + 111 + throw lastError; 112 + } 113 + 114 + /** 115 + * Build a PDS blob URL 116 + * @param pds - PDS endpoint 117 + * @param did - Repository DID 118 + * @param cid - Blob CID 119 + * @returns Full blob URL 120 + */ 121 + export function buildPdsBlobUrl(pds: string, did: string, cid: string): string { 122 + const pdsBase = pds.replace(/\/$/, ''); 123 + return `${pdsBase}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 124 + }
+89
src/lib/utils/at-uri.ts
··· 1 + /** 2 + * AT URI parsing and conversion utilities 3 + */ 4 + 5 + /** 6 + * Parsed AT URI components 7 + */ 8 + export interface ParsedAtUri { 9 + did: string; 10 + collection: string; 11 + rkey: string; 12 + } 13 + 14 + /** 15 + * Parse an AT URI into its components 16 + * @param atUri - AT URI in format: at://did:plc:xxx/collection/rkey 17 + * @returns Parsed components or null if invalid 18 + */ 19 + export function parseAtUri(atUri: string): ParsedAtUri | null { 20 + if (!atUri.startsWith('at://')) return null; 21 + 22 + const withoutProtocol = atUri.slice(5); // Remove "at://" 23 + const parts = withoutProtocol.split('/'); 24 + 25 + if (parts.length !== 3) return null; 26 + 27 + const [did, collection, rkey] = parts; 28 + 29 + if (!did.startsWith('did:') || !collection || !rkey) return null; 30 + 31 + return { did, collection, rkey }; 32 + } 33 + 34 + /** 35 + * Convert AT URI to HTTPS URL for com.atproto.repo.getRecord 36 + * @param atUri - AT URI in format: at://did:plc:xxx/collection/rkey 37 + * @param pdsEndpoint - PDS endpoint (e.g., "https://cortinarius.us-west.host.bsky.network") 38 + * @returns HTTPS URL for getRecord XRPC call 39 + */ 40 + export function atUriToHttps(atUri: string, pdsEndpoint: string): string | null { 41 + const parsed = parseAtUri(atUri); 42 + if (!parsed) return null; 43 + 44 + // Ensure PDS endpoint doesn't have trailing slash 45 + const pds = pdsEndpoint.replace(/\/$/, ''); 46 + 47 + return `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.did)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`; 48 + } 49 + 50 + /** 51 + * Construct an AT URI from components 52 + * @param did - DID of the repository 53 + * @param collection - Collection name 54 + * @param rkey - Record key 55 + * @returns AT URI string 56 + */ 57 + export function buildAtUri(did: string, collection: string, rkey: string): string { 58 + return `at://${did}/${collection}/${rkey}`; 59 + } 60 + 61 + /** 62 + * Extract rkey from an AT URI 63 + * @param atUri - AT URI 64 + * @returns rkey or null if invalid 65 + */ 66 + export function extractRkey(atUri: string): string | null { 67 + const parsed = parseAtUri(atUri); 68 + return parsed?.rkey ?? null; 69 + } 70 + 71 + /** 72 + * Validate if string is a valid AT URI 73 + * @param uri - String to validate 74 + * @returns true if valid AT URI 75 + */ 76 + export function isAtUri(uri: string): boolean { 77 + return parseAtUri(uri) !== null; 78 + } 79 + 80 + /** 81 + * Convert DID to PDS hostname format 82 + * @param did - DID to convert 83 + * @returns PDS hostname or null if unable to derive 84 + */ 85 + export function didToPdsHostname(did: string): string | null { 86 + // This is a simplified version - real implementation should use DID resolution 87 + // For now, we'll return null and rely on explicit PDS configuration 88 + return null; 89 + }
+46
src/lib/utils/cache.ts
··· 1 + /** 2 + * Simple in-memory cache with TTL support 3 + */ 4 + 5 + interface CacheEntry<T> { 6 + value: T; 7 + expires: number; 8 + } 9 + 10 + class Cache { 11 + private store = new Map<string, CacheEntry<any>>(); 12 + private defaultTTL = 5 * 60 * 1000; // 5 minutes 13 + 14 + get<T>(key: string): T | null { 15 + const entry = this.store.get(key); 16 + if (!entry) return null; 17 + 18 + if (Date.now() > entry.expires) { 19 + this.store.delete(key); 20 + return null; 21 + } 22 + 23 + return entry.value; 24 + } 25 + 26 + set<T>(key: string, value: T, ttl?: number): void { 27 + this.store.set(key, { 28 + value, 29 + expires: Date.now() + (ttl ?? this.defaultTTL) 30 + }); 31 + } 32 + 33 + delete(key: string): void { 34 + this.store.delete(key); 35 + } 36 + 37 + clear(): void { 38 + this.store.clear(); 39 + } 40 + 41 + setDefaultTTL(ttl: number): void { 42 + this.defaultTTL = ttl; 43 + } 44 + } 45 + 46 + export const cache = new Cache();
+41
src/lib/utils/document.ts
··· 1 + /** 2 + * Extract rkey from AT URI 3 + */ 4 + export function extractRkey(uri: string): string | null { 5 + const match = uri.match(/at:\/\/[^\/]+\/[^\/]+\/(.+)$/); 6 + return match ? match[1] : null; 7 + } 8 + 9 + /** 10 + * Get document slug (uses path if available, otherwise rkey) 11 + */ 12 + export function getDocumentSlug(document: { uri: string; value: { path?: string } }): string { 13 + if (document.value.path) { 14 + // Remove leading slash if present 15 + return document.value.path.replace(/^\//, ''); 16 + } 17 + return extractRkey(document.uri) || ''; 18 + } 19 + 20 + /** 21 + * Get document canonical URL 22 + */ 23 + export function getDocumentUrl(document: { 24 + value: { site: string; path?: string }; 25 + uri: string; 26 + }): string { 27 + const { site, path } = document.value; 28 + 29 + // If site is an AT URI, we'll use our local route 30 + if (site.startsWith('at://')) { 31 + return `/documents/${getDocumentSlug(document as any)}`; 32 + } 33 + 34 + // If site is HTTPS and we have a path, combine them 35 + if (path) { 36 + return `${site.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; 37 + } 38 + 39 + // Fallback to local route 40 + return `/documents/${extractRkey(document.uri)}`; 41 + }
+33
src/lib/utils/theme.ts
··· 1 + import type { RGBColor } from '../types.js'; 2 + 3 + /** 4 + * Convert RGB color object to CSS rgb() string 5 + */ 6 + export function rgbToCSS(color: RGBColor): string { 7 + return `rgb(${color.r}, ${color.g}, ${color.b})`; 8 + } 9 + 10 + /** 11 + * Convert RGB color object to hex string 12 + */ 13 + export function rgbToHex(color: RGBColor): string { 14 + const toHex = (n: number) => n.toString(16).padStart(2, '0'); 15 + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`; 16 + } 17 + 18 + /** 19 + * Get theme CSS variables from BasicTheme 20 + */ 21 + export function getThemeVars(theme: { 22 + background: RGBColor; 23 + foreground: RGBColor; 24 + accent: RGBColor; 25 + accentForeground: RGBColor; 26 + }): Record<string, string> { 27 + return { 28 + '--theme-background': rgbToCSS(theme.background), 29 + '--theme-foreground': rgbToCSS(theme.foreground), 30 + '--theme-accent': rgbToCSS(theme.accent), 31 + '--theme-accent-foreground': rgbToCSS(theme.accentForeground) 32 + }; 33 + }
+40
src/routes/+page.server.ts
··· 1 + import { createClient } from '$lib/index.js'; 2 + import { getConfigFromEnv } from '$lib/config/env.js'; 3 + import type { PageServerLoad } from './$types.js'; 4 + 5 + export const load: PageServerLoad = async ({ fetch }) => { 6 + const config = getConfigFromEnv(); 7 + 8 + if (!config) { 9 + return { 10 + error: 'Missing configuration. Please set PUBLIC_ATPROTO_DID in your .env file.', 11 + publications: [], 12 + documents: [] 13 + }; 14 + } 15 + 16 + const client = createClient(config); 17 + 18 + try { 19 + const [publications, documents] = await Promise.all([ 20 + client.fetchAllPublications(fetch), 21 + client.fetchAllDocuments(fetch) 22 + ]); 23 + 24 + const pds = await client.getPDS(fetch); 25 + 26 + return { 27 + config, 28 + publications: JSON.parse(JSON.stringify(publications)), 29 + documents: JSON.parse(JSON.stringify(documents)), 30 + pds 31 + }; 32 + } catch (error) { 33 + console.error('Failed to load data:', error); 34 + return { 35 + error: error instanceof Error ? error.message : 'Failed to load data', 36 + publications: [], 37 + documents: [] 38 + }; 39 + } 40 + };
+215 -3
src/routes/+page.svelte
··· 1 - <h1>Welcome to your library project</h1> 2 - <p>Create your package using @sveltejs/package and preview/showcase your work with SvelteKit</p> 3 - <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 1 + <script lang="ts"> 2 + import type { PageData } from './$types.js'; 3 + import { PublicationCard, DocumentCard } from '$lib/components/index.js'; 4 + 5 + const { data }: { data: PageData } = $props(); 6 + </script> 7 + 8 + <svelte:head> 9 + <title>Standard.Site Library - Showcase</title> 10 + <meta 11 + name="description" 12 + content="A SvelteKit library for fetching site.standard.* records from AT Protocol" 13 + /> 14 + </svelte:head> 15 + 16 + <div class="mx-auto max-w-7xl px-4 py-12"> 17 + <!-- Hero Section --> 18 + <header class="mb-16 text-center"> 19 + <h1 class="mb-4 text-5xl font-bold tracking-tight"> 20 + <span class="bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"> 21 + Standard.Site 22 + </span> 23 + <span class="text-gray-900">Library</span> 24 + </h1> 25 + <p class="mx-auto mb-6 max-w-2xl text-xl text-gray-600"> 26 + A powerful SvelteKit library for fetching <code 27 + class="rounded bg-gray-100 px-2 py-1 text-base">site.standard.*</code 28 + > records from the AT Protocol ecosystem 29 + </p> 30 + <div class="flex items-center justify-center gap-4"> 31 + <a 32 + href="https://github.com/ewanc26/svelte-standard-site" 33 + target="_blank" 34 + rel="noopener noreferrer" 35 + class="rounded-lg bg-gray-900 px-6 py-3 font-semibold text-white transition hover:bg-gray-800" 36 + > 37 + View on GitHub 38 + </a> 39 + </div> 40 + </header> 41 + 42 + {#if data.error} 43 + <!-- Error State --> 44 + <div class="mb-12 rounded-xl border-2 border-red-200 bg-red-50 p-8"> 45 + <div class="flex items-start gap-4"> 46 + <svg 47 + class="size-6 shrink-0 text-red-600" 48 + fill="none" 49 + viewBox="0 0 24 24" 50 + stroke="currentColor" 51 + > 52 + <path 53 + stroke-linecap="round" 54 + stroke-linejoin="round" 55 + stroke-width="2" 56 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 57 + /> 58 + </svg> 59 + <div class="flex-1"> 60 + <h2 class="mb-2 text-xl font-bold text-red-900">Configuration Error</h2> 61 + <p class="mb-4 text-red-800">{data.error}</p> 62 + <div class="rounded-lg bg-white p-4"> 63 + <p class="mb-2 font-semibold text-gray-900">Quick Fix:</p> 64 + <ol class="list-inside list-decimal space-y-2 text-sm text-gray-700"> 65 + <li> 66 + Create a <code class="rounded bg-gray-100 px-2 py-1">.env</code> file in your project 67 + root 68 + </li> 69 + <li> 70 + Add: <code class="rounded bg-gray-100 px-2 py-1" 71 + >PUBLIC_ATPROTO_DID=your-did-here</code 72 + > 73 + </li> 74 + <li>Restart your dev server</li> 75 + </ol> 76 + </div> 77 + </div> 78 + </div> 79 + </div> 80 + {:else} 81 + <!-- Configuration Info --> 82 + <section class="mb-16 rounded-xl bg-linear-to-r from-blue-50 to-purple-50 p-8"> 83 + <h2 class="mb-6 text-2xl font-bold text-gray-900">Active Configuration</h2> 84 + <div class="grid gap-6 md:grid-cols-2"> 85 + <div class="rounded-lg bg-white p-6 shadow-sm"> 86 + <dt class="mb-2 text-sm font-medium text-gray-600">DID</dt> 87 + <dd class="font-mono text-sm break-all text-gray-900">{data.config?.did}</dd> 88 + </div> 89 + <div class="rounded-lg bg-white p-6 shadow-sm"> 90 + <dt class="mb-2 text-sm font-medium text-gray-600">PDS Endpoint</dt> 91 + <dd class="font-mono text-sm break-all text-gray-900"> 92 + {data.pds || 'Auto-resolved'} 93 + </dd> 94 + </div> 95 + </div> 96 + </section> 97 + 98 + <!-- Publications Section --> 99 + <section class="mb-16"> 100 + <div class="mb-8 flex items-end justify-between"> 101 + <div> 102 + <h2 class="text-3xl font-bold text-gray-900">Publications</h2> 103 + <p class="mt-2 text-gray-600"> 104 + Site.standard.publication records from the configured repository 105 + </p> 106 + </div> 107 + <div class="rounded-full bg-blue-100 px-4 py-2 text-sm font-semibold text-blue-800"> 108 + {data.publications.length} found 109 + </div> 110 + </div> 111 + {#if data.publications.length === 0} 112 + <div class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center"> 113 + <svg 114 + class="mx-auto mb-4 size-12 text-gray-400" 115 + fill="none" 116 + viewBox="0 0 24 24" 117 + stroke="currentColor" 118 + > 119 + <path 120 + stroke-linecap="round" 121 + stroke-linejoin="round" 122 + stroke-width="2" 123 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 124 + /> 125 + </svg> 126 + <h3 class="mb-2 text-lg font-semibold text-gray-900">No Publications Found</h3> 127 + <p class="text-gray-600"> 128 + This repository doesn't have any <code>site.standard.publication</code> records yet. 129 + </p> 130 + </div> 131 + {:else} 132 + <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 133 + {#each data.publications as publication} 134 + <PublicationCard {publication} /> 135 + {/each} 136 + </div> 137 + {/if} 138 + </section> 139 + 140 + <!-- Documents Section --> 141 + <section> 142 + <div class="mb-8 flex items-end justify-between"> 143 + <div> 144 + <h2 class="text-3xl font-bold text-gray-900">Documents</h2> 145 + <p class="mt-2 text-gray-600"> 146 + <code>site.standard.document</code> records, sorted by publication date 147 + </p> 148 + </div> 149 + <div class="rounded-full bg-purple-100 px-4 py-2 text-sm font-semibold text-purple-800"> 150 + {data.documents.length} found 151 + </div> 152 + </div> 153 + {#if data.documents.length === 0} 154 + <div class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center"> 155 + <svg 156 + class="mx-auto mb-4 size-12 text-gray-400" 157 + fill="none" 158 + viewBox="0 0 24 24" 159 + stroke="currentColor" 160 + > 161 + <path 162 + stroke-linecap="round" 163 + stroke-linejoin="round" 164 + stroke-width="2" 165 + d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" 166 + /> 167 + </svg> 168 + <h3 class="mb-2 text-lg font-semibold text-gray-900">No Documents Found</h3> 169 + <p class="text-gray-600"> 170 + This repository doesn't have any <code>site.standard.document</code> records yet. 171 + </p> 172 + </div> 173 + {:else} 174 + <div class="space-y-6"> 175 + {#each data.documents as document} 176 + <DocumentCard {document} /> 177 + {/each} 178 + </div> 179 + {/if} 180 + </section> 181 + 182 + <!-- Stats Footer --> 183 + <footer class="mt-16 rounded-xl bg-gray-50 p-8 text-center"> 184 + <div class="mb-4 flex items-center justify-center gap-8"> 185 + <div> 186 + <div class="text-4xl font-bold text-blue-600">{data.publications.length}</div> 187 + <div class="text-sm font-medium text-gray-600"> 188 + Publication{data.publications.length === 1 ? '' : 's'} 189 + </div> 190 + </div> 191 + <div class="h-12 w-px bg-gray-300"></div> 192 + <div> 193 + <div class="text-4xl font-bold text-purple-600">{data.documents.length}</div> 194 + <div class="text-sm font-medium text-gray-600"> 195 + Document{data.documents.length === 1 ? '' : 's'} 196 + </div> 197 + </div> 198 + </div> 199 + <p class="text-sm text-gray-600"> 200 + Powered by <a 201 + href="https://atproto.com" 202 + target="_blank" 203 + rel="noopener noreferrer" 204 + class="font-semibold text-blue-600 hover:underline">AT Protocol</a 205 + >, created by 206 + <a 207 + href="https://ewancroft.uk" 208 + target="_blank" 209 + rel="noopener noreferrer" 210 + class="font-semibold text-blue-600 hover:underline">Ewan Croft</a 211 + > 212 + </p> 213 + </footer> 214 + {/if} 215 + </div>
+53
src/routes/[pub_rkey]/[doc_rkey]/+page.server.ts
··· 1 + import { createClient } from '$lib/index.js'; 2 + import { getConfigFromEnv } from '$lib/config/env.js'; 3 + import { extractRkey } from '$lib/utils/document.js'; 4 + import { error } from '@sveltejs/kit'; 5 + import type { PageServerLoad } from './$types.js'; 6 + 7 + export const load: PageServerLoad = async ({ params, fetch }) => { 8 + const config = getConfigFromEnv(); 9 + 10 + if (!config) { 11 + throw error(500, 'Missing configuration. Please set PUBLIC_ATPROTO_DID in your .env file.'); 12 + } 13 + 14 + const client = createClient(config); 15 + const { pub_rkey, doc_rkey } = params; 16 + 17 + try { 18 + // 1. Fetch the specific document by its rkey 19 + // Assuming client.fetchDocument exists; if not, use fetchAllDocuments and find 20 + const documents = await client.fetchAllDocuments(fetch); 21 + 22 + const document = documents.find((doc) => { 23 + const rkey = extractRkey(doc.uri); 24 + const parentPubRkey = extractRkey(doc.value.site); 25 + // Validate both the document rkey AND that it belongs to the publication in the URL 26 + return rkey === doc_rkey && parentPubRkey === pub_rkey; 27 + }); 28 + 29 + if (!document) { 30 + throw error(404, 'Document not found under this publication'); 31 + } 32 + 33 + // 2. Fetch the publication metadata 34 + const publication = await client.fetchPublication(pub_rkey, fetch); 35 + 36 + if (!publication) { 37 + throw error(404, 'Publication not found'); 38 + } 39 + 40 + // 3. Return sanitized data to prevent "non-POJO" serialization errors 41 + return { 42 + document: JSON.parse(JSON.stringify(document)), 43 + publication: JSON.parse(JSON.stringify(publication)), 44 + config 45 + }; 46 + } catch (err) { 47 + console.error('Failed to load document:', err); 48 + if (err && typeof err === 'object' && 'status' in err) { 49 + throw err; 50 + } 51 + throw error(500, 'Failed to load document'); 52 + } 53 + };
+204
src/routes/[pub_rkey]/[doc_rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageData } from './$types.js'; 3 + import { getThemeVars } from '$lib/utils/theme.js'; 4 + import { extractRkey } from '$lib/utils/document.js'; 5 + 6 + const { data }: { data: PageData } = $props(); 7 + 8 + const themeVars = $derived( 9 + data.publication?.value.basicTheme ? getThemeVars(data.publication.value.basicTheme) : {} 10 + ); 11 + 12 + function formatDate(dateString: string): string { 13 + return new Date(dateString).toLocaleDateString('en-US', { 14 + year: 'numeric', 15 + month: 'long', 16 + day: 'numeric' 17 + }); 18 + } 19 + </script> 20 + 21 + <svelte:head> 22 + <title>{data.document.value.title}</title> 23 + {#if data.document.value.description} 24 + <meta name="description" content={data.document.value.description} /> 25 + {/if} 26 + </svelte:head> 27 + 28 + <div 29 + class="min-h-screen" 30 + style:background-color={data.publication?.value.basicTheme 31 + ? `var(--theme-background)` 32 + : undefined} 33 + style={Object.entries(themeVars) 34 + .map(([k, v]) => `${k}:${v}`) 35 + .join(';')} 36 + > 37 + <article class="mx-auto max-w-4xl px-4 py-12"> 38 + <!-- Back link --> 39 + <a 40 + href="/" 41 + class="mb-8 inline-flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70" 42 + style:color={data.publication?.value.basicTheme ? `var(--theme-accent)` : undefined} 43 + > 44 + <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 45 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> 46 + </svg> 47 + Back to Home 48 + </a> 49 + 50 + <!-- Publication info if available --> 51 + {#if data.publication} 52 + <div class="mb-8 flex items-center gap-3"> 53 + {#if data.publication.value.icon} 54 + <img 55 + src={data.publication.value.icon} 56 + alt="{data.publication.value.name} icon" 57 + class="size-10 rounded-lg object-cover" 58 + /> 59 + {/if} 60 + <div> 61 + <div 62 + class="text-sm font-medium" 63 + style:color={data.publication.value.basicTheme ? `var(--theme-foreground)` : undefined} 64 + > 65 + {data.publication.value.name} 66 + </div> 67 + {#if data.publication.value.url} 68 + <a 69 + href={data.publication.value.url} 70 + target="_blank" 71 + rel="noopener noreferrer" 72 + class="text-xs transition-opacity hover:opacity-70" 73 + style:color={data.publication.value.basicTheme ? `var(--theme-accent)` : undefined} 74 + > 75 + {data.publication.value.url} 76 + </a> 77 + {/if} 78 + </div> 79 + </div> 80 + {/if} 81 + 82 + <!-- Document header --> 83 + <header class="mb-8"> 84 + <h1 85 + class="mb-4 text-4xl leading-tight font-bold md:text-5xl" 86 + style:color={data.publication?.value.basicTheme ? `var(--theme-foreground)` : undefined} 87 + > 88 + {data.document.value.title} 89 + </h1> 90 + 91 + <div 92 + class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b pb-6 text-sm" 93 + style:border-color={data.publication?.value.basicTheme 94 + ? `var(--theme-foreground, rgb(229, 231, 235))` 95 + : 'rgb(229, 231, 235)'} 96 + style:opacity={data.publication?.value.basicTheme ? '0.3' : '1'} 97 + > 98 + <time 99 + class="font-medium" 100 + style:color={data.publication?.value.basicTheme 101 + ? `var(--theme-foreground)` 102 + : 'rgb(17, 24, 39)'} 103 + > 104 + Published {formatDate(data.document.value.publishedAt)} 105 + </time> 106 + {#if data.document.value.updatedAt} 107 + <span 108 + style:color={data.publication?.value.basicTheme 109 + ? `var(--theme-foreground)` 110 + : 'rgb(107, 114, 128)'} 111 + > 112 + Updated {formatDate(data.document.value.updatedAt)} 113 + </span> 114 + {/if} 115 + </div> 116 + </header> 117 + 118 + <!-- Cover image --> 119 + {#if data.document.value.coverImage} 120 + <div class="mb-8"> 121 + <img 122 + src={data.document.value.coverImage} 123 + alt="{data.document.value.title} cover" 124 + class="w-full rounded-lg object-cover shadow-lg" 125 + style="max-height: 500px;" 126 + /> 127 + </div> 128 + {/if} 129 + 130 + <!-- Document content --> 131 + <div 132 + class="mx-auto prose prose-lg max-w-none" 133 + style:color={data.publication?.value.basicTheme ? `var(--theme-foreground)` : undefined} 134 + > 135 + {#if data.document.value.textContent} 136 + <div class="whitespace-pre-wrap">{data.document.value.textContent}</div> 137 + {:else if data.document.value.content} 138 + <div class="rounded-lg border border-gray-200 bg-gray-50 p-6"> 139 + <p class="mb-2 text-sm font-medium text-gray-700">Raw Content:</p> 140 + <pre class="overflow-x-auto text-xs">{JSON.stringify( 141 + data.document.value.content, 142 + null, 143 + 2 144 + )}</pre> 145 + </div> 146 + {:else} 147 + <p class="text-gray-500 italic">No content available</p> 148 + {/if} 149 + </div> 150 + 151 + <!-- Tags --> 152 + {#if data.document.value.tags && data.document.value.tags.length > 0} 153 + <div 154 + class="mt-12 border-t pt-8" 155 + style:border-color={data.publication?.value.basicTheme 156 + ? `var(--theme-foreground, rgb(229, 231, 235))` 157 + : 'rgb(229, 231, 235)'} 158 + style:opacity={data.publication?.value.basicTheme ? '0.3' : '1'} 159 + > 160 + <h2 161 + class="mb-4 text-sm font-medium tracking-wide uppercase" 162 + style:color={data.publication?.value.basicTheme 163 + ? `var(--theme-foreground)` 164 + : 'rgb(107, 114, 128)'} 165 + > 166 + Tags 167 + </h2> 168 + <div class="flex flex-wrap gap-2"> 169 + {#each data.document.value.tags as tag} 170 + <span 171 + class="rounded-full px-4 py-2 text-sm font-medium" 172 + style:background-color={data.publication?.value.basicTheme 173 + ? `var(--theme-accent, rgb(243, 244, 246))` 174 + : 'rgb(243, 244, 246)'} 175 + style:color={data.publication?.value.basicTheme 176 + ? `var(--theme-accent-foreground, rgb(75, 85, 99))` 177 + : 'rgb(75, 85, 99)'} 178 + > 179 + {tag} 180 + </span> 181 + {/each} 182 + </div> 183 + </div> 184 + {/if} 185 + 186 + <!-- Bluesky post reference --> 187 + {#if data.document.value.bskyPostRef} 188 + <div class="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-4"> 189 + <p class="text-sm text-blue-900"> 190 + <a 191 + href="https://bsky.app/profile/{data.config?.did}/post/{extractRkey( 192 + data.document.value.bskyPostRef.uri 193 + )}" 194 + target="_blank" 195 + rel="noopener noreferrer" 196 + class="font-medium hover:underline" 197 + > 198 + View discussion on Bluesky → 199 + </a> 200 + </p> 201 + </div> 202 + {/if} 203 + </article> 204 + </div>