a standard.site publication renderer for SvelteKit.

feat: enhance documentation and design system components

- Rewrite API.md and EXAMPLES.md with a new structure and more comprehensive guides.
- Introduce a themed component system including StandardSiteLayout, ThemedCard, and ThemeToggle.
- Add themeStore and theme utilities for managing light/dark modes and dynamic CSS variables.
- Update DocumentCard and PublicationCard to support themes and Lucide icons.
- Add @lucide/svelte as a dependency for enhanced UI elements.
- Export new style entry points for base and theme CSS.

+2924 -1201
+466 -354
API.md
··· 4 4 5 5 ## Table of Contents 6 6 7 - - [SiteStandardClient](#sitestandardclient) 7 + - [Components](#components) 8 + - [Stores](#stores) 9 + - [Client](#client) 8 10 - [Types](#types) 9 - - [Utility Functions](#utility-functions) 10 - - [Components](#components) 11 - - [Configuration](#configuration) 11 + - [Utilities](#utilities) 12 12 13 - ## SiteStandardClient 13 + ## Components 14 14 15 - The main client class for interacting with site.standard.\* records. 15 + ### StandardSiteLayout 16 16 17 - ### Constructor 17 + A complete page layout with header, footer, and built-in theme management. 18 18 19 19 ```typescript 20 - constructor(config: SiteStandardConfig) 20 + interface StandardSiteLayoutProps { 21 + /** Site title displayed in header */ 22 + title?: string; 23 + /** Custom header content (replaces default header) */ 24 + header?: Snippet; 25 + /** Custom footer content (replaces default footer) */ 26 + footer?: Snippet; 27 + /** Main content */ 28 + children: Snippet; 29 + /** Additional CSS classes for main container */ 30 + class?: string; 31 + /** Show theme toggle button in default header */ 32 + showThemeToggle?: boolean; 33 + } 21 34 ``` 22 35 23 - Creates a new instance of the client. 36 + **Default Values:** 37 + 38 + - `title`: `"My Site"` 39 + - `showThemeToggle`: `true` 40 + 41 + **Usage:** 42 + 43 + ```svelte 44 + <StandardSiteLayout title="My Site"> 45 + <h1>Content here</h1> 46 + </StandardSiteLayout> 47 + ``` 48 + 49 + **With Custom Header:** 50 + 51 + ```svelte 52 + <StandardSiteLayout title="My Site"> 53 + {#snippet header()} 54 + <nav>Custom header</nav> 55 + {/snippet} 56 + 57 + <h1>Content</h1> 58 + </StandardSiteLayout> 59 + ``` 24 60 25 - **Parameters:** 61 + --- 26 62 27 - - `config` (SiteStandardConfig): Configuration object 63 + ### ThemeToggle 28 64 29 - **Example:** 65 + A button component for toggling between light and dark modes with smooth animations. 30 66 31 67 ```typescript 32 - import { createClient } from 'svelte-standard-site'; 68 + interface ThemeToggleProps { 69 + /** Additional CSS classes */ 70 + class?: string; 71 + } 72 + ``` 33 73 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 - }); 74 + **Usage:** 75 + 76 + ```svelte 77 + <ThemeToggle class="ml-auto" /> 39 78 ``` 40 79 41 - ### Methods 80 + **Features:** 81 + 82 + - Smooth icon transitions 83 + - System preference detection 84 + - Persistent theme storage 85 + - Loading state indicator 86 + - Accessible ARIA labels 87 + 88 + --- 89 + 90 + ### DocumentCard 42 91 43 - #### `fetchPublication` 92 + Displays a `site.standard.document` record as a styled card with cover image, metadata, and tags. 44 93 45 94 ```typescript 46 - async fetchPublication( 47 - rkey: string, 48 - fetchFn?: typeof fetch 49 - ): Promise<AtProtoRecord<Publication> | null> 95 + interface DocumentCardProps { 96 + /** The document record to display */ 97 + document: AtProtoRecord<Document>; 98 + /** Additional CSS classes */ 99 + class?: string; 100 + /** Whether to show the cover image */ 101 + showCover?: boolean; 102 + /** Custom href override (defaults to /[pub_rkey]/[doc_rkey]) */ 103 + href?: string; 104 + } 105 + ``` 106 + 107 + **Default Values:** 108 + 109 + - `showCover`: `true` 110 + - `href`: Auto-generated from document URI 111 + 112 + **Usage:** 113 + 114 + ```svelte 115 + <DocumentCard {document} showCover={true} class="shadow-lg" /> 50 116 ``` 51 117 52 - Fetches a single publication by its record key. 118 + **Features:** 53 119 54 - **Parameters:** 120 + - Responsive design 121 + - Cover image display 122 + - Date formatting 123 + - Tag pills 124 + - Hover states 125 + - Dark mode support 55 126 56 - - `rkey` (string): The record key (TID) of the publication 57 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 127 + --- 58 128 59 - **Returns:** Promise resolving to the publication record or null if not found 129 + ### PublicationCard 60 130 61 - **Example:** 131 + Displays a `site.standard.publication` record with icon, description, and external link. 62 132 63 133 ```typescript 64 - const pub = await client.fetchPublication('3lwafzkjqm25s'); 65 - if (pub) { 66 - console.log(pub.value.name); 134 + interface PublicationCardProps { 135 + /** The publication record to display */ 136 + publication: AtProtoRecord<Publication>; 137 + /** Additional CSS classes */ 138 + class?: string; 139 + /** Whether to show external link icon */ 140 + showExternalIcon?: boolean; 67 141 } 68 142 ``` 69 143 70 - #### `fetchAllPublications` 144 + **Default Values:** 145 + 146 + - `showExternalIcon`: `true` 147 + 148 + **Usage:** 149 + 150 + ```svelte 151 + <PublicationCard {publication} showExternalIcon={true} /> 152 + ``` 153 + 154 + **Features:** 155 + 156 + - Icon display 157 + - Theme color support 158 + - External link indicator 159 + - Hover states 160 + - Dark mode support 161 + 162 + --- 163 + 164 + ## Stores 165 + 166 + ### themeStore 167 + 168 + A Svelte store for managing light/dark theme state. 71 169 72 170 ```typescript 73 - async fetchAllPublications( 74 - fetchFn?: typeof fetch 75 - ): Promise<AtProtoRecord<Publication>[]> 171 + interface ThemeState { 172 + isDark: boolean; 173 + mounted: boolean; 174 + } 175 + 176 + interface ThemeStore { 177 + subscribe: (callback: (state: ThemeState) => void) => () => void; 178 + init: () => void | (() => void); 179 + toggle: () => void; 180 + setTheme: (isDark: boolean) => void; 181 + } 76 182 ``` 77 183 78 - Fetches all publications for the configured DID with automatic pagination. 184 + **Methods:** 185 + 186 + #### `init()` 187 + 188 + Initialize the theme store. Automatically detects saved preference and system preference. 189 + 190 + ```typescript 191 + import { themeStore } from 'svelte-standard-site'; 192 + import { onMount } from 'svelte'; 79 193 80 - **Parameters:** 194 + onMount(() => { 195 + themeStore.init(); 196 + }); 197 + ``` 81 198 82 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 199 + **Returns:** Optional cleanup function 83 200 84 - **Returns:** Promise resolving to an array of publication records 201 + #### `toggle()` 85 202 86 - **Example:** 203 + Toggle between light and dark modes. 87 204 88 205 ```typescript 89 - const publications = await client.fetchAllPublications(fetch); 90 - console.log(`Found ${publications.length} publications`); 206 + themeStore.toggle(); 91 207 ``` 92 208 93 - #### `fetchDocument` 209 + #### `setTheme(isDark: boolean)` 210 + 211 + Set a specific theme mode. 94 212 95 213 ```typescript 96 - async fetchDocument( 97 - rkey: string, 98 - fetchFn?: typeof fetch 99 - ): Promise<AtProtoRecord<Document> | null> 214 + themeStore.setTheme(true); // Dark mode 215 + themeStore.setTheme(false); // Light mode 100 216 ``` 101 217 102 - Fetches a single document by its record key. 218 + #### `subscribe(callback)` 103 219 104 - **Parameters:** 220 + Subscribe to theme changes. 105 221 106 - - `rkey` (string): The record key (TID) of the document 107 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 222 + ```typescript 223 + const unsubscribe = themeStore.subscribe((state) => { 224 + console.log('Dark mode:', state.isDark); 225 + console.log('Mounted:', state.mounted); 226 + }); 108 227 109 - **Returns:** Promise resolving to the document record or null if not found 228 + // Don't forget to unsubscribe 229 + unsubscribe(); 230 + ``` 110 231 111 - **Example:** 232 + **State Properties:** 233 + 234 + - `isDark: boolean` - Current theme state (true = dark, false = light) 235 + - `mounted: boolean` - Whether the store has been initialized 236 + 237 + --- 238 + 239 + ## Client 240 + 241 + ### SiteStandardClient 242 + 243 + Main client for fetching AT Protocol records. 112 244 113 245 ```typescript 114 - const doc = await client.fetchDocument('3lxbm5kqrs2s'); 115 - if (doc) { 116 - console.log(doc.value.title); 246 + class SiteStandardClient { 247 + constructor(config: SiteStandardConfig); 248 + 249 + // Fetch methods 250 + fetchPublication( 251 + rkey: string, 252 + fetchFn?: typeof fetch 253 + ): Promise<AtProtoRecord<Publication> | null>; 254 + fetchAllPublications(fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication>[]>; 255 + fetchDocument(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document> | null>; 256 + fetchAllDocuments(fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>; 257 + fetchDocumentsByPublication( 258 + publicationUri: string, 259 + fetchFn?: typeof fetch 260 + ): Promise<AtProtoRecord<Document>[]>; 261 + fetchByAtUri<T>(atUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<T> | null>; 262 + 263 + // Utility methods 264 + clearCache(): void; 265 + getPDS(fetchFn?: typeof fetch): Promise<string>; 117 266 } 118 267 ``` 119 268 120 - #### `fetchAllDocuments` 269 + ### createClient() 270 + 271 + Factory function for creating a client instance. 121 272 122 273 ```typescript 123 - async fetchAllDocuments( 124 - fetchFn?: typeof fetch 125 - ): Promise<AtProtoRecord<Document>[]> 274 + function createClient(config: SiteStandardConfig): SiteStandardClient; 275 + ``` 276 + 277 + **Usage:** 278 + 279 + ```typescript 280 + import { createClient } from 'svelte-standard-site'; 281 + 282 + const client = createClient({ 283 + did: 'did:plc:your-did-here', 284 + pds: 'https://your-pds.example.com', // optional 285 + cacheTTL: 300000 // optional, 5 minutes 286 + }); 126 287 ``` 127 288 128 - Fetches all documents for the configured DID with automatic pagination. Results are sorted by `publishedAt` (newest first). 289 + --- 290 + 291 + ### Methods 292 + 293 + #### `fetchPublication(rkey, fetchFn?)` 294 + 295 + Fetch a single publication by record key. 129 296 130 297 **Parameters:** 131 298 132 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 299 + - `rkey: string` - The record key 300 + - `fetchFn?: typeof fetch` - Optional custom fetch function (for SSR) 133 301 134 - **Returns:** Promise resolving to an array of document records 302 + **Returns:** `Promise<AtProtoRecord<Publication> | null>` 135 303 136 304 **Example:** 137 305 138 306 ```typescript 139 - const documents = await client.fetchAllDocuments(fetch); 140 - documents.forEach((doc) => { 141 - console.log(doc.value.title, new Date(doc.value.publishedAt)); 142 - }); 307 + const pub = await client.fetchPublication('3lwafzkjqm25s'); 308 + if (pub) { 309 + console.log(pub.value.name); 310 + } 143 311 ``` 144 312 145 - #### `fetchDocumentsByPublication` 313 + --- 314 + 315 + #### `fetchAllPublications(fetchFn?)` 316 + 317 + Fetch all publications for the configured DID. 318 + 319 + **Parameters:** 320 + 321 + - `fetchFn?: typeof fetch` - Optional custom fetch function 322 + 323 + **Returns:** `Promise<AtProtoRecord<Publication>[]>` 324 + 325 + **Example:** 146 326 147 327 ```typescript 148 - async fetchDocumentsByPublication( 149 - publicationUri: string, 150 - fetchFn?: typeof fetch 151 - ): Promise<AtProtoRecord<Document>[]> 328 + const pubs = await client.fetchAllPublications(); 329 + console.log(`Found ${pubs.length} publications`); 152 330 ``` 153 331 154 - Fetches all documents that belong to a specific publication. 332 + --- 333 + 334 + #### `fetchDocument(rkey, fetchFn?)` 335 + 336 + Fetch a single document by record key. 337 + 338 + **Parameters:** 339 + 340 + - `rkey: string` - The record key 341 + - `fetchFn?: typeof fetch` - Optional custom fetch function 342 + 343 + **Returns:** `Promise<AtProtoRecord<Document> | null>` 344 + 345 + --- 346 + 347 + #### `fetchAllDocuments(fetchFn?)` 348 + 349 + Fetch all documents, sorted by `publishedAt` (newest first). 155 350 156 351 **Parameters:** 157 352 158 - - `publicationUri` (string): AT URI of the publication 159 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 353 + - `fetchFn?: typeof fetch` - Optional custom fetch function 160 354 161 - **Returns:** Promise resolving to an array of document records 355 + **Returns:** `Promise<AtProtoRecord<Document>[]>` 162 356 163 357 **Example:** 164 358 165 359 ```typescript 166 - const docs = await client.fetchDocumentsByPublication( 167 - 'at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s' 168 - ); 360 + const docs = await client.fetchAllDocuments(); 361 + const latest = docs[0]; // Most recent document 169 362 ``` 170 363 171 - #### `fetchByAtUri` 364 + --- 172 365 173 - ```typescript 174 - async fetchByAtUri<T = Publication | Document>( 175 - atUri: string, 176 - fetchFn?: typeof fetch 177 - ): Promise<AtProtoRecord<T> | null> 178 - ``` 366 + #### `fetchDocumentsByPublication(publicationUri, fetchFn?)` 179 367 180 - Fetches a record by its full AT URI. 368 + Fetch all documents belonging to a specific publication. 181 369 182 370 **Parameters:** 183 371 184 - - `atUri` (string): Full AT URI of the record 185 - - `fetchFn` (typeof fetch, optional): Custom fetch function for SSR 372 + - `publicationUri: string` - AT URI of the publication 373 + - `fetchFn?: typeof fetch` - Optional custom fetch function 186 374 187 - **Returns:** Promise resolving to the record or null if not found 375 + **Returns:** `Promise<AtProtoRecord<Document>[]>` 188 376 189 377 **Example:** 190 378 191 379 ```typescript 192 - const record = await client.fetchByAtUri( 193 - 'at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s' 380 + const docs = await client.fetchDocumentsByPublication( 381 + 'at://did:plc:xxx/site.standard.publication/rkey' 194 382 ); 195 383 ``` 196 384 197 - #### `clearCache` 385 + --- 386 + 387 + #### `fetchByAtUri<T>(atUri, fetchFn?)` 388 + 389 + Fetch any record by its AT URI. 390 + 391 + **Parameters:** 392 + 393 + - `atUri: string` - The AT URI 394 + - `fetchFn?: typeof fetch` - Optional custom fetch function 395 + 396 + **Returns:** `Promise<AtProtoRecord<T> | null>` 397 + 398 + **Example:** 198 399 199 400 ```typescript 200 - clearCache(): void 401 + const record = await client.fetchByAtUri<Document>('at://did:plc:xxx/site.standard.document/rkey'); 201 402 ``` 202 403 203 - Clears all cached data. 404 + --- 405 + 406 + #### `clearCache()` 407 + 408 + Clear all cached data. 204 409 205 410 **Example:** 206 411 ··· 208 413 client.clearCache(); 209 414 ``` 210 415 211 - #### `getPDS` 416 + --- 212 417 213 - ```typescript 214 - async getPDS(fetchFn?: typeof fetch): Promise<string> 215 - ``` 418 + #### `getPDS(fetchFn?)` 216 419 217 - Gets the resolved PDS endpoint for the configured DID. 420 + Get the resolved PDS endpoint for the configured DID. 218 421 219 - **Returns:** Promise resolving to the PDS URL 422 + **Parameters:** 423 + 424 + - `fetchFn?: typeof fetch` - Optional custom fetch function 425 + 426 + **Returns:** `Promise<string>` 220 427 221 428 **Example:** 222 429 223 430 ```typescript 224 431 const pds = await client.getPDS(); 225 - console.log('Using PDS:', pds); 432 + console.log(`Using PDS: ${pds}`); 226 433 ``` 434 + 435 + --- 227 436 228 437 ## Types 229 438 230 - ### SiteStandardConfig 231 - 232 - Configuration object for the client. 439 + ### Core Types 233 440 234 441 ```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; 442 + interface AtProtoRecord<T> { 443 + uri: string; 444 + cid: string; 445 + value: T; 242 446 } 243 - ``` 244 447 245 - ### Publication 448 + interface AtProtoBlob { 449 + $type: 'blob'; 450 + ref: { $link: string }; 451 + mimeType: string; 452 + size: number; 453 + } 246 454 247 - Represents a site.standard.publication record. 455 + interface StrongRef { 456 + uri: string; 457 + cid: string; 458 + } 459 + ``` 460 + 461 + ### Publication Types 248 462 249 463 ```typescript 250 464 interface Publication { ··· 256 470 basicTheme?: BasicTheme; 257 471 preferences?: PublicationPreferences; 258 472 } 473 + 474 + interface BasicTheme { 475 + primary: RGBColor; 476 + secondary: RGBColor; 477 + accent: RGBColor; 478 + } 479 + 480 + interface RGBColor { 481 + r: number; // 0-255 482 + g: number; // 0-255 483 + b: number; // 0-255 484 + } 485 + 486 + interface PublicationPreferences { 487 + defaultShowCoverImage?: boolean; 488 + allowComments?: boolean; 489 + allowReactions?: boolean; 490 + } 259 491 ``` 260 492 261 - ### Document 262 - 263 - Represents a site.standard.document record. 493 + ### Document Types 264 494 265 495 ```typescript 266 496 interface Document { 267 497 $type: 'site.standard.document'; 268 - site: string; // AT URI or HTTPS URL 498 + site: string; // AT URI or HTTPS URL to publication 269 499 title: string; 270 500 path?: string; 271 501 description?: string; 272 502 coverImage?: AtProtoBlob; 273 - content?: any; 274 - textContent?: string; 275 - bskyPostRef?: StrongRef; 503 + content?: any; // Rich content object 504 + textContent?: string; // Plain text fallback 505 + bskyPostRef?: StrongRef; // Reference to Bluesky post 276 506 tags?: string[]; 277 507 publishedAt: string; // ISO 8601 datetime 278 508 updatedAt?: string; // ISO 8601 datetime 279 509 } 280 510 ``` 281 511 282 - ### AtProtoRecord 283 - 284 - Generic wrapper for AT Protocol records. 512 + ### Configuration Types 285 513 286 514 ```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 515 + interface SiteStandardConfig { 516 + did: string; 517 + pds?: string; // Optional, auto-resolved if not provided 518 + cacheTTL?: number; // Cache time-to-live in milliseconds 291 519 } 292 - ``` 293 520 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; 521 + interface ResolvedIdentity { 522 + did: string; 523 + pds: string; 524 + handle?: string; 306 525 } 307 526 ``` 308 527 309 - ### BasicTheme 528 + --- 310 529 311 - Theme configuration for a publication. 530 + ## Utilities 312 531 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 - ``` 532 + ### AT URI Utilities 321 533 322 - ### StrongRef 534 + #### `parseAtUri(uri: string)` 323 535 324 - Reference to another AT Protocol record. 536 + Parse an AT URI into its components. 325 537 326 538 ```typescript 327 - interface StrongRef { 328 - uri: string; // AT URI 329 - cid: string; // Content identifier 330 - } 539 + const parsed = parseAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 540 + // Returns: { did: 'did:plc:xxx', collection: 'site.standard.publication', rkey: 'rkey' } 331 541 ``` 332 542 333 - ### ResolvedIdentity 543 + #### `buildAtUri(did: string, collection: string, rkey: string)` 334 544 335 - Result of DID resolution. 545 + Build an AT URI from components. 336 546 337 547 ```typescript 338 - interface ResolvedIdentity { 339 - did: string; 340 - pds: string; // PDS endpoint URL 341 - handle?: string; // User handle 342 - } 548 + const uri = buildAtUri('did:plc:xxx', 'site.standard.publication', 'rkey'); 549 + // Returns: 'at://did:plc:xxx/site.standard.publication/rkey' 343 550 ``` 344 551 345 - ## Utility Functions 552 + #### `extractRkey(uri: string)` 346 553 347 - ### AT URI Utilities 348 - 349 - #### `parseAtUri` 554 + Extract the record key from an AT URI. 350 555 351 556 ```typescript 352 - function parseAtUri(atUri: string): ParsedAtUri | null; 557 + const rkey = extractRkey('at://did:plc:xxx/site.standard.publication/rkey'); 558 + // Returns: 'rkey' 353 559 ``` 354 560 355 - Parses an AT URI into its components. 561 + #### `isAtUri(value: string)` 356 562 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` 563 + Check if a string is a valid AT URI. 367 564 368 565 ```typescript 369 - function atUriToHttps(atUri: string, pdsEndpoint: string): string | null; 566 + const valid = isAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 567 + // Returns: true 370 568 ``` 371 569 372 - Converts an AT URI to an HTTPS URL for the getRecord XRPC endpoint. 570 + #### `atUriToHttps(atUri: string, pds: string)` 373 571 374 - **Example:** 572 + Convert an AT URI to an HTTPS URL for API calls. 375 573 376 574 ```typescript 377 - import { atUriToHttps } from 'svelte-standard-site'; 378 - 379 575 const url = atUriToHttps( 380 576 'at://did:plc:xxx/site.standard.publication/rkey', 381 577 'https://pds.example.com' 382 578 ); 579 + // Returns: 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=...' 383 580 ``` 384 581 385 - #### `buildAtUri` 582 + --- 386 583 387 - ```typescript 388 - function buildAtUri(did: string, collection: string, rkey: string): string; 389 - ``` 584 + ### Identity Resolution 390 585 391 - Constructs an AT URI from components. 586 + #### `resolveIdentity(did: string, fetchFn?: typeof fetch)` 392 587 393 - **Example:** 588 + Resolve a DID to its PDS endpoint and handle. 394 589 395 590 ```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' 591 + const identity = await resolveIdentity('did:plc:xxx'); 592 + // Returns: { did: 'did:plc:xxx', pds: 'https://...', handle: 'user.bsky.social' } 400 593 ``` 401 594 402 - #### `extractRkey` 595 + --- 403 596 404 - ```typescript 405 - function extractRkey(atUri: string): string | null; 406 - ``` 597 + ### Blob Utilities 407 598 408 - Extracts the record key from an AT URI. 599 + #### `buildPdsBlobUrl(pds: string, did: string, cid: string)` 409 600 410 - **Example:** 601 + Build a URL for fetching blob content. 411 602 412 603 ```typescript 413 - import { extractRkey } from 'svelte-standard-site'; 414 - 415 - const rkey = extractRkey('at://did:plc:xxx/site.standard.publication/3lwafzkjqm25s'); 416 - // '3lwafzkjqm25s' 604 + const url = buildPdsBlobUrl('https://pds.example.com', 'did:plc:xxx', 'bafyrei...'); 417 605 ``` 418 606 419 - #### `isAtUri` 607 + --- 420 608 421 - ```typescript 422 - function isAtUri(uri: string): boolean; 423 - ``` 609 + ### Theme Utilities 424 610 425 - Validates if a string is a valid AT URI. 611 + #### `rgbToCSS(color: RGBColor)` 426 612 427 - **Example:** 613 + Convert an RGB color object to CSS rgb() string. 428 614 429 615 ```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 616 + const css = rgbToCSS({ r: 100, g: 150, b: 200 }); 617 + // Returns: 'rgb(100, 150, 200)' 434 618 ``` 435 619 436 - ### Agent Utilities 620 + #### `rgbToHex(color: RGBColor)` 437 621 438 - #### `resolveIdentity` 622 + Convert an RGB color object to hex string. 439 623 440 624 ```typescript 441 - async function resolveIdentity(did: string, fetchFn?: typeof fetch): Promise<ResolvedIdentity>; 625 + const hex = rgbToHex({ r: 100, g: 150, b: 200 }); 626 + // Returns: '#6496c8' 442 627 ``` 443 628 444 - Resolves a DID to its PDS endpoint using Slingshot. 629 + #### `getThemeVars(theme: BasicTheme)` 445 630 446 - **Example:** 631 + Convert a BasicTheme to CSS custom properties object. 447 632 448 633 ```typescript 449 - import { resolveIdentity } from 'svelte-standard-site'; 450 - 451 - const identity = await resolveIdentity('did:plc:xxx'); 452 - console.log(identity.pds); // 'https://...' 634 + const vars = getThemeVars({ 635 + primary: { r: 100, g: 150, b: 200 }, 636 + secondary: { r: 150, g: 100, b: 200 }, 637 + accent: { r: 200, g: 100, b: 150 } 638 + }); 639 + // Returns: { '--theme-primary': 'rgb(100, 150, 200)', ... } 453 640 ``` 454 641 455 - #### `buildPdsBlobUrl` 642 + --- 456 643 457 - ```typescript 458 - function buildPdsBlobUrl(pds: string, did: string, cid: string): string; 459 - ``` 644 + ### Document Utilities 460 645 461 - Constructs a blob URL for retrieving files from a PDS. 646 + #### `getDocumentSlug(document: AtProtoRecord<Document>)` 462 647 463 - **Example:** 648 + Generate a URL slug from a document's path or URI. 464 649 465 650 ```typescript 466 - import { buildPdsBlobUrl } from 'svelte-standard-site'; 467 - 468 - const url = buildPdsBlobUrl('https://pds.example.com', 'did:plc:xxx', 'bafyrei...'); 651 + const slug = getDocumentSlug(document); 469 652 ``` 470 653 471 - ### Cache 654 + #### `getDocumentUrl(document: AtProtoRecord<Document>, publicationRkey?: string)` 472 655 473 - #### `cache` 474 - 475 - Global cache instance used by the library. 656 + Generate a full URL path for a document. 476 657 477 658 ```typescript 478 - const cache: Cache; 659 + const url = getDocumentUrl(document, 'pub123'); 660 + // Returns: '/pub123/doc456' or custom path 479 661 ``` 480 662 481 - **Methods:** 663 + --- 482 664 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 665 + ### Cache Utilities 488 666 489 - **Example:** 667 + The library includes built-in caching with automatic expiration. 490 668 491 669 ```typescript 492 670 import { cache } from 'svelte-standard-site'; 493 671 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 672 + // Get cached value 673 + const value = cache.get<MyType>('my-key'); 501 674 502 - ### PublicationCard 675 + // Set cached value 676 + cache.set('my-key', myValue, 300000); // 5 minutes TTL 503 677 504 - Displays a publication card with icon, name, description, and link. 505 - 506 - **Props:** 678 + // Delete cached value 679 + cache.delete('my-key'); 507 680 508 - ```typescript 509 - interface Props { 510 - publication: AtProtoRecord<Publication>; 511 - class?: string; 512 - } 681 + // Clear all cache 682 + cache.clear(); 513 683 ``` 514 684 515 - **Example:** 685 + --- 516 686 517 - ```svelte 518 - <script> 519 - import { PublicationCard } from 'svelte-standard-site'; 687 + ## Environment Configuration 520 688 521 - export let data; 522 - </script> 523 - 524 - <PublicationCard publication={data.publication} class="my-custom-class" /> 525 - ``` 526 - 527 - **Styling:** 689 + ### getConfigFromEnv() 528 690 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:** 691 + Read configuration from environment variables. 544 692 545 693 ```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` 694 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 586 695 587 - ```typescript 588 - function getConfigFromEnv(): SiteStandardConfig | null; 696 + const config = getConfigFromEnv(); 697 + // Returns: { did: '...', pds: '...', cacheTTL: ... } or null 589 698 ``` 590 699 591 - Reads configuration from environment variables. 592 - 593 700 **Environment Variables:** 594 701 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 702 + - `PUBLIC_ATPROTO_DID` - Required DID 703 + - `PUBLIC_ATPROTO_PDS` - Optional PDS endpoint 704 + - `PUBLIC_CACHE_TTL` - Optional cache TTL in milliseconds 598 705 599 - **Example:** 706 + ### validateEnv() 707 + 708 + Validate that required environment variables are set. Throws if missing. 600 709 601 710 ```typescript 602 - import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 711 + import { validateEnv } from 'svelte-standard-site/config/env'; 603 712 604 - const config = getConfigFromEnv(); 605 - if (config) { 606 - const client = createClient(config); 607 - } 713 + validateEnv(); // Throws if PUBLIC_ATPROTO_DID is not set 608 714 ``` 609 715 610 - #### `validateEnv` 716 + --- 611 717 612 - ```typescript 613 - function validateEnv(): void; 614 - ``` 718 + ## Error Handling 615 719 616 - Validates that required environment variables are set. Throws an error if validation fails. 617 - 618 - **Example:** 720 + All async methods can throw errors. Always use try-catch: 619 721 620 722 ```typescript 621 - import { validateEnv } from 'svelte-standard-site/config/env'; 622 - 623 723 try { 624 - validateEnv(); 724 + const docs = await client.fetchAllDocuments(); 725 + // Handle success 625 726 } catch (error) { 626 - console.error('Missing required environment variables'); 727 + console.error('Failed to fetch documents:', error); 728 + // Handle error 627 729 } 628 730 ``` 731 + 732 + ## Performance Tips 733 + 734 + 1. **Pass fetch function in SSR** for proper request tracking 735 + 2. **Use caching** - it's built-in and automatic 736 + 3. **Batch requests** with `Promise.all()` when possible 737 + 4. **Clear cache** strategically if data changes frequently 738 + 5. **Pre-render static pages** for better performance 739 + 740 + For more examples, see [EXAMPLES.md](./EXAMPLES.md).
+774 -279
EXAMPLES.md
··· 1 - # Usage Examples 1 + # svelte-standard-site Examples 2 + 3 + This file contains comprehensive examples of using svelte-standard-site in various scenarios. 4 + 5 + ## Table of Contents 2 6 3 - This document provides comprehensive examples of using the svelte-standard-site library. 7 + - [Basic Setup](#basic-setup) 8 + - [Simple Blog](#simple-blog) 9 + - [Using Utility Components](#using-utility-components) 10 + - [Building Custom Cards](#building-custom-cards) 11 + - [Multi-Publication Site](#multi-publication-site) 12 + - [Custom Styling](#custom-styling) 13 + - [Custom Layout](#custom-layout) 14 + - [Programmatic Theme Control](#programmatic-theme-control) 15 + - [Internationalization](#internationalization) 16 + - [Server-Side Rendering](#server-side-rendering) 4 17 5 18 ## Basic Setup 6 19 7 - ### 1. Install the Package 20 + ### 1. Install and Configure 8 21 9 22 ```bash 10 23 pnpm add svelte-standard-site 11 24 ``` 12 25 13 - ### 2. Configure Environment Variables 14 - 15 - Create a `.env` file: 26 + Create `.env`: 16 27 17 28 ```env 18 - PUBLIC_ATPROTO_DID=did:plc:revjuqmkvrw6fnkxppqtszpv 29 + PUBLIC_ATPROTO_DID=did:plc:your-did-here 19 30 ``` 20 31 21 - ## Example 1: Simple Blog List 32 + ### 2. Root Layout 22 33 23 - Fetch and display all documents as blog posts. 34 + ```svelte 35 + <!-- src/routes/+layout.svelte --> 36 + <script lang="ts"> 37 + import 'svelte-standard-site/styles/base.css'; 38 + import type { Snippet } from 'svelte'; 39 + 40 + interface Props { 41 + children: Snippet; 42 + } 43 + 44 + let { children }: Props = $props(); 45 + </script> 46 + 47 + {@render children()} 48 + ``` 49 + 50 + ### 3. Home Page 51 + 52 + ```svelte 53 + <!-- src/routes/+page.svelte --> 54 + <script lang="ts"> 55 + import { StandardSiteLayout, PublicationCard } from 'svelte-standard-site'; 56 + import type { PageData } from './$types'; 57 + 58 + const { data }: { data: PageData } = $props(); 59 + </script> 60 + 61 + <StandardSiteLayout title="My Site"> 62 + <h1>Welcome!</h1> 63 + 64 + <div class="grid gap-6 md:grid-cols-3"> 65 + {#each data.publications as publication} 66 + <PublicationCard {publication} /> 67 + {/each} 68 + </div> 69 + </StandardSiteLayout> 70 + ``` 71 + 72 + ## Simple Blog 73 + 74 + ### Load Function 24 75 25 76 ```typescript 26 77 // src/routes/blog/+page.server.ts 27 78 import { createClient } from 'svelte-standard-site'; 28 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 79 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 29 80 import type { PageServerLoad } from './$types'; 30 81 31 82 export const load: PageServerLoad = async ({ fetch }) => { 32 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 83 + const config = getConfigFromEnv(); 84 + if (!config) { 85 + throw new Error('Missing AT Proto configuration'); 86 + } 87 + 88 + const client = createClient(config); 33 89 const documents = await client.fetchAllDocuments(fetch); 34 90 35 91 return { ··· 38 94 }; 39 95 ``` 40 96 97 + ### Blog Index Page 98 + 41 99 ```svelte 42 100 <!-- src/routes/blog/+page.svelte --> 43 101 <script lang="ts"> 44 - import { DocumentCard } from 'svelte-standard-site'; 102 + import { StandardSiteLayout, DocumentCard } from 'svelte-standard-site'; 45 103 import type { PageData } from './$types'; 46 104 47 105 const { data }: { data: PageData } = $props(); 48 106 </script> 49 107 50 - <div class="container"> 51 - <h1>Blog Posts</h1> 52 - <div class="posts"> 108 + <StandardSiteLayout title="My Blog"> 109 + <div class="mb-8"> 110 + <h1 class="text-ink-900 dark:text-ink-50 text-4xl font-bold">Blog Posts</h1> 111 + <p class="text-ink-700 dark:text-ink-200 mt-2">Thoughts, tutorials, and updates</p> 112 + </div> 113 + 114 + <div class="space-y-6"> 53 115 {#each data.posts as post} 54 - <DocumentCard document={post} /> 116 + <DocumentCard document={post} showCover={true} /> 55 117 {/each} 56 118 </div> 57 - </div> 119 + </StandardSiteLayout> 58 120 ``` 59 121 60 - ## Example 2: Single Post Page 61 - 62 - Display a single document by its record key (slug). 122 + ### Individual Post Page 63 123 64 124 ```typescript 65 - // src/routes/blog/[slug]/+page.server.ts 125 + // src/routes/blog/[pub_rkey]/[doc_rkey]/+page.server.ts 66 126 import { createClient } from 'svelte-standard-site'; 67 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 127 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 68 128 import { error } from '@sveltejs/kit'; 69 129 import type { PageServerLoad } from './$types'; 70 130 71 131 export const load: PageServerLoad = async ({ params, fetch }) => { 72 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 73 - const document = await client.fetchDocument(params.slug, fetch); 132 + const config = getConfigFromEnv(); 133 + if (!config) { 134 + throw new Error('Missing configuration'); 135 + } 136 + 137 + const client = createClient(config); 138 + const document = await client.fetchDocument(params.doc_rkey, fetch); 74 139 75 140 if (!document) { 76 141 throw error(404, 'Post not found'); ··· 83 148 ``` 84 149 85 150 ```svelte 86 - <!-- src/routes/blog/[slug]/+page.svelte --> 151 + <!-- src/routes/blog/[pub_rkey]/[doc_rkey]/+page.svelte --> 87 152 <script lang="ts"> 153 + import { StandardSiteLayout } from 'svelte-standard-site'; 88 154 import type { PageData } from './$types'; 89 155 90 156 const { data }: { data: PageData } = $props(); 91 - const post = data.post.value; 157 + 158 + const post = $derived(data.post.value); 159 + 160 + function formatDate(date: string) { 161 + return new Date(date).toLocaleDateString('en-US', { 162 + year: 'numeric', 163 + month: 'long', 164 + day: 'numeric' 165 + }); 166 + } 92 167 </script> 93 168 94 - <article> 95 - {#if post.coverImage} 96 - <img src={post.coverImage.ref.$link} alt={post.title} /> 169 + <svelte:head> 170 + <title>{post.title} - My Blog</title> 171 + {#if post.description} 172 + <meta name="description" content={post.description} /> 97 173 {/if} 174 + </svelte:head> 98 175 99 - <h1>{post.title}</h1> 176 + <StandardSiteLayout title="My Blog"> 177 + <article class="mx-auto prose prose-lg dark:prose-invert"> 178 + {#if post.coverImage} 179 + <img src={post.coverImage} alt={post.title} class="w-full rounded-xl" /> 180 + {/if} 100 181 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} 182 + <header class="mb-8"> 183 + <h1>{post.title}</h1> 184 + <div class="text-ink-600 dark:text-ink-400 flex gap-4 text-sm"> 185 + <time datetime={post.publishedAt}> 186 + {formatDate(post.publishedAt)} 187 + </time> 188 + {#if post.updatedAt} 189 + <span>Updated: {formatDate(post.updatedAt)}</span> 190 + {/if} 108 191 </div> 109 - {/if} 110 - </div> 111 - 112 - {#if post.description} 113 - <p class="description">{post.description}</p> 114 - {/if} 192 + {#if post.tags && post.tags.length > 0} 193 + <div class="mt-4 flex flex-wrap gap-2"> 194 + {#each post.tags as tag} 195 + <span 196 + class="bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200 rounded-full px-3 py-1 text-xs font-medium" 197 + > 198 + {tag} 199 + </span> 200 + {/each} 201 + </div> 202 + {/if} 203 + </header> 115 204 116 - {#if post.textContent} 117 - <div class="content"> 118 - {post.textContent} 119 - </div> 120 - {/if} 121 - </article> 205 + {@html post.content || post.textContent || ''} 206 + </article> 207 + </StandardSiteLayout> 122 208 ``` 123 209 124 - ## Example 3: Publication-Filtered Posts 125 - 126 - Display documents from a specific publication. 210 + ## Using Utility Components 127 211 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'; 212 + The modular utility components make it easy to build consistent, theme-aware UIs. 134 213 135 - export const load: PageServerLoad = async ({ params, fetch }) => { 136 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 214 + ### Using DateDisplay 137 215 138 - // Fetch the publication 139 - const publication = await client.fetchPublication(params.pubkey, fetch); 140 - if (!publication) { 141 - throw error(404, 'Publication not found'); 142 - } 216 + ```svelte 217 + <script lang="ts"> 218 + import { DateDisplay } from 'svelte-standard-site'; 219 + import type { PageData } from './$types'; 143 220 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); 221 + const { data }: { data: PageData } = $props(); 222 + </script> 147 223 148 - return { 149 - publication, 150 - documents 151 - }; 152 - }; 224 + <article> 225 + <h1>{data.post.value.title}</h1> 226 + 227 + <!-- Simple date display --> 228 + <DateDisplay date={data.post.value.publishedAt} /> 229 + 230 + <!-- With label and icon --> 231 + <DateDisplay 232 + date={data.post.value.updatedAt} 233 + label="Last updated: " 234 + showIcon={true} 235 + class="text-sm text-ink-600 dark:text-ink-400" 236 + /> 237 + 238 + <!-- Custom locale --> 239 + <DateDisplay date={data.post.value.publishedAt} locale="fr-FR" /> 240 + </article> 153 241 ``` 242 + 243 + ### Using TagList 154 244 155 245 ```svelte 156 - <!-- src/routes/publications/[pubkey]/+page.svelte --> 157 246 <script lang="ts"> 158 - import { PublicationCard, DocumentCard } from 'svelte-standard-site'; 247 + import { TagList } from 'svelte-standard-site'; 159 248 import type { PageData } from './$types'; 160 249 161 250 const { data }: { data: PageData } = $props(); 251 + const hasTheme = $derived(!!data.publication?.value.basicTheme); 162 252 </script> 163 253 164 - <div class="publication-page"> 165 - <PublicationCard publication={data.publication} /> 254 + <!-- Simple tag list --> 255 + <TagList tags={data.post.value.tags || []} /> 166 256 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> 257 + <!-- With theme support --> 258 + <TagList 259 + tags={data.post.value.tags || []} 260 + {hasTheme} 261 + class="mt-4" 262 + /> 174 263 ``` 175 264 176 - ## Example 4: Using AT URIs 265 + ### Using ThemedText 177 266 178 - Fetch a record using its full AT URI. 267 + ```svelte 268 + <script lang="ts"> 269 + import { ThemedText } from 'svelte-standard-site'; 270 + import type { PageData } from './$types'; 179 271 180 - ```typescript 181 - import { createClient } from 'svelte-standard-site'; 272 + const { data }: { data: PageData } = $props(); 273 + const hasTheme = $derived(!!data.publication?.value.basicTheme); 274 + </script> 182 275 183 - const client = createClient({ did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' }); 276 + <!-- Title with theme --> 277 + <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-4"> 278 + {data.post.value.title} 279 + </ThemedText> 184 280 185 - // Fetch using AT URI 186 - const publication = await client.fetchByAtUri( 187 - 'at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s' 188 - ); 281 + <!-- Semi-transparent description --> 282 + <ThemedText {hasTheme} opacity={70} element="p" class="text-lg"> 283 + {data.post.value.description} 284 + </ThemedText> 189 285 190 - console.log(publication?.value.name); 286 + <!-- Accent color for links --> 287 + <ThemedText {hasTheme} variant="accent" element="span"> 288 + Read more → 289 + </ThemedText> 191 290 ``` 192 291 193 - ## Example 5: Manual Configuration 292 + ### Combining Utility Components 194 293 195 - Configure the client without environment variables. 294 + ```svelte 295 + <script lang="ts"> 296 + import { 297 + ThemedContainer, 298 + ThemedText, 299 + DateDisplay, 300 + TagList 301 + } from 'svelte-standard-site'; 302 + import type { PageData } from './$types'; 196 303 197 - ```typescript 198 - import { createClient } from 'svelte-standard-site'; 304 + const { data }: { data: PageData } = $props(); 305 + const theme = $derived(data.publication?.value.basicTheme); 306 + const hasTheme = $derived(!!theme); 307 + </script> 199 308 200 - const client = createClient({ 201 - did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 202 - pds: 'https://cortinarius.us-west.host.bsky.network', 203 - cacheTTL: 600000 // 10 minutes 204 - }); 309 + <ThemedContainer {theme} element="article" class="p-8"> 310 + <!-- Title --> 311 + <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-2"> 312 + {data.post.value.title} 313 + </ThemedText> 314 + 315 + <!-- Description --> 316 + <ThemedText {hasTheme} opacity={70} element="p" class="text-lg mb-4"> 317 + {data.post.value.description} 318 + </ThemedText> 319 + 320 + <!-- Metadata --> 321 + <div class="flex gap-4 mb-6"> 322 + <DateDisplay date={data.post.value.publishedAt} /> 323 + {#if data.post.value.updatedAt} 324 + <DateDisplay 325 + date={data.post.value.updatedAt} 326 + label="Updated " 327 + showIcon={true} 328 + /> 329 + {/if} 330 + </div> 331 + 332 + <!-- Tags --> 333 + <TagList tags={data.post.value.tags || []} {hasTheme} /> 334 + 335 + <!-- Content --> 336 + <div class="prose max-w-none mt-8"> 337 + {@html data.post.value.content} 338 + </div> 339 + </ThemedContainer> 205 340 ``` 206 341 207 - ## Example 6: Custom Publication Listing 342 + ## Building Custom Cards 208 343 209 - Create a custom component to display publications. 344 + Use ThemedCard and utility components to build custom card layouts. 345 + 346 + ### Blog Post Card 210 347 211 348 ```svelte 212 349 <script lang="ts"> 213 - import type { AtProtoRecord, Publication } from 'svelte-standard-site'; 350 + import { 351 + ThemedCard, 352 + ThemedText, 353 + DateDisplay, 354 + TagList 355 + } from 'svelte-standard-site'; 356 + import type { Document, Publication, AtProtoRecord } from 'svelte-standard-site'; 214 357 215 358 interface Props { 216 - publications: AtProtoRecord<Publication>[]; 359 + document: AtProtoRecord<Document>; 360 + publication?: AtProtoRecord<Publication>; 217 361 } 218 362 219 - const { publications }: Props = $props(); 363 + let { document, publication }: Props = $props(); 364 + 365 + const theme = $derived(publication?.value.basicTheme); 366 + const hasTheme = $derived(!!theme); 367 + const value = $derived(document.value); 220 368 </script> 221 369 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} /> 370 + <ThemedCard {theme} href="/blog/{document.uri.split('/').pop()}" class="hover:shadow-lg transition-shadow"> 371 + <div class="flex gap-6"> 372 + {#if value.coverImage} 373 + <img 374 + src={value.coverImage} 375 + alt={value.title} 376 + class="w-32 h-32 object-cover rounded-lg" 377 + /> 378 + {/if} 379 + 380 + <div class="flex-1"> 381 + <ThemedText {hasTheme} element="h3" class="text-2xl font-bold mb-2"> 382 + {value.title} 383 + </ThemedText> 384 + 385 + {#if value.description} 386 + <ThemedText {hasTheme} opacity={70} element="p" class="mb-4 line-clamp-2"> 387 + {value.description} 388 + </ThemedText> 389 + {/if} 390 + 391 + <div class="flex items-center gap-4 mb-3"> 392 + <DateDisplay date={value.publishedAt} class="text-sm" /> 393 + </div> 394 + 395 + {#if value.tags?.length} 396 + <TagList tags={value.tags} {hasTheme} /> 227 397 {/if} 228 - <h3>{pub.value.name}</h3> 229 - <p>{pub.value.description}</p> 230 - </a> 231 - {/each} 232 - </div> 398 + </div> 399 + </div> 400 + </ThemedCard> 401 + ``` 402 + 403 + ### Author Card 233 404 234 - <style> 235 - .publications-grid { 236 - display: grid; 237 - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 238 - gap: 1.5rem; 405 + ```svelte 406 + <script lang="ts"> 407 + import { ThemedCard, ThemedText } from 'svelte-standard-site'; 408 + 409 + interface Props { 410 + name: string; 411 + bio: string; 412 + avatar?: string; 413 + theme?: any; 239 414 } 415 + 416 + let { name, bio, avatar, theme }: Props = $props(); 417 + const hasTheme = $derived(!!theme); 418 + </script> 240 419 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; 420 + <ThemedCard {theme} class="p-6"> 421 + <div class="flex items-start gap-4"> 422 + {#if avatar} 423 + <img src={avatar} alt={name} class="w-16 h-16 rounded-full" /> 424 + {/if} 425 + 426 + <div> 427 + <ThemedText {hasTheme} element="h3" class="text-xl font-bold mb-2"> 428 + {name} 429 + </ThemedText> 430 + 431 + <ThemedText {hasTheme} opacity={70} element="p"> 432 + {bio} 433 + </ThemedText> 434 + </div> 435 + </div> 436 + </ThemedCard> 437 + ``` 438 + 439 + ### Feature Card with Icon 440 + 441 + ```svelte 442 + <script lang="ts"> 443 + import { ThemedCard, ThemedText } from 'svelte-standard-site'; 444 + import type { Snippet } from 'svelte'; 445 + 446 + interface Props { 447 + title: string; 448 + description: string; 449 + icon: Snippet; 450 + theme?: any; 248 451 } 452 + 453 + let { title, description, icon, theme }: Props = $props(); 454 + const hasTheme = $derived(!!theme); 455 + </script> 249 456 250 - .pub-card:hover { 251 - border-color: #3b82f6; 252 - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); 253 - } 457 + <ThemedCard {theme} class="p-6 text-center"> 458 + <div class="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-100 dark:bg-primary-900"> 459 + {@render icon()} 460 + </div> 461 + 462 + <ThemedText {hasTheme} element="h3" class="text-xl font-bold mb-2"> 463 + {title} 464 + </ThemedText> 465 + 466 + <ThemedText {hasTheme} opacity={70} element="p"> 467 + {description} 468 + </ThemedText> 469 + </ThemedCard> 470 + ``` 471 + 472 + ## Internationalization 473 + 474 + ### Automatic Locale Detection 475 + 476 + The DateDisplay component automatically detects the user's browser locale. 477 + 478 + ```svelte 479 + <script lang="ts"> 480 + import { DateDisplay } from 'svelte-standard-site'; 481 + </script> 482 + 483 + <!-- Automatically formats based on user's locale --> 484 + <DateDisplay date="2026-01-19T12:00:00Z" /> 254 485 255 - .pub-card img { 256 - width: 100%; 257 - height: 150px; 258 - object-fit: cover; 259 - border-radius: 0.375rem; 260 - margin-bottom: 1rem; 261 - } 486 + <!-- 487 + Results: 488 + - en-US: "January 19, 2026" 489 + - fr-FR: "19 janvier 2026" 490 + - de-DE: "19. Januar 2026" 491 + - ja-JP: "2026年1月19日" 492 + - es-ES: "19 de enero de 2026" 493 + --> 494 + ``` 262 495 263 - .pub-card h3 { 264 - font-size: 1.25rem; 265 - font-weight: 600; 266 - margin-bottom: 0.5rem; 267 - } 496 + ### Explicit Locale Override 497 + 498 + ```svelte 499 + <script lang="ts"> 500 + import { DateDisplay } from 'svelte-standard-site'; 501 + 502 + // User preference from settings or profile 503 + let userLocale = $state('fr-FR'); 504 + </script> 268 505 269 - .pub-card p { 270 - font-size: 0.875rem; 271 - color: #6b7280; 272 - } 273 - </style> 506 + <DateDisplay date="2026-01-19T12:00:00Z" locale={userLocale} /> 274 507 ``` 275 508 276 - ## Example 7: Search and Filter 509 + ### Multi-Language Blog 277 510 278 - Implement search and tag filtering. 511 + ```svelte 512 + <script lang="ts"> 513 + import { 514 + StandardSiteLayout, 515 + ThemedContainer, 516 + ThemedText, 517 + DateDisplay 518 + } from 'svelte-standard-site'; 519 + import type { PageData } from './$types'; 520 + 521 + const { data }: { data: PageData } = $props(); 522 + 523 + // Detect user's language 524 + let locale = $state('en-US'); 525 + 526 + $effect(() => { 527 + if (typeof navigator !== 'undefined') { 528 + locale = navigator.language || 'en-US'; 529 + } 530 + }); 531 + 532 + const theme = $derived(data.publication?.value.basicTheme); 533 + const hasTheme = $derived(!!theme); 534 + </script> 535 + 536 + <StandardSiteLayout title={data.publication?.value.name}> 537 + <ThemedContainer {theme}> 538 + <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-4"> 539 + {data.post.value.title} 540 + </ThemedText> 541 + 542 + <!-- Date automatically formats to user's locale --> 543 + <DateDisplay 544 + date={data.post.value.publishedAt} 545 + {locale} 546 + class="text-sm text-ink-600 dark:text-ink-400" 547 + /> 548 + 549 + <div class="prose max-w-none mt-8"> 550 + {@html data.post.value.content} 551 + </div> 552 + </ThemedContainer> 553 + </StandardSiteLayout> 554 + ``` 555 + 556 + ## Multi-Publication Site 279 557 280 558 ```typescript 281 - // src/routes/search/+page.server.ts 559 + // src/routes/+page.server.ts 282 560 import { createClient } from 'svelte-standard-site'; 283 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 561 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 284 562 import type { PageServerLoad } from './$types'; 285 563 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') || ''; 564 + export const load: PageServerLoad = async ({ fetch }) => { 565 + const config = getConfigFromEnv(); 566 + if (!config) { 567 + return { error: 'Configuration missing', publications: [], documents: [] }; 568 + } 292 569 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); 570 + const client = createClient(config); 299 571 300 - const matchesTag = !tag || doc.value.tags?.includes(tag); 572 + const [publications, documents] = await Promise.all([ 573 + client.fetchAllPublications(fetch), 574 + client.fetchAllDocuments(fetch) 575 + ]); 301 576 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 - }); 577 + // Group documents by publication 578 + const documentsByPub = new Map(); 579 + for (const doc of documents) { 580 + const pubUri = doc.value.site; 581 + if (!documentsByPub.has(pubUri)) { 582 + documentsByPub.set(pubUri, []); 583 + } 584 + documentsByPub.get(pubUri).push(doc); 585 + } 310 586 311 587 return { 312 - documents: filtered, 313 - tags: Array.from(allTags).sort(), 314 - query, 315 - selectedTag: tag 588 + publications, 589 + documents, 590 + documentsByPub: Object.fromEntries(documentsByPub) 316 591 }; 317 592 }; 318 593 ``` 319 594 320 - ## Example 8: RSS Feed 595 + ```svelte 596 + <!-- src/routes/+page.svelte --> 597 + <script lang="ts"> 598 + import { StandardSiteLayout, PublicationCard, DocumentCard } from 'svelte-standard-site'; 599 + import type { PageData } from './$types'; 600 + 601 + const { data }: { data: PageData } = $props(); 602 + </script> 321 603 322 - Generate an RSS feed from documents. 604 + <StandardSiteLayout title="My Publications"> 605 + {#each data.publications as publication} 606 + <section class="mb-16"> 607 + <div class="mb-8"> 608 + <PublicationCard {publication} /> 609 + </div> 323 610 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'; 611 + <h2 class="text-ink-900 dark:text-ink-50 mb-6 text-2xl font-bold"> 612 + Recent from {publication.value.name} 613 + </h2> 614 + 615 + {#if data.documentsByPub[publication.uri]} 616 + <div class="space-y-6"> 617 + {#each data.documentsByPub[publication.uri].slice(0, 5) as document} 618 + <DocumentCard {document} /> 619 + {/each} 620 + </div> 621 + {:else} 622 + <p class="text-ink-600 dark:text-ink-400">No documents yet</p> 623 + {/if} 624 + </section> 625 + {/each} 626 + </StandardSiteLayout> 627 + ``` 329 628 330 - export const GET: RequestHandler = async ({ fetch }) => { 331 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 332 - const documents = await client.fetchAllDocuments(fetch); 629 + ## Custom Styling 333 630 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>`; 631 + ### Override Theme Colors 353 632 354 - return new Response(rss, { 355 - headers: { 356 - 'Content-Type': 'application/xml' 357 - } 358 - }); 359 - }; 633 + ```css 634 + /* src/app.css or src/lib/styles/custom.css */ 635 + @import 'svelte-standard-site/styles/base.css'; 360 636 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;'); 637 + /* Override primary color */ 638 + :root { 639 + --color-primary-50: oklch(18.2% 0.018 280); 640 + --color-primary-100: oklch(26.5% 0.03 280); 641 + --color-primary-200: oklch(40.5% 0.048 280); 642 + --color-primary-300: oklch(54% 0.065 280); 643 + --color-primary-400: oklch(66.5% 0.08 280); 644 + --color-primary-500: oklch(78.5% 0.095 280); 645 + --color-primary-600: oklch(82.2% 0.078 280); 646 + --color-primary-700: oklch(86.5% 0.062 280); 647 + --color-primary-800: oklch(91% 0.042 280); 648 + --color-primary-900: oklch(95.8% 0.022 280); 649 + --color-primary-950: oklch(98% 0.012 280); 368 650 } 369 651 ``` 370 652 371 - ## Example 9: Sitemap Generation 653 + ### Custom Component Styles 654 + 655 + ```svelte 656 + <script lang="ts"> 657 + import { DocumentCard } from 'svelte-standard-site'; 658 + </script> 372 659 373 - Generate a sitemap from documents. 660 + <DocumentCard 661 + {document} 662 + class=" 663 + border-primary-500 664 + border-2 665 + shadow-2xl 666 + transition-transform 667 + hover:scale-105 668 + " 669 + /> 670 + ``` 374 671 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'; 672 + ## Custom Layout 380 673 381 - export const GET: RequestHandler = async ({ fetch }) => { 382 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 383 - const documents = await client.fetchAllDocuments(fetch); 674 + ### Full Custom Layout with Theme Support 384 675 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>`; 676 + ```svelte 677 + <!-- src/routes/+layout.svelte --> 678 + <script lang="ts"> 679 + import 'svelte-standard-site/styles/base.css'; 680 + import { ThemeToggle, themeStore } from 'svelte-standard-site'; 681 + import { onMount } from 'svelte'; 682 + import { page } from '$app/stores'; 683 + import type { Snippet } from 'svelte'; 405 684 406 - return new Response(sitemap, { 407 - headers: { 408 - 'Content-Type': 'application/xml' 409 - } 685 + interface Props { 686 + children: Snippet; 687 + } 688 + 689 + let { children }: Props = $props(); 690 + 691 + // Navigation items 692 + const navItems = [ 693 + { href: '/', label: 'Home' }, 694 + { href: '/blog', label: 'Blog' }, 695 + { href: '/about', label: 'About' } 696 + ]; 697 + 698 + onMount(() => { 699 + themeStore.init(); 410 700 }); 411 - }; 701 + </script> 702 + 703 + <svelte:head> 704 + <script> 705 + // Prevent FOUC 706 + (function () { 707 + const stored = localStorage.getItem('theme'); 708 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 709 + const isDark = stored === 'dark' || (!stored && prefersDark); 710 + const htmlElement = document.documentElement; 711 + 712 + if (isDark) { 713 + htmlElement.classList.add('dark'); 714 + htmlElement.style.colorScheme = 'dark'; 715 + } else { 716 + htmlElement.classList.remove('dark'); 717 + htmlElement.style.colorScheme = 'light'; 718 + } 719 + })(); 720 + </script> 721 + </svelte:head> 722 + 723 + <div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50 min-h-screen"> 724 + <!-- Custom Header --> 725 + <header 726 + class="border-canvas-200 bg-canvas-50/90 dark:border-canvas-800 dark:bg-canvas-950/90 border-b backdrop-blur-md" 727 + > 728 + <nav class="container mx-auto flex items-center justify-between px-4 py-4"> 729 + <a href="/" class="text-primary-600 dark:text-primary-400 text-2xl font-bold"> MyBrand </a> 730 + 731 + <ul class="flex items-center gap-6"> 732 + {#each navItems as item} 733 + <li> 734 + <a 735 + href={item.href} 736 + class="hover:text-primary-600 dark:hover:text-primary-400 font-medium transition-colors 737 + {$page.url.pathname === item.href 738 + ? 'text-primary-600 dark:text-primary-400' 739 + : 'text-ink-700 dark:text-ink-200'}" 740 + > 741 + {item.label} 742 + </a> 743 + </li> 744 + {/each} 745 + <li> 746 + <ThemeToggle /> 747 + </li> 748 + </ul> 749 + </nav> 750 + </header> 751 + 752 + <!-- Main Content --> 753 + <main class="container mx-auto px-4 py-12"> 754 + {@render children()} 755 + </main> 756 + 757 + <!-- Custom Footer --> 758 + <footer 759 + class="border-canvas-200 bg-canvas-50 dark:border-canvas-800 dark:bg-canvas-950 border-t py-8" 760 + > 761 + <div class="text-ink-700 dark:text-ink-200 container mx-auto px-4 text-center text-sm"> 762 + <p>&copy; {new Date().getFullYear()} MyBrand. All rights reserved.</p> 763 + <p class="mt-2"> 764 + Powered by 765 + <a 766 + href="https://github.com/ewanc26/svelte-standard-site" 767 + class="text-primary-600 dark:text-primary-400 hover:underline" 768 + > 769 + svelte-standard-site 770 + </a> 771 + </p> 772 + </div> 773 + </footer> 774 + </div> 412 775 ``` 413 776 414 - ## Example 10: Client-Side Usage 777 + ## Programmatic Theme Control 415 778 416 - Use the client in browser context (not recommended for production due to CORS, but useful for prototyping). 779 + ### Theme Toggle Button 417 780 418 781 ```svelte 419 782 <script lang="ts"> 420 - import { createClient } from 'svelte-standard-site'; 783 + import { themeStore } from 'svelte-standard-site'; 421 784 import { onMount } from 'svelte'; 422 785 423 - let documents = $state([]); 424 - let loading = $state(true); 786 + let isDark = $state(false); 787 + 788 + onMount(() => { 789 + themeStore.init(); 790 + 791 + const unsubscribe = themeStore.subscribe((state) => { 792 + isDark = state.isDark; 793 + }); 794 + 795 + return unsubscribe; 796 + }); 797 + </script> 798 + 799 + <button onclick={() => themeStore.toggle()} class="bg-primary-600 rounded-lg px-4 py-2 text-white"> 800 + Switch to {isDark ? 'Light' : 'Dark'} Mode 801 + </button> 802 + ``` 803 + 804 + ### System Preference Detection 805 + 806 + ```svelte 807 + <script lang="ts"> 808 + import { themeStore } from 'svelte-standard-site'; 809 + import { onMount } from 'svelte'; 810 + 811 + let systemPreference = $state<'light' | 'dark'>('light'); 812 + let currentTheme = $state<'light' | 'dark' | 'system'>('system'); 813 + 814 + onMount(() => { 815 + themeStore.init(); 816 + 817 + // Detect system preference 818 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 819 + systemPreference = mediaQuery.matches ? 'dark' : 'light'; 820 + 821 + // Check if user has overridden 822 + const stored = localStorage.getItem('theme'); 823 + currentTheme = stored ? (stored as 'light' | 'dark') : 'system'; 425 824 426 - onMount(async () => { 427 - const client = createClient({ did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' }); 428 - documents = await client.fetchAllDocuments(); 429 - loading = false; 825 + // Listen for changes 826 + mediaQuery.addEventListener('change', (e) => { 827 + systemPreference = e.matches ? 'dark' : 'light'; 828 + }); 430 829 }); 830 + 831 + function setTheme(theme: 'light' | 'dark' | 'system') { 832 + if (theme === 'system') { 833 + localStorage.removeItem('theme'); 834 + themeStore.setTheme(systemPreference === 'dark'); 835 + } else { 836 + themeStore.setTheme(theme === 'dark'); 837 + } 838 + currentTheme = theme; 839 + } 431 840 </script> 432 841 433 - {#if loading} 434 - <p>Loading...</p> 435 - {:else} 436 - {#each documents as doc} 437 - <div>{doc.value.title}</div> 438 - {/each} 439 - {/if} 842 + <div class="flex gap-2"> 843 + <button 844 + onclick={() => setTheme('light')} 845 + class="rounded px-4 py-2 {currentTheme === 'light' 846 + ? 'bg-primary-600 text-white' 847 + : 'bg-canvas-200'}" 848 + > 849 + Light 850 + </button> 851 + <button 852 + onclick={() => setTheme('dark')} 853 + class="rounded px-4 py-2 {currentTheme === 'dark' 854 + ? 'bg-primary-600 text-white' 855 + : 'bg-canvas-200'}" 856 + > 857 + Dark 858 + </button> 859 + <button 860 + onclick={() => setTheme('system')} 861 + class="rounded px-4 py-2 {currentTheme === 'system' 862 + ? 'bg-primary-600 text-white' 863 + : 'bg-canvas-200'}" 864 + > 865 + System 866 + </button> 867 + </div> 868 + ``` 869 + 870 + ## Server-Side Rendering 871 + 872 + ### Pre-render Static Pages 873 + 874 + ```typescript 875 + // svelte.config.js 876 + import adapter from '@sveltejs/adapter-static'; 877 + 878 + export default { 879 + kit: { 880 + adapter: adapter({ 881 + pages: 'build', 882 + assets: 'build', 883 + fallback: null, 884 + precompress: false 885 + }), 886 + prerender: { 887 + entries: ['*'] 888 + } 889 + } 890 + }; 891 + ``` 892 + 893 + ### Generate Dynamic Routes 894 + 895 + ```typescript 896 + // src/routes/blog/[pub_rkey]/[doc_rkey]/+page.server.ts 897 + import { createClient } from 'svelte-standard-site'; 898 + import { getConfigFromEnv } from 'svelte-standard-site/config/env'; 899 + import { error } from '@sveltejs/kit'; 900 + import type { PageServerLoad, EntryGenerator } from './$types'; 901 + 902 + export const load: PageServerLoad = async ({ params, fetch }) => { 903 + const config = getConfigFromEnv(); 904 + if (!config) throw error(500, 'Configuration missing'); 905 + 906 + const client = createClient(config); 907 + const document = await client.fetchDocument(params.doc_rkey, fetch); 908 + 909 + if (!document) throw error(404, 'Post not found'); 910 + 911 + return { post: document }; 912 + }; 913 + 914 + export const entries: EntryGenerator = async () => { 915 + const config = getConfigFromEnv(); 916 + if (!config) return []; 917 + 918 + const client = createClient(config); 919 + const documents = await client.fetchAllDocuments(); 920 + 921 + return documents.map((doc) => { 922 + const pubRkey = doc.value.site.split('/').pop() || ''; 923 + const docRkey = doc.uri.split('/').pop() || ''; 924 + return { pub_rkey: pubRkey, doc_rkey: docRkey }; 925 + }); 926 + }; 440 927 ``` 441 928 442 - ## Best Practices 929 + ## Tips and Best Practices 443 930 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 931 + 1. **Always import base.css** in your root layout for consistent styling 932 + 2. **Use the ThemeToggle component** or manage theme with themeStore 933 + 3. **Leverage utility components** - Use DateDisplay, TagList, ThemedText, etc. for consistency 934 + 4. **Follow DRY principles** - Don't manually format dates or apply theme colors repeatedly 935 + 5. **Leverage the design tokens** (ink, canvas, primary, etc.) for consistency 936 + 6. **Pass custom classes** to components for one-off customizations 937 + 7. **Use server-side rendering** for better SEO and performance 938 + 8. **Cache aggressively** - the library has built-in caching 939 + 9. **Handle errors gracefully** - always check for null/undefined data 940 + 10. **Test dark mode** - all components support it out of the box 941 + 11. **Embrace locale-aware dates** - DateDisplay automatically formats for user's locale 942 + 943 + For more examples and detailed documentation, visit the [GitHub repository](https://github.com/ewanc26/svelte-standard-site).
+402 -165
README.md
··· 1 1 # svelte-standard-site 2 2 3 - A SvelteKit library for fetching and working with `site.standard.*` records from the AT Protocol. 3 + A comprehensive SvelteKit library for building sites powered by `site.standard.*` records from the AT Protocol. Includes a complete design system with light/dark mode support and pre-built components. 4 4 5 5 Also on [Tangled](https://tangled.org/did:plc:ofrbh253gwicbkc5nktqepol/svelte-standard-site). 6 6 7 7 ## Features 8 8 9 + - 🎨 **Complete Design System** - Beautiful, accessible design language with ink, canvas, primary, secondary, and accent color palettes 10 + - 🌓 **Light/Dark Mode** - Built-in theme toggle with system preference detection and zero FOUC 11 + - 🧩 **Pre-built Components** - Ready-to-use cards, layouts, and UI elements 12 + - 🔧 **Modular Architecture** - Reusable utility components for consistent theming and formatting 13 + - 🌍 **Internationalization** - Automatic locale-aware date formatting 9 14 - 🔄 **Automatic PDS Resolution** - Resolves DIDs to their Personal Data Server endpoints 10 15 - 📦 **Type-Safe** - Full TypeScript support with complete type definitions 11 16 - 🚀 **SSR Ready** - Works seamlessly with SvelteKit's server-side rendering 12 17 - 💾 **Built-in Caching** - Reduces API calls with intelligent caching 13 - - 🎯 **Simple API** - Easy to use, set it and forget it configuration 18 + - 🎯 **Customizable** - All components respect `site.standard.*` lexicons while allowing full customization 14 19 - 🔗 **AT URI Support** - Parse and convert AT URIs to HTTPS URLs 20 + - ♿ **Accessible** - WCAG compliant with proper ARIA labels and keyboard navigation 15 21 16 22 ## Installation 17 23 ··· 25 31 26 32 ## Quick Start 27 33 28 - ### 1. Configure Environment Variables 34 + ### 1. Import Base Styles 35 + 36 + In your root `+layout.svelte`: 37 + 38 + ```svelte 39 + <script> 40 + import 'svelte-standard-site/styles/base.css'; 41 + </script> 42 + ``` 43 + 44 + ### 2. Configure Environment Variables 29 45 30 46 Create a `.env` file in your project root: 31 47 ··· 37 53 PUBLIC_CACHE_TTL=300000 38 54 ``` 39 55 40 - ### 2. Create a Client 56 + ### 3. Use the Layout Component 41 57 42 - ```typescript 43 - import { createClient } from 'svelte-standard-site'; 58 + The simplest way to get started is with the `StandardSiteLayout` component: 44 59 45 - const client = createClient({ 46 - did: 'did:plc:revjuqmkvrw6fnkxppqtszpv' 47 - }); 60 + ```svelte 61 + <script lang="ts"> 62 + import { StandardSiteLayout } from 'svelte-standard-site'; 63 + </script> 48 64 49 - // Fetch a single publication 50 - const publication = await client.fetchPublication('3lwafzkjqm25s'); 51 - 52 - // Fetch all publications 53 - const publications = await client.fetchAllPublications(); 54 - 55 - // Fetch all documents 56 - const documents = await client.fetchAllDocuments(); 57 - 58 - // Fetch documents for a specific publication 59 - const pubDocs = await client.fetchDocumentsByPublication( 60 - 'at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s' 61 - ); 65 + <StandardSiteLayout title="My Site" showThemeToggle={true}> 66 + <h1>Welcome to my site!</h1> 67 + <p>Powered by site.standard records.</p> 68 + </StandardSiteLayout> 62 69 ``` 63 70 64 - ### 3. Use in SvelteKit Load Functions 71 + ### 4. Fetch and Display Records 65 72 66 73 ```typescript 67 74 // src/routes/+page.server.ts ··· 92 99 ```svelte 93 100 <!-- src/routes/+page.svelte --> 94 101 <script lang="ts"> 102 + import { StandardSiteLayout, PublicationCard, DocumentCard } from 'svelte-standard-site'; 95 103 import type { PageData } from './$types'; 96 104 97 105 const { data }: { data: PageData } = $props(); 98 106 </script> 99 107 100 - <h1>Publications</h1> 101 - {#each data.publications as pub} 102 - <article> 103 - <h2>{pub.value.name}</h2> 104 - <p>{pub.value.description}</p> 105 - <a href={pub.value.url}>Visit</a> 106 - </article> 107 - {/each} 108 + <StandardSiteLayout title="My Publications"> 109 + <section> 110 + <h2>Publications</h2> 111 + <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 112 + {#each data.publications as publication} 113 + <PublicationCard {publication} /> 114 + {/each} 115 + </div> 116 + </section> 117 + 118 + <section> 119 + <h2>Recent Posts</h2> 120 + <div class="space-y-6"> 121 + {#each data.documents as document} 122 + <DocumentCard {document} showCover={true} /> 123 + {/each} 124 + </div> 125 + </section> 126 + </StandardSiteLayout> 127 + ``` 128 + 129 + ## Components 130 + 131 + ### Core Components 132 + 133 + #### StandardSiteLayout 134 + 135 + A complete page layout with header, footer, and theme management. 136 + 137 + ```svelte 138 + <StandardSiteLayout title="My Site" showThemeToggle={true} class="custom-class"> 139 + {#snippet header()} 140 + <!-- Custom header --> 141 + {/snippet} 142 + 143 + {#snippet footer()} 144 + <!-- Custom footer --> 145 + {/snippet} 146 + 147 + <!-- Main content --> 148 + </StandardSiteLayout> 149 + ``` 150 + 151 + **Props:** 152 + 153 + - `title?: string` - Site title (default: "My Site") 154 + - `showThemeToggle?: boolean` - Show theme toggle button (default: true) 155 + - `class?: string` - Additional CSS classes for main container 156 + - `header?: Snippet` - Custom header snippet (replaces default) 157 + - `footer?: Snippet` - Custom footer snippet (replaces default) 158 + - `children: Snippet` - Main content 159 + 160 + ### ThemeToggle 161 + 162 + A button component for toggling between light and dark modes. 163 + 164 + ```svelte 165 + <script> 166 + import { ThemeToggle } from 'svelte-standard-site'; 167 + </script> 168 + 169 + <ThemeToggle class="my-custom-class" /> 170 + ``` 171 + 172 + **Props:** 173 + 174 + - `class?: string` - Additional CSS classes 175 + 176 + #### DocumentCard 177 + 178 + Displays a `site.standard.document` record with title, description, cover image, tags, and dates. 179 + 180 + ```svelte 181 + <DocumentCard {document} showCover={true} href="/custom/path" class="custom-class" /> 182 + ``` 183 + 184 + **Props:** 185 + 186 + - `document: AtProtoRecord<Document>` - The document record (required) 187 + - `publication?: AtProtoRecord<Publication>` - Optional publication for theme support 188 + - `showCover?: boolean` - Show cover image (default: true) 189 + - `href?: string` - Custom href override 190 + - `class?: string` - Additional CSS classes 191 + 192 + #### PublicationCard 193 + 194 + Displays a `site.standard.publication` record with icon, name, description, and link. 195 + 196 + ```svelte 197 + <PublicationCard {publication} showExternalIcon={true} class="custom-class" /> 198 + ``` 199 + 200 + **Props:** 201 + 202 + - `publication: AtProtoRecord<Publication>` - The publication record (required) 203 + - `showExternalIcon?: boolean` - Show external link icon (default: true) 204 + - `class?: string` - Additional CSS classes 205 + 206 + ### Reusable Utility Components 207 + 208 + #### DateDisplay 209 + 210 + Consistently formats and displays dates with automatic locale detection. 211 + 212 + ```svelte 213 + <DateDisplay date={document.publishedAt} /> 214 + <DateDisplay date={document.updatedAt} label="Updated " showIcon={true} locale="fr-FR" /> 215 + ``` 216 + 217 + **Props:** 218 + 219 + - `date: string` - ISO date string (required) 220 + - `label?: string` - Optional label prefix 221 + - `class?: string` - Additional CSS classes 222 + - `showIcon?: boolean` - Show update icon (default: false) 223 + - `style?: string` - Inline styles 224 + - `locale?: string` - Locale override (default: browser locale) 225 + 226 + **Features:** 227 + - Automatically detects user's browser locale 228 + - Supports custom locale override 229 + - Examples: "January 19, 2026" (en-US), "19 janvier 2026" (fr-FR) 230 + 231 + #### TagList 232 + 233 + Displays a list of tags with theme support. 234 + 235 + ```svelte 236 + <TagList tags={document.tags} hasTheme={!!publication?.basicTheme} /> 237 + ``` 238 + 239 + **Props:** 240 + 241 + - `tags: string[]` - Array of tag strings (required) 242 + - `hasTheme?: boolean` - Whether to apply custom theme (default: false) 243 + - `class?: string` - Additional CSS classes 244 + 245 + #### ThemedContainer 246 + 247 + Wraps content with theme CSS variables applied. 248 + 249 + ```svelte 250 + <ThemedContainer theme={publication.basicTheme} element="article"> 251 + <!-- Content automatically inherits theme --> 252 + </ThemedContainer> 253 + ``` 254 + 255 + **Props:** 256 + 257 + - `theme?: BasicTheme` - Optional theme to apply 258 + - `children: Snippet` - Content to wrap (required) 259 + - `class?: string` - Additional CSS classes 260 + - `element?: 'div' | 'article' | 'section'` - HTML element type (default: 'div') 261 + 262 + #### ThemedText 263 + 264 + Displays text with theme-aware colors. 265 + 266 + ```svelte 267 + <ThemedText hasTheme={!!theme} element="h1" class="text-4xl"> 268 + Title 269 + </ThemedText> 270 + <ThemedText hasTheme={!!theme} opacity={70} element="p"> 271 + Description 272 + </ThemedText> 273 + ``` 274 + 275 + **Props:** 276 + 277 + - `hasTheme?: boolean` - Whether to apply custom theme (default: false) 278 + - `opacity?: number` - Opacity level 0-100 (default: 100) 279 + - `variant?: 'foreground' | 'accent'` - Color variant (default: 'foreground') 280 + - `element?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'div'` - HTML element (default: 'span') 281 + - `class?: string` - Additional CSS classes 282 + - `children?: any` - Content to render 283 + 284 + #### ThemedCard 285 + 286 + Base card component with theme support for building custom cards. 287 + 288 + ```svelte 289 + <ThemedCard theme={publication.basicTheme} class="p-6"> 290 + <!-- Card content --> 291 + </ThemedCard> 292 + 293 + <!-- With link --> 294 + <ThemedCard theme={publication.basicTheme} href="/article" class="hover:shadow-lg"> 295 + <!-- Clickable card content --> 296 + </ThemedCard> 297 + ``` 298 + 299 + **Props:** 300 + 301 + - `theme?: BasicTheme` - Optional theme to apply 302 + - `children: Snippet` - Card content (required) 303 + - `class?: string` - Additional CSS classes 304 + - `href?: string` - Optional link (wraps in anchor tag) 305 + 306 + ## Design System 307 + 308 + The library uses a comprehensive color system with semantic naming: 309 + 310 + - **Ink** - Text colors (`ink-50` to `ink-950`) 311 + - **Canvas** - Background colors (`canvas-50` to `canvas-950`) 312 + - **Primary** - Primary brand colors (`primary-50` to `primary-950`) 313 + - **Secondary** - Secondary brand colors (`secondary-50` to `secondary-950`) 314 + - **Accent** - Accent colors (`accent-50` to `accent-950`) 315 + 316 + All colors automatically adapt to light/dark mode using Tailwind's `light-dark()` function. 317 + 318 + ### Example Usage 319 + 320 + ```svelte 321 + <div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50"> 322 + <h1 class="text-primary-600 dark:text-primary-400">Hello World</h1> 323 + <p class="text-ink-700 dark:text-ink-200">Supporting text</p> 324 + </div> 325 + ``` 326 + 327 + ## Theme Store 328 + 329 + Programmatically control the theme: 330 + 331 + ```typescript 332 + import { themeStore } from 'svelte-standard-site'; 333 + 334 + // Initialize (automatically called by ThemeToggle) 335 + themeStore.init(); 336 + 337 + // Toggle theme 338 + themeStore.toggle(); 108 339 109 - <h1>Documents</h1> 110 - {#each data.documents as doc} 111 - <article> 112 - <h2>{doc.value.title}</h2> 113 - <p>{doc.value.description}</p> 114 - <time>{new Date(doc.value.publishedAt).toLocaleDateString()}</time> 115 - </article> 116 - {/each} 340 + // Set specific theme 341 + themeStore.setTheme(true); // dark mode 342 + themeStore.setTheme(false); // light mode 343 + 344 + // Subscribe to changes 345 + themeStore.subscribe((state) => { 346 + console.log(state.isDark); // boolean 347 + console.log(state.mounted); // boolean 348 + }); 117 349 ``` 118 350 119 - ## API Reference 351 + ## Theme Utilities 352 + 353 + Helper functions for working with theme colors: 354 + 355 + ```typescript 356 + import { 357 + mixThemeColor, 358 + getThemedTextColor, 359 + getThemedBackground, 360 + getThemedBorder, 361 + getThemedAccent, 362 + themeToCssVars 363 + } from 'svelte-standard-site'; 364 + 365 + // Generate color-mix CSS 366 + const semiTransparent = mixThemeColor('--theme-foreground', 50); 367 + // => 'color-mix(in srgb, var(--theme-foreground) 50%, transparent)' 368 + 369 + // Get theme-aware text color 370 + const textStyle = getThemedTextColor(hasTheme, 70); 371 + // => { color: 'color-mix(in srgb, var(--theme-foreground) 70%, transparent)' } 372 + 373 + // Get theme-aware background 374 + const bgStyle = getThemedBackground(hasTheme); 375 + // => { backgroundColor: 'var(--theme-background)' } 376 + 377 + // Get theme-aware border 378 + const borderStyle = getThemedBorder(hasTheme, 20); 379 + // => { borderColor: 'color-mix(in srgb, var(--theme-foreground) 20%, transparent)' } 120 380 121 - ### `SiteStandardClient` 381 + // Get theme-aware accent color 382 + const accentStyle = getThemedAccent(hasTheme, 15); 383 + // => { backgroundColor: '...', color: 'var(--theme-accent)' } 122 384 123 - The main client for interacting with site.standard records. 385 + // Convert BasicTheme to CSS vars 386 + const cssVars = themeToCssVars(publication.basicTheme); 387 + // => { '--theme-background': 'rgb(255, 245, 235)', ... } 388 + ``` 124 389 125 - #### Constructor 390 + **Available Functions:** 391 + 392 + - `mixThemeColor(variable: string, opacity: number, fallback?: string): string` - Generate color-mix CSS 393 + - `getThemedTextColor(hasTheme: boolean, opacity?: number): { color?: string }` - Get themed text color 394 + - `getThemedBackground(hasTheme: boolean, opacity?: number): { backgroundColor?: string }` - Get themed background 395 + - `getThemedBorder(hasTheme: boolean, opacity?: number): { borderColor?: string }` - Get themed border 396 + - `getThemedAccent(hasTheme: boolean, opacity?: number): { color?: string; backgroundColor?: string }` - Get themed accent 397 + - `themeToCssVars(theme?: BasicTheme): Record<string, string>` - Convert theme to CSS variables 398 + 399 + ## Client API 400 + 401 + ### Creating a Client 126 402 127 403 ```typescript 128 - new SiteStandardClient(config: SiteStandardConfig) 404 + import { createClient } from 'svelte-standard-site'; 405 + 406 + const client = createClient({ 407 + did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 408 + pds: 'https://cortinarius.us-west.host.bsky.network', // optional 409 + cacheTTL: 300000 // optional, in milliseconds 410 + }); 129 411 ``` 130 412 131 - #### Methods 413 + ### Methods 132 414 133 415 - `fetchPublication(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication> | null>` 134 - - Fetch a single publication by record key 135 - 136 416 - `fetchAllPublications(fetchFn?: typeof fetch): Promise<AtProtoRecord<Publication>[]>` 137 - - Fetch all publications for the configured DID 138 - 139 417 - `fetchDocument(rkey: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document> | null>` 140 - - Fetch a single document by record key 141 - 142 418 - `fetchAllDocuments(fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 143 - - Fetch all documents for the configured DID, sorted by publishedAt (newest first) 144 - 145 419 - `fetchDocumentsByPublication(publicationUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<Document>[]>` 146 - - Fetch all documents belonging to a specific publication 147 - 148 420 - `fetchByAtUri<T>(atUri: string, fetchFn?: typeof fetch): Promise<AtProtoRecord<T> | null>` 149 - - Fetch any record by its AT URI 150 - 151 421 - `clearCache(): void` 152 - - Clear all cached data 153 - 154 422 - `getPDS(fetchFn?: typeof fetch): Promise<string>` 155 - - Get the resolved PDS endpoint 156 423 157 - ### Types 424 + ## Types 158 425 159 426 ```typescript 160 427 interface Publication { ··· 189 456 } 190 457 ``` 191 458 192 - ### Utility Functions 459 + ## Customization 193 460 194 - #### AT URI Utilities 461 + ### Custom Styles 195 462 196 - ```typescript 197 - import { parseAtUri, atUriToHttps, buildAtUri, isAtUri } from 'svelte-standard-site'; 463 + Override CSS variables in your own stylesheet: 198 464 199 - // Parse an AT URI 200 - const parsed = parseAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 201 - // Returns: { did: 'did:plc:xxx', collection: 'site.standard.publication', rkey: 'rkey' } 465 + ```css 466 + :root { 467 + --color-primary-600: oklch(70% 0.15 280); /* Custom purple */ 468 + } 202 469 203 - // Convert AT URI to HTTPS URL 204 - const url = atUriToHttps( 205 - 'at://did:plc:xxx/site.standard.publication/rkey', 206 - 'https://pds.example.com' 207 - ); 208 - // Returns: 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=...' 209 - 210 - // Build an AT URI 211 - const uri = buildAtUri('did:plc:xxx', 'site.standard.publication', 'rkey'); 212 - // Returns: 'at://did:plc:xxx/site.standard.publication/rkey' 213 - 214 - // Validate AT URI 215 - const valid = isAtUri('at://did:plc:xxx/site.standard.publication/rkey'); 216 - // Returns: true 470 + [data-theme='dark'] { 471 + --color-primary-600: oklch(75% 0.15 280); 472 + } 217 473 ``` 218 474 219 - #### PDS Resolution 475 + ### Custom Layout 220 476 221 - ```typescript 222 - import { resolveIdentity, buildPdsBlobUrl } from 'svelte-standard-site'; 477 + Create your own layout while using the theme system: 223 478 224 - // Resolve a DID to its PDS 225 - const identity = await resolveIdentity('did:plc:xxx'); 226 - // Returns: { did: 'did:plc:xxx', pds: 'https://...', handle?: 'user.bsky.social' } 227 - 228 - // Build a blob URL 229 - const blobUrl = buildPdsBlobUrl('https://pds.example.com', 'did:plc:xxx', 'bafyrei...'); 230 - // Returns: 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=...&cid=...' 231 - ``` 479 + ```svelte 480 + <script lang="ts"> 481 + import { ThemeToggle, themeStore } from 'svelte-standard-site'; 482 + import 'svelte-standard-site/styles/base.css'; 483 + import { onMount } from 'svelte'; 232 484 233 - ## Configuration 485 + onMount(() => { 486 + themeStore.init(); 487 + }); 488 + </script> 234 489 235 - ### From Environment Variables 490 + <div class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50 min-h-screen"> 491 + <header> 492 + <nav> 493 + <a href="/">Home</a> 494 + <ThemeToggle /> 495 + </nav> 496 + </header> 236 497 237 - ```typescript 238 - import { getConfigFromEnv, validateEnv } from 'svelte-standard-site/config/env'; 498 + <main> 499 + <slot /> 500 + </main> 239 501 240 - // Get config (returns null if missing) 241 - const config = getConfigFromEnv(); 242 - 243 - // Validate config (throws if missing) 244 - validateEnv(); 502 + <footer> 503 + <!-- Your footer --> 504 + </footer> 505 + </div> 245 506 ``` 246 507 247 - ### Manual Configuration 508 + ### Extending Components 248 509 249 - ```typescript 250 - import { createClient } from 'svelte-standard-site'; 510 + All components accept a `class` prop for customization: 251 511 252 - const client = createClient({ 253 - did: 'did:plc:revjuqmkvrw6fnkxppqtszpv', 254 - pds: 'https://cortinarius.us-west.host.bsky.network', // optional 255 - cacheTTL: 300000 // optional, in milliseconds 256 - }); 512 + ```svelte 513 + <DocumentCard {document} class="border-primary-500 border-4 shadow-2xl" /> 257 514 ``` 258 515 259 - ## Caching 516 + ## Advanced Usage 260 517 261 - The library includes built-in caching to reduce API calls: 518 + ### Building a Blog 262 519 263 - - Default TTL: 5 minutes (300,000ms) 264 - - Configurable via `cacheTTL` option or `PUBLIC_CACHE_TTL` env var 265 - - Cache can be cleared manually with `client.clearCache()` 520 + See the [EXAMPLES.md](./EXAMPLES.md) file for a complete blog implementation example. 266 521 267 - ## AT URI Structure 522 + ### Using with Different Lexicons 268 523 269 - AT URIs follow this format: 524 + The library is built around `site.standard.*` lexicons but can be adapted: 270 525 271 - ``` 272 - at://did:plc:revjuqmkvrw6fnkxppqtszpv/site.standard.publication/3lwafzkjqm25s 273 - └─────────┬─────────┘ └──────────┬──────────┘ └────┬────┘ 274 - DID Collection Record Key 526 + ```typescript 527 + // Fetch any AT Proto record 528 + const customRecord = await client.fetchByAtUri<CustomType>('at://did:plc:xxx/custom.lexicon/rkey'); 275 529 ``` 276 530 277 - The library automatically converts these to HTTPS URLs for API calls: 531 + ## Accessibility 278 532 279 - ``` 280 - https://cortinarius.us-west.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did:plc:revjuqmkvrw6fnkxppqtszpv&collection=site.standard.publication&rkey=3lwafzkjqm25s 281 - ``` 533 + All components follow WCAG guidelines: 282 534 283 - ## Example: Building a Blog 535 + - Proper semantic HTML 536 + - ARIA labels and roles 537 + - Keyboard navigation support 538 + - Focus visible indicators 539 + - High contrast mode support 540 + - Screen reader compatibility 284 541 285 - ```typescript 286 - // src/routes/blog/+page.server.ts 287 - import { createClient } from 'svelte-standard-site'; 288 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 542 + ## Browser Support 289 543 290 - export const load = async ({ fetch }) => { 291 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 292 - const documents = await client.fetchAllDocuments(fetch); 293 - 294 - return { 295 - posts: documents.map((doc) => ({ 296 - title: doc.value.title, 297 - description: doc.value.description, 298 - publishedAt: doc.value.publishedAt, 299 - slug: doc.uri.split('/').pop(), 300 - tags: doc.value.tags || [] 301 - })) 302 - }; 303 - }; 304 - ``` 544 + - Modern browsers with CSS `light-dark()` support 545 + - Tailwind CSS v4+ required 546 + - Svelte 5+ required 305 547 306 - ```typescript 307 - // src/routes/blog/[slug]/+page.server.ts 308 - import { createClient } from 'svelte-standard-site'; 309 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 310 - import { error } from '@sveltejs/kit'; 548 + ## License 311 549 312 - export const load = async ({ params, fetch }) => { 313 - const client = createClient({ did: PUBLIC_ATPROTO_DID }); 314 - const document = await client.fetchDocument(params.slug, fetch); 550 + [AGPL-3.0](./LICENSE) 315 551 316 - if (!document) { 317 - throw error(404, 'Post not found'); 318 - } 552 + ## Contributing 319 553 320 - return { 321 - post: document.value 322 - }; 323 - }; 324 - ``` 554 + Contributions are welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. 325 555 326 - ## License 556 + ## Credits 327 557 328 - [AGPL-3.0](./LICENSE) 558 + - Built by [Ewan Croft](https://ewancroft.uk) 559 + - Powered by [AT Protocol](https://atproto.com) 560 + - Icons from [Lucide](https://lucide.dev) 561 + - Typography by [Inter](https://rsms.me/inter/) 329 562 330 - ## Contributing 563 + ## Links 331 564 332 - Contributions are welcome! Please feel free to submit a Pull Request. 565 + - [GitHub Repository](https://github.com/ewanc26/svelte-standard-site) 566 + - [NPM Package](https://www.npmjs.com/package/svelte-standard-site) 567 + - [Documentation](https://github.com/ewanc26/svelte-standard-site#readme) 568 + - [AT Protocol Documentation](https://atproto.com) 569 + - [site.standard Specification](https://github.com/noeleon/site.standard)
+17 -4
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", 4 + "description": "A comprehensive SvelteKit library for building sites powered by site.standard.* records from the AT Protocol, with a complete design system and pre-built components", 5 5 "license": "AGPL-3.0", 6 6 "author": { 7 7 "name": "Ewan Croft", ··· 37 37 "./config/env": { 38 38 "types": "./dist/config/env.d.ts", 39 39 "default": "./dist/config/env.js" 40 + }, 41 + "./styles/base.css": { 42 + "default": "./dist/styles/base.css" 43 + }, 44 + "./styles/themes.css": { 45 + "default": "./dist/styles/themes.css" 40 46 } 41 47 }, 42 48 "peerDependencies": { 43 - "svelte": "^5.0.0" 49 + "svelte": "^5.0.0", 50 + "@sveltejs/kit": "^2.0.0" 44 51 }, 45 52 "devDependencies": { 46 53 "@sveltejs/adapter-auto": "^7.0.0", ··· 67 74 "bluesky", 68 75 "site-standard", 69 76 "blog", 70 - "cms" 77 + "cms", 78 + "design-system", 79 + "components", 80 + "dark-mode", 81 + "light-mode", 82 + "theme" 71 83 ], 72 84 "repository": { 73 85 "type": "git", ··· 78 90 "url": "https://github.com/ewanc26/svelte-standard-site/issues" 79 91 }, 80 92 "dependencies": { 81 - "@atproto/api": "^0.18.16" 93 + "@atproto/api": "^0.18.16", 94 + "@lucide/svelte": "^0.562.0" 82 95 } 83 96 }
+12
pnpm-lock.yaml
··· 11 11 '@atproto/api': 12 12 specifier: ^0.18.16 13 13 version: 0.18.16 14 + '@lucide/svelte': 15 + specifier: ^0.562.0 16 + version: 0.562.0(svelte@5.46.4) 14 17 devDependencies: 15 18 '@sveltejs/adapter-auto': 16 19 specifier: ^7.0.0 ··· 252 255 253 256 '@jridgewell/trace-mapping@0.3.31': 254 257 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 258 + 259 + '@lucide/svelte@0.562.0': 260 + resolution: {integrity: sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw==} 261 + peerDependencies: 262 + svelte: ^5 255 263 256 264 '@polka/url@1.0.0-next.29': 257 265 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} ··· 1105 1113 dependencies: 1106 1114 '@jridgewell/resolve-uri': 3.1.2 1107 1115 '@jridgewell/sourcemap-codec': 1.5.5 1116 + 1117 + '@lucide/svelte@0.562.0(svelte@5.46.4)': 1118 + dependencies: 1119 + svelte: 5.46.4 1108 1120 1109 1121 '@polka/url@1.0.0-next.29': {} 1110 1122
+74 -53
src/lib/components/DocumentCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { Document, AtProtoRecord } from '../types.js'; 3 - import { getDocumentSlug } from '../utils/document.js'; 2 + import type { Document, Publication, AtProtoRecord } from '../types.js'; 4 3 import { extractRkey } from '$lib/index.js'; 4 + import { ThemedCard, ThemedText, DateDisplay, TagList } from './index.js'; 5 5 6 6 interface Props { 7 7 document: AtProtoRecord<Document>; 8 + publication?: AtProtoRecord<Publication>; 8 9 class?: string; 9 10 showCover?: boolean; 11 + href?: string; 10 12 } 11 13 12 - const { document, class: className = '', showCover = true }: Props = $props(); 14 + const { 15 + document, 16 + publication, 17 + class: className = '', 18 + showCover = true, 19 + href: customHref 20 + }: Props = $props(); 13 21 14 22 const value = $derived(document.value); 23 + const hasTheme = $derived(!!publication?.value.basicTheme); 15 24 16 - // Construct the rkey-based URL: /[pub_rkey]/[doc_rkey] 17 25 const pubRkey = $derived(extractRkey(value.site)); 18 26 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 - } 27 + const href = $derived(customHref || `/${pubRkey}/${docRkey}`); 28 28 </script> 29 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} 30 + <ThemedCard 31 + theme={publication?.value.basicTheme} 32 + {href} 33 + class="flex gap-6 duration-200 focus-within:shadow-md hover:shadow-md {className}" 34 + > 35 + {#if showCover && value.coverImage} 36 + <img 37 + src={value.coverImage} 38 + alt="{value.title} cover" 39 + class="h-48 w-32 shrink-0 rounded-lg object-cover shadow-sm" 40 + /> 41 + {/if} 41 42 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> 43 + <div class="flex-1"> 44 + <ThemedText 45 + {hasTheme} 46 + element="h3" 47 + class="group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 text-2xl font-bold transition-colors" 48 + > 49 + {value.title} 50 + </ThemedText> 46 51 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 + {#if value.description} 53 + <ThemedText 54 + {hasTheme} 55 + opacity={70} 56 + element="p" 57 + class="mb-4 line-clamp-3 text-sm leading-relaxed" 58 + > 59 + {value.description} 60 + </ThemedText> 61 + {/if} 52 62 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> 63 + <div class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"> 64 + <DateDisplay 65 + date={value.publishedAt} 66 + class="font-medium" 67 + style={hasTheme 68 + ? `color: ${hasTheme ? 'color-mix(in srgb, var(--theme-foreground) 80%, transparent)' : ''}` 69 + : ''} 70 + /> 71 + {#if value.updatedAt} 72 + <span 73 + class="flex items-center gap-1" 74 + style:color={hasTheme 75 + ? 'color-mix(in srgb, var(--theme-foreground) 60%, transparent)' 76 + : undefined} 77 + > 78 + <span 79 + class="h-1 w-1 rounded-full" 80 + class:bg-ink-400={!hasTheme} 81 + class:dark:bg-ink-600={!hasTheme} 82 + style:background-color={hasTheme 83 + ? 'color-mix(in srgb, var(--theme-foreground) 40%, transparent)' 84 + : undefined} 85 + ></span> 86 + Updated <DateDisplay date={value.updatedAt} /> 87 + </span> 71 88 {/if} 72 89 </div> 73 - </article> 74 - </a> 90 + 91 + {#if value.tags && value.tags.length > 0} 92 + <TagList tags={value.tags} {hasTheme} /> 93 + {/if} 94 + </div> 95 + </ThemedCard>
+24 -20
src/lib/components/PublicationCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Publication, AtProtoRecord } from '../types.js'; 3 - import { getThemeVars } from '../utils/theme.js'; 3 + import { ThemedCard, ThemedText } from './index.js'; 4 + import { ExternalLink } from '@lucide/svelte'; 4 5 5 6 interface Props { 6 7 publication: AtProtoRecord<Publication>; 7 8 class?: string; 9 + showExternalIcon?: boolean; 8 10 } 9 11 10 - const { publication, class: className = '' }: Props = $props(); 12 + const { publication, class: className = '', showExternalIcon = true }: Props = $props(); 11 13 12 - // Access value reactively through a getter 13 14 const value = $derived(publication.value); 14 - const themeVars = $derived(value.basicTheme ? getThemeVars(value.basicTheme) : {}); 15 + const hasTheme = $derived(!!value.basicTheme); 15 16 </script> 16 17 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 - > 18 + <ThemedCard theme={value.basicTheme} class="focus-within:shadow-lg hover:shadow-lg {className}"> 23 19 <div class="mb-4 flex gap-4"> 24 20 {#if value.icon} 25 21 <img 26 22 src={value.icon} 27 23 alt="{value.name} icon" 28 - class="size-16 flex-shrink-0 rounded-lg object-cover" 24 + class="size-16 shrink-0 rounded-lg object-cover" 29 25 /> 30 26 {/if} 31 - <div class="flex-1"> 32 - <h3 class="mb-2 text-xl font-semibold text-gray-900">{value.name}</h3> 27 + <div class="min-w-0 flex-1"> 28 + <ThemedText {hasTheme} element="h3" class="mb-2 text-xl font-semibold"> 29 + {value.name} 30 + </ThemedText> 33 31 {#if value.description} 34 - <p class="text-sm text-gray-600">{value.description}</p> 32 + <ThemedText {hasTheme} opacity={70} element="p" class="text-sm"> 33 + {value.description} 34 + </ThemedText> 35 35 {/if} 36 36 </div> 37 37 </div> ··· 39 39 href={value.url} 40 40 target="_blank" 41 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} 42 + class="inline-flex items-center gap-2 font-medium transition-opacity hover:opacity-80 focus-visible:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2" 43 + class:text-primary-600={!hasTheme} 44 + class:dark:text-primary-400={!hasTheme} 45 + class:focus-visible:outline-primary-600={!hasTheme} 46 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 47 + style:outline-color={hasTheme ? 'var(--theme-accent)' : undefined} 44 48 > 45 49 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> 50 + {#if showExternalIcon} 51 + <ExternalLink class="size-4" aria-hidden="true" /> 52 + {/if} 49 53 </a> 50 - </article> 54 + </ThemedCard>
+101
src/lib/components/StandardSiteLayout.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + import ThemeToggle from './ThemeToggle.svelte'; 4 + 5 + interface Props { 6 + /** Site title */ 7 + title?: string; 8 + /** Header slot for custom header content */ 9 + header?: Snippet; 10 + /** Footer slot for custom footer content */ 11 + footer?: Snippet; 12 + /** Main content */ 13 + children: Snippet; 14 + /** Additional CSS classes for the main container */ 15 + class?: string; 16 + /** Show theme toggle in default header */ 17 + showThemeToggle?: boolean; 18 + } 19 + 20 + let { 21 + title = 'My Site', 22 + header, 23 + footer, 24 + children, 25 + class: className = '', 26 + showThemeToggle = true 27 + }: Props = $props(); 28 + </script> 29 + 30 + <svelte:head> 31 + <script> 32 + // Prevent flash of unstyled content (FOUC) by applying theme before page renders 33 + (function () { 34 + const stored = localStorage.getItem('theme'); 35 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 36 + const isDark = stored === 'dark' || (!stored && prefersDark); 37 + const htmlElement = document.documentElement; 38 + 39 + if (isDark) { 40 + htmlElement.classList.add('dark'); 41 + htmlElement.style.colorScheme = 'dark'; 42 + } else { 43 + htmlElement.classList.remove('dark'); 44 + htmlElement.style.colorScheme = 'light'; 45 + } 46 + })(); 47 + </script> 48 + </svelte:head> 49 + 50 + <div 51 + class="bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50 flex min-h-screen flex-col overflow-x-hidden" 52 + > 53 + {#if header} 54 + {@render header()} 55 + {:else} 56 + <header 57 + class="border-canvas-200 bg-canvas-50/90 dark:border-canvas-800 dark:bg-canvas-950/90 sticky top-0 z-50 w-full border-b backdrop-blur-md" 58 + > 59 + <nav 60 + class="container mx-auto flex items-center justify-between px-4 py-4" 61 + aria-label="Main navigation" 62 + > 63 + <a 64 + href="/" 65 + class="text-ink-900 hover:text-primary-600 focus-visible:text-primary-600 focus-visible:outline-primary-600 dark:text-ink-50 dark:hover:text-primary-400 dark:focus-visible:text-primary-400 text-xl font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2" 66 + > 67 + {title} 68 + </a> 69 + 70 + {#if showThemeToggle} 71 + <ThemeToggle /> 72 + {/if} 73 + </nav> 74 + </header> 75 + {/if} 76 + 77 + <main id="main-content" class="container mx-auto grow px-4 py-8 {className}" tabindex="-1"> 78 + {@render children()} 79 + </main> 80 + 81 + {#if footer} 82 + {@render footer()} 83 + {:else} 84 + <footer 85 + class="border-canvas-200 bg-canvas-50 dark:border-canvas-800 dark:bg-canvas-950 mt-auto w-full border-t py-6" 86 + > 87 + <div class="text-ink-700 dark:text-ink-200 container mx-auto px-4 text-center text-sm"> 88 + <p> 89 + &copy; {new Date().getFullYear()} 90 + {title}. Powered by svelte-standard-site and the AT Protocol. Created by <a 91 + href="https://ewancroft.uk" 92 + target="_blank" 93 + rel="noopener noreferrer" 94 + class="text-primary-600 hover:text-primary-700 focus-visible:outline-primary-600 dark:text-primary-400 dark:hover:text-primary-500 font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2" 95 + >Ewan Croft</a 96 + >. 97 + </p> 98 + </div> 99 + </footer> 100 + {/if} 101 + </div>
+55
src/lib/components/ThemeToggle.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Sun, Moon } from '@lucide/svelte'; 4 + import { themeStore } from '../stores/index.js'; 5 + 6 + interface Props { 7 + class?: string; 8 + } 9 + 10 + let { class: className = '' }: Props = $props(); 11 + 12 + let isDark = $state(false); 13 + let mounted = $state(false); 14 + 15 + onMount(() => { 16 + themeStore.init(); 17 + 18 + const unsubscribe = themeStore.subscribe((state) => { 19 + isDark = state.isDark; 20 + mounted = state.mounted; 21 + }); 22 + 23 + return unsubscribe; 24 + }); 25 + 26 + function toggle() { 27 + themeStore.toggle(); 28 + } 29 + </script> 30 + 31 + <button 32 + onclick={toggle} 33 + class="bg-canvas-200 text-ink-900 hover:bg-canvas-300 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700 relative flex h-10 w-10 items-center justify-center rounded-lg transition-all focus-visible:outline-2 focus-visible:outline-offset-2 {className}" 34 + aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} 35 + type="button" 36 + > 37 + {#if mounted} 38 + <div class="relative h-5 w-5"> 39 + <Sun 40 + class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 41 + ? 'scale-0 -rotate-90 opacity-0' 42 + : 'scale-100 rotate-0 opacity-100'}" 43 + aria-hidden="true" 44 + /> 45 + <Moon 46 + class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark 47 + ? 'scale-100 rotate-0 opacity-100' 48 + : 'scale-0 rotate-90 opacity-0'}" 49 + aria-hidden="true" 50 + /> 51 + </div> 52 + {:else} 53 + <div class="bg-canvas-300 dark:bg-canvas-700 h-5 w-5 animate-pulse rounded"></div> 54 + {/if} 55 + </button>
+38
src/lib/components/common/DateDisplay.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + date: string; 4 + label?: string; 5 + class?: string; 6 + showIcon?: boolean; 7 + style?: string; 8 + locale?: string; 9 + } 10 + 11 + let { date, label, class: className = '', showIcon = false, style, locale }: Props = $props(); 12 + 13 + function formatDate(dateString: string): string { 14 + // Use browser's locale if not specified 15 + const userLocale = locale || navigator.language || 'en-US'; 16 + 17 + return new Date(dateString).toLocaleDateString(userLocale, { 18 + year: 'numeric', 19 + month: 'long', 20 + day: 'numeric' 21 + }); 22 + } 23 + </script> 24 + 25 + <time datetime={date} class={className} {style}> 26 + {#if showIcon} 27 + <svg class="inline size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 28 + <path 29 + stroke-linecap="round" 30 + stroke-linejoin="round" 31 + stroke-width="2" 32 + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" 33 + /> 34 + </svg> 35 + {/if} 36 + {#if label}{label}{/if} 37 + {formatDate(date)} 38 + </time>
+31
src/lib/components/common/TagList.svelte
··· 1 + <script lang="ts"> 2 + import { getThemedAccent } from '$lib/utils/theme-helpers.js'; 3 + 4 + interface Props { 5 + tags: string[]; 6 + hasTheme?: boolean; 7 + class?: string; 8 + } 9 + 10 + let { tags, hasTheme = false, class: className = '' }: Props = $props(); 11 + 12 + const accentStyles = $derived(getThemedAccent(hasTheme, 20)); 13 + </script> 14 + 15 + {#if tags.length > 0} 16 + <div class="flex flex-wrap gap-2 {className}"> 17 + {#each tags as tag} 18 + <span 19 + class="rounded-full px-4 py-2 text-sm font-medium transition-all hover:scale-105" 20 + class:bg-primary-50={!hasTheme} 21 + class:text-primary-700={!hasTheme} 22 + class:dark:bg-primary-950={!hasTheme} 23 + class:dark:text-primary-300={!hasTheme} 24 + style:background-color={accentStyles.backgroundColor} 25 + style:color={hasTheme ? accentStyles.color : undefined} 26 + > 27 + #{tag} 28 + </span> 29 + {/each} 30 + </div> 31 + {/if}
+65
src/lib/components/common/ThemedCard.svelte
··· 1 + <script lang="ts"> 2 + import type { BasicTheme } from '$lib/types.js'; 3 + import { getThemeVars } from '$lib/utils/theme.js'; 4 + import { getThemedBorder } from '$lib/utils/theme-helpers.js'; 5 + import type { Snippet } from 'svelte'; 6 + 7 + interface Props { 8 + theme?: BasicTheme; 9 + children: Snippet; 10 + class?: string; 11 + href?: string; 12 + } 13 + 14 + let { theme, children, class: className = '', href }: Props = $props(); 15 + 16 + const themeVars = $derived(theme ? getThemeVars(theme) : {}); 17 + const hasTheme = $derived(!!theme); 18 + const borderStyles = $derived(getThemedBorder(hasTheme)); 19 + 20 + const styles = $derived( 21 + Object.entries(themeVars) 22 + .map(([k, v]) => `${k}:${v}`) 23 + .join(';') 24 + ); 25 + 26 + const allStyles = $derived(() => { 27 + const base = styles; 28 + if (borderStyles.borderColor) { 29 + return `${base};border-color:${borderStyles.borderColor}`; 30 + } 31 + return base; 32 + }); 33 + </script> 34 + 35 + {#if href} 36 + <a {href} class="group block"> 37 + <article 38 + class="rounded-lg border p-6 transition-all {className}" 39 + class:bg-canvas-50={!hasTheme} 40 + class:dark:bg-canvas-950={!hasTheme} 41 + class:border-canvas-200={!hasTheme} 42 + class:dark:border-canvas-800={!hasTheme} 43 + class:hover:border-primary-300={!hasTheme} 44 + class:dark:hover:border-primary-700={!hasTheme} 45 + class:focus-within:border-primary-300={!hasTheme} 46 + class:dark:focus-within:border-primary-700={!hasTheme} 47 + style:background-color={hasTheme ? 'var(--theme-background)' : undefined} 48 + style={allStyles()} 49 + > 50 + {@render children()} 51 + </article> 52 + </a> 53 + {:else} 54 + <article 55 + class="rounded-lg border p-6 transition-all {className}" 56 + class:bg-canvas-50={!hasTheme} 57 + class:dark:bg-canvas-950={!hasTheme} 58 + class:border-canvas-200={!hasTheme} 59 + class:dark:border-canvas-800={!hasTheme} 60 + style:background-color={hasTheme ? 'var(--theme-background)' : undefined} 61 + style={allStyles()} 62 + > 63 + {@render children()} 64 + </article> 65 + {/if}
+55
src/lib/components/common/ThemedContainer.svelte
··· 1 + <script lang="ts"> 2 + import type { BasicTheme } from '$lib/types.js'; 3 + import { getThemeVars } from '$lib/utils/theme.js'; 4 + import type { Snippet } from 'svelte'; 5 + 6 + interface Props { 7 + theme?: BasicTheme; 8 + children: Snippet; 9 + class?: string; 10 + element?: 'div' | 'article' | 'section'; 11 + } 12 + 13 + let { theme, children, class: className = '', element = 'div' }: Props = $props(); 14 + 15 + const themeVars = $derived(theme ? getThemeVars(theme) : {}); 16 + const hasTheme = $derived(!!theme); 17 + 18 + const styles = $derived( 19 + Object.entries(themeVars) 20 + .map(([k, v]) => `${k}:${v}`) 21 + .join(';') 22 + ); 23 + </script> 24 + 25 + {#if element === 'article'} 26 + <article 27 + class={className} 28 + class:bg-canvas-50={!hasTheme} 29 + class:dark:bg-canvas-950={!hasTheme} 30 + style:background-color={hasTheme ? 'var(--theme-background)' : undefined} 31 + style={styles} 32 + > 33 + {@render children()} 34 + </article> 35 + {:else if element === 'section'} 36 + <section 37 + class={className} 38 + class:bg-canvas-50={!hasTheme} 39 + class:dark:bg-canvas-950={!hasTheme} 40 + style:background-color={hasTheme ? 'var(--theme-background)' : undefined} 41 + style={styles} 42 + > 43 + {@render children()} 44 + </section> 45 + {:else} 46 + <div 47 + class={className} 48 + class:bg-canvas-50={!hasTheme} 49 + class:dark:bg-canvas-950={!hasTheme} 50 + style:background-color={hasTheme ? 'var(--theme-background)' : undefined} 51 + style={styles} 52 + > 53 + {@render children()} 54 + </div> 55 + {/if}
+75
src/lib/components/common/ThemedText.svelte
··· 1 + <script lang="ts"> 2 + import { getThemedTextColor, getThemedAccent } from '$lib/utils/theme-helpers.js'; 3 + 4 + interface Props { 5 + hasTheme?: boolean; 6 + opacity?: number; 7 + variant?: 'foreground' | 'accent'; 8 + element?: 'span' | 'p' | 'h1' | 'h2' | 'h3' | 'div'; 9 + class?: string; 10 + children?: any; 11 + } 12 + 13 + let { 14 + hasTheme = false, 15 + opacity = 100, 16 + variant = 'foreground', 17 + element = 'span', 18 + class: className = '', 19 + children 20 + }: Props = $props(); 21 + 22 + const colorStyles = $derived( 23 + variant === 'accent' ? getThemedAccent(hasTheme) : getThemedTextColor(hasTheme, opacity) 24 + ); 25 + 26 + // Default Tailwind classes when no theme 27 + const defaultClasses = $derived(() => { 28 + if (hasTheme) return ''; 29 + 30 + if (variant === 'accent') { 31 + return 'text-primary-600 dark:text-primary-400'; 32 + } 33 + 34 + switch (opacity) { 35 + case 100: 36 + return 'text-ink-900 dark:text-ink-50'; 37 + case 80: 38 + return 'text-ink-800 dark:text-ink-100'; 39 + case 70: 40 + return 'text-ink-700 dark:text-ink-200'; 41 + case 60: 42 + return 'text-ink-600 dark:text-ink-400'; 43 + case 50: 44 + return 'text-ink-500 dark:text-ink-500'; 45 + default: 46 + return 'text-ink-700 dark:text-ink-200'; 47 + } 48 + }); 49 + </script> 50 + 51 + {#if element === 'p'} 52 + <p class="{defaultClasses()} {className}" style:color={colorStyles.color}> 53 + {@render children?.()} 54 + </p> 55 + {:else if element === 'h1'} 56 + <h1 class="{defaultClasses()} {className}" style:color={colorStyles.color}> 57 + {@render children?.()} 58 + </h1> 59 + {:else if element === 'h2'} 60 + <h2 class="{defaultClasses()} {className}" style:color={colorStyles.color}> 61 + {@render children?.()} 62 + </h2> 63 + {:else if element === 'h3'} 64 + <h3 class="{defaultClasses()} {className}" style:color={colorStyles.color}> 65 + {@render children?.()} 66 + </h3> 67 + {:else if element === 'div'} 68 + <div class="{defaultClasses()} {className}" style:color={colorStyles.color}> 69 + {@render children?.()} 70 + </div> 71 + {:else} 72 + <span class="{defaultClasses()} {className}" style:color={colorStyles.color}> 73 + {@render children?.()} 74 + </span> 75 + {/if}
+10 -1
src/lib/components/index.ts
··· 1 + export { default as DocumentCard } from './DocumentCard.svelte'; 1 2 export { default as PublicationCard } from './PublicationCard.svelte'; 2 - export { default as DocumentCard } from './DocumentCard.svelte'; 3 + export { default as ThemeToggle } from './ThemeToggle.svelte'; 4 + export { default as StandardSiteLayout } from './StandardSiteLayout.svelte'; 5 + 6 + // Common reusable components 7 + export { default as DateDisplay } from './common/DateDisplay.svelte'; 8 + export { default as TagList } from './common/TagList.svelte'; 9 + export { default as ThemedContainer } from './common/ThemedContainer.svelte'; 10 + export { default as ThemedText } from './common/ThemedText.svelte'; 11 + export { default as ThemedCard } from './common/ThemedCard.svelte';
+25
src/lib/index.ts
··· 1 1 // Main exports 2 2 export { SiteStandardClient, createClient } from './client.js'; 3 3 4 + // Component exports 5 + export { 6 + DocumentCard, 7 + PublicationCard, 8 + ThemeToggle, 9 + StandardSiteLayout, 10 + DateDisplay, 11 + TagList, 12 + ThemedContainer, 13 + ThemedText, 14 + ThemedCard 15 + } from './components/index.js'; 16 + 17 + // Store exports 18 + export { themeStore } from './stores/index.js'; 19 + 4 20 // Type exports 5 21 export type { 6 22 AtProtoBlob, ··· 23 39 export { cache } from './utils/cache.js'; 24 40 25 41 export { rgbToCSS, rgbToHex, getThemeVars } from './utils/theme.js'; 42 + 43 + export { 44 + mixThemeColor, 45 + getThemedTextColor, 46 + getThemedBackground, 47 + getThemedBorder, 48 + getThemedAccent, 49 + themeToCssVars 50 + } from './utils/theme-helpers.js'; 26 51 27 52 export { 28 53 getDocumentSlug,
+1
src/lib/stores/index.ts
··· 1 + export { themeStore } from './theme.js';
+80
src/lib/stores/theme.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { browser } from '$app/environment'; 3 + 4 + interface ThemeState { 5 + isDark: boolean; 6 + mounted: boolean; 7 + } 8 + 9 + function createThemeStore() { 10 + const { subscribe, set, update } = writable<ThemeState>({ 11 + isDark: false, 12 + mounted: false 13 + }); 14 + 15 + return { 16 + subscribe, 17 + init: () => { 18 + if (!browser) return; 19 + 20 + const stored = localStorage.getItem('theme'); 21 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 22 + const isDark = stored === 'dark' || (!stored && prefersDark); 23 + 24 + update((state) => ({ 25 + ...state, 26 + isDark, 27 + mounted: true 28 + })); 29 + 30 + applyTheme(isDark); 31 + 32 + // Listen for system preference changes 33 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 34 + const handleChange = (e: MediaQueryListEvent) => { 35 + if (!localStorage.getItem('theme')) { 36 + update((state) => ({ ...state, isDark: e.matches })); 37 + applyTheme(e.matches); 38 + } 39 + }; 40 + mediaQuery.addEventListener('change', handleChange); 41 + 42 + return () => { 43 + mediaQuery.removeEventListener('change', handleChange); 44 + }; 45 + }, 46 + toggle: () => { 47 + if (!browser) return; 48 + 49 + update((state) => { 50 + const newIsDark = !state.isDark; 51 + localStorage.setItem('theme', newIsDark ? 'dark' : 'light'); 52 + applyTheme(newIsDark); 53 + return { ...state, isDark: newIsDark }; 54 + }); 55 + }, 56 + setTheme: (isDark: boolean) => { 57 + if (!browser) return; 58 + 59 + localStorage.setItem('theme', isDark ? 'dark' : 'light'); 60 + applyTheme(isDark); 61 + update((state) => ({ ...state, isDark })); 62 + } 63 + }; 64 + } 65 + 66 + function applyTheme(isDark: boolean) { 67 + if (!browser) return; 68 + 69 + const htmlElement = document.documentElement; 70 + 71 + if (isDark) { 72 + htmlElement.classList.add('dark'); 73 + htmlElement.style.colorScheme = 'dark'; 74 + } else { 75 + htmlElement.classList.remove('dark'); 76 + htmlElement.style.colorScheme = 'light'; 77 + } 78 + } 79 + 80 + export const themeStore = createThemeStore();
+188
src/lib/styles/base.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); 2 + @import 'tailwindcss'; 3 + @import './themes.css'; 4 + 5 + @theme { 6 + /* Font Family */ 7 + --font-family-sans: 8 + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 9 + 'Segoe UI Symbol', 'Noto Color Emoji'; 10 + 11 + /* Ink - Slate-tinted text (230°) */ 12 + --color-ink-50: light-dark(oklch(17.5% 0.012 230), oklch(97.6% 0.008 230)); 13 + --color-ink-100: light-dark(oklch(25% 0.022 230), oklch(93.2% 0.017 230)); 14 + --color-ink-200: light-dark(oklch(38.5% 0.037 230), oklch(85.2% 0.032 230)); 15 + --color-ink-300: light-dark(oklch(50.5% 0.052 230), oklch(75.2% 0.048 230)); 16 + --color-ink-400: light-dark(oklch(62% 0.065 230), oklch(65.2% 0.062 230)); 17 + --color-ink-500: light-dark(oklch(73% 0.078 230), oklch(55.2% 0.078 230)); 18 + --color-ink-600: light-dark(oklch(78% 0.062 230), oklch(45.2% 0.065 230)); 19 + --color-ink-700: light-dark(oklch(83.5% 0.048 230), oklch(35.2% 0.052 230)); 20 + --color-ink-800: light-dark(oklch(89% 0.032 230), oklch(25.2% 0.037 230)); 21 + --color-ink-900: light-dark(oklch(94.5% 0.017 230), oklch(18.2% 0.022 230)); 22 + --color-ink-950: light-dark(oklch(97.6% 0.008 230), oklch(12.5% 0.012 230)); 23 + 24 + /* Canvas - Slate-tinted backgrounds (230°) */ 25 + --color-canvas-50: light-dark(oklch(17.8% 0.014 230), oklch(98.6% 0.005 230)); 26 + --color-canvas-100: light-dark(oklch(25.8% 0.025 230), oklch(96.6% 0.011 230)); 27 + --color-canvas-200: light-dark(oklch(39.5% 0.042 230), oklch(92.5% 0.024 230)); 28 + --color-canvas-300: light-dark(oklch(52% 0.058 230), oklch(86.5% 0.038 230)); 29 + --color-canvas-400: light-dark(oklch(64% 0.072 230), oklch(80.5% 0.055 230)); 30 + --color-canvas-500: light-dark(oklch(75.5% 0.085 230), oklch(75.5% 0.068 230)); 31 + --color-canvas-600: light-dark(oklch(80.5% 0.055 230), oklch(64% 0.072 230)); 32 + --color-canvas-700: light-dark(oklch(86.5% 0.038 230), oklch(52% 0.058 230)); 33 + --color-canvas-800: light-dark(oklch(92.5% 0.024 230), oklch(39.5% 0.042 230)); 34 + --color-canvas-900: light-dark(oklch(96.6% 0.011 230), oklch(25.8% 0.025 230)); 35 + --color-canvas-950: light-dark(oklch(98.6% 0.005 230), oklch(17.8% 0.014 230)); 36 + 37 + /* Slate - Primary colors (230°) */ 38 + --color-primary-50: light-dark(oklch(18.2% 0.018 230), oklch(97.8% 0.012 230)); 39 + --color-primary-100: light-dark(oklch(26.5% 0.03 230), oklch(94.8% 0.022 230)); 40 + --color-primary-200: light-dark(oklch(40.5% 0.048 230), oklch(89.5% 0.042 230)); 41 + --color-primary-300: light-dark(oklch(54% 0.065 230), oklch(79.5% 0.062 230)); 42 + --color-primary-400: light-dark(oklch(66.5% 0.08 230), oklch(69.5% 0.078 230)); 43 + --color-primary-500: light-dark(oklch(78.5% 0.095 230), oklch(59.5% 0.095 230)); 44 + --color-primary-600: light-dark(oklch(82.2% 0.078 230), oklch(49.5% 0.08 230)); 45 + --color-primary-700: light-dark(oklch(86.5% 0.062 230), oklch(39.5% 0.065 230)); 46 + --color-primary-800: light-dark(oklch(91% 0.042 230), oklch(29.5% 0.048 230)); 47 + --color-primary-900: light-dark(oklch(95.8% 0.022 230), oklch(21.5% 0.03 230)); 48 + --color-primary-950: light-dark(oklch(98% 0.012 230), oklch(15.2% 0.018 230)); 49 + 50 + /* Steel Grey - Secondary colors (215°) */ 51 + --color-secondary-50: light-dark(oklch(18.5% 0.02 215), oklch(97.9% 0.013 215)); 52 + --color-secondary-100: light-dark(oklch(26.8% 0.033 215), oklch(95% 0.024 215)); 53 + --color-secondary-200: light-dark(oklch(41% 0.052 215), oklch(89.8% 0.045 215)); 54 + --color-secondary-300: light-dark(oklch(54.5% 0.07 215), oklch(80.2% 0.065 215)); 55 + --color-secondary-400: light-dark(oklch(67% 0.087 215), oklch(70.2% 0.082 215)); 56 + --color-secondary-500: light-dark(oklch(79% 0.103 215), oklch(60.2% 0.103 215)); 57 + --color-secondary-600: light-dark(oklch(82.8% 0.082 215), oklch(50.2% 0.087 215)); 58 + --color-secondary-700: light-dark(oklch(87% 0.065 215), oklch(40.2% 0.07 215)); 59 + --color-secondary-800: light-dark(oklch(91.5% 0.045 215), oklch(30.5% 0.052 215)); 60 + --color-secondary-900: light-dark(oklch(96% 0.024 215), oklch(22.2% 0.033 215)); 61 + --color-secondary-950: light-dark(oklch(98.2% 0.013 215), oklch(15.8% 0.02 215)); 62 + 63 + /* Charcoal - Accent colors (240°) */ 64 + --color-accent-50: light-dark(oklch(18.5% 0.022 240), oklch(98% 0.014 240)); 65 + --color-accent-100: light-dark(oklch(26.8% 0.036 240), oklch(95.2% 0.026 240)); 66 + --color-accent-200: light-dark(oklch(41% 0.058 240), oklch(90% 0.048 240)); 67 + --color-accent-300: light-dark(oklch(54.5% 0.078 240), oklch(80.8% 0.072 240)); 68 + --color-accent-400: light-dark(oklch(67% 0.097 240), oklch(71% 0.092 240)); 69 + --color-accent-500: light-dark(oklch(79% 0.115 240), oklch(61% 0.115 240)); 70 + --color-accent-600: light-dark(oklch(82.8% 0.092 240), oklch(51% 0.097 240)); 71 + --color-accent-700: light-dark(oklch(87% 0.072 240), oklch(41% 0.078 240)); 72 + --color-accent-800: light-dark(oklch(91.5% 0.048 240), oklch(31% 0.058 240)); 73 + --color-accent-900: light-dark(oklch(96% 0.026 240), oklch(22.5% 0.036 240)); 74 + --color-accent-950: light-dark(oklch(98.2% 0.014 240), oklch(16.2% 0.022 240)); 75 + } 76 + 77 + @layer base { 78 + /* Base styles for consistent typography and accessibility */ 79 + html { 80 + scroll-behavior: smooth; 81 + overflow-x: hidden; 82 + width: 100%; 83 + } 84 + 85 + @media (prefers-reduced-motion: reduce) { 86 + html { 87 + scroll-behavior: auto; 88 + } 89 + 90 + *, 91 + *::before, 92 + *::after { 93 + animation-duration: 0.01ms !important; 94 + animation-iteration-count: 1 !important; 95 + transition-duration: 0.01ms !important; 96 + } 97 + } 98 + 99 + body { 100 + font-family: var(--font-family-sans); 101 + text-rendering: optimizeLegibility; 102 + -webkit-font-smoothing: antialiased; 103 + -moz-osx-font-smoothing: grayscale; 104 + overflow-x: hidden; 105 + width: 100%; 106 + max-width: 100vw; 107 + background-color: var(--color-canvas-50); 108 + } 109 + 110 + @media (prefers-color-scheme: dark) { 111 + body { 112 + background-color: var(--color-canvas-950); 113 + } 114 + } 115 + 116 + /* Skip to content link for keyboard navigation */ 117 + .skip-to-content { 118 + position: absolute; 119 + left: -9999px; 120 + z-index: 999; 121 + padding: 1rem 1.5rem; 122 + background-color: var(--color-primary-600); 123 + color: white; 124 + font-weight: 600; 125 + text-decoration: none; 126 + border-radius: 0.5rem; 127 + } 128 + 129 + .skip-to-content:focus { 130 + left: 1rem; 131 + top: 1rem; 132 + outline: 2px solid var(--color-primary-800); 133 + outline-offset: 2px; 134 + } 135 + 136 + /* Focus visible styles for accessibility - Enhanced for better visibility */ 137 + *:focus-visible { 138 + outline: 3px solid var(--color-primary-600); 139 + outline-offset: 2px; 140 + border-radius: 0.25rem; 141 + } 142 + 143 + /* High contrast mode support */ 144 + @media (prefers-contrast: high) { 145 + *:focus-visible { 146 + outline-width: 4px; 147 + } 148 + } 149 + 150 + /* Ensure all elements stay within viewport */ 151 + * { 152 + min-width: 0; 153 + } 154 + 155 + img, 156 + video, 157 + iframe, 158 + embed, 159 + object { 160 + max-width: 100%; 161 + height: auto; 162 + } 163 + 164 + /* Improve link accessibility */ 165 + a { 166 + text-decoration-skip-ink: auto; 167 + } 168 + 169 + /* Better button accessibility */ 170 + button:disabled { 171 + cursor: not-allowed; 172 + } 173 + 174 + /* Screen reader only utility */ 175 + .sr-only { 176 + position: absolute; 177 + width: 1px; 178 + height: 1px; 179 + padding: 0; 180 + margin: -1px; 181 + overflow: hidden; 182 + clip: rect(0, 0, 0, 0); 183 + white-space: nowrap; 184 + border-width: 0; 185 + } 186 + } 187 + 188 + @plugin '@tailwindcss/typography';
+5
src/lib/styles/themes.css
··· 1 + /* Color Theme System - Modular Theme Imports */ 2 + /* Slate theme is the default and included in base.css */ 3 + /* Add additional themes here as needed */ 4 + 5 + /* Default theme is Slate (230°) defined in base.css */
+87
src/lib/utils/theme-helpers.ts
··· 1 + import type { BasicTheme } from '../types.js'; 2 + import { rgbToCSS } from './theme.js'; 3 + 4 + /** 5 + * Generate color-mix CSS for theme colors with transparency 6 + */ 7 + export function mixThemeColor(variable: string, opacity: number, fallback = 'transparent'): string { 8 + return `color-mix(in srgb, var(${variable}) ${opacity}%, ${fallback})`; 9 + } 10 + 11 + /** 12 + * Get theme-aware color styles for text 13 + */ 14 + export function getThemedTextColor( 15 + hasTheme: boolean, 16 + opacity = 100 17 + ): { 18 + color?: string; 19 + } { 20 + if (!hasTheme) return {}; 21 + return opacity === 100 22 + ? { color: 'var(--theme-foreground)' } 23 + : { color: mixThemeColor('--theme-foreground', opacity) }; 24 + } 25 + 26 + /** 27 + * Get theme-aware background color 28 + */ 29 + export function getThemedBackground( 30 + hasTheme: boolean, 31 + opacity?: number 32 + ): { 33 + backgroundColor?: string; 34 + } { 35 + if (!hasTheme) return {}; 36 + if (opacity === undefined) { 37 + return { backgroundColor: 'var(--theme-background)' }; 38 + } 39 + return { backgroundColor: mixThemeColor('--theme-background', opacity) }; 40 + } 41 + 42 + /** 43 + * Get theme-aware border color 44 + */ 45 + export function getThemedBorder( 46 + hasTheme: boolean, 47 + opacity = 20 48 + ): { 49 + borderColor?: string; 50 + } { 51 + if (!hasTheme) return {}; 52 + return { borderColor: mixThemeColor('--theme-foreground', opacity) }; 53 + } 54 + 55 + /** 56 + * Get theme-aware accent color 57 + */ 58 + export function getThemedAccent( 59 + hasTheme: boolean, 60 + opacity?: number 61 + ): { 62 + color?: string; 63 + backgroundColor?: string; 64 + } { 65 + if (!hasTheme) return {}; 66 + if (opacity === undefined) { 67 + return { color: 'var(--theme-accent)' }; 68 + } 69 + return { 70 + backgroundColor: mixThemeColor('--theme-accent', opacity), 71 + color: 'var(--theme-accent)' 72 + }; 73 + } 74 + 75 + /** 76 + * Convert BasicTheme to CSS custom properties 77 + */ 78 + export function themeToCssVars(theme?: BasicTheme): Record<string, string> { 79 + if (!theme) return {}; 80 + 81 + return { 82 + '--theme-background': rgbToCSS(theme.background), 83 + '--theme-foreground': rgbToCSS(theme.foreground), 84 + '--theme-accent': rgbToCSS(theme.accent), 85 + '--theme-accent-foreground': rgbToCSS(theme.accentForeground) 86 + }; 87 + }
+7 -2
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import './layout.css'; 2 + import '../lib/styles/base.css'; 3 + import type { Snippet } from 'svelte'; 4 + 5 + interface Props { 6 + children: Snippet; 7 + } 3 8 4 - const { children } = $props(); 9 + let { children }: Props = $props(); 5 10 </script> 6 11 7 12 {@render children()}
+175 -179
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types.js'; 3 - import { PublicationCard, DocumentCard } from '$lib/components/index.js'; 3 + import { PublicationCard, DocumentCard, StandardSiteLayout } from '$lib/components/index.js'; 4 4 5 5 const { data }: { data: PageData } = $props(); 6 6 </script> 7 7 8 8 <svelte:head> 9 - <title>Standard.Site Library - Showcase</title> 9 + <title>svelte-standard-site - Showcase</title> 10 10 <meta 11 11 name="description" 12 12 content="A SvelteKit library for fetching site.standard.* records from AT Protocol" 13 13 /> 14 14 </svelte:head> 15 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" 16 + <StandardSiteLayout title="svelte-standard-site" showThemeToggle={true}> 17 + <div class="mx-auto max-w-7xl"> 18 + <!-- Hero Section --> 19 + <header class="mb-16 text-center"> 20 + <h1 class="text-ink-900 dark:text-ink-50 mb-4 text-5xl font-bold tracking-tight"> 21 + svelte-standard-site 22 + </h1> 23 + <p class="text-ink-700 dark:text-ink-200 mx-auto mb-6 max-w-2xl text-xl"> 24 + A powerful SvelteKit library for fetching <code 25 + class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1 text-base">site.standard.*</code 26 + > records from the AT Protocol ecosystem 27 + </p> 28 + <div class="flex items-center justify-center gap-4"> 29 + <a 30 + href="https://github.com/ewanc26/svelte-standard-site" 31 + target="_blank" 32 + rel="noopener noreferrer" 33 + class="bg-primary-600 hover:bg-primary-700 focus-visible:outline-primary-600 dark:bg-primary-500 dark:hover:bg-primary-600 rounded-lg px-6 py-3 font-semibold text-white transition focus-visible:outline-2 focus-visible:outline-offset-2" 51 34 > 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> 35 + View on GitHub 36 + </a> 78 37 </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> 38 + </header> 97 39 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"> 40 + {#if data.error} 41 + <!-- Error State --> 42 + <div 43 + class="border-accent-300 bg-accent-50 dark:border-accent-700 dark:bg-accent-950 mb-12 rounded-xl border-2 p-8" 44 + > 45 + <div class="flex items-start gap-4"> 113 46 <svg 114 - class="mx-auto mb-4 size-12 text-gray-400" 47 + class="text-accent-600 dark:text-accent-400 size-6 shrink-0" 115 48 fill="none" 116 49 viewBox="0 0 24 24" 117 50 stroke="currentColor" ··· 120 53 stroke-linecap="round" 121 54 stroke-linejoin="round" 122 55 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" 56 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 124 57 /> 125 58 </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> 59 + <div class="flex-1"> 60 + <h2 class="text-accent-900 dark:text-accent-50 mb-2 text-xl font-bold"> 61 + Configuration Error 62 + </h2> 63 + <p class="text-accent-800 dark:text-accent-200 mb-4">{data.error}</p> 64 + <div class="bg-canvas-50 dark:bg-canvas-950 rounded-lg p-4"> 65 + <p class="text-ink-900 dark:text-ink-50 mb-2 font-semibold">Quick Fix:</p> 66 + <ol class="text-ink-700 dark:text-ink-200 list-inside list-decimal space-y-2 text-sm"> 67 + <li> 68 + Create a <code class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1" 69 + >.env</code 70 + > file in your project root 71 + </li> 72 + <li> 73 + Add: <code class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1" 74 + >PUBLIC_ATPROTO_DID=your-did-here</code 75 + > 76 + </li> 77 + <li>Restart your dev server</li> 78 + </ol> 79 + </div> 80 + </div> 130 81 </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} 82 + </div> 83 + {:else} 84 + <!-- Configuration Info --> 85 + <section class="bg-primary-50 dark:bg-primary-950 mb-16 rounded-xl p-8"> 86 + <h2 class="text-ink-900 dark:text-ink-50 mb-6 text-2xl font-bold">Active Configuration</h2> 87 + <div class="grid gap-6 md:grid-cols-2"> 88 + <div class="bg-canvas-50 dark:bg-canvas-950 rounded-lg p-6 shadow-sm"> 89 + <dt class="text-ink-700 dark:text-ink-200 mb-2 text-sm font-medium">DID</dt> 90 + <dd class="text-ink-900 dark:text-ink-50 font-mono text-sm break-all"> 91 + {data.config?.did} 92 + </dd> 93 + </div> 94 + <div class="bg-canvas-50 dark:bg-canvas-950 rounded-lg p-6 shadow-sm"> 95 + <dt class="text-ink-700 dark:text-ink-200 mb-2 text-sm font-medium">PDS Endpoint</dt> 96 + <dd class="text-ink-900 dark:text-ink-50 font-mono text-sm break-all"> 97 + {data.pds || 'Auto-resolved'} 98 + </dd> 99 + </div> 136 100 </div> 137 - {/if} 138 - </section> 101 + </section> 139 102 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> 103 + <!-- Publications Section --> 104 + <section class="mb-16"> 105 + <div class="mb-8 flex items-end justify-between gap-4"> 106 + <div class="min-w-0 flex-1"> 107 + <h2 class="text-ink-900 dark:text-ink-50 text-3xl font-bold">Publications</h2> 108 + <p class="text-ink-700 dark:text-ink-200 mt-2"> 109 + site.standard.publication records from the configured repository 110 + </p> 111 + </div> 112 + <div 113 + class="bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-100 rounded-full px-4 py-2 text-sm font-semibold whitespace-nowrap" 114 + > 115 + {data.publications.length} found 116 + </div> 148 117 </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" 118 + {#if data.publications.length === 0} 119 + <div 120 + class="border-canvas-300 dark:border-canvas-700 rounded-xl border-2 border-dashed p-12 text-center" 160 121 > 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> 122 + <svg 123 + class="text-ink-500 dark:text-ink-400 mx-auto mb-4 size-12" 124 + fill="none" 125 + viewBox="0 0 24 24" 126 + stroke="currentColor" 127 + > 128 + <path 129 + stroke-linecap="round" 130 + stroke-linejoin="round" 131 + stroke-width="2" 132 + 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" 133 + /> 134 + </svg> 135 + <h3 class="text-ink-900 dark:text-ink-50 mb-2 text-lg font-semibold"> 136 + No Publications Found 137 + </h3> 138 + <p class="text-ink-700 dark:text-ink-200"> 139 + This repository doesn't have any <code 140 + class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1" 141 + >site.standard.publication</code 142 + > records yet. 143 + </p> 144 + </div> 145 + {:else} 146 + <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> 147 + {#each data.publications as publication} 148 + <PublicationCard {publication} /> 149 + {/each} 150 + </div> 151 + {/if} 152 + </section> 181 153 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'} 154 + <!-- Documents Section --> 155 + <section> 156 + <div class="mb-8 flex items-end justify-between gap-4"> 157 + <div class="min-w-0 flex-1"> 158 + <h2 class="text-ink-900 dark:text-ink-50 text-3xl font-bold">Documents</h2> 159 + <p class="text-ink-700 dark:text-ink-200 mt-2"> 160 + <code class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1" 161 + >site.standard.document</code 162 + > records, sorted by publication date 163 + </p> 189 164 </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'} 165 + <div 166 + class="bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-100 rounded-full px-4 py-2 text-sm font-semibold whitespace-nowrap" 167 + > 168 + {data.documents.length} found 196 169 </div> 197 170 </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> 171 + {#if data.documents.length === 0} 172 + <div 173 + class="border-canvas-300 dark:border-canvas-700 rounded-xl border-2 border-dashed p-12 text-center" 174 + > 175 + <svg 176 + class="text-ink-500 dark:text-ink-400 mx-auto mb-4 size-12" 177 + fill="none" 178 + viewBox="0 0 24 24" 179 + stroke="currentColor" 180 + > 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + stroke-width="2" 185 + 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" 186 + /> 187 + </svg> 188 + <h3 class="text-ink-900 dark:text-ink-50 mb-2 text-lg font-semibold"> 189 + No Documents Found 190 + </h3> 191 + <p class="text-ink-700 dark:text-ink-200"> 192 + This repository doesn't have any <code 193 + class="bg-canvas-200 dark:bg-canvas-800 rounded px-2 py-1" 194 + >site.standard.document</code 195 + > records yet. 196 + </p> 197 + </div> 198 + {:else} 199 + <div class="space-y-6"> 200 + {#each data.documents as document} 201 + {@const publication = 202 + data.publications.find((pub: { uri: any }) => pub.uri === document.value.site) ?? 203 + undefined} 204 + <DocumentCard {document} {publication} /> 205 + {/each} 206 + </div> 207 + {/if} 208 + </section> 209 + {/if} 210 + </div> 211 + </StandardSiteLayout>
+157 -144
src/routes/[pub_rkey]/[doc_rkey]/+page.svelte
··· 2 2 import type { PageData } from './$types.js'; 3 3 import { getThemeVars } from '$lib/utils/theme.js'; 4 4 import { extractRkey } from '$lib/utils/document.js'; 5 + import { ThemedContainer, ThemedText, DateDisplay, TagList } from '$lib/components/index.js'; 6 + import { mixThemeColor } from '$lib/utils/theme-helpers.js'; 5 7 6 8 const { data }: { data: PageData } = $props(); 7 9 8 10 const themeVars = $derived( 9 11 data.publication?.value.basicTheme ? getThemeVars(data.publication.value.basicTheme) : {} 10 12 ); 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 - } 13 + const hasTheme = $derived(!!data.publication?.value.basicTheme); 19 14 </script> 20 15 21 16 <svelte:head> 22 17 <title>{data.document.value.title}</title> 23 18 {#if data.document.value.description} 24 19 <meta name="description" content={data.document.value.description} /> 20 + {/if} 21 + {#if hasTheme} 22 + <!-- prettier-ignore --> 23 + <style> 24 + body {{ 25 + background-color: {themeVars['--theme-background'] || 'var(--color-canvas-50)'} !important; 26 + }} 27 + </style> 25 28 {/if} 26 29 </svelte:head> 27 30 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> 31 + <ThemedContainer theme={data.publication?.value.basicTheme} class="flex flex-col"> 32 + <!-- Header Bar --> 33 + <header 34 + class="sticky top-0 z-10 border-b backdrop-blur-sm" 35 + style:border-color={hasTheme ? mixThemeColor('--theme-foreground', 15) : undefined} 36 + style:background-color={hasTheme ? mixThemeColor('--theme-background', 80) : undefined} 37 + > 38 + <nav class="mx-auto flex max-w-4xl items-center justify-between px-6 py-4"> 39 + <a 40 + href="/" 41 + class="group flex items-center gap-2 text-sm font-medium transition-all hover:gap-3" 42 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 43 + > 44 + <svg 45 + class="size-4 transition-transform group-hover:-translate-x-0.5" 46 + fill="none" 47 + viewBox="0 0 24 24" 48 + stroke="currentColor" 49 + stroke-width="2.5" 50 + > 51 + <path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> 52 + </svg> 53 + Back to Home 54 + </a> 49 55 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> 56 + {#if data.publication} 57 + <a 58 + href={data.publication.value.url || '/'} 59 + target={data.publication.value.url ? '_blank' : undefined} 60 + rel={data.publication.value.url ? 'noopener noreferrer' : undefined} 61 + class="flex items-center gap-2 transition-opacity hover:opacity-70" 62 + > 63 + {#if data.publication.value.icon} 64 + <img 65 + src={data.publication.value.icon} 66 + alt="{data.publication.value.name} icon" 67 + class="size-6 rounded object-cover" 68 + /> 77 69 {/if} 78 - </div> 79 - </div> 80 - {/if} 70 + <ThemedText {hasTheme} element="span" class="text-sm font-semibold"> 71 + {data.publication.value.name} 72 + </ThemedText> 73 + </a> 74 + {/if} 75 + </nav> 76 + </header> 81 77 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 - > 78 + <!-- Main Content --> 79 + <article class="mx-auto max-w-3xl px-6 py-12"> 80 + <!-- Title Section --> 81 + <header class="mb-12"> 82 + <ThemedText {hasTheme} element="h1" class="mb-6 text-5xl font-bold leading-tight tracking-tight"> 88 83 {data.document.value.title} 89 - </h1> 84 + </ThemedText> 90 85 91 86 <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'} 87 + class="flex flex-wrap items-center gap-4 text-sm" 88 + style:color={hasTheme ? mixThemeColor('--theme-foreground', 60) : undefined} 97 89 > 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> 90 + <DateDisplay date={data.document.value.publishedAt} /> 106 91 {#if data.document.value.updatedAt} 107 92 <span 108 - style:color={data.publication?.value.basicTheme 109 - ? `var(--theme-foreground)` 110 - : 'rgb(107, 114, 128)'} 93 + class="flex items-center gap-1.5" 94 + style:color={hasTheme ? mixThemeColor('--theme-foreground', 50) : undefined} 111 95 > 112 - Updated {formatDate(data.document.value.updatedAt)} 96 + <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 97 + <path 98 + stroke-linecap="round" 99 + stroke-linejoin="round" 100 + stroke-width="2" 101 + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" 102 + /> 103 + </svg> 104 + Updated <DateDisplay date={data.document.value.updatedAt} /> 113 105 </span> 114 106 {/if} 115 107 </div> 116 108 </header> 117 109 118 - <!-- Cover image --> 110 + <!-- Cover Image --> 119 111 {#if data.document.value.coverImage} 120 - <div class="mb-8"> 112 + <div class="-mx-6 mb-12 sm:mx-0"> 121 113 <img 122 114 src={data.document.value.coverImage} 123 115 alt="{data.document.value.title} cover" 124 - class="w-full rounded-lg object-cover shadow-lg" 125 - style="max-height: 500px;" 116 + class="w-full rounded-none object-cover sm:rounded-2xl" 117 + style="max-height: 28rem; object-position: center;" 126 118 /> 127 119 </div> 128 120 {/if} 129 121 130 - <!-- Document content --> 122 + <!-- Content --> 131 123 <div 132 - class="mx-auto prose prose-lg max-w-none" 133 - style:color={data.publication?.value.basicTheme ? `var(--theme-foreground)` : undefined} 124 + class="prose prose-lg max-w-none" 125 + style:color={hasTheme ? 'var(--theme-foreground)' : undefined} 134 126 > 135 127 {#if data.document.value.textContent} 136 - <div class="whitespace-pre-wrap">{data.document.value.textContent}</div> 128 + <div class="leading-relaxed">{data.document.value.textContent}</div> 137 129 {: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> 130 + <div 131 + class="rounded-xl border p-6" 132 + style:border-color={hasTheme ? mixThemeColor('--theme-foreground', 20) : undefined} 133 + style:background-color={hasTheme ? mixThemeColor('--theme-foreground', 5) : undefined} 134 + > 135 + <p 136 + class="mb-3 text-sm font-semibold uppercase tracking-wider" 137 + style:color={hasTheme ? mixThemeColor('--theme-foreground', 60) : undefined} 138 + > 139 + Raw Content 140 + </p> 141 + <pre 142 + class="overflow-x-auto text-xs leading-relaxed" 143 + style:color={hasTheme ? 'var(--theme-foreground)' : undefined}>{JSON.stringify(data.document.value.content, null, 2)}</pre> 145 144 </div> 146 145 {:else} 147 - <p class="text-gray-500 italic">No content available</p> 146 + <ThemedText {hasTheme} opacity={50} element="p" class="italic"> 147 + No content available 148 + </ThemedText> 148 149 {/if} 149 150 </div> 150 151 151 152 <!-- Tags --> 152 153 {#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> 154 + <div class="mt-16 pt-8"> 155 + <TagList tags={data.document.value.tags} {hasTheme} /> 183 156 </div> 184 157 {/if} 185 158 186 - <!-- Bluesky post reference --> 159 + <!-- Bluesky Reference --> 187 160 {#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> 161 + <div 162 + class="mt-12 rounded-2xl border p-6" 163 + style:border-color={hasTheme ? mixThemeColor('--theme-accent', 30) : undefined} 164 + style:background-color={hasTheme ? mixThemeColor('--theme-accent', 10) : undefined} 165 + > 166 + <a 167 + href="https://bsky.app/profile/{data.config?.did}/post/{extractRkey( 168 + data.document.value.bskyPostRef.uri 169 + )}" 170 + target="_blank" 171 + rel="noopener noreferrer" 172 + class="flex items-center gap-3 font-medium transition-all hover:gap-4" 173 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 174 + > 175 + <svg class="size-5 shrink-0" fill="currentColor" viewBox="0 0 24 24"> 176 + <path 177 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 178 + /> 179 + </svg> 180 + <span>Continue the conversation on Bluesky</span> 181 + <svg class="size-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 182 + <path 183 + stroke-linecap="round" 184 + stroke-linejoin="round" 185 + stroke-width="2" 186 + d="M14 5l7 7m0 0l-7 7m7-7H3" 187 + /> 188 + </svg> 189 + </a> 201 190 </div> 202 191 {/if} 203 192 </article> 204 - </div> 193 + 194 + <!-- Footer --> 195 + <footer 196 + class="mt-auto border-t py-8" 197 + style:border-color={hasTheme ? mixThemeColor('--theme-foreground', 15) : undefined} 198 + > 199 + <div class="mx-auto max-w-3xl px-6 text-center"> 200 + <p 201 + class="text-sm" 202 + style:color={hasTheme ? mixThemeColor('--theme-foreground', 50) : undefined} 203 + > 204 + Powered by 205 + <a 206 + href="https://atproto.com" 207 + target="_blank" 208 + rel="noopener noreferrer" 209 + class="font-medium underline decoration-1 underline-offset-2 transition-all hover:decoration-2" 210 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 211 + > 212 + AT Protocol 213 + </a> 214 + </p> 215 + </div> 216 + </footer> 217 + </ThemedContainer>