my website at ewancroft.uk
6
fork

Configure Feed

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

feat!: migrate to Standard.site exclusively, remove WhiteWind/Leaflet support

BREAKING CHANGE: Removed all WhiteWind and Leaflet platform support in favor of Standard.site exclusively.

Major changes:
- Remove WhiteWind and Leaflet document/publication fetching
- Create new Standard.site documents service (documents.ts)
- Replace BlogPostCard with DocumentCard component
- Update all routes to use Standard.site publications only
- Remove PUBLIC_ENABLE_WHITEWIND environment variable
- Update archive page to display Standard.site documents
- Fix fetchFn parameter passing in musicbrainz.ts and fetch.ts
- Update README with Standard.site-only documentation
- Simplify publication system to single platform
- Remove Leaflet publication base_path logic

All slug mappings now point exclusively to Standard.site publications.
RSS feeds now generate from Standard.site documents only.
Archive page displays Standard.site documents grouped by date.

Migration guide available in updated README.md

+1095 -862
+5 -17
.env.example
··· 2 2 # You can find this in your Bluesky profile settings or at https://bsky.app 3 3 PUBLIC_ATPROTO_DID=did:plc:your-did-here 4 4 5 - # Enable WhiteWind support (optional) 6 - # Set to "true" to check WhiteWind for blog posts, "false" to disable 7 - # If disabled, only Leaflet posts will be fetched and redirected 8 - # Default: false 9 - PUBLIC_ENABLE_WHITEWIND=false 10 - 11 5 # Fallback URL (optional) 12 - # If a document cannot be found on WhiteWind or Leaflet, redirect here 6 + # If a document cannot be found, redirect here 13 7 # Example: https://archive.example.com 14 8 # Leave empty to return a 404 error instead 15 9 PUBLIC_BLOG_FALLBACK_URL="" ··· 17 11 # Publication to Slug Mapping 18 12 # Configure your publication slugs in src/lib/config/slugs.ts 19 13 # This allows you to access publications via friendly URLs like /blog, /notes, etc. 20 - # Example: { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' } 14 + # Example: { slug: 'blog', publicationRkey: '3m3x4bgbsh22k', platform: 'standard.site' } 21 15 # 22 - # Each publication in Leaflet can have its own base_path configured, which will be 23 - # automatically used when redirecting. If no base_path is set, the system falls back 24 - # to the standard Leaflet URL format (https://leaflet.pub/lish/{did}/{rkey}). 25 - 26 - # If you have `com.whtwnd.blog.entry` records in your AT Protocol 27 - # repository, they will also be fetched and displayed on your website 28 - # alongside your Leaflet posts. 29 - # The WhiteWind posts are always linked to using the following format: 30 - # https://whtwnd.com/[did]/[rkey]. 16 + # Each Standard.site publication has a URL configured in its record, which will be 17 + # automatically used when redirecting. The format is determined by the publication's 18 + # configured URL and the document's path field. 31 19 32 20 # Slingshot Configuration (optional) 33 21 # Local Slingshot instance for development - primary source for AT Protocol data
+336 -116
README.md
··· 8 8 9 9 ### Core AT Protocol Integration 10 10 11 - - **Dynamic Profile Display**: Automatically fetch and display your Bluesky profile information with avatar, banner, follower counts, and bio 11 + - **Dynamic Profile Display**: Automatically fetch and display your Bluesky profile information with avatar, banner, follower counts, pronouns, and bio 12 12 - **Site Metadata**: Store and display comprehensive site information using the `uk.ewancroft.site.info` lexicon (credits, tech stack, privacy statement, licenses) 13 - - **Smart Caching**: Intelligent 5-minute in-memory cache with TTL support for all AT Protocol data 13 + - **Smart Caching**: Intelligent in-memory cache with configurable TTL support for all AT Protocol data 14 14 - **PDS Resolution**: Automatic PDS discovery with fallback to Bluesky public API for maximum reliability 15 + - **Standard.site Integration**: Full support for Standard.site document storage and display 15 16 16 17 ### Content & Publishing 17 18 18 - - **Multi-Platform Blog System**: 19 - - **Leaflet** (`pub.leaflet.document`) - Primary platform with custom domain support 20 - - **WhiteWind** (`com.whtwnd.blog.entry`) - Optional secondary platform (disabled by default) 21 - - Intelligent RSS feed generation with full content support 22 - - Automatic draft filtering and non-public post handling 19 + - **Standard.site Publishing System**: 20 + - Store and retrieve documents using the Standard.site protocol 23 21 - Multi-publication support via slug mapping 22 + - Intelligent RSS feed generation 23 + - Archive page displaying all your documents 24 + - Full integration with the AT Protocol ecosystem 25 + - Automatic document fetching and caching 24 26 25 27 - **Flexible Publication Management**: 26 - - Map friendly URL slugs to AT Protocol publications 28 + - Map friendly URL slugs to Standard.site publications 27 29 - Support for unlimited publications with individual configurations 28 - - Custom base paths for each publication 29 - - Smart redirects with platform prioritization 30 - - Intelligent fallback handling for missing content 30 + - Smart redirects to publication URLs 31 + - Publication-filtered RSS feeds 31 32 32 33 - **Bluesky Post Display**: 33 34 - Showcase latest non-reply posts with rich media support ··· 35 36 - Quoted post embedding with media preservation 36 37 - Image galleries with alt text support 37 38 - External link cards with preview generation 38 - - Video embed support 39 + - Video embed support with HLS.js streaming 39 40 40 41 - **Engagement Tracking**: 41 42 - Real-time like and repost counts via Constellation API ··· 46 47 47 48 - **Now Playing Display**: Show currently playing or recently played tracks via `fm.teal.alpha.actor.status` 48 49 - **Play History**: Display listening history via `fm.teal.alpha.feed.play` 49 - - **Album Artwork**: 50 - - **Primary**: MusicBrainz Cover Art Archive integration (no API key required!) 51 - - **Automatic Search**: Searches MusicBrainz when release IDs are missing 52 - - **Smart Caching**: Caches MusicBrainz lookups to avoid repeated searches 53 - - **Fallback**: AT Protocol blob storage for custom artwork 50 + - **Album Artwork System**: 51 + - **Server-side Proxy**: CORS-free artwork fetching through `/api/artwork` endpoint 52 + - **Cascading Fallback**: MusicBrainz → iTunes → Deezer → Last.fm 53 + - **MusicBrainz Integration**: Cover Art Archive with automatic release search 54 + - **Smart Caching**: Caches artwork URLs and search results 55 + - **AT Protocol Blob Fallback**: Uses blob storage when external artwork unavailable 54 56 - **Rich Metadata**: Artist names, album info, duration, and relative timestamps 55 57 - **Multi-Service Support**: Works with Last.fm, Spotify, and other scrobbling services 56 58 - **Intelligent Expiry**: Automatically handles expired "now playing" status ··· 71 73 72 74 ### User Experience 73 75 76 + - **12 Color Themes**: Choose from a curated selection of beautiful color themes: 77 + - **Neutral**: Sage, Monochrome, Slate 78 + - **Warm**: Ruby, Coral, Sunset, Amber 79 + - **Cool**: Forest, Teal, Ocean 80 + - **Vibrant**: Lavender, Rose 81 + - All themes use OKLCH color space for perceptually uniform colors 82 + - System preference detection with manual override 83 + - Persistent theme selection across sessions 84 + 74 85 - **Link Board**: Display curated link collections from Linkat (`blue.linkat.board`) with emoji icons 86 + 75 87 - **Dark Mode**: Seamless light/dark theme switching with system preference detection 88 + 76 89 - **Wolf Mode**: Fun "wolf speak" text transformation toggle that converts text to wolf sounds while preserving: 77 90 - Numbers and abbreviations (1K, 2M, 30s, etc.) 78 91 - Capitalization patterns (UPPERCASE → AWOO, Capitalized → Awoo) 79 92 - Punctuation and formatting 80 93 - Navigation and interactive elements 94 + 95 + - **Decimal Clock**: Unique decimal time display (optional feature) 96 + 97 + - **Happy Mac Easter Egg**: Hidden surprise for visitors to discover 98 + 81 99 - **Scroll to Top**: Smooth scroll-to-top button for long pages 100 + 82 101 - **Responsive Design**: Mobile-first layout that adapts to all screen sizes 102 + 83 103 - **SEO Optimization**: Comprehensive meta tags, Open Graph, and Twitter Card support 104 + 84 105 - **RSS/Atom Feeds**: Multiple feed endpoints for blog posts and status updates 106 + 107 + - **Archive Page**: Browse all your Standard.site documents in one place 85 108 86 109 ### Technical Features 87 110 ··· 92 115 - **Blob URL Construction**: Proper PDS blob URL generation for media assets 93 116 - **Media Extraction**: Automatic CID extraction from various image object formats 94 117 - **Facet Processing**: Rich text with link detection and mention highlighting 118 + - **Video Streaming**: HLS.js integration for adaptive video playback 119 + - **Configurable Cache TTL**: Fine-tune cache durations for different data types 120 + - **CORS Support**: Flexible cross-origin configuration for API endpoints 95 121 96 122 ## 📋 Configuration 97 123 ··· 99 125 100 126 Quick start: 101 127 102 - 1. Copy `.env.example` to `.env.local` and add your AT Protocol DID 128 + 1. Copy `.env` to `.env.local` and update with your AT Protocol DID 103 129 2. Configure publication slugs in `src/lib/config/slugs.ts` 104 130 3. Update static files (robots.txt, sitemap.xml, favicons) 105 - 4. Run `npm install && npm run dev` 131 + 4. Customize themes in `src/lib/config/themes.config.ts` (optional) 132 + 5. Run `npm install && npm run dev` 133 + 134 + ### Environment Variables 135 + 136 + ```ini 137 + # Required: Your AT Protocol DID 138 + PUBLIC_ATPROTO_DID=did:plc:your-did-here 139 + 140 + # Optional: Blog fallback URL 141 + PUBLIC_BLOG_FALLBACK_URL=https://example.com/blog 142 + 143 + # Optional: Slingshot integration 144 + PUBLIC_LOCAL_SLINGSHOT_URL=http://localhost:3000 145 + PUBLIC_SLINGSHOT_URL=https://slingshot.microcosm.blue 146 + 147 + # Site Metadata (for SEO and social sharing) 148 + PUBLIC_SITE_TITLE=Your Site Title 149 + PUBLIC_SITE_DESCRIPTION=Your site description 150 + PUBLIC_SITE_KEYWORDS=your, keywords, here 151 + PUBLIC_SITE_URL=https://yoursite.com 152 + 153 + # CORS Configuration (comma-separated origins) 154 + PUBLIC_CORS_ALLOWED_ORIGINS=https://yoursite.com,https://www.yoursite.com 155 + 156 + # Optional: Customizable Cache TTL (in seconds) 157 + CACHE_TTL_PROFILE=60 158 + CACHE_TTL_SITE_INFO=120 159 + CACHE_TTL_LINKS=60 160 + CACHE_TTL_MUSIC_STATUS=10 161 + CACHE_TTL_KIBUN_STATUS=15 162 + CACHE_TTL_TANGLED_REPOS=60 163 + CACHE_TTL_BLOG_POSTS=30 164 + CACHE_TTL_PUBLICATIONS=60 165 + CACHE_TTL_INDIVIDUAL_POST=60 166 + CACHE_TTL_IDENTITY=1440 167 + ``` 106 168 107 169 ## 🚀 Getting Started 108 170 ··· 152 214 │ ├── lib/ 153 215 │ │ ├── assets/ # Static assets (images, icons) 154 216 │ │ ├── components/ # Reusable Svelte components 155 - │ │ │ ├── layout/ # Header, Footer, Navigation, ThemeToggle, WolfToggle 217 + │ │ │ ├── HappyMacEasterEgg.svelte 218 + │ │ │ ├── layout/ # Header, Footer, Navigation 219 + │ │ │ │ ├── ColorThemeToggle.svelte 220 + │ │ │ │ ├── DecimalClock.svelte 221 + │ │ │ │ ├── DecimalClockInfoBox.svelte 222 + │ │ │ │ ├── ThemeToggle.svelte 223 + │ │ │ │ ├── WolfToggle.svelte 156 224 │ │ │ │ └── main/ 157 - │ │ │ │ ├── card/ # ProfileCard, MusicStatusCard, etc. 225 + │ │ │ │ ├── card/ # Status cards 226 + │ │ │ │ │ ├── BlueskyPostCard.svelte 227 + │ │ │ │ │ ├── KibunStatusCard.svelte 228 + │ │ │ │ │ ├── LinkCard.svelte 229 + │ │ │ │ │ ├── MusicStatusCard.svelte 230 + │ │ │ │ │ ├── PostCard.svelte 231 + │ │ │ │ │ ├── ProfileCard.svelte 232 + │ │ │ │ │ └── TangledRepoCard.svelte 158 233 │ │ │ │ ├── DynamicLinks.svelte 159 - │ │ │ │ ├── ScrollToTop.svelte 160 - │ │ │ │ └── TangledRepos.svelte 234 + │ │ │ │ └── ScrollToTop.svelte 161 235 │ │ │ ├── seo/ # MetaTags component 162 - │ │ │ └── ui/ # Reusable UI components (Card, etc.) 236 + │ │ │ └── ui/ # Reusable UI components 237 + │ │ │ ├── BlogPostCard.svelte 238 + │ │ │ ├── Card.svelte 239 + │ │ │ ├── DocumentCard.svelte 240 + │ │ │ ├── Dropdown.svelte 241 + │ │ │ ├── Pagination.svelte 242 + │ │ │ ├── PostsGroupedView.svelte 243 + │ │ │ ├── SearchBar.svelte 244 + │ │ │ └── Tabs.svelte 163 245 │ │ ├── config/ # Configuration files 164 - │ │ │ └── slugs.ts # Slug to publication mapping 246 + │ │ │ ├── cache.config.ts # Cache TTL settings 247 + │ │ │ ├── slugs.ts # Slug to publication mapping 248 + │ │ │ └── themes.config.ts # Theme definitions 165 249 │ │ ├── data/ # Static data (navigation items) 166 250 │ │ ├── helper/ # Helper functions (meta tags, OG images) 167 251 │ │ ├── services/ # External service integrations 168 252 │ │ │ └── atproto/ # AT Protocol service layer 169 253 │ │ │ ├── agents.ts # Agent management & PDS resolution 170 254 │ │ │ ├── cache.ts # In-memory caching 255 + │ │ │ ├── documents.ts # Standard.site documents 171 256 │ │ │ ├── engagement.ts # Post engagement (likes/reposts) 172 - │ │ │ ├── fetch.ts # Profile, status, site info, music status 257 + │ │ │ ├── fetch.ts # Profile, status, site info, music 173 258 │ │ │ ├── media.ts # Blob URL & image handling 174 259 │ │ │ ├── musicbrainz.ts # MusicBrainz API integration 175 - │ │ │ ├── posts.ts # Blog posts, Bluesky posts, publications 176 - │ │ │ ├── tangled.ts # Tangled repository fetching 260 + │ │ │ ├── pagination/ # Pagination utilities 261 + │ │ │ ├── posts.ts # Blog posts, Bluesky posts 262 + │ │ │ ├── standard.ts # Standard.site integration 177 263 │ │ │ └── types.ts # TypeScript type definitions 178 264 │ │ ├── stores/ # Svelte stores 265 + │ │ │ ├── colorTheme.ts # Color theme management 266 + │ │ │ ├── dropdownState.ts # Dropdown state 267 + │ │ │ ├── happyMac.ts # Happy Mac easter egg 179 268 │ │ │ └── wolfMode.ts # Wolf mode text transformation 180 - │ │ └── utils/ # Utility functions (date formatting, etc.) 269 + │ │ ├── styles/ # Theme CSS files 270 + │ │ │ └── themes/ # Individual theme stylesheets 271 + │ │ └── utils/ # Utility functions 181 272 │ ├── routes/ # SvelteKit routes 182 - │ │ ├── [slug=slug]/ # Dynamic slug-based publication routes 273 + │ │ ├── [slug=slug]/ # Dynamic slug-based routes 183 274 │ │ │ ├── [rkey]/ # Individual document redirects 184 275 │ │ │ ├── atom/ # Deprecated Atom feeds (410 Gone) 185 276 │ │ │ └── rss/ # RSS feed endpoints 277 + │ │ ├── api/ # API endpoints 278 + │ │ │ └── artwork/ # Album artwork proxy 279 + │ │ ├── archive/ # Standard.site documents archive 186 280 │ │ ├── favicon.ico/ # Favicon endpoint 187 - │ │ ├── now/ # Status feed endpoints 188 - │ │ │ ├── atom/ # Deprecated Atom feeds 189 - │ │ │ └── rss/ # RSS feeds 190 281 │ │ └── site/ 191 282 │ │ └── meta/ # Site metadata page 192 283 │ ├── app.css # Global styles ··· 203 294 204 295 - **agents.ts**: Agent management with automatic PDS resolution and fallback to the Bluesky public API 205 296 - **fetch.ts**: Profile, status, site info, links, and music status fetching 206 - - **posts.ts**: Blog posts (WhiteWind & Leaflet), Bluesky posts, and publications 207 - - **tangled.ts**: Repository information from Tangled lexicon 297 + - **posts.ts**: Standard.site documents and Bluesky posts 298 + - **documents.ts**: Standard.site document fetching and management 299 + - **standard.ts**: Standard.site integration utilities 208 300 - **engagement.ts**: Post engagement data (likes/reposts) via Constellation API 209 301 - **media.ts**: Image and blob URL handling with CID extraction 210 - - **musicbrainz.ts**: MusicBrainz API integration for album artwork 302 + - **musicbrainz.ts**: MusicBrainz API integration for album artwork with cascading fallbacks 211 303 - **cache.ts**: In-memory caching with configurable TTL support 304 + - **pagination/**: Utilities for paginated AT Protocol queries 212 305 - **types.ts**: Comprehensive TypeScript definitions for all data structures 213 306 214 307 ### Usage Examples ··· 219 312 fetchBlogPosts, 220 313 fetchLatestBlueskyPost, 221 314 fetchMusicStatus, 222 - fetchTangledRepos 315 + fetchKibunStatus, 316 + fetchTangledRepos, 317 + fetchDocuments 223 318 } from '$lib/services/atproto'; 224 319 225 320 // Fetch profile data 226 - const profile = await fetchProfile(); 321 + const profile = await fetchProfile(fetch); 227 322 228 - // Fetch blog posts from WhiteWind and/or Leaflet 229 - const { posts } = await fetchBlogPosts(); 323 + // Fetch blog posts from Standard.site 324 + const { posts } = await fetchBlogPosts(fetch); 230 325 231 326 // Fetch latest Bluesky post 232 - const post = await fetchLatestBlueskyPost(); 327 + const post = await fetchLatestBlueskyPost(fetch); 233 328 234 329 // Fetch current or last played music 235 - const musicStatus = await fetchMusicStatus(); 330 + const musicStatus = await fetchMusicStatus(fetch); 331 + 332 + // Fetch current mood status 333 + const kibunStatus = await fetchKibunStatus(fetch); 236 334 237 335 // Fetch code repositories 238 - const repos = await fetchTangledRepos(); 336 + const repos = await fetchTangledRepos(fetch); 337 + 338 + // Fetch Standard.site documents 339 + const documents = await fetchDocuments(fetch); 239 340 ``` 240 341 241 342 ## 📝 Publication System 242 343 243 - The publication system uses friendly URL slugs that map to Leaflet publications, with support for multiple platforms and intelligent URL redirects. 344 + The publication system uses friendly URL slugs that map to Standard.site publications with intelligent URL redirects. 244 345 245 346 ### Slug Configuration 246 347 ··· 250 351 export const slugMappings: SlugMapping[] = [ 251 352 { 252 353 slug: 'blog', // Access via /blog 253 - publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey 354 + publicationRkey: '3m3x4bgbsh22k' // Standard.site publication rkey 254 355 }, 255 356 { 256 357 slug: 'notes', // Access via /notes ··· 259 360 ]; 260 361 ``` 261 362 262 - ### Supported Platforms 263 - 264 - 1. **Leaflet** (`pub.leaflet.document`) – **Prioritized by default** 265 - - Format: Custom domain or `https://leaflet.pub/lish/{did}/{publication}/{rkey}` 266 - - Supports multiple publications via slug mapping 267 - - Respects `base_path` configuration 268 - - Always checked first 269 - 270 - 2. **WhiteWind** (`com.whtwnd.blog.entry`) – **Optional, disabled by default** 271 - - Format: `https://whtwnd.com/{did}/{rkey}` 272 - - Automatically filters out drafts and non-public posts 273 - - Only checked if `PUBLIC_ENABLE_WHITEWIND=true` 274 - 275 363 ### Publication Routes 276 364 277 - - `/{slug}` – Redirects to your publication homepage (configured in slugs.ts) 278 - - `/{slug}/{rkey}` – Smart redirect to the correct platform (checks Leaflet first, then WhiteWind if enabled) 279 - - `/{slug}/rss` – Intelligent RSS feed (redirects to Leaflet RSS by default, or generates WhiteWind RSS if enabled) 365 + - `/{slug}` – Redirects to your Standard.site publication homepage 366 + - `/{slug}/{rkey}` – Redirects to the specific document on Standard.site 367 + - `/{slug}/rss` – RSS feed for all documents in the publication 280 368 - `/{slug}/atom` – Deprecated (returns 410 Gone, use RSS instead) 281 - 282 - ### Priority Order 283 - 284 - 1. **Leaflet** is always checked first for publications and documents 285 - 2. The slug mapping determines which publication to check 286 - 3. **WhiteWind** is only checked if `PUBLIC_ENABLE_WHITEWIND=true` 287 - 4. If neither platform has the document, it falls back to `PUBLIC_BLOG_FALLBACK_URL` if configured 288 - 5. Returns 404 if the document isn't found and no fallback is set 369 + - `/archive` – Browse all Standard.site documents across all publications 289 370 290 371 ### RSS Feed Behavior 291 372 292 - - **WhiteWind disabled** (default): Redirects to Leaflet's native RSS feed (includes full content) 293 - - **WhiteWind enabled with posts**: Generates an RSS feed with WhiteWind post links 294 - - **No posts found**: Returns 404 373 + Generates an RSS 2.0 feed containing all documents from the specified publication: 374 + - Includes title, link, publication date, and description 375 + - Filtered by publication rkey 376 + - Cached for 1 hour for performance 377 + - Returns 404 if publication has no documents 295 378 296 379 ### Finding Your Publication Rkey 297 380 298 - 1. Visit your Leaflet publication page 299 - 2. The URL will be in the format: `https://leaflet.pub/lish/{did}/{rkey}` 300 - 3. Copy the `{rkey}` part (e.g., `3m3x4bgbsh22k`) 381 + 1. Visit your Standard.site publication 382 + 2. The publication rkey is part of the publication's AT Protocol URI 383 + 3. You can find it in your Standard.site publication settings 301 384 4. Add it to your slug mapping in `src/lib/config/slugs.ts` 302 385 303 386 ## 🎵 Music Integration ··· 311 394 312 395 ### Album Artwork System 313 396 314 - The music card uses a sophisticated artwork retrieval system: 397 + The music card uses a sophisticated server-side artwork retrieval system with cascading fallbacks: 398 + 399 + 1. **Server-side API Proxy** (`/api/artwork`) 400 + - Solves CORS issues by proxying requests through your server 401 + - Caches artwork URLs to reduce external API calls 402 + - Handles all external API interactions 315 403 316 - 1. **MusicBrainz Cover Art Archive** (Primary) 317 - - Uses `releaseMbId` from music records 318 - - Free, no API key required 319 - - Automatic search fallback when IDs are missing 320 - - Caches search results to avoid repeated lookups 404 + 2. **Cascading Artwork Sources**: 405 + - **MusicBrainz Cover Art Archive** (Primary) 406 + - Uses `releaseMbId` from music records when available 407 + - Automatic search by album name + artist if ID missing 408 + - Free, no API key required 409 + - **iTunes Search API** (Fallback 1) 410 + - Searches by album + artist or track + artist 411 + - Returns high-resolution artwork (600x600) 412 + - **Deezer API** (Fallback 2) 413 + - Album artwork search 414 + - Multiple quality options (XL, big, medium) 415 + - **Last.fm API** (Fallback 3) 416 + - Album info with artwork 417 + - Requires album name 418 + - **AT Protocol Blob Storage** (Final Fallback) 419 + - Uses `artwork` field from records 420 + - Proper PDS blob URL construction 321 421 322 - 2. **AT Protocol Blob Storage** (Fallback) 323 - - Uses `artwork` field from records 324 - - Proper PDS blob URL construction 422 + 3. **Smart Caching**: 423 + - Caches MusicBrainz search results to avoid repeated lookups 424 + - Caches final artwork URLs 425 + - Configurable TTL for music status 325 426 326 427 ### Features 327 428 ··· 329 430 - Shows relative timestamps ("2 minutes ago") 330 431 - Links to origin URLs (Last.fm, Spotify, etc.) 331 432 - Responsive artwork display with fallback icons 332 - - Smart caching with 5-minute TTL 433 + - Smart caching with configurable TTL (default: 2 minutes) 333 434 - Automatic status expiry handling 435 + - Prioritizes album art over track art for better accuracy 334 436 335 437 ### Configuration 336 438 ··· 338 440 339 441 ```ini 340 442 PUBLIC_ATPROTO_DID=did:plc:your-did-here 443 + 444 + # Optional: Adjust music status cache duration (in seconds) 445 + CACHE_TTL_MUSIC_STATUS=120 341 446 ``` 342 447 343 - The card will automatically display your current or last played track. 448 + The card will automatically display your current or last played track with album artwork. 449 + 450 + ## 🎨 Theme System 451 + 452 + The site features 12 beautiful color themes organized into four categories: 453 + 454 + ### Available Themes 455 + 456 + **Neutral Themes** 457 + - **Sage**: Calm green-blue 458 + - **Monochrome**: Pure greyscale 459 + - **Slate**: Blue-grey (default) 460 + 461 + **Warm Themes** 462 + - **Ruby**: Bold red 463 + - **Coral**: Orange-pink 464 + - **Sunset**: Warm orange 465 + - **Amber**: Bright yellow 466 + 467 + **Cool Themes** 468 + - **Forest**: Natural green 469 + - **Teal**: Blue-green 470 + - **Ocean**: Deep blue 471 + 472 + **Vibrant Themes** 473 + - **Lavender**: Soft purple 474 + - **Rose**: Pink-red 475 + 476 + ### Theme Features 477 + 478 + - **OKLCH Color Space**: Perceptually uniform colors for consistent brightness 479 + - **System Detection**: Automatically detects light/dark mode preference 480 + - **Persistent Selection**: Theme choice saved across sessions 481 + - **Smooth Transitions**: Animated color changes 482 + - **Accessible**: All themes meet WCAG contrast requirements 483 + 484 + ### Customizing Themes 485 + 486 + Edit `src/lib/config/themes.config.ts` to add or modify themes: 487 + 488 + ```typescript 489 + export const THEMES: readonly ThemeDefinition[] = [ 490 + { 491 + value: 'mytheme', 492 + label: 'My Theme', 493 + description: 'Custom colors', 494 + color: 'oklch(80% 0.2 180)', 495 + category: 'cool' 496 + }, 497 + // ... more themes 498 + ]; 499 + ``` 344 500 345 501 ## 🔐 CORS Configuration 346 502 ··· 374 530 375 531 CORS is automatically applied to all routes under `/api/`: 376 532 377 - - `/api/artwork` - Album artwork fetching service 533 + - `/api/artwork` - Album artwork fetching service with cascading fallbacks 378 534 379 535 ### Testing CORS 380 536 ··· 397 553 3. **Multiple Domains**: List all your domains that need API access 398 554 4. **HTTPS Only**: Always use HTTPS origins in production 399 555 400 - ## 🎨 Styling 401 - 402 - The project uses: 403 - 404 - - **Tailwind CSS 4**: Latest Tailwind with new features and improved performance 405 - - **@tailwindcss/typography**: Beautiful prose styling for blog content 406 - - **@tailwindcss/vite**: Vite plugin for optimal Tailwind integration 407 - - **Custom Color Palette**: Semantic color tokens (canvas, ink, primary) for consistent theming 408 - - **Dark Mode**: System preference detection with manual override 409 - - **Responsive Design**: Mobile-first approach with breakpoint utilities 410 - 411 556 ## 🏗️ Building for Production 412 557 413 558 ```bash ··· 422 567 423 568 ## 📦 Deployment 424 569 425 - This project uses `@sveltejs/adapter-auto`, which automatically selects the best adapter for your deployment platform: 570 + This project uses `@sveltejs/adapter-vercel` optimized for Vercel deployment: 571 + 572 + ### Vercel (Recommended) 573 + 574 + 1. Push your repository to GitHub/GitLab/Bitbucket 575 + 2. Import project in Vercel 576 + 3. Add environment variables from `.env.local` 577 + 4. Deploy 578 + 579 + ### Other Platforms 580 + 581 + To use a different platform, change the adapter in `svelte.config.js`: 426 582 427 - - **Vercel**: Automatic detection and optimization 428 - - **Netlify**: Automatic detection and optimization 429 - - **Cloudflare Pages**: Automatic detection and optimization 430 - - **Node.js**: Fallback option 583 + ```javascript 584 + import adapter from '@sveltejs/adapter-auto'; // or adapter-node, adapter-static, etc. 585 + ``` 431 586 432 587 For other platforms, see the [SvelteKit adapters documentation](https://kit.svelte.dev/docs/adapters). 433 588 ··· 438 593 ### Site Information (`uk.ewancroft.site.info`) 439 594 440 595 Store comprehensive site metadata: 441 - 442 596 - Technology stack 443 597 - Privacy statement 444 598 - Open-source information ··· 461 615 462 616 Display code repositories with descriptions, labels, and metadata. 463 617 618 + ### Standard.site Documents 619 + 620 + Store and display documents using the Standard.site protocol. 621 + 464 622 ## 🛠️ Development 465 623 466 624 ### Available Scripts ··· 478 636 The project uses: 479 637 480 638 - **TypeScript** – Full type safety throughout 481 - - **Prettier** – Consistent code formatting 639 + - **Prettier** – Consistent code formatting with plugins for Svelte and Tailwind 482 640 - **svelte-check** – Svelte-specific linting 483 641 - **Svelte 5 Runes** – Modern reactivity with better performance 484 642 643 + ### Tech Stack 644 + 645 + - **Framework**: SvelteKit 2.50+ with Svelte 5 646 + - **Styling**: Tailwind CSS 4 with typography plugin 647 + - **AT Protocol**: @atproto/api v0.18.1 648 + - **Video**: HLS.js for adaptive streaming 649 + - **Icons**: @lucide/svelte 650 + - **Build Tool**: Vite 7 651 + - **TypeScript**: v5.9+ 652 + 485 653 ## 🤝 Contributing 486 654 487 655 Contributions are welcome! Please feel free to submit a pull request. ··· 502 670 - [SvelteKit Documentation](https://kit.svelte.dev/) 503 671 - [Tailwind CSS Documentation](https://tailwindcss.com/) 504 672 - [Bluesky](https://bsky.app/) 505 - - [WhiteWind](https://whtwnd.com/) 506 - - [Leaflet](https://leaflet.pub/) 673 + 674 + - [Standard.site](https://standard.site/) 507 675 - [teal.fm](https://teal.fm/) 508 676 - [kibun.social](https://kibun.social/) 509 677 - [MusicBrainz](https://musicbrainz.org/) ··· 536 704 const profile = cache.get<ProfileData>('profile:did:plc:...'); 537 705 ``` 538 706 707 + ### Customizing Cache TTL 708 + 709 + Edit cache durations in `.env.local`: 710 + 711 + ```ini 712 + # Profile data (default: 60 seconds) 713 + CACHE_TTL_PROFILE=300 714 + 715 + # Music status (default: 120 seconds) 716 + CACHE_TTL_MUSIC_STATUS=60 717 + 718 + # Kibun status (default: 120 seconds) 719 + CACHE_TTL_KIBUN_STATUS=90 720 + ``` 721 + 539 722 ### Music Status Not Showing Artwork 540 723 541 724 If your music status doesn't show album artwork: 542 725 543 - 1. Ensure your scrobbler (e.g., piper) is including `releaseMbId` in records 726 + 1. Ensure your scrobbler includes `releaseMbId` in records (best option) 544 727 2. The system will automatically search MusicBrainz if IDs are missing 545 - 3. Check browser console for MusicBrainz search results 546 - 4. Fallback to blob storage if available 547 - 5. Icon placeholder displays if no artwork is found 728 + 3. Album name + artist name provides better results than track name 729 + 4. Check browser console for artwork search results 730 + 5. Fallback to AT Protocol blob storage if external sources fail 731 + 6. Icon placeholder displays if no artwork is found 732 + 733 + The cascading fallback system tries multiple sources: 734 + - MusicBrainz (with automatic search) 735 + - iTunes 736 + - Deezer 737 + - Last.fm 738 + - AT Protocol blob storage 548 739 549 740 ### Documents Not Found 550 741 551 742 1. Verify `PUBLIC_ATPROTO_DID` is correct 552 743 2. Check slug mapping in `src/lib/config/slugs.ts` 553 744 3. Ensure publication rkey matches your Leaflet publication 554 - 4. Verify documents are published (not drafts) 555 - 5. If using WhiteWind, ensure `PUBLIC_ENABLE_WHITEWIND=true` 556 - 6. Check browser console for AT Protocol service errors 745 + 4. Check browser console for AT Protocol service errors 746 + 5. Verify your Standard.site publications are properly configured 747 + 6. For Standard.site documents, check the `/archive` page 557 748 558 749 ### Wolf Mode Not Working 559 750 ··· 561 752 2. Check browser console for errors 562 753 3. Wolf mode preserves navigation and interactive elements 563 754 4. Numbers and abbreviations are preserved intentionally 755 + 5. Toggle is located in the header navigation 756 + 757 + ### Theme Not Persisting 758 + 759 + 1. Check browser localStorage is enabled 760 + 2. Clear site data and try again 761 + 3. Verify the theme value is valid in `themes.config.ts` 762 + 4. Check console for theme-related errors 564 763 565 764 ### Build Errors 566 765 ··· 570 769 4. Reinstall: `npm install` 571 770 5. Try building: `npm run build` 572 771 772 + ### CORS Issues with Artwork 773 + 774 + The artwork system uses a server-side proxy to avoid CORS issues: 775 + 776 + 1. Ensure the `/api/artwork` endpoint is accessible 777 + 2. Check `PUBLIC_CORS_ALLOWED_ORIGINS` includes your domain 778 + 3. Verify external APIs (MusicBrainz, iTunes, etc.) are accessible 779 + 4. Check server logs for API errors 780 + 781 + ### SvelteKit Fetch Error 782 + 783 + If you see "Cannot use relative URL with global fetch": 784 + 785 + 1. Ensure all data fetching functions receive the `fetch` parameter 786 + 2. Pass `fetch` from `load` functions to service functions 787 + 3. Use `event.fetch` in server-side code 788 + 4. This was fixed in the latest version 789 + 573 790 ## 🙏 Acknowledgements 574 791 575 792 - Thanks to the AT Protocol team for creating an open, decentralized protocol 576 - - Thanks to the Bluesky, WhiteWind, Leaflet, teal.fm, kibun.social, Tangled, and Linkat teams 577 - - Thanks to MusicBrainz for providing free album artwork via the Cover Art Archive 793 + - Thanks to the Bluesky, Standard.site, teal.fm, kibun.social, Tangled, and Linkat teams 794 + - Thanks to MusicBrainz, iTunes, Deezer, and Last.fm for providing free artwork APIs 795 + - Thanks to the Cover Art Archive for hosting album artwork 578 796 - Inspired by the personal-web movement and IndieWeb principles 579 797 - Built with love using modern web technologies 580 798 581 799 --- 582 800 583 801 Built with ❤️ using SvelteKit, AT Protocol, and open-source tools 802 + 803 + **Version**: 10.6.0
+11 -22
src/lib/components/layout/main/card/PostCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { Card } from '$lib/components/ui'; 3 - import { BlogPostCard } from '$lib/components/ui'; 4 - import type { BlogPostsData } from '$lib/services/atproto'; 3 + import { DocumentCard } from '$lib/components/ui'; 4 + import type { StandardSiteDocument } from '$lib/services/atproto'; 5 5 6 6 interface Props { 7 - blogPosts?: BlogPostsData | null; 7 + documents?: StandardSiteDocument[] | null; 8 8 } 9 9 10 - let { blogPosts = null }: Props = $props(); 10 + let { documents = null }: Props = $props(); 11 11 </script> 12 12 13 13 <div class="mx-auto w-full max-w-2xl"> 14 - {#if !blogPosts} 14 + {#if !documents} 15 15 <Card loading={true} variant="elevated" padding="md"> 16 16 {#snippet skeleton()} 17 17 <div class="mb-4 h-6 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 18 18 <div class="space-y-3"> 19 19 {#each Array(3) as _} 20 20 <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 21 - <div class="mb-2 flex gap-2"> 22 - <div class="h-5 w-16 rounded bg-canvas-300 dark:bg-canvas-700"></div> 23 - <div class="h-5 w-20 rounded bg-canvas-300 dark:bg-canvas-700"></div> 24 - </div> 25 21 <div class="mb-2 h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 26 22 <div class="mb-2 h-4 w-full rounded bg-canvas-300 dark:bg-canvas-700"></div> 27 23 <div class="h-3 w-24 rounded bg-canvas-300 dark:bg-canvas-700"></div> ··· 30 26 </div> 31 27 {/snippet} 32 28 </Card> 33 - {:else if blogPosts.posts && blogPosts.posts.length > 0} 29 + {:else if documents && documents.length > 0} 34 30 <Card variant="elevated" padding="md"> 35 31 {#snippet children()} 36 32 <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Recent Posts</h2> 37 33 <div class="space-y-3"> 38 - {#each blogPosts.posts as post} 39 - <BlogPostCard {post} /> 34 + {#each documents as document} 35 + <DocumentCard {document} /> 40 36 {/each} 41 37 </div> 42 38 {/snippet} ··· 46 42 {#snippet children()} 47 43 <div class="text-center"> 48 44 <p class="text-ink-700 dark:text-ink-300"> 49 - No blog posts available. Write on 50 - <a 51 - href="https://whtwnd.com/" 52 - class="text-primary-600 hover:underline dark:text-primary-400" 53 - target="_blank" 54 - rel="noopener noreferrer">WhiteWind</a 55 - > 56 - or 45 + No documents available. Start writing on 57 46 <a 58 - href="https://leaflet.pub/" 47 + href="https://standard.site/" 59 48 class="text-primary-600 hover:underline dark:text-primary-400" 60 49 target="_blank" 61 - rel="noopener noreferrer">Leaflet</a 50 + rel="noopener noreferrer">Standard.site</a 62 51 > 63 52 to get started! 64 53 </p>
+11
src/lib/components/ui/BlogPostCard.svelte
··· 17 17 18 18 <InternalCard href={post.url}> 19 19 {#snippet children()} 20 + <!-- Cover Image (Standard.site only) --> 21 + {#if post.coverImage} 22 + <div class="mb-3 overflow-hidden rounded-lg"> 23 + <img 24 + src={post.coverImage} 25 + alt={post.title} 26 + class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" 27 + /> 28 + </div> 29 + {/if} 30 + 20 31 <div class="relative min-w-0 flex-1 space-y-2"> 21 32 <!-- Badges: Platform and Publication --> 22 33 {#if badges.length > 0}
+83
src/lib/components/ui/DocumentCard.svelte
··· 1 + <script lang="ts"> 2 + import { ExternalLink, Tag } from '@lucide/svelte'; 3 + import type { StandardSiteDocument } from '$lib/services/atproto'; 4 + import { InternalCard } from '$lib/components/ui'; 5 + import { formatLocalizedDate } from '$lib/utils/locale'; 6 + 7 + interface Props { 8 + document: StandardSiteDocument; 9 + locale?: string; 10 + } 11 + 12 + let { document, locale }: Props = $props(); 13 + </script> 14 + 15 + <InternalCard href={document.url}> 16 + {#snippet children()} 17 + <!-- Cover Image --> 18 + {#if document.coverImage} 19 + <div class="mb-3 overflow-hidden rounded-lg"> 20 + <img 21 + src={document.coverImage} 22 + alt={document.title} 23 + class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" 24 + /> 25 + </div> 26 + {/if} 27 + 28 + <div class="relative min-w-0 flex-1 space-y-2"> 29 + <!-- Publication Badge --> 30 + {#if document.publicationName} 31 + <div class="flex flex-wrap items-center gap-2"> 32 + <span 33 + class="rounded bg-accent-500 px-2 py-0.5 text-xs font-semibold text-white uppercase dark:bg-accent-600" 34 + > 35 + {document.publicationName} 36 + </span> 37 + </div> 38 + {/if} 39 + 40 + <!-- Title --> 41 + <h4 42 + class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50" 43 + > 44 + {document.title} 45 + </h4> 46 + 47 + <!-- Description --> 48 + {#if document.description} 49 + <p 50 + class="overflow-wrap-anywhere line-clamp-2 text-sm wrap-break-word text-ink-700 dark:text-ink-200" 51 + > 52 + {document.description} 53 + </p> 54 + {/if} 55 + 56 + <!-- Timestamp --> 57 + <div class="pt-1"> 58 + <p class="text-xs font-medium text-ink-800 dark:text-ink-100"> 59 + {formatLocalizedDate(document.publishedAt, locale)} 60 + </p> 61 + </div> 62 + </div> 63 + 64 + <!-- Right column: External Link Icon and Tags --> 65 + <div class="flex shrink-0 flex-col items-end justify-between gap-2 self-stretch"> 66 + <!-- External Link Icon --> 67 + <ExternalLink 68 + class="h-4 w-4 text-ink-700 transition-colors dark:text-ink-200" 69 + aria-hidden="true" 70 + /> 71 + 72 + <!-- Tags --> 73 + {#if document.tags && document.tags.length > 0} 74 + <div class="flex items-center gap-1.5 rounded bg-ink-100 px-2 py-0.5 dark:bg-ink-800"> 75 + <Tag class="h-3 w-3 text-ink-700 dark:text-ink-200" aria-hidden="true" /> 76 + <span class="text-xs font-medium text-ink-800 dark:text-ink-100"> 77 + {document.tags.length} 78 + </span> 79 + </div> 80 + {/if} 81 + </div> 82 + {/snippet} 83 + </InternalCard>
+3
src/lib/components/ui/index.ts
··· 9 9 export { default as SearchBar } from './SearchBar.svelte'; 10 10 export { default as Tabs } from './Tabs.svelte'; 11 11 export { default as PostsGroupedView } from './PostsGroupedView.svelte'; 12 + export { default as DocumentCard } from './DocumentCard.svelte'; 13 + 14 + // Deprecated: Use DocumentCard instead 12 15 export { default as BlogPostCard } from './BlogPostCard.svelte';
+1 -1
src/lib/config/slugs.ts
··· 43 43 if (!mapping) return null; 44 44 return { 45 45 rkey: mapping.publicationRkey, 46 - platform: mapping.platform || 'leaflet' // Default to leaflet for backwards compatibility 46 + platform: mapping.platform 47 47 }; 48 48 } 49 49
+12 -12
src/lib/data/slug-mappings.ts
··· 1 1 /** 2 2 * Slug to Publication mapping data 3 3 * 4 - * Maps friendly URL slugs to publication rkeys from Leaflet or Standard.site. 4 + * Maps friendly URL slugs to Standard.site publication rkeys. 5 5 * This allows you to access publications via /{slug} instead of using rkeys. 6 6 * 7 7 * Example: 8 - * - /blog → maps to Leaflet publication with rkey "3m3x4bgbsh22k" 8 + * - /blog → maps to Standard.site publication with rkey "3m3x4bgbsh22k" 9 9 * - /notes → maps to Standard.site publication with rkey "xyz123abc" 10 10 */ 11 11 12 - export type PublicationPlatform = 'leaflet' | 'standard.site'; 12 + export type PublicationPlatform = 'standard.site'; 13 13 14 14 export interface SlugMapping { 15 15 /** The URL-friendly slug (will be normalized automatically) */ 16 16 slug: string; 17 17 /** The publication rkey */ 18 18 publicationRkey: string; 19 - /** The platform this publication belongs to (defaults to 'leaflet' for backwards compatibility) */ 20 - platform?: PublicationPlatform; 19 + /** The platform this publication belongs to (always 'standard.site') */ 20 + platform: PublicationPlatform; 21 21 } 22 22 23 23 /** ··· 33 33 export const slugMappings: SlugMapping[] = [ 34 34 { 35 35 slug: 'blog', 36 - publicationRkey: '3m3x4bgbsh22k', // my blog publication rkey 37 - platform: 'leaflet' 36 + publicationRkey: '3m3x4bgbsh22k', // Your blog publication rkey 37 + platform: 'standard.site' 38 38 }, 39 39 { 40 40 slug: 'cailean', 41 - publicationRkey: '3m4222fxc3k2q', // Cailean Uen's publication rkey for his journal 42 - platform: 'leaflet' 41 + publicationRkey: '3m4222fxc3k2q', // Cailean Uen's journal publication rkey 42 + platform: 'standard.site' 43 43 }, 44 44 { 45 45 slug: 'creativity', 46 - publicationRkey: '3m6afhzlxt22p', // my creativity dump publication rkey 47 - platform: 'leaflet' 46 + publicationRkey: '3m6afhzlxt22p', // Your creativity dump publication rkey 47 + platform: 'standard.site' 48 48 } 49 49 // Add more mappings as needed: 50 50 // { slug: 'notes', publicationRkey: 'xyz123abc', platform: 'standard.site' }, 51 - // { slug: 'essays', publicationRkey: 'def456ghi', platform: 'leaflet' }, 51 + // { slug: 'essays', publicationRkey: 'def456ghi', platform: 'standard.site' }, 52 52 ];
+6 -9
src/lib/helper/badges.ts
··· 8 8 9 9 /** 10 10 * Get badge configuration for a post based on platform and publication 11 + * Standard.site posts get jade color styling 11 12 */ 12 13 export function getPostBadges(post: BlogPost): PostBadge[] { 13 14 const badges: PostBadge[] = []; 14 15 15 - // Platform badge 16 - if (post.platform === 'WhiteWind') { 17 - badges.push({ text: 'WhiteWind', color: 'mint', variant: 'soft' }); 18 - } else if (post.platform === 'leaflet') { 19 - badges.push({ text: 'Leaflet', color: 'sage', variant: 'soft' }); 20 - } 16 + // Platform badge - Standard.site 17 + badges.push({ text: 'Standard.site', color: 'jade', variant: 'solid' }); 21 18 22 - // Publication name badge for Leaflet posts 23 - if (post.publicationName && post.platform === 'leaflet') { 24 - badges.push({ text: post.publicationName, color: 'sage', variant: 'solid' }); 19 + // Publication name badge 20 + if (post.publicationName) { 21 + badges.push({ text: post.publicationName, color: 'jade', variant: 'soft' }); 25 22 } 26 23 27 24 return badges;
+5 -5
src/lib/helper/posts.ts
··· 20 20 const descMatch = post.description?.toLowerCase().includes(lowerQuery); 21 21 const platformMatch = post.platform.toLowerCase().includes(lowerQuery); 22 22 const pubMatch = post.publicationName?.toLowerCase().includes(lowerQuery); 23 - const tagsMatch = post.tags?.some((tag) => tag.toLowerCase().includes(lowerQuery)); 23 + const tagsMatch = post.tags?.some((tag: string) => tag.toLowerCase().includes(lowerQuery)); 24 24 return titleMatch || descMatch || platformMatch || pubMatch || tagsMatch; 25 25 }); 26 26 } ··· 77 77 } 78 78 79 79 /** 80 - * Extract all unique tags from posts (normalized to lowercase) 80 + * Extract all unique tags from posts or documents (normalized to lowercase) 81 81 */ 82 - export function getAllTags(posts: BlogPost[]): string[] { 82 + export function getAllTags(items: Array<{ tags?: string[] }>): string[] { 83 83 const tagsSet = new Set<string>(); 84 - posts.forEach((post) => { 85 - post.tags?.forEach((tag) => tagsSet.add(tag.toLowerCase())); 84 + items.forEach((item) => { 85 + item.tags?.forEach((tag: string) => tagsSet.add(tag.toLowerCase())); 86 86 }); 87 87 return Array.from(tagsSet).sort(); 88 88 }
+404
src/lib/services/atproto/documents.ts
··· 1 + /** 2 + * Standard.site Document Service 3 + * 4 + * Based on: /Volumes/Storage/Developer/clones/docs.surf/packages/server/src/utils/document.ts 5 + * 6 + * This service handles fetching, resolving, and caching Standard.site documents and publications. 7 + * All legacy platform support (WhiteWind, Leaflet) has been removed. 8 + */ 9 + 10 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 11 + import { cache } from './cache'; 12 + import { withFallback, resolveIdentity } from './agents'; 13 + import { buildPdsBlobUrl } from './media'; 14 + import type { 15 + StandardSitePublication, 16 + StandardSitePublicationsData, 17 + StandardSiteDocument, 18 + StandardSiteDocumentsData, 19 + StandardSiteBasicTheme, 20 + StandardSiteThemeColor 21 + } from './types'; 22 + 23 + /** 24 + * Raw document record from PDS (matches docs.surf pattern) 25 + */ 26 + interface DocumentRecord { 27 + site: string; 28 + path?: string; 29 + title: string; 30 + description?: string; 31 + coverImage?: unknown; 32 + content?: unknown; 33 + textContent?: string; 34 + bskyPostRef?: { uri: string; cid: string }; 35 + tags?: string[]; 36 + publishedAt: string; 37 + updatedAt?: string; 38 + } 39 + 40 + /** 41 + * Raw publication record from PDS (matches docs.surf pattern) 42 + */ 43 + interface PublicationRecord { 44 + url: string; 45 + name: string; 46 + description?: string; 47 + icon?: unknown; 48 + basicTheme?: { 49 + background: StandardSiteThemeColor; 50 + foreground: StandardSiteThemeColor; 51 + accent: StandardSiteThemeColor; 52 + accentForeground: StandardSiteThemeColor; 53 + }; 54 + preferences?: { 55 + showInDiscover?: boolean; 56 + }; 57 + } 58 + 59 + /** 60 + * Fetches a single publication record from an at:// URI 61 + * Based on docs.surf fetchPublication() 62 + */ 63 + async function fetchPublicationByUri( 64 + publicationUri: string, 65 + fetchFn?: typeof fetch 66 + ): Promise<StandardSitePublication | null> { 67 + // Extract rkey from URI 68 + const rkey = publicationUri.split('/').pop(); 69 + if (!rkey) return null; 70 + 71 + try { 72 + const record = await withFallback( 73 + PUBLIC_ATPROTO_DID, 74 + async (agent) => { 75 + const response = await agent.com.atproto.repo.getRecord({ 76 + repo: PUBLIC_ATPROTO_DID, 77 + collection: 'site.standard.publication', 78 + rkey 79 + }); 80 + return response.data; 81 + }, 82 + true, 83 + fetchFn 84 + ); 85 + 86 + if (!record) return null; 87 + 88 + const pubValue = record.value as unknown as PublicationRecord; 89 + if (!pubValue.url || !pubValue.name) return null; 90 + 91 + // Resolve icon blob URL if present 92 + const icon = pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined; 93 + 94 + // Extract basic theme if present 95 + let basicTheme: StandardSiteBasicTheme | undefined; 96 + if (pubValue.basicTheme) { 97 + basicTheme = { 98 + background: pubValue.basicTheme.background, 99 + foreground: pubValue.basicTheme.foreground, 100 + accent: pubValue.basicTheme.accent, 101 + accentForeground: pubValue.basicTheme.accentForeground 102 + }; 103 + } 104 + 105 + return { 106 + name: pubValue.name, 107 + rkey, 108 + uri: publicationUri, 109 + url: pubValue.url, 110 + description: pubValue.description, 111 + icon, 112 + basicTheme, 113 + preferences: pubValue.preferences 114 + }; 115 + } catch (error) { 116 + console.warn(`Failed to fetch publication ${publicationUri}:`, error); 117 + return null; 118 + } 119 + } 120 + 121 + /** 122 + * Resolves the canonical view URL for a document 123 + * Always uses external publication URLs 124 + */ 125 + function resolveViewUrl( 126 + site: string, 127 + path: string | undefined, 128 + publicationUrl: string | undefined, 129 + rkey: string 130 + ): string { 131 + // Determine document path (use path if provided, otherwise fallback to /rkey) 132 + const docPath = path || `/${rkey}`; 133 + 134 + // Ensure path starts with / 135 + const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`; 136 + 137 + // Check if site is a publication URI (at://) or direct URL 138 + if (site.startsWith('at://')) { 139 + // Publication-based document 140 + if (!publicationUrl) { 141 + // Shouldn't happen, but fallback to using the URI 142 + console.warn(`Missing publication URL for document with site: ${site}`); 143 + return `${site}${normalizedPath}`; 144 + } 145 + 146 + // Use the publication's external URL 147 + const baseUrl = publicationUrl.startsWith('http') 148 + ? publicationUrl 149 + : `https://${publicationUrl}`; 150 + 151 + // Remove trailing slash and construct URL 152 + return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 153 + } else { 154 + // Loose document with direct URL 155 + const baseUrl = site.startsWith('http') ? site : `https://${site}`; 156 + 157 + // Remove trailing slash and construct URL 158 + return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`; 159 + } 160 + } 161 + 162 + /** 163 + * Helper function to get a blob URL 164 + * Based on docs.surf buildBlobUrl() 165 + */ 166 + async function getBlobUrl(blob: any, fetchFn?: typeof fetch): Promise<string | undefined> { 167 + try { 168 + const cid = blob.ref?.$link || blob.cid; 169 + if (!cid) return undefined; 170 + 171 + const resolved = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 172 + return buildPdsBlobUrl(resolved.pds, PUBLIC_ATPROTO_DID, cid); 173 + } catch (error) { 174 + console.warn('Failed to resolve blob URL:', error); 175 + return undefined; 176 + } 177 + } 178 + 179 + /** 180 + * Fetches all Standard.site publications for a user 181 + */ 182 + export async function fetchPublications( 183 + fetchFn?: typeof fetch 184 + ): Promise<StandardSitePublicationsData> { 185 + console.info('[Standard.site] Fetching publications'); 186 + const cacheKey = `standard-site:publications:${PUBLIC_ATPROTO_DID}`; 187 + const cached = cache.get<StandardSitePublicationsData>(cacheKey); 188 + 189 + if (cached) { 190 + console.debug('[Standard.site] Returning cached publications'); 191 + return cached; 192 + } 193 + 194 + const publications: StandardSitePublication[] = []; 195 + console.info('[Standard.site] Cache miss, fetching from network'); 196 + 197 + try { 198 + console.debug('[Standard.site] Querying publication records'); 199 + const publicationsRecords = await withFallback( 200 + PUBLIC_ATPROTO_DID, 201 + async (agent) => { 202 + const response = await agent.com.atproto.repo.listRecords({ 203 + repo: PUBLIC_ATPROTO_DID, 204 + collection: 'site.standard.publication', 205 + limit: 100 206 + }); 207 + return response.data.records; 208 + }, 209 + true, 210 + fetchFn 211 + ); 212 + 213 + for (const pubRecord of publicationsRecords) { 214 + const pubValue = pubRecord.value as unknown as PublicationRecord; 215 + const rkey = pubRecord.uri.split('/').pop() || ''; 216 + 217 + // Resolve icon blob URL if present 218 + const icon = pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined; 219 + 220 + // Extract basic theme if present 221 + let basicTheme: StandardSiteBasicTheme | undefined; 222 + if (pubValue.basicTheme) { 223 + basicTheme = { 224 + background: pubValue.basicTheme.background, 225 + foreground: pubValue.basicTheme.foreground, 226 + accent: pubValue.basicTheme.accent, 227 + accentForeground: pubValue.basicTheme.accentForeground 228 + }; 229 + } 230 + 231 + publications.push({ 232 + name: pubValue.name, 233 + rkey, 234 + uri: pubRecord.uri, 235 + url: pubValue.url, 236 + description: pubValue.description, 237 + icon, 238 + basicTheme, 239 + preferences: pubValue.preferences 240 + }); 241 + } 242 + 243 + const data: StandardSitePublicationsData = { publications }; 244 + cache.set(cacheKey, data); 245 + return data; 246 + } catch (error) { 247 + console.warn('Failed to fetch Standard.site publications:', error); 248 + return { publications: [] }; 249 + } 250 + } 251 + 252 + /** 253 + * Fetches all Standard.site documents for a user 254 + * Based on docs.surf processDocument() pattern 255 + */ 256 + export async function fetchDocuments(fetchFn?: typeof fetch): Promise<StandardSiteDocumentsData> { 257 + console.info('[Standard.site] Fetching documents'); 258 + const cacheKey = `standard-site:documents:${PUBLIC_ATPROTO_DID}`; 259 + const cached = cache.get<StandardSiteDocumentsData>(cacheKey); 260 + 261 + if (cached) { 262 + console.debug('[Standard.site] Returning cached documents'); 263 + return cached; 264 + } 265 + 266 + const documents: StandardSiteDocument[] = []; 267 + console.info('[Standard.site] Cache miss, fetching from network'); 268 + 269 + try { 270 + // Fetch all publications first to map URIs to publication data 271 + const publicationsData = await fetchPublications(fetchFn); 272 + const publicationsMap = new Map<string, StandardSitePublication>(); 273 + 274 + for (const pub of publicationsData.publications) { 275 + publicationsMap.set(pub.uri, pub); 276 + } 277 + 278 + console.debug('[Standard.site] Querying document records'); 279 + const documentsRecords = await withFallback( 280 + PUBLIC_ATPROTO_DID, 281 + async (agent) => { 282 + const response = await agent.com.atproto.repo.listRecords({ 283 + repo: PUBLIC_ATPROTO_DID, 284 + collection: 'site.standard.document', 285 + limit: 100 286 + }); 287 + return response.data.records; 288 + }, 289 + true, 290 + fetchFn 291 + ); 292 + 293 + for (const docRecord of documentsRecords) { 294 + const docValue = docRecord.value as unknown as DocumentRecord; 295 + const rkey = docRecord.uri.split('/').pop() || ''; 296 + 297 + // Extract fields from document record 298 + const site = docValue.site; 299 + const path = docValue.path; 300 + const title = docValue.title; 301 + const description = docValue.description; 302 + const textContent = docValue.textContent; 303 + const content = docValue.content; 304 + const bskyPostRef = docValue.bskyPostRef; 305 + const tags = docValue.tags; 306 + const publishedAt = docValue.publishedAt; 307 + const updatedAt = docValue.updatedAt; 308 + 309 + // Resolve publication if site is at:// URI 310 + let publication: StandardSitePublication | undefined; 311 + let publicationRkey: string | undefined; 312 + let pubUrl: string | undefined; 313 + 314 + if (site.startsWith('at://')) { 315 + // Publication-based document 316 + publication = publicationsMap.get(site); 317 + publicationRkey = site.split('/').pop(); 318 + pubUrl = publication?.url; 319 + } else { 320 + // Loose document - site is the base URL 321 + pubUrl = site; 322 + } 323 + 324 + // Construct canonical view URL 325 + const url = resolveViewUrl(site, path, pubUrl, rkey); 326 + 327 + // Resolve cover image blob URL if present 328 + const coverImage = docValue.coverImage 329 + ? await getBlobUrl(docValue.coverImage, fetchFn) 330 + : undefined; 331 + 332 + documents.push({ 333 + title, 334 + rkey, 335 + uri: docRecord.uri, 336 + url, 337 + site, 338 + path, 339 + description, 340 + coverImage, 341 + content, 342 + textContent, 343 + bskyPostRef, 344 + tags, 345 + publishedAt, 346 + updatedAt, 347 + publicationName: publication?.name, 348 + publicationRkey 349 + }); 350 + } 351 + 352 + // Sort by publishedAt (newest first) 353 + documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); 354 + 355 + const data: StandardSiteDocumentsData = { documents }; 356 + cache.set(cacheKey, data); 357 + return data; 358 + } catch (error) { 359 + console.warn('Failed to fetch Standard.site documents:', error); 360 + return { documents: [] }; 361 + } 362 + } 363 + 364 + /** 365 + * Fetches recent documents (top N) 366 + */ 367 + export async function fetchRecentDocuments( 368 + limit: number = 5, 369 + fetchFn?: typeof fetch 370 + ): Promise<StandardSiteDocument[]> { 371 + const { documents } = await fetchDocuments(fetchFn); 372 + return documents.slice(0, limit); 373 + } 374 + 375 + /** 376 + * Converts Standard.site documents to BlogPost format 377 + */ 378 + function convertDocumentToBlogPost(doc: StandardSiteDocument): import('./types').BlogPost { 379 + return { 380 + title: doc.title, 381 + url: doc.url, 382 + createdAt: doc.publishedAt, 383 + platform: 'standard.site', 384 + description: doc.description, 385 + rkey: doc.rkey, 386 + publicationName: doc.publicationName, 387 + publicationRkey: doc.publicationRkey, 388 + tags: doc.tags, 389 + coverImage: doc.coverImage, 390 + textContent: doc.textContent, 391 + updatedAt: doc.updatedAt 392 + }; 393 + } 394 + 395 + /** 396 + * Fetches blog posts from Standard.site documents 397 + */ 398 + export async function fetchBlogPosts( 399 + fetchFn?: typeof fetch 400 + ): Promise<{ posts: import('./types').BlogPost[] }> { 401 + const { documents } = await fetchDocuments(fetchFn); 402 + const posts = documents.map(convertDocumentToBlogPost); 403 + return { posts }; 404 + }
+4 -4
src/lib/services/atproto/fetch.ts
··· 235 235 if (releaseName && artistName) { 236 236 console.info('[MusicStatus] Prioritizing album artwork search'); 237 237 artworkUrl = 238 - (await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined; 238 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 239 239 } 240 240 241 241 // Priority 2: Fall back to track-based search if album search failed 242 242 if (!artworkUrl && trackName && artistName) { 243 243 console.info('[MusicStatus] Falling back to track-based artwork search'); 244 244 artworkUrl = 245 - (await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined; 245 + (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 246 246 } 247 247 248 248 // Priority 3: Final fallback to atproto blob if no external artwork found ··· 326 326 if (releaseName && artistName) { 327 327 console.info('[MusicStatus] Prioritizing album artwork search'); 328 328 artworkUrl = 329 - (await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined; 329 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 330 330 } 331 331 332 332 // Priority 2: Fall back to track-based search if album search failed 333 333 if (!artworkUrl && trackName && artistName) { 334 334 console.info('[MusicStatus] Falling back to track-based artwork search'); 335 335 artworkUrl = 336 - (await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined; 336 + (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 337 337 } 338 338 339 339 // Priority 3: Final fallback to atproto blob if no external artwork found
+12 -12
src/lib/services/atproto/index.ts
··· 2 2 * Unified AT Protocol service exports 3 3 * 4 4 * This module provides a clean API for interacting with AT Protocol services, 5 - * including profile data, blog posts, Bluesky posts, and custom lexicons. 5 + * focusing exclusively on Standard.site documents and publications. 6 + * Legacy platform support (WhiteWind, Leaflet) has been removed. 6 7 */ 7 8 8 9 // Export all types ··· 11 12 SiteInfoData, 12 13 LinkData, 13 14 LinkCard, 15 + BlueskyPost, 14 16 BlogPost, 15 - BlogPostsData, 16 - LeafletPublication, 17 - LeafletPublicationsData, 18 - BlueskyPost, 19 17 PostAuthor, 20 18 ExternalLink, 21 19 Facet, ··· 51 49 fetchTangledRepos 52 50 } from './fetch'; 53 51 54 - export { fetchStandardSitePublications, fetchStandardSiteDocuments } from './standard'; 55 - 52 + // Export Standard.site document functions 56 53 export { 57 - fetchBlogPosts, 58 - fetchLeafletPublications, 59 - fetchLatestBlueskyPost, 60 - fetchPostFromUri 61 - } from './posts'; 54 + fetchPublications, 55 + fetchDocuments, 56 + fetchRecentDocuments, 57 + fetchBlogPosts 58 + } from './documents'; 59 + 60 + // Export Bluesky post functions 61 + export { fetchLatestBlueskyPost, fetchPostFromUri } from './posts'; 62 62 63 63 // Export utility functions 64 64 export { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from './media';
+4 -2
src/lib/services/atproto/musicbrainz.ts
··· 358 358 trackName: string, 359 359 artistName: string, 360 360 releaseName?: string, 361 - releaseMbId?: string 361 + releaseMbId?: string, 362 + fetchFn?: typeof fetch 362 363 ): Promise<string | null> { 363 364 try { 364 365 // Build query parameters ··· 378 379 }); 379 380 380 381 // Call our server-side API endpoint 381 - const response = await fetch(`/api/artwork?${params.toString()}`); 382 + const fetchFunc = fetchFn || fetch; 383 + const response = await fetchFunc(`/api/artwork?${params.toString()}`); 382 384 383 385 if (!response.ok) { 384 386 console.error('[Artwork] API request failed:', response.status);
+17 -201
src/lib/services/atproto/posts.ts
··· 1 1 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 2 import { cache } from './cache'; 3 - import { withFallback, defaultAgent, createAgent } from './agents'; 4 - import { resolveIdentity } from './agents'; 5 - import { buildPdsBlobUrl } from './media'; 6 - import { fetchAllEngagement } from './engagement'; 7 - import type { 8 - BlogPost, 9 - BlogPostsData, 10 - BlueskyPost, 11 - PostAuthor, 12 - ExternalLink, 13 - LeafletPublication, 14 - LeafletPublicationsData 15 - } from './types'; 3 + import { withFallback } from './agents'; 4 + import type { BlogPost, BlogPostsData, BlueskyPost, PostAuthor, ExternalLink } from './types'; 16 5 import { fetchStandardSiteDocuments } from './standard'; 17 6 18 7 /** 19 - * Fetches all Leaflet publications for a user 20 - */ 21 - export async function fetchLeafletPublications( 22 - fetchFn?: typeof fetch 23 - ): Promise<LeafletPublicationsData> { 24 - console.info('[Leaflet] Fetching publications'); 25 - const cacheKey = `leaflet:publications:${PUBLIC_ATPROTO_DID}`; 26 - const cached = cache.get<LeafletPublicationsData>(cacheKey); 27 - if (cached) { 28 - console.debug('[Leaflet] Returning cached publications'); 29 - return cached; 30 - } 31 - 32 - const publications: LeafletPublication[] = []; 33 - console.info('[Leaflet] Cache miss, fetching from network'); 34 - 35 - try { 36 - console.debug('[Leaflet] Querying publications records'); 37 - const publicationsRecords = await withFallback( 38 - PUBLIC_ATPROTO_DID, 39 - async (agent) => { 40 - const response = await agent.com.atproto.repo.listRecords({ 41 - repo: PUBLIC_ATPROTO_DID, 42 - collection: 'pub.leaflet.publication', 43 - limit: 100 44 - }); 45 - return response.data.records; 46 - }, 47 - true, 48 - fetchFn 49 - ); 50 - 51 - for (const pubRecord of publicationsRecords) { 52 - const pubValue = pubRecord.value as any; 53 - const rkey = pubRecord.uri.split('/').pop() || ''; 54 - 55 - publications.push({ 56 - name: pubValue.name || 'Untitled Publication', 57 - rkey, 58 - uri: pubRecord.uri, 59 - basePath: pubValue.base_path, 60 - description: pubValue.description, 61 - icon: pubValue.icon ? await getBlobUrl(pubValue.icon, fetchFn) : undefined 62 - }); 63 - } 64 - 65 - const data: LeafletPublicationsData = { publications }; 66 - cache.set(cacheKey, data); 67 - return data; 68 - } catch (error) { 69 - console.warn('Failed to fetch Leaflet publications:', error); 70 - return { publications: [] }; 71 - } 72 - } 73 - 74 - /** 75 - * Helper function to get a blob URL for Leaflet publication icons 76 - */ 77 - async function getBlobUrl(blob: any, fetchFn?: typeof fetch): Promise<string | undefined> { 78 - try { 79 - const cid = blob.ref?.$link || blob.cid; 80 - if (!cid) return undefined; 81 - 82 - const resolved = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 83 - return buildPdsBlobUrl(resolved.pds, PUBLIC_ATPROTO_DID, cid); 84 - } catch (error) { 85 - console.warn('Failed to resolve blob URL:', error); 86 - return undefined; 87 - } 88 - } 89 - 90 - /** 91 - * Fetches blog posts from WhiteWind, Leaflet, and Standard.site sources 92 - * Supports multiple publications from all platforms 8 + * Fetches blog posts from Standard.site only 9 + * @param fetchFn - Optional fetch function for SSR 93 10 */ 94 11 export async function fetchBlogPosts(fetchFn?: typeof fetch): Promise<BlogPostsData> { 95 12 const cacheKey = `blogposts:${PUBLIC_ATPROTO_DID}`; ··· 98 15 99 16 const posts: BlogPost[] = []; 100 17 101 - // Fetch WhiteWind posts 102 - try { 103 - const whiteWindRecords = await withFallback( 104 - PUBLIC_ATPROTO_DID, 105 - async (agent) => { 106 - const response = await agent.com.atproto.repo.listRecords({ 107 - repo: PUBLIC_ATPROTO_DID, 108 - collection: 'com.whtwnd.blog.entry', 109 - limit: 100 110 - }); 111 - return response.data.records; 112 - }, 113 - true, 114 - fetchFn 115 - ); 116 - 117 - for (const record of whiteWindRecords) { 118 - const value = record.value as any; 119 - // Skip drafts and non-public posts 120 - if (value.isDraft || (value.visibility && value.visibility !== 'public')) { 121 - continue; 122 - } 123 - 124 - posts.push({ 125 - title: value.title || 'Untitled Post', 126 - url: `https://whtwnd.com/${PUBLIC_ATPROTO_DID}/${record.uri.split('/').pop()}`, 127 - createdAt: value.createdAt || record.value.createdAt || new Date().toISOString(), 128 - platform: 'WhiteWind', 129 - description: value.subtitle, 130 - rkey: record.uri.split('/').pop() || '' 131 - }); 132 - } 133 - } catch (error) { 134 - console.warn('Failed to fetch WhiteWind posts:', error); 135 - } 136 - 137 - // Fetch Leaflet publications and documents 138 - try { 139 - // Get all publications first 140 - const publicationsData = await fetchLeafletPublications(fetchFn); 141 - const publicationsMap = new Map<string, LeafletPublication>(); 142 - for (const pub of publicationsData.publications) { 143 - publicationsMap.set(pub.uri, pub); 144 - } 145 - 146 - // Fetch all Leaflet documents 147 - const leafletDocsRecords = await withFallback( 148 - PUBLIC_ATPROTO_DID, 149 - async (agent) => { 150 - const response = await agent.com.atproto.repo.listRecords({ 151 - repo: PUBLIC_ATPROTO_DID, 152 - collection: 'pub.leaflet.document', 153 - limit: 100 154 - }); 155 - return response.data.records; 156 - }, 157 - true, 158 - fetchFn 159 - ); 160 - 161 - for (const record of leafletDocsRecords) { 162 - const value = record.value as any; 163 - const rkey = record.uri.split('/').pop() || ''; 164 - const publicationUri = value.publication; 165 - const publication = publicationsMap.get(publicationUri); 166 - 167 - // Determine URL based on priority: publication base_path → Leaflet /p/[DID]/[rkey] format 168 - let url: string; 169 - const publicationRkey = publicationUri ? publicationUri.split('/').pop() : undefined; 170 - 171 - if (publication?.basePath) { 172 - // Ensure basePath is a complete URL 173 - const basePath = publication.basePath.startsWith('http') 174 - ? publication.basePath 175 - : `https://${publication.basePath}`; 176 - url = `${basePath}/${rkey}`; 177 - } else { 178 - // Fallback format: https://leaflet.pub/p/[DID]/[rkey] 179 - url = `https://leaflet.pub/p/${PUBLIC_ATPROTO_DID}/${rkey}`; 180 - } 181 - 182 - posts.push({ 183 - title: value.title || 'Untitled Document', 184 - url, 185 - createdAt: value.publishedAt || new Date().toISOString(), 186 - platform: 'leaflet', 187 - description: value.description, 188 - rkey, 189 - publicationName: publication?.name, 190 - publicationRkey, 191 - tags: value.tags || undefined 192 - }); 193 - } 194 - } catch (error) { 195 - console.warn('Failed to fetch Leaflet documents:', error); 196 - } 197 - 198 18 // Fetch Standard.site documents 199 19 try { 200 20 const standardDocumentsData = await fetchStandardSiteDocuments(fetchFn); ··· 208 28 description: doc.description, 209 29 rkey: doc.rkey, 210 30 publicationName: doc.publicationName, 211 - publicationRkey: doc.publicationRkey 31 + publicationRkey: doc.publicationRkey, 32 + coverImage: doc.coverImage, 33 + textContent: doc.textContent, 34 + updatedAt: doc.updatedAt, 35 + tags: doc.tags 212 36 }); 213 37 } 214 38 } catch (error) { 215 39 console.warn('Failed to fetch Standard.site documents:', error); 216 40 } 217 41 218 - // Sort by date (newest first) and take top 5 219 - posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 42 + // Sort by date (newest first) 43 + posts.sort((a, b) => { 44 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); 45 + }); 46 + 220 47 const topPosts = posts.slice(0, 5); 221 48 222 49 const data: BlogPostsData = { posts: topPosts }; ··· 525 352 } 526 353 } 527 354 528 - // Get engagement data from Constellation as a fallback 529 - let finalLikeCount = postData.likeCount; 530 - let finalRepostCount = postData.repostCount; 531 - 532 - try { 533 - const [likers, reposters] = await Promise.all([ 534 - fetchAllEngagement(postData.uri, 'app.bsky.feed.like'), 535 - fetchAllEngagement(postData.uri, 'app.bsky.feed.repost') 536 - ]); 537 - finalLikeCount = Math.max(postData.likeCount || 0, likers.length); 538 - finalRepostCount = Math.max(postData.repostCount || 0, reposters.length); 539 - } catch (error: unknown) { 540 - console.warn('[fetchPostFromUri] Failed to fetch engagement from Constellation:', error); 541 - } 355 + // Get engagement data (like/repost counts) from the post data 356 + const finalLikeCount = postData.likeCount || 0; 357 + const finalRepostCount = postData.repostCount || 0; 542 358 543 359 const post: BlueskyPost = { 544 360 text: value.text,
+5 -14
src/lib/services/atproto/types.ts
··· 102 102 title: string; 103 103 url: string; 104 104 createdAt: string; 105 - platform: 'WhiteWind' | 'leaflet' | 'standard.site'; 105 + platform: 'standard.site'; 106 106 description?: string; 107 107 rkey: string; 108 108 publicationName?: string; 109 109 publicationRkey?: string; 110 110 tags?: string[]; 111 + // Standard.site specific fields 112 + coverImage?: string; 113 + textContent?: string; 114 + updatedAt?: string; 111 115 } 112 116 113 117 export interface BlogPostsData { 114 118 posts: BlogPost[]; 115 - } 116 - 117 - export interface LeafletPublication { 118 - name: string; 119 - rkey: string; 120 - uri: string; 121 - basePath?: string; 122 - description?: string; 123 - icon?: string; 124 - } 125 - 126 - export interface LeafletPublicationsData { 127 - publications: LeafletPublication[]; 128 119 } 129 120 130 121 export interface Facet {
+1 -1
src/routes/+page.svelte
··· 53 53 <DynamicLinks /> 54 54 </div> 55 55 <div class="mb-6 break-inside-avoid"> 56 - <PostCard blogPosts={data.blogPosts} /> 56 + <PostCard documents={data.documents} /> 57 57 </div> 58 58 <div class="mb-6 break-inside-avoid"> 59 59 <TangledRepoCard repos={data.tangledRepos} profile={data.profile} />
+4 -4
src/routes/+page.ts
··· 4 4 fetchKibunStatus, 5 5 fetchLatestBlueskyPost, 6 6 fetchTangledRepos, 7 - fetchBlogPosts 7 + fetchRecentDocuments 8 8 } from '$lib/services/atproto'; 9 9 10 10 export const load: PageLoad = async ({ fetch, parent }) => { ··· 12 12 const { profile } = await parent(); 13 13 14 14 // Fetch page-specific data in parallel for better performance 15 - const [musicStatus, kibunStatus, latestPost, tangledRepos, blogPosts] = await Promise.allSettled([ 15 + const [musicStatus, kibunStatus, latestPost, tangledRepos, documents] = await Promise.allSettled([ 16 16 fetchMusicStatus(fetch), 17 17 fetchKibunStatus(fetch), 18 18 fetchLatestBlueskyPost(fetch), 19 19 fetchTangledRepos(fetch), 20 - fetchBlogPosts(fetch) 20 + fetchRecentDocuments(5, fetch) // Fetch 5 most recent documents 21 21 ]); 22 22 23 23 return { ··· 28 28 kibunStatus: kibunStatus.status === 'fulfilled' ? kibunStatus.value : null, 29 29 latestPost: latestPost.status === 'fulfilled' ? latestPost.value : null, 30 30 tangledRepos: tangledRepos.status === 'fulfilled' ? tangledRepos.value : null, 31 - blogPosts: blogPosts.status === 'fulfilled' ? blogPosts.value : null 31 + documents: documents.status === 'fulfilled' ? documents.value : [] 32 32 }; 33 33 };
+14 -38
src/routes/[slug=slug]/+server.ts
··· 1 1 import type { RequestHandler } from '@sveltejs/kit'; 2 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 3 - import { fetchLeafletPublications, fetchStandardSitePublications } from '$lib/services/atproto'; 2 + import { fetchPublications } from '$lib/services/atproto'; 4 3 import { getPublicationFromSlug } from '$lib/config/slugs'; 5 4 6 5 /** 7 6 * Dynamic slug root redirect handler 8 7 * 9 - * Redirects /{slug} to the appropriate publication (Leaflet or Standard.site): 10 - * - Uses the slug mapping config to find the publication rkey and platform 11 - * - For Leaflet: Priority 1: Publication base_path, Priority 2: /lish format 12 - * - For Standard.site: Uses the publication URL directly 13 - * 14 - * Individual posts are handled by the [rkey] route. 8 + * Redirects /{slug} to the appropriate Standard.site publication URL 9 + * Uses the slug mapping config to find the publication rkey 10 + * Individual documents are handled by the [rkey] route. 15 11 */ 16 12 export const GET: RequestHandler = async ({ params, url }) => { 17 13 const slug = params.slug; ··· 55 51 ); 56 52 } 57 53 58 - const { rkey: publicationRkey, platform } = publicationInfo; 54 + const { rkey: publicationRkey } = publicationInfo; 59 55 let redirectUrl: string | null = null; 60 56 61 57 try { 62 - if (platform === 'standard.site') { 63 - // Fetch Standard.site publications 64 - const { publications } = await fetchStandardSitePublications(); 65 - const publication = publications.find((p) => p.rkey === publicationRkey); 66 - 67 - if (publication) { 68 - // Use the publication URL directly 69 - redirectUrl = publication.url.startsWith('http') 70 - ? publication.url 71 - : `https://${publication.url}`; 72 - } 73 - } else { 74 - // Fetch Leaflet publications 75 - const { publications } = await fetchLeafletPublications(); 76 - const publication = publications.find((p) => p.rkey === publicationRkey); 58 + // Fetch Standard.site publications 59 + const { publications } = await fetchPublications(); 60 + const publication = publications.find((p) => p.rkey === publicationRkey); 77 61 78 - if (publication?.basePath) { 79 - // Ensure basePath is a complete URL 80 - redirectUrl = publication.basePath.startsWith('http') 81 - ? publication.basePath 82 - : `https://${publication.basePath}`; 83 - } else { 84 - // Use Leaflet /lish format 85 - redirectUrl = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}`; 86 - } 62 + if (publication) { 63 + // Use the publication URL directly 64 + redirectUrl = publication.url.startsWith('http') 65 + ? publication.url 66 + : `https://${publication.url}`; 87 67 } 88 68 } catch (error) { 89 - console.error(`Error fetching ${platform} publication:`, error); 90 - // Fallback based on platform 91 - if (platform === 'leaflet') { 92 - redirectUrl = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}`; 93 - } 69 + console.error(`Error fetching publication:`, error); 94 70 } 95 71 96 72 // If we have a redirect URL, use it
+49 -162
src/routes/[slug=slug]/[rkey]/+server.ts
··· 1 1 import type { RequestHandler } from '@sveltejs/kit'; 2 - import { 3 - PUBLIC_ATPROTO_DID, 4 - PUBLIC_BLOG_FALLBACK_URL, 5 - PUBLIC_ENABLE_WHITEWIND 6 - } from '$env/static/public'; 2 + import { PUBLIC_ATPROTO_DID, PUBLIC_BLOG_FALLBACK_URL } from '$env/static/public'; 7 3 import { withFallback } from '$lib/services/atproto'; 8 - import { fetchLeafletPublications, fetchStandardSitePublications } from '$lib/services/atproto'; 4 + import { fetchPublications } from '$lib/services/atproto'; 9 5 import { getPublicationFromSlug } from '$lib/config/slugs'; 10 6 import type { PublicationPlatform } from '$lib/data/slug-mappings'; 11 7 12 8 /** 13 9 * Smart document redirect handler for slugged publications 14 10 * 15 - * Automatically detects whether the post is from Standard.site, Leaflet, or WhiteWind 16 - * and redirects to the appropriate URL. 17 - * 18 - * Priority order: 19 - * 1. Standard.site: Uses publication's URL + document path 20 - * 2. Leaflet: Uses publication's base_path or https://leaflet.pub/{DID}/{publicationRkey}/{rkey} 21 - * 3. WhiteWind: https://whtwnd.com/{DID}/{rkey} (only if PUBLIC_ENABLE_WHITEWIND is true) 11 + * Automatically detects Standard.site documents and redirects to the canonical URL. 12 + * Uses the publication's URL + document path to construct the final URL. 22 13 * 23 14 * If detection fails, falls back to PUBLIC_BLOG_FALLBACK_URL or returns 404. 24 - * 25 - * Uses slug mapping to determine which publication and platform to check. 26 15 */ 27 16 28 - async function detectPostPlatform( 17 + async function detectDocumentUrl( 29 18 rkey: string, 30 - publicationRkey: string, 31 - platform: PublicationPlatform 32 - ): Promise<{ platform: 'whitewind' | 'leaflet' | 'standard.site' | 'unknown'; url?: string }> { 19 + publicationRkey: string 20 + ): Promise<{ url?: string } | null> { 33 21 try { 34 - // Check based on the platform specified in slug mapping 35 - if (platform === 'standard.site') { 36 - // Check Standard.site documents 37 - const standardRecord = await withFallback( 38 - PUBLIC_ATPROTO_DID, 39 - async (agent) => { 40 - try { 41 - const response = await agent.com.atproto.repo.getRecord({ 42 - repo: PUBLIC_ATPROTO_DID, 43 - collection: 'site.standard.document', 44 - rkey 45 - }); 46 - return response.data; 47 - } catch (err) { 48 - return null; 49 - } 50 - }, 51 - true 52 - ); 53 - 54 - if (standardRecord) { 55 - const value = standardRecord.value as any; 56 - const documentSite = value?.site; 57 - 58 - // Fetch publications to get the publication info 59 - const { publications } = await fetchStandardSitePublications(); 60 - let publication = null; 61 - 62 - // Check if site points to a publication URI 63 - if (documentSite?.startsWith('at://')) { 64 - publication = publications.find((p) => p.uri === documentSite); 65 - 66 - // Verify this document belongs to the requested publication 67 - if (publication && publication.rkey !== publicationRkey) { 68 - return { platform: 'unknown' }; 69 - } 70 - } 71 - 72 - // Build the URL 73 - let url: string; 74 - if (publication) { 75 - const basePath = publication.url.endsWith('/') 76 - ? publication.url.slice(0, -1) 77 - : publication.url; 78 - const docPath = value.path || `/${rkey}`; 79 - url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 80 - } else { 81 - // Use the site value directly (it's a URL) 82 - const basePath = documentSite.endsWith('/') ? documentSite.slice(0, -1) : documentSite; 83 - const docPath = value.path || `/${rkey}`; 84 - url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 85 - } 86 - 87 - return { 88 - platform: 'standard.site', 89 - url 90 - }; 91 - } 92 - } 93 - 94 - // Check Leaflet (prioritized for leaflet platform or as fallback) 95 - const leafletRecord = await withFallback( 22 + // Check Standard.site documents 23 + const standardRecord = await withFallback( 96 24 PUBLIC_ATPROTO_DID, 97 25 async (agent) => { 98 26 try { 99 27 const response = await agent.com.atproto.repo.getRecord({ 100 28 repo: PUBLIC_ATPROTO_DID, 101 - collection: 'pub.leaflet.document', 29 + collection: 'site.standard.document', 102 30 rkey 103 31 }); 104 32 return response.data; 105 33 } catch (err) { 106 - // Record not found 107 34 return null; 108 35 } 109 36 }, 110 - true // Use PDS first for custom collections 37 + true 111 38 ); 112 39 113 - if (leafletRecord) { 114 - const value = leafletRecord.value as any; 115 - const documentPublicationUri = value?.publication; 40 + if (standardRecord) { 41 + const value = standardRecord.value as any; 42 + const documentSite = value?.site; 43 + 44 + // Fetch publications to get the publication info 45 + const { publications } = await fetchPublications(); 46 + let publication = null; 116 47 117 - // Fetch publications to get base path 118 - const { publications } = await fetchLeafletPublications(); 119 - const publication = documentPublicationUri 120 - ? publications.find((p) => p.uri === documentPublicationUri) 121 - : null; 48 + // Check if site points to a publication URI 49 + if (documentSite?.startsWith('at://')) { 50 + publication = publications.find((p) => p.uri === documentSite); 122 51 123 - // Check if this document belongs to the requested publication (from slug) 124 - if (publication && publication.rkey !== publicationRkey) { 125 - // Document belongs to a different publication 126 - return { platform: 'unknown' }; 52 + // Verify this document belongs to the requested publication 53 + if (publication && publication.rkey !== publicationRkey) { 54 + return null; 55 + } 127 56 } 128 57 129 - // Determine URL based on publication base_path or Leaflet /lish format 58 + // Build the URL 130 59 let url: string; 131 - const docPublicationRkey = publication?.rkey || publicationRkey; 132 - 133 - if (publication?.basePath) { 134 - // Ensure basePath is a complete URL 135 - const basePath = publication.basePath.startsWith('http') 136 - ? publication.basePath 137 - : `https://${publication.basePath}`; 138 - url = `${basePath}/${rkey}`; 139 - } else if (docPublicationRkey) { 140 - url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${docPublicationRkey}/${rkey}`; 60 + if (publication) { 61 + const basePath = publication.url.endsWith('/') 62 + ? publication.url.slice(0, -1) 63 + : publication.url; 64 + const docPath = value.path || `/${rkey}`; 65 + url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 141 66 } else { 142 - url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 67 + // Use the site value directly (it's a URL) 68 + const basePath = documentSite.endsWith('/') ? documentSite.slice(0, -1) : documentSite; 69 + const docPath = value.path || `/${rkey}`; 70 + url = `${basePath}${docPath.startsWith('/') ? docPath : '/' + docPath}`; 143 71 } 144 72 145 - return { 146 - platform: 'leaflet', 147 - url 148 - }; 73 + return { url }; 149 74 } 150 75 151 - // Check WhiteWind as fallback (only if enabled) 152 - if (PUBLIC_ENABLE_WHITEWIND === 'true') { 153 - const whiteWindRecord = await withFallback( 154 - PUBLIC_ATPROTO_DID, 155 - async (agent) => { 156 - try { 157 - const response = await agent.com.atproto.repo.getRecord({ 158 - repo: PUBLIC_ATPROTO_DID, 159 - collection: 'com.whtwnd.blog.entry', 160 - rkey 161 - }); 162 - return response.data; 163 - } catch (err) { 164 - // Record not found 165 - return null; 166 - } 167 - }, 168 - true // Use PDS first for custom collections 169 - ); 170 - 171 - if (whiteWindRecord) { 172 - const value = whiteWindRecord.value as any; 173 - // Skip drafts and non-public posts 174 - if (!value?.isDraft && (!value?.visibility || value.visibility === 'public')) { 175 - return { 176 - platform: 'whitewind', 177 - url: `https://whtwnd.com/${PUBLIC_ATPROTO_DID}/${rkey}` 178 - }; 179 - } 180 - } 181 - } 182 - 183 - return { platform: 'unknown' }; 76 + return null; 184 77 } catch (error) { 185 - console.error('Error detecting post platform:', error); 186 - return { platform: 'unknown' }; 78 + console.error('Error detecting document URL:', error); 79 + return null; 187 80 } 188 81 } 189 82 ··· 216 109 ); 217 110 } 218 111 219 - const { rkey: publicationRkey, platform } = publicationInfo; 112 + const { rkey: publicationRkey } = publicationInfo; 220 113 221 114 // Validate TID format (AT Protocol record key) 222 115 const tidPattern = /^[a-zA-Z0-9]{12,16}$/; ··· 230 123 }); 231 124 } 232 125 233 - // Detect platform and get appropriate URL 234 - const detection = await detectPostPlatform(rkey, publicationRkey, platform); 126 + // Detect document and get canonical URL 127 + const detection = await detectDocumentUrl(rkey, publicationRkey); 235 128 236 129 let targetUrl: string | null = null; 237 130 let statusCode = 301; 238 131 239 - if (detection.platform !== 'unknown' && detection.url) { 240 - // Found the post on WhiteWind or Leaflet 132 + if (detection?.url) { 133 + // Found the document 241 134 targetUrl = detection.url; 242 135 } else if (PUBLIC_BLOG_FALLBACK_URL) { 243 136 // Use fallback URL from environment variable 244 137 targetUrl = `${PUBLIC_BLOG_FALLBACK_URL}/${rkey}`; 245 138 } else { 246 139 // No fallback configured, return 404 247 - const platformName = platform === 'standard.site' ? 'Standard.site' : 'Leaflet'; 248 - const publicationNote = `\n\nNote: Only checking ${platformName} publication with rkey: ${publicationRkey}`; 249 - const whiteWindNote = 250 - PUBLIC_ENABLE_WHITEWIND === 'true' ? '\n- WhiteWind: https://whtwnd.com' : ''; 251 - const standardSiteNote = 252 - platform === 'standard.site' ? '\n- Standard.site: https://standard.site' : ''; 253 - 254 140 return new Response( 255 141 `Document not found: ${rkey} 256 142 257 - This document could not be found in the ${platformName} publication for slug "${slug}"${PUBLIC_ENABLE_WHITEWIND === 'true' ? ' or WhiteWind' : ''}.${publicationNote} 143 + This document could not be found in the Standard.site publication for slug "${slug}". 258 144 259 - Please check: 260 - - Leaflet: https://leaflet.pub${standardSiteNote}${whiteWindNote}`, 145 + Note: Only checking Standard.site publication with rkey: ${publicationRkey} 146 + 147 + Please check: https://standard.site`, 261 148 { 262 149 status: 404, 263 150 headers: {
+11 -76
src/routes/[slug=slug]/rss/+server.ts
··· 3 3 PUBLIC_ATPROTO_DID, 4 4 PUBLIC_SITE_TITLE, 5 5 PUBLIC_SITE_DESCRIPTION, 6 - PUBLIC_SITE_URL, 7 - PUBLIC_ENABLE_WHITEWIND 6 + PUBLIC_SITE_URL 8 7 } from '$env/static/public'; 9 - import { fetchBlogPosts, fetchLeafletPublications } from '$lib/services/atproto'; 8 + import { fetchBlogPosts } from '$lib/services/atproto'; 10 9 import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 11 10 12 11 /** 13 - * RSS 2.0 feed for publications (accessed via /{slug}/rss) 12 + * RSS 2.0 feed for Standard.site publications (accessed via /{slug}/rss) 14 13 * 15 - * Strategy: 16 - * 1. If WhiteWind is disabled or no WhiteWind posts exist, redirect to Leaflet RSS feed 17 - * 2. If WhiteWind is enabled and WhiteWind posts exist, generate RSS with WhiteWind posts 18 - * 3. If mixed content and WhiteWind is enabled, prioritize WhiteWind and generate RSS for those 14 + * Generates an RSS feed for all documents in the specified publication. 19 15 */ 20 16 export const GET: RequestHandler = async ({ params }) => { 21 17 const slug = params.slug; ··· 49 45 const { posts } = await fetchBlogPosts(); 50 46 51 47 // Filter posts for this specific publication 52 - const publicationPosts = posts.filter( 53 - (p) => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 54 - ); 55 - 56 - // Separate WhiteWind and Leaflet posts 57 - const whiteWindPosts = publicationPosts.filter((p) => p.platform === 'WhiteWind'); 58 - const leafletPosts = publicationPosts.filter((p) => p.platform === 'leaflet'); 59 - 60 - // If WhiteWind is enabled and we have WhiteWind posts, generate RSS for them 61 - if (PUBLIC_ENABLE_WHITEWIND === 'true' && whiteWindPosts.length > 0) { 62 - // slug is guaranteed to be defined here 63 - return generateWhiteWindRSS(whiteWindPosts, slug as string); 64 - } 48 + const publicationPosts = posts.filter((p) => p.publicationRkey === publicationRkey); 65 49 66 - // If WhiteWind is disabled or only Leaflet posts exist, redirect to Leaflet RSS feed 67 - if (leafletPosts.length > 0) { 68 - return await redirectToLeafletRSS(publicationRkey); 50 + // Generate RSS for Standard.site posts 51 + if (publicationPosts.length > 0) { 52 + return generateRSS(publicationPosts, slug); 69 53 } 70 54 71 55 // No posts at all ··· 87 71 }; 88 72 89 73 /** 90 - * Generate RSS feed for WhiteWind posts 74 + * Generate RSS feed for Standard.site posts 91 75 */ 92 - function generateWhiteWindRSS(posts: Array<any>, slug: string): Response { 76 + function generateRSS(posts: Array<any>, slug: string): Response { 93 77 const rss = `<?xml version="1.0" encoding="UTF-8"?> 94 78 <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> 95 79 <channel> ··· 102 86 <generator>SvelteKit with AT Protocol</generator> 103 87 ${posts 104 88 .map((post) => { 105 - const description = post.description || 'Read this post on WhiteWind'; 89 + const description = post.description || 'Read this post on Standard.site'; 106 90 107 91 return ` <item> 108 92 <title>${escapeXml(post.title)}</title> ··· 123 107 'Cache-Control': 'public, max-age=3600' 124 108 } 125 109 }); 126 - } 127 - 128 - /** 129 - * Redirect to Leaflet's native RSS feed 130 - */ 131 - async function redirectToLeafletRSS(publicationRkey: string): Promise<Response> { 132 - try { 133 - const { publications } = await fetchLeafletPublications(); 134 - 135 - // Find the specific publication 136 - const publication = publications.find((p) => p.rkey === publicationRkey); 137 - 138 - if (publication) { 139 - const rssUrl = getLeafletRSSUrl(publication); 140 - return Response.redirect(rssUrl, 307); // Temporary redirect 141 - } 142 - 143 - // Publication not found 144 - return new Response(`Leaflet publication not found for rkey: ${publicationRkey}`, { 145 - status: 404, 146 - headers: { 147 - 'Content-Type': 'text/plain; charset=utf-8' 148 - } 149 - }); 150 - } catch (error) { 151 - console.error('Error redirecting to Leaflet RSS:', error); 152 - return new Response('Error finding Leaflet RSS feed', { 153 - status: 500, 154 - headers: { 155 - 'Content-Type': 'text/plain; charset=utf-8' 156 - } 157 - }); 158 - } 159 - } 160 - 161 - /** 162 - * Get the RSS URL for a Leaflet publication 163 - */ 164 - function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string { 165 - if (publication.basePath) { 166 - // Ensure basePath is a complete URL 167 - const basePath = publication.basePath.startsWith('http') 168 - ? publication.basePath 169 - : `https://${publication.basePath}`; 170 - return `${basePath}/rss`; 171 - } 172 - 173 - // Fallback to Leaflet /lish format 174 - return `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publication.rkey}/rss`; 175 110 } 176 111 177 112 function escapeXml(unsafe: string): string {
+89 -66
src/routes/archive/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { 3 - Card, 4 - SearchBar, 5 - Pagination, 6 - Tabs, 7 - PostsGroupedView, 8 - Dropdown 9 - } from '$lib/components/ui'; 10 - import type { BlogPost } from '$lib/services/atproto'; 2 + import { Card, SearchBar, Pagination, Tabs, Dropdown } from '$lib/components/ui'; 3 + import { DocumentCard } from '$lib/components/ui'; 4 + import type { StandardSiteDocument } from '$lib/services/atproto'; 11 5 import { getUserLocale } from '$lib/utils/locale'; 12 - import { filterPosts, getSortedYears, groupPostsByDate, getAllTags } from '$lib/helper/posts'; 6 + import { getAllTags } from '$lib/helper/posts'; 13 7 14 8 interface Props { 15 9 data: { 16 - allPosts: BlogPost[]; 10 + documents: StandardSiteDocument[]; 17 11 }; 18 12 } 19 13 ··· 28 22 let selectedPublication = $state(''); 29 23 let selectedTag = $state(''); 30 24 let currentPage = $state(1); 31 - const postsPerPage = 50; 25 + const documentsPerPage = 50; 32 26 33 - // Get available years from all posts 34 - const allGrouped = $derived(groupPostsByDate(data.allPosts, userLocale)); 35 - const availableYears = $derived(getSortedYears(allGrouped)); 27 + // Get available years from all documents 28 + const availableYears = $derived.by(() => { 29 + const years = new Set<number>(); 30 + data.documents.forEach((doc) => { 31 + const year = new Date(doc.publishedAt).getFullYear(); 32 + years.add(year); 33 + }); 34 + return Array.from(years).sort((a, b) => b - a); // Newest first 35 + }); 36 36 37 37 // Create year tabs (All + individual years) 38 38 const yearTabs = $derived([ ··· 43 43 // Get unique publications 44 44 const publications = $derived.by(() => { 45 45 const pubs = new Map<string, string>(); 46 - data.allPosts.forEach((post) => { 47 - if (post.platform === 'leaflet' && post.publicationName) { 48 - const key = `${post.publicationName}-${post.publicationRkey || 'default'}`; 49 - pubs.set(key, post.publicationName); 46 + data.documents.forEach((doc) => { 47 + if (doc.publicationName && doc.publicationRkey) { 48 + const key = `${doc.publicationName}-${doc.publicationRkey}`; 49 + pubs.set(key, doc.publicationName); 50 50 } 51 51 }); 52 52 return Array.from(pubs.entries()).map(([key, name]) => ({ ··· 56 56 }); 57 57 58 58 // Get unique tags 59 - const allTags = $derived(getAllTags(data.allPosts)); 59 + const allTags = $derived(getAllTags(data.documents)); 60 60 const tagOptions = $derived( 61 61 allTags.map((tag) => ({ 62 62 value: tag, ··· 64 64 })) 65 65 ); 66 66 67 - // Filter posts by search, year, and publication 68 - const filteredBySearch = $derived(filterPosts(data.allPosts, searchQuery)); 67 + // Filter documents by search query 68 + const filteredBySearch = $derived.by(() => { 69 + if (!searchQuery) return data.documents; 70 + const query = searchQuery.toLowerCase(); 71 + return data.documents.filter((doc) => { 72 + return ( 73 + doc.title.toLowerCase().includes(query) || 74 + doc.description?.toLowerCase().includes(query) || 75 + doc.publicationName?.toLowerCase().includes(query) || 76 + doc.tags?.some((tag) => tag.toLowerCase().includes(query)) 77 + ); 78 + }); 79 + }); 69 80 81 + // Filter by year 70 82 const filteredByYear = $derived.by(() => { 71 83 if (selectedYear === 'all') return filteredBySearch; 72 - return filteredBySearch.filter((post) => { 73 - const postYear = new Date(post.createdAt).getFullYear(); 74 - return postYear === parseInt(selectedYear); 84 + return filteredBySearch.filter((doc) => { 85 + const docYear = new Date(doc.publishedAt).getFullYear(); 86 + return docYear === parseInt(selectedYear); 75 87 }); 76 88 }); 77 89 90 + // Filter by publication 78 91 const filteredByPublication = $derived.by(() => { 79 92 if (!selectedPublication) return filteredByYear; 80 - return filteredByYear.filter((post: BlogPost) => { 81 - if (post.platform === 'WhiteWind' && selectedPublication === 'whitewind') return true; 82 - if (post.platform === 'leaflet') { 83 - const key = `${post.publicationName}-${post.publicationRkey || 'default'}`; 84 - return key === selectedPublication; 85 - } 86 - return false; 93 + return filteredByYear.filter((doc) => { 94 + if (!doc.publicationName || !doc.publicationRkey) return false; 95 + const key = `${doc.publicationName}-${doc.publicationRkey}`; 96 + return key === selectedPublication; 87 97 }); 88 98 }); 89 99 90 - const filteredPosts = $derived.by(() => { 100 + // Filter by tag 101 + const filteredDocuments = $derived.by(() => { 91 102 if (!selectedTag) return filteredByPublication; 92 - return filteredByPublication.filter((post: BlogPost) => { 93 - return post.tags?.some((tag) => tag.toLowerCase() === selectedTag.toLowerCase()); 103 + return filteredByPublication.filter((doc) => { 104 + return doc.tags?.some((tag) => tag.toLowerCase() === selectedTag.toLowerCase()); 94 105 }); 95 106 }); 96 - 97 - // Add WhiteWind to publication options if there are WhiteWind posts 98 - const hasWhiteWind = $derived(data.allPosts.some((p) => p.platform === 'WhiteWind')); 99 - const publicationOptions = $derived.by(() => [ 100 - ...(hasWhiteWind ? [{ value: 'whitewind', label: 'WhiteWind' }] : []), 101 - ...publications 102 - ]); 103 107 104 108 // Pagination calculations 105 - const totalPages = $derived(Math.ceil(filteredPosts.length / postsPerPage)); 106 - const paginatedPosts = $derived( 107 - filteredPosts.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage) 109 + const totalPages = $derived(Math.ceil(filteredDocuments.length / documentsPerPage)); 110 + const paginatedDocuments = $derived( 111 + filteredDocuments.slice((currentPage - 1) * documentsPerPage, currentPage * documentsPerPage) 108 112 ); 109 113 114 + // Group documents by month 115 + const groupedDocuments = $derived.by(() => { 116 + const groups = new Map<string, StandardSiteDocument[]>(); 117 + 118 + paginatedDocuments.forEach((doc) => { 119 + const date = new Date(doc.publishedAt); 120 + const monthKey = date.toLocaleDateString(userLocale, { year: 'numeric', month: 'long' }); 121 + 122 + if (!groups.has(monthKey)) { 123 + groups.set(monthKey, []); 124 + } 125 + groups.get(monthKey)!.push(doc); 126 + }); 127 + 128 + return groups; 129 + }); 130 + 110 131 // Reset to page 1 when filters change 111 132 $effect(() => { 112 133 searchQuery; ··· 132 153 <div class="mb-8 text-center"> 133 154 <h1 class="mb-4 text-4xl font-bold text-ink-900 md:text-5xl dark:text-ink-50">Archive</h1> 134 155 <p class="text-lg text-ink-700 dark:text-ink-200"> 135 - Browse all {data.allPosts.length} blog posts from WhiteWind and Leaflet, organised by date. 156 + Browse all {data.documents.length} documents from Standard.site 136 157 </p> 137 158 </div> 138 159 ··· 140 161 <div class="mb-6"> 141 162 <SearchBar 142 163 bind:value={searchQuery} 143 - placeholder="Search posts by title, description, platform, publication, or tags..." 144 - resultCount={searchQuery ? filteredPosts.length : undefined} 164 + placeholder="Search documents by title, description, publication, or tags..." 165 + resultCount={searchQuery ? filteredDocuments.length : undefined} 145 166 /> 146 167 </div> 147 168 148 169 <!-- Filters Row --> 149 170 <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end"> 150 171 <!-- Publication Dropdown --> 151 - {#if publicationOptions.length > 0} 172 + {#if publications.length > 0} 152 173 <div class="flex-1 sm:max-w-xs"> 153 174 <Dropdown 154 175 bind:value={selectedPublication} 155 - options={publicationOptions} 176 + options={publications} 156 177 label="Filter by Publication" 157 178 placeholder="All Publications" 158 179 /> ··· 176 197 <Tabs tabs={yearTabs} activeTab={selectedYear} onTabChange={handleYearChange} /> 177 198 178 199 <!-- Archive Content --> 179 - {#if filteredPosts.length === 0} 200 + {#if filteredDocuments.length === 0} 180 201 <Card variant="flat" padding="lg"> 181 202 {#snippet children()} 182 203 <div class="text-center"> 183 204 {#if searchQuery || selectedPublication || selectedTag} 184 205 <p class="text-ink-700 dark:text-ink-300"> 185 - No posts found matching your filters. Try adjusting your search or filters. 206 + No documents found matching your filters. Try adjusting your search or filters. 186 207 </p> 187 208 {:else} 188 209 <p class="text-ink-700 dark:text-ink-300"> 189 - No blog posts found. Start writing on 210 + No documents found. Start writing on 190 211 <a 191 - href="https://whtwnd.com/" 192 - class="text-primary-600 hover:underline dark:text-primary-400" 193 - target="_blank" 194 - rel="noopener noreferrer">WhiteWind</a 195 - > 196 - or 197 - <a 198 - href="https://leaflet.pub/" 212 + href="https://standard.site/" 199 213 class="text-primary-600 hover:underline dark:text-primary-400" 200 214 target="_blank" 201 - rel="noopener noreferrer">Leaflet</a 215 + rel="noopener noreferrer">Standard.site</a 202 216 >! 203 217 </p> 204 218 {/if} ··· 206 220 {/snippet} 207 221 </Card> 208 222 {:else} 209 - <!-- Posts Grouped View --> 210 - <PostsGroupedView posts={paginatedPosts} locale={userLocale} /> 223 + <!-- Grouped Documents View --> 224 + {#each Array.from(groupedDocuments.entries()) as [monthKey, docs]} 225 + <div class="mb-8"> 226 + <h3 class="mb-4 text-lg font-semibold text-ink-900 dark:text-ink-50">{monthKey}</h3> 227 + <div class="space-y-3"> 228 + {#each docs as document} 229 + <DocumentCard {document} locale={userLocale} /> 230 + {/each} 231 + </div> 232 + </div> 233 + {/each} 211 234 212 235 <!-- Pagination --> 213 236 <Pagination 214 237 {currentPage} 215 238 {totalPages} 216 - totalItems={filteredPosts.length} 217 - itemsPerPage={postsPerPage} 239 + totalItems={filteredDocuments.length} 240 + itemsPerPage={documentsPerPage} 218 241 onPageChange={handlePageChange} 219 242 /> 220 243 {/if}
+8 -100
src/routes/archive/+page.ts
··· 1 1 import type { PageLoad } from './$types'; 2 2 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 3 - import { fetchAllUserRecords } from '$lib/services/atproto/pagination'; 4 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 5 - import type { BlogPost } from '$lib/services/atproto'; 6 - 7 - /** 8 - * Fetches ALL blog posts from WhiteWind and Leaflet with proper pagination 9 - */ 10 - async function fetchAllBlogPosts(fetchFn?: typeof fetch): Promise<BlogPost[]> { 11 - const posts: BlogPost[] = []; 12 - 13 - // Fetch WhiteWind posts with pagination 14 - try { 15 - const whiteWindRecords = await fetchAllUserRecords('com.whtwnd.blog.entry', fetchFn); 16 - 17 - for (const record of whiteWindRecords) { 18 - const value = record.value as any; 19 - // Skip drafts and non-public posts 20 - if (value.isDraft || (value.visibility && value.visibility !== 'public')) { 21 - continue; 22 - } 23 - 24 - posts.push({ 25 - title: value.title || 'Untitled Post', 26 - url: `https://whtwnd.com/${PUBLIC_ATPROTO_DID}/${record.uri.split('/').pop()}`, 27 - createdAt: value.createdAt || record.value.createdAt || new Date().toISOString(), 28 - platform: 'WhiteWind', 29 - description: value.subtitle, 30 - rkey: record.uri.split('/').pop() || '' 31 - }); 32 - } 33 - } catch (error) { 34 - console.warn('Failed to fetch WhiteWind posts:', error); 35 - } 36 - 37 - // Fetch Leaflet publications and documents with pagination 38 - try { 39 - // Fetch all publications 40 - const publicationsRecords = await fetchAllUserRecords('pub.leaflet.publication', fetchFn); 41 - 42 - const publicationsMap = new Map<string, { name: string; basePath?: string }>(); 43 - for (const pubRecord of publicationsRecords) { 44 - const pubValue = pubRecord.value as any; 45 - publicationsMap.set(pubRecord.uri, { 46 - name: pubValue.name || 'Untitled Publication', 47 - basePath: pubValue.base_path 48 - }); 49 - } 50 - 51 - // Fetch all Leaflet documents 52 - const leafletDocsRecords = await fetchAllUserRecords('pub.leaflet.document', fetchFn); 53 - 54 - for (const record of leafletDocsRecords) { 55 - const value = record.value as any; 56 - const rkey = record.uri.split('/').pop() || ''; 57 - const publicationUri = value.publication; 58 - const publication = publicationsMap.get(publicationUri); 59 - 60 - // Determine URL based on priority: publication base_path → Leaflet /lish format 61 - let url: string; 62 - const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 63 - 64 - if (publication?.basePath) { 65 - // Ensure basePath is a complete URL 66 - const basePath = publication.basePath.startsWith('http') 67 - ? publication.basePath 68 - : `https://${publication.basePath}`; 69 - url = `${basePath}/${rkey}`; 70 - } else if (publicationRkey) { 71 - url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 72 - } else { 73 - url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 74 - } 75 - 76 - posts.push({ 77 - title: value.title || 'Untitled Document', 78 - url, 79 - createdAt: value.publishedAt || new Date().toISOString(), 80 - platform: 'leaflet', 81 - description: value.description, 82 - rkey, 83 - publicationName: publication?.name, 84 - publicationRkey: publicationRkey || undefined, 85 - tags: value.tags || undefined 86 - }); 87 - } 88 - } catch (error) { 89 - console.warn('Failed to fetch Leaflet documents:', error); 90 - } 91 - 92 - // Sort by date (newest first) 93 - posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 94 - 95 - return posts; 96 - } 3 + import { fetchDocuments } from '$lib/services/atproto'; 97 4 98 5 export const load: PageLoad = async ({ fetch }) => { 99 - // Fetch all blog posts 100 - let allPosts: BlogPost[] = []; 6 + // Fetch all Standard.site documents 7 + let documents: import('$lib/services/atproto').StandardSiteDocument[] = []; 101 8 102 9 try { 103 - allPosts = await fetchAllBlogPosts(fetch); 10 + const data = await fetchDocuments(fetch); 11 + documents = data.documents; 104 12 } catch (err) { 105 - console.warn('Archive page: failed to fetch blog posts', err); 13 + console.warn('Archive page: failed to fetch documents', err); 106 14 } 107 15 108 16 // Create page metadata 109 17 const meta: Partial<SiteMetadata> = { 110 18 title: 'Archive', 111 - description: `Browse all ${allPosts.length} blog posts organised by date` 19 + description: `Browse all ${documents.length} documents from Standard.site` 112 20 }; 113 21 114 22 return { 115 23 meta, 116 - allPosts 24 + documents 117 25 }; 118 26 };