my website at ewancroft.uk
6
fork

Configure Feed

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

feat: add configurable slug-to-publication mapping system

Replace hardcoded blog routes with a flexible slug mapping system that allows multiple publications to be accessed via friendly URLs.

BREAKING CHANGES:
- Routes moved from /blog/* to /[slug]/* (configurable)
- Environment variables removed: PUBLIC_LEAFLET_BASE_PATH, PUBLIC_LEAFLET_BLOG_PUBLICATION
- New config file required: src/lib/config/slugs.ts

New Features:
- Add slug-to-publication-rkey mapping in src/lib/config/slugs.ts
- Support multiple publications with different URL slugs
- Each publication can use its own base_path from Leaflet API
- Maintain all existing redirect logic and platform detection

Changes:
- Create src/lib/config/slugs.ts with SlugMapping interface
- Add helper functions: getPublicationRkeyFromSlug, getSlugFromPublicationRkey, getAllSlugs
- Migrate /blog/+server.ts to /[slug]/+server.ts with slug validation
- Migrate /blog/[rkey]/+server.ts to /[slug]/[rkey]/+server.ts with publication filtering
- Migrate /blog/rss/+server.ts to /[slug]/rss/+server.ts with slug-based filtering
- Migrate /blog/atom/+server.ts to /[slug]/atom/+server.ts with slug validation
- Remove PUBLIC_LEAFLET_BASE_PATH priority in posts.ts (rely on publication base_path)
- Update README.md with new configuration instructions and examples

Migration Guide:
1. Create src/lib/config/slugs.ts with your publication mappings
2. Remove PUBLIC_LEAFLET_BASE_PATH and PUBLIC_LEAFLET_BLOG_PUBLICATION from .env
3. Access publications via /{slug} instead of /blog (default slug: 'blog')

+370 -228
+1
.cspell.json
··· 11 11 "bandcamp", 12 12 "Batarong", 13 13 "Behaviour", 14 + "bgbsh", 14 15 "blogposts", 15 16 "blueskypost", 16 17 "bradlc",
+94 -42
README.md
··· 1 - # Ewan’s Corner 1 + # Ewan's Corner 2 2 3 3 A modern, AT Protocol-powered personal website built with SvelteKit 2 and Tailwind CSS 4. 4 4 5 5 ## 🌟 Features 6 6 7 7 - **AT Protocol Integration**: Fetch and display content from your AT Protocol repository 8 + - **Multi-Publication Support**: Map friendly URL slugs to Leaflet publications with a simple config 8 9 - **Multi-Platform Blog**: Seamlessly aggregate blog posts from WhiteWind and/or Leaflet (configurable) 9 10 - **Dynamic Profile**: Automatically display your Bluesky profile information 10 11 - **Custom Status**: Show real-time status updates using custom AT Protocol lexicons 11 12 - **Link Board**: Display a Linkat board with emoji-styled link cards 12 13 - **Bluesky Posts**: Showcase your latest non-reply Bluesky posts with rich media support 13 - - **Smart Blog Redirects**: Intelligent redirection system for blog-post URLs with platform prioritisation 14 + - **Smart Redirects**: Intelligent redirection system for publication URLs with platform prioritisation 14 15 - **Responsive Design**: Mobile-first layout with dark-mode support 15 16 - **RSS Feed**: Intelligent RSS-feed handling for WhiteWind and/or Leaflet posts 16 17 - **Type-Safe**: Full TypeScript support throughout the application ··· 51 52 # Required: Your AT Protocol DID 52 53 PUBLIC_ATPROTO_DID=did:plc:your-did-here 53 54 54 - # Optional: Custom Leaflet blog domain 55 - PUBLIC_LEAFLET_BASE_PATH=https://blog.example.com 56 - 57 - # Optional: Specific Leaflet publication rkey for blog posts 58 - PUBLIC_LEAFLET_BLOG_PUBLICATION= 59 - 60 55 # Optional: Enable WhiteWind blog support (default: false) 61 56 PUBLIC_ENABLE_WHITEWIND=false 62 57 ··· 70 65 PUBLIC_SITE_URL="https://example.com" 71 66 ``` 72 67 73 - 4. Start the development server: 68 + 4. Configure your publication slugs in `src/lib/config/slugs.ts`: 69 + 70 + ```typescript 71 + export const slugMappings: SlugMapping[] = [ 72 + { 73 + slug: 'blog', 74 + publicationRkey: '3m3x4bgbsh22k' // Your publication rkey 75 + } 76 + // Add more mappings as needed: 77 + // { slug: 'notes', publicationRkey: 'xyz123abc' }, 78 + // { slug: 'essays', publicationRkey: 'def456ghi' }, 79 + ]; 80 + ``` 81 + 82 + 5. Start the development server: 74 83 75 84 ```bash 76 85 npm run dev ··· 88 97 │ │ ├── components/ # Reusable Svelte components 89 98 │ │ │ ├── layout/ # Header, Footer, Navigation 90 99 │ │ │ └── ui/ # UI components (Card, etc.) 100 + │ │ ├── config/ # Configuration files 101 + │ │ │ └── slugs.ts # Slug to publication mapping 91 102 │ │ ├── data/ # Static data (navigation items) 92 103 │ │ ├── helper/ # Helper functions (meta tags, OG images) 93 104 │ │ ├── services/ # External service integrations 94 105 │ │ │ └── atproto/ # AT Protocol service layer 95 106 │ │ └── utils/ # Utility functions 96 107 │ ├── routes/ # SvelteKit routes 97 - │ │ ├── blog/ # Blog redirect handlers 108 + │ │ ├── [slug]/ # Dynamic slug-based publication routes 98 109 │ │ ├── now/ # Status-feed endpoints 99 110 │ │ └── site/ # Site-metadata pages 100 111 │ ├── app.css # Global styles ··· 131 142 const post = await fetchLatestBlueskyPost(); 132 143 ``` 133 144 134 - ## 📝 Blog System 145 + ## 📝 Publication System 135 146 136 - The blog system supports multiple platforms with configurable prioritisation and intelligent URL redirects. 147 + The publication system uses friendly URL slugs that map to Leaflet publications, with support for multiple platforms and intelligent URL redirects. 148 + 149 + ### Slug Configuration 150 + 151 + Publications are mapped to URL slugs in `src/lib/config/slugs.ts`: 152 + 153 + ```typescript 154 + export const slugMappings: SlugMapping[] = [ 155 + { 156 + slug: 'blog', // Access via /blog 157 + publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey 158 + }, 159 + { 160 + slug: 'notes', // Access via /notes 161 + publicationRkey: 'xyz123abc' 162 + } 163 + ]; 164 + ``` 137 165 138 166 ### Supported Platforms 139 167 140 168 1. **Leaflet** (`pub.leaflet.document`) – **Prioritised by default** 141 169 - Format: Custom domain or `https://leaflet.pub/lish/{did}/{publication}/{rkey}` 142 - - Supports multiple publications 170 + - Supports multiple publications via slug mapping 143 171 - Respects `base_path` configuration 144 172 - Always checked first 145 173 ··· 148 176 - Automatically filters out drafts and non-public posts 149 177 - Only checked if `PUBLIC_ENABLE_WHITEWIND=true` 150 178 151 - ### Blog Routes 179 + ### Publication Routes 152 180 153 - - `/blog` – Redirects to your blog homepage (Leaflet by default, WhiteWind if configured) 154 - - `/blog/{rkey}` – Smart redirect to the correct platform (checks Leaflet first, then WhiteWind if enabled) 155 - - `/blog/rss` – Intelligent RSS feed (redirects to Leaflet RSS by default, or generates WhiteWind RSS if enabled) 156 - - `/blog/atom` – Deprecated (returns *410 Gone*, use RSS instead) 181 + - `/{slug}` – Redirects to your publication homepage (configured in slugs.ts) 182 + - `/{slug}/{rkey}` – Smart redirect to the correct platform (checks Leaflet first, then WhiteWind if enabled) 183 + - `/{slug}/rss` – Intelligent RSS feed (redirects to Leaflet RSS by default, or generates WhiteWind RSS if enabled) 184 + - `/{slug}/atom` – Deprecated (returns *410 Gone*, use RSS instead) 157 185 158 186 ### How It Works 159 187 160 188 **Priority Order:** 161 189 162 - 1. **Leaflet** is always checked first for blog posts 163 - 2. **WhiteWind** is only checked if `PUBLIC_ENABLE_WHITEWIND=true` 164 - 3. If neither platform has the post, it falls back to `PUBLIC_BLOG_FALLBACK_URL` if configured 165 - 4. Returns *404* if the post isn’t found and no fallback is set 190 + 1. **Leaflet** is always checked first for publications and documents 191 + 2. The slug mapping determines which publication to check 192 + 3. **WhiteWind** is only checked if `PUBLIC_ENABLE_WHITEWIND=true` 193 + 4. If neither platform has the document, it falls back to `PUBLIC_BLOG_FALLBACK_URL` if configured 194 + 5. Returns *404* if the document isn't found and no fallback is set 166 195 167 - **When a user visits `/blog/{rkey}`:** 196 + **When a user visits `/{slug}/{rkey}`:** 168 197 169 - 1. The system checks Leaflet for the post (with optional publication filtering) 170 - 2. If not found and WhiteWind is enabled, it checks WhiteWind 171 - 3. Redirects to the appropriate platform URL 172 - 4. Falls back to `PUBLIC_BLOG_FALLBACK_URL` if configured 173 - 5. Returns 404 if no post is found and no fallback exists 198 + 1. The system looks up the publication rkey from the slug configuration 199 + 2. It checks Leaflet for the document in that specific publication 200 + 3. If not found and WhiteWind is enabled, it checks WhiteWind 201 + 4. Redirects to the appropriate platform URL 202 + 5. Falls back to `PUBLIC_BLOG_FALLBACK_URL` if configured 203 + 6. Returns 404 if no document is found and no fallback exists 174 204 175 205 **RSS Feed Behaviour:** 176 206 177 - - **WhiteWind disabled** (default): Redirects to Leaflet’s native RSS feed (includes full content) 207 + - **WhiteWind disabled** (default): Redirects to Leaflet's native RSS feed (includes full content) 178 208 - **WhiteWind enabled with posts**: Generates an RSS feed with WhiteWind post links 179 209 - **No posts found**: Returns 404 180 210 181 211 ### Configuration 182 212 183 - Control blog behaviour with environment variables: 213 + Control publication behaviour with environment variables: 184 214 185 215 ```ini 186 216 # Use a custom domain for Leaflet posts (recommended) 187 217 PUBLIC_LEAFLET_BASE_PATH=https://blog.example.com 188 - 189 - # Only check a specific Leaflet publication 190 - PUBLIC_LEAFLET_BLOG_PUBLICATION=3kzcijpj2z2a 191 218 192 219 # Enable WhiteWind support (set to "true" to enable, default: "false") 193 220 PUBLIC_ENABLE_WHITEWIND=false ··· 196 223 PUBLIC_BLOG_FALLBACK_URL=https://archive.example.com/blog 197 224 ``` 198 225 226 + And configure your slug mappings in `src/lib/config/slugs.ts`: 227 + 228 + ```typescript 229 + export const slugMappings: SlugMapping[] = [ 230 + { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }, 231 + { slug: 'essays', publicationRkey: 'abc123xyz' }, 232 + { slug: 'notes', publicationRkey: 'def456uvw' } 233 + ]; 234 + ``` 235 + 199 236 ### Why Leaflet is Prioritised 200 237 201 - - **Better Performance**: Leaflet’s RSS feeds include full post content 202 - - **Custom Domains**: Native support for custom domains (e.g. `blog.example.com`) 238 + - **Better Performance**: Leaflet's RSS feeds include full post content 239 + - **Custom Domains**: Each publication can have its own `base_path` configured in Leaflet 203 240 - **Rich Features**: Better media handling and publication management 241 + - **Multiple Publications**: Easy management of multiple publications with slug mapping 204 242 - **Active Development**: Leaflet is actively maintained and improved 205 243 206 244 ### Enabling WhiteWind ··· 213 251 214 252 With WhiteWind enabled: 215 253 216 - - Blog posts are checked on both platforms (Leaflet first, then WhiteWind) 254 + - Documents are checked on both platforms (Leaflet first, then WhiteWind) 217 255 - RSS feed includes WhiteWind posts if they exist 218 - - `/blog` redirects to WhiteWind if no Leaflet configuration is set 256 + - `/{slug}` redirects to WhiteWind if no Leaflet configuration is set 257 + 258 + ### Finding Your Publication Rkey 259 + 260 + 1. Visit your Leaflet publication page 261 + 2. The URL will be in the format: `https://leaflet.pub/lish/{did}/{rkey}` 262 + 3. Copy the `{rkey}` part (e.g., `3m3x4bgbsh22k`) 263 + 4. Add it to your slug mapping in `src/lib/config/slugs.ts` 219 264 220 265 ## 🎨 Styling 221 266 ··· 346 391 347 392 ## 🐛 Troubleshooting 348 393 349 - ### Blog Posts Not Found 394 + ### Documents Not Found 350 395 351 396 1. Check your `PUBLIC_ATPROTO_DID` is correct 352 - 2. Verify posts are not drafts (WhiteWind) or unpublished (Leaflet) 353 - 3. Check the publication configuration if using `PUBLIC_LEAFLET_BLOG_PUBLICATION` 354 - 4. If using WhiteWind, ensure `PUBLIC_ENABLE_WHITEWIND=true` is set 355 - 5. Check the browser console for AT Protocol service errors 397 + 2. Verify the slug mapping in `src/lib/config/slugs.ts` is correct 398 + 3. Ensure the publication rkey matches your Leaflet publication 399 + 4. Verify documents are not drafts (WhiteWind) or unpublished (Leaflet) 400 + 5. If using WhiteWind, ensure `PUBLIC_ENABLE_WHITEWIND=true` is set 401 + 6. Check the browser console for AT Protocol service errors 402 + 403 + ### Slug Not Found 404 + 405 + 1. Add your slug mapping to `src/lib/config/slugs.ts` 406 + 2. Ensure the format is: `{ slug: 'your-slug', publicationRkey: 'your-rkey' }` 407 + 3. Restart the development server after changes 356 408 357 409 ### Profile Data Not Loading 358 410
+1
src/lib/config/index.ts
··· 1 + export * from './slugs';
+58
src/lib/config/slugs.ts
··· 1 + /** 2 + * Slug to Leaflet Publication mapping configuration 3 + * 4 + * Maps friendly URL slugs to Leaflet publication rkeys. 5 + * This allows you to access publications via /{slug} instead of /blog 6 + * 7 + * Example: 8 + * - /blog → maps to publication with rkey "3m3x4bgbsh22k" 9 + * - /notes → maps to publication with rkey "xyz123abc" 10 + */ 11 + 12 + export interface SlugMapping { 13 + /** The URL-friendly slug */ 14 + slug: string; 15 + /** The Leaflet publication rkey */ 16 + publicationRkey: string; 17 + } 18 + 19 + /** 20 + * Slug to publication rkey mappings 21 + * Add your custom mappings here 22 + */ 23 + export const slugMappings: SlugMapping[] = [ 24 + { 25 + slug: 'blog', 26 + publicationRkey: '3m3x4bgbsh22k' // my blog publication rkey 27 + }, 28 + { 29 + slug: 'cailean', 30 + publicationRkey: '3m4222fxc3k2q' // Cailean Uen's publication rkey for his journal 31 + } 32 + // Add more mappings as needed: 33 + // { slug: 'notes', publicationRkey: 'xyz123abc' }, 34 + // { slug: 'essays', publicationRkey: 'def456ghi' }, 35 + ]; 36 + 37 + /** 38 + * Get publication rkey from slug 39 + */ 40 + export function getPublicationRkeyFromSlug(slug: string): string | null { 41 + const mapping = slugMappings.find(m => m.slug === slug); 42 + return mapping?.publicationRkey || null; 43 + } 44 + 45 + /** 46 + * Get slug from publication rkey 47 + */ 48 + export function getSlugFromPublicationRkey(rkey: string): string | null { 49 + const mapping = slugMappings.find(m => m.publicationRkey === rkey); 50 + return mapping?.slug || null; 51 + } 52 + 53 + /** 54 + * Get all configured slugs 55 + */ 56 + export function getAllSlugs(): string[] { 57 + return slugMappings.map(m => m.slug); 58 + }
+19 -21
src/lib/services/atproto/posts.ts
··· 1 - import { PUBLIC_ATPROTO_DID, PUBLIC_LEAFLET_BASE_PATH } from '$env/static/public'; 1 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 2 import { cache } from './cache'; 3 3 import { withFallback, defaultAgent } from './agents'; 4 4 import { resolveIdentity } from './agents'; ··· 146 146 ); 147 147 148 148 for (const record of leafletDocsRecords) { 149 - const value = record.value as any; 150 - const rkey = record.uri.split('/').pop() || ''; 151 - const publicationUri = value.publication; 152 - const publication = publicationsMap.get(publicationUri); 149 + const value = record.value as any; 150 + const rkey = record.uri.split('/').pop() || ''; 151 + const publicationUri = value.publication; 152 + const publication = publicationsMap.get(publicationUri); 153 153 154 - // Determine URL based on priority: env var → publication base_path → Leaflet /lish format 155 - let url: string; 156 - const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 154 + // Determine URL based on priority: publication base_path → Leaflet /lish format 155 + let url: string; 156 + const publicationRkey = publicationUri ? publicationUri.split('/').pop() : ''; 157 157 158 - if (PUBLIC_LEAFLET_BASE_PATH) { 159 - url = `${PUBLIC_LEAFLET_BASE_PATH}/${rkey}`; 160 - } else if (publication?.basePath) { 161 - // Ensure basePath is a complete URL 162 - const basePath = publication.basePath.startsWith('http') 163 - ? publication.basePath 164 - : `https://${publication.basePath}`; 165 - url = `${basePath}/${rkey}`; 166 - } else if (publicationRkey) { 167 - url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 168 - } else { 169 - url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 170 - } 158 + if (publication?.basePath) { 159 + // Ensure basePath is a complete URL 160 + const basePath = publication.basePath.startsWith('http') 161 + ? publication.basePath 162 + : `https://${publication.basePath}`; 163 + url = `${basePath}/${rkey}`; 164 + } else if (publicationRkey) { 165 + url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 166 + } else { 167 + url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 168 + } 171 169 172 170 posts.push({ 173 171 title: value.title || 'Untitled Document',
+95
src/routes/[slug]/+server.ts
··· 1 + import type { RequestHandler } from '@sveltejs/kit'; 2 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 3 + import { fetchLeafletPublications } from '$lib/services/atproto'; 4 + import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 5 + 6 + /** 7 + * Dynamic slug root redirect handler 8 + * 9 + * Redirects /{slug} to the appropriate Leaflet publication: 10 + * - Uses the slug mapping config to find the publication rkey 11 + * - Priority 1: Publication base_path from Leaflet API 12 + * - Priority 2: Leaflet /lish format 13 + * 14 + * Individual posts are handled by the [rkey] route. 15 + */ 16 + export const GET: RequestHandler = async ({ params, url }) => { 17 + const slug = params.slug; 18 + 19 + // If there's a path after /{slug}, let it fall through to other routes 20 + const slugPath = url.pathname.replace(new RegExp(`^/${slug}/?`), ''); 21 + 22 + if (slugPath && !['rss', 'atom'].includes(slugPath)) { 23 + // This will be caught by the [rkey] route 24 + return new Response(null, { 25 + status: 404, 26 + headers: { 27 + 'Content-Type': 'text/plain; charset=utf-8' 28 + } 29 + }); 30 + } 31 + 32 + // For /{slug} root, redirect to the publication 33 + if (!slugPath || slugPath === '') { 34 + // Get the publication rkey from the slug 35 + const publicationRkey = getPublicationRkeyFromSlug(slug); 36 + 37 + if (!publicationRkey) { 38 + return new Response( 39 + `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, 40 + { 41 + status: 404, 42 + headers: { 43 + 'Content-Type': 'text/plain; charset=utf-8' 44 + } 45 + } 46 + ); 47 + } 48 + 49 + let redirectUrl: string | null = null; 50 + 51 + try { 52 + // Fetch publications to get base path 53 + const { publications } = await fetchLeafletPublications(); 54 + const publication = publications.find(p => p.rkey === publicationRkey); 55 + 56 + if (publication?.basePath) { 57 + // Ensure basePath is a complete URL 58 + redirectUrl = publication.basePath.startsWith('http') 59 + ? publication.basePath 60 + : `https://${publication.basePath}`; 61 + } else { 62 + // Use Leaflet /lish format 63 + redirectUrl = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}`; 64 + } 65 + } catch (error) { 66 + console.error('Error fetching Leaflet publication:', error); 67 + // Fallback to /lish format 68 + redirectUrl = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}`; 69 + } 70 + 71 + // If we have a redirect URL, use it 72 + if (redirectUrl) { 73 + return new Response(null, { 74 + status: 301, 75 + headers: { 76 + Location: redirectUrl, 77 + 'Cache-Control': 'public, max-age=3600' 78 + } 79 + }); 80 + } 81 + 82 + // No publication found 83 + return new Response( 84 + `Publication not found for slug: ${slug}\n\nPlease check your configuration in src/lib/config/slugs.ts`, 85 + { 86 + status: 404, 87 + headers: { 88 + 'Content-Type': 'text/plain; charset=utf-8' 89 + } 90 + } 91 + ); 92 + } 93 + 94 + return new Response(null, { status: 404 }); 95 + };
-93
src/routes/blog/+server.ts
··· 1 - import type { RequestHandler } from '@sveltejs/kit'; 2 - import { 3 - PUBLIC_ATPROTO_DID, 4 - PUBLIC_LEAFLET_BASE_PATH, 5 - PUBLIC_LEAFLET_BLOG_PUBLICATION, 6 - PUBLIC_ENABLE_WHITEWIND 7 - } from '$env/static/public'; 8 - import { fetchLeafletPublications } from '$lib/services/atproto'; 9 - 10 - /** 11 - * Blog root redirect handler 12 - * 13 - * Redirects /blog to the appropriate blog platform: 14 - * - Priority 1: Leaflet blog (if PUBLIC_LEAFLET_BASE_PATH is configured) 15 - * - Priority 2: Leaflet publication page (if PUBLIC_LEAFLET_BLOG_PUBLICATION is set) 16 - * - Fallback: WhiteWind blog page (if PUBLIC_ENABLE_WHITEWIND is true) 17 - * 18 - * Individual posts are handled by the [rkey] route which detects 19 - * whether the post is from Leaflet or WhiteWind (if enabled). 20 - */ 21 - export const GET: RequestHandler = async ({ url }) => { 22 - // If there's a path after /blog, let it fall through to other routes 23 - const blogPath = url.pathname.replace(/^\/blog\/?/, ''); 24 - 25 - if (blogPath && !['rss', 'atom'].includes(blogPath)) { 26 - // This will be caught by the [rkey] route 27 - return new Response(null, { 28 - status: 404, 29 - headers: { 30 - 'Content-Type': 'text/plain; charset=utf-8' 31 - } 32 - }); 33 - } 34 - 35 - // For /blog root, redirect to the blog platform 36 - if (!blogPath || blogPath === '') { 37 - let redirectUrl: string | null = null; 38 - 39 - // Priority 1: Use Leaflet base path if configured 40 - if (PUBLIC_LEAFLET_BASE_PATH) { 41 - redirectUrl = PUBLIC_LEAFLET_BASE_PATH; 42 - } 43 - // Priority 2: Use Leaflet publication page if configured 44 - else if (PUBLIC_LEAFLET_BLOG_PUBLICATION) { 45 - try { 46 - const { publications } = await fetchLeafletPublications(); 47 - const publication = publications.find(p => p.rkey === PUBLIC_LEAFLET_BLOG_PUBLICATION); 48 - 49 - if (publication?.basePath) { 50 - // Ensure basePath is a complete URL 51 - redirectUrl = publication.basePath.startsWith('http') 52 - ? publication.basePath 53 - : `https://${publication.basePath}`; 54 - } else { 55 - // Use Leaflet /lish format 56 - redirectUrl = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${PUBLIC_LEAFLET_BLOG_PUBLICATION}`; 57 - } 58 - } catch (error) { 59 - console.error('Error fetching Leaflet publication:', error); 60 - // Will check WhiteWind fallback below 61 - } 62 - } 63 - 64 - // Fallback: WhiteWind blog page (if enabled) 65 - if (!redirectUrl && PUBLIC_ENABLE_WHITEWIND === 'true') { 66 - redirectUrl = `https://whtwnd.com/${PUBLIC_ATPROTO_DID}`; 67 - } 68 - 69 - // If we have a redirect URL, use it 70 - if (redirectUrl) { 71 - return new Response(null, { 72 - status: 301, 73 - headers: { 74 - Location: redirectUrl, 75 - 'Cache-Control': 'public, max-age=3600' 76 - } 77 - }); 78 - } 79 - 80 - // No blog configured 81 - return new Response( 82 - 'Blog not configured. Please set PUBLIC_LEAFLET_BASE_PATH or PUBLIC_LEAFLET_BLOG_PUBLICATION in your environment variables.', 83 - { 84 - status: 404, 85 - headers: { 86 - 'Content-Type': 'text/plain; charset=utf-8' 87 - } 88 - } 89 - ); 90 - } 91 - 92 - return new Response(null, { status: 404 }); 93 - };
+39 -31
src/routes/blog/[rkey]/+server.ts src/routes/[slug]/[rkey]/+server.ts
··· 1 1 import type { RequestHandler } from '@sveltejs/kit'; 2 2 import { 3 3 PUBLIC_ATPROTO_DID, 4 - PUBLIC_LEAFLET_BASE_PATH, 5 - PUBLIC_LEAFLET_BLOG_PUBLICATION, 6 4 PUBLIC_BLOG_FALLBACK_URL, 7 5 PUBLIC_ENABLE_WHITEWIND 8 6 } from '$env/static/public'; 9 7 import { withFallback } from '$lib/services/atproto'; 10 8 import { fetchLeafletPublications } from '$lib/services/atproto'; 9 + import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 11 10 12 11 /** 13 - * Smart blog post redirect handler 12 + * Smart document redirect handler for slugged publications 14 13 * 15 14 * Automatically detects whether the post is from Leaflet or WhiteWind (if enabled) 16 15 * and redirects to the appropriate URL. 17 16 * 18 17 * Priority order: 19 - * 1. Leaflet: {LEAFLET_BASE_PATH}/{rkey} or https://leaflet.pub/{DID}/{rkey} 18 + * 1. Leaflet: Uses publication's base_path or https://leaflet.pub/{DID}/{publicationRkey}/{rkey} 20 19 * 2. WhiteWind: https://whtwnd.com/{DID}/{rkey} (only if PUBLIC_ENABLE_WHITEWIND is true) 21 20 * 22 21 * If detection fails, falls back to PUBLIC_BLOG_FALLBACK_URL or returns 404. 23 22 * 24 - * Supports multiple Leaflet publications: 25 - * - If PUBLIC_LEAFLET_BLOG_PUBLICATION is set, only checks that specific publication 26 - * - Otherwise, checks all publications for the document 23 + * Uses slug mapping to determine which publication to check. 27 24 */ 28 25 29 26 async function detectPostPlatform( 30 - rkey: string 27 + rkey: string, 28 + publicationRkey: string 31 29 ): Promise<{ platform: 'whitewind' | 'leaflet' | 'unknown'; url?: string }> { 32 30 try { 33 31 // Check Leaflet FIRST (prioritized) using atproto services ··· 51 49 52 50 if (leafletRecord) { 53 51 const value = leafletRecord.value as any; 54 - const publicationUri = value?.publication; 52 + const documentPublicationUri = value?.publication; 55 53 56 54 // Fetch publications to get base path 57 55 const { publications } = await fetchLeafletPublications(); 58 - const publication = publicationUri 59 - ? publications.find((p) => p.uri === publicationUri) 56 + const publication = documentPublicationUri 57 + ? publications.find((p) => p.uri === documentPublicationUri) 60 58 : null; 61 59 62 - // If a specific blog publication is configured, check if this document belongs to it 63 - if (PUBLIC_LEAFLET_BLOG_PUBLICATION && publication) { 64 - if (publication.rkey !== PUBLIC_LEAFLET_BLOG_PUBLICATION) { 65 - // Document belongs to a different publication, not the blog 66 - return { platform: 'unknown' }; 67 - } 60 + // Check if this document belongs to the requested publication (from slug) 61 + if (publication && publication.rkey !== publicationRkey) { 62 + // Document belongs to a different publication 63 + return { platform: 'unknown' }; 68 64 } 69 65 70 - // Determine URL based on priority: env var → publication base_path → Leaflet /lish format 66 + // Determine URL based on publication base_path or Leaflet /lish format 71 67 let url: string; 72 - const publicationRkey = publication?.rkey || ''; 68 + const docPublicationRkey = publication?.rkey || publicationRkey; 73 69 74 - if (PUBLIC_LEAFLET_BASE_PATH) { 75 - url = `${PUBLIC_LEAFLET_BASE_PATH}/${rkey}`; 76 - } else if (publication?.basePath) { 70 + if (publication?.basePath) { 77 71 // Ensure basePath is a complete URL 78 72 const basePath = publication.basePath.startsWith('http') 79 73 ? publication.basePath 80 74 : `https://${publication.basePath}`; 81 75 url = `${basePath}/${rkey}`; 82 - } else if (publicationRkey) { 83 - url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`; 76 + } else if (docPublicationRkey) { 77 + url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${docPublicationRkey}/${rkey}`; 84 78 } else { 85 79 url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`; 86 80 } ··· 131 125 } 132 126 133 127 export const GET: RequestHandler = async ({ params, url }) => { 128 + const slug = params.slug; 134 129 const rkey = params.rkey; 135 130 131 + // Get the publication rkey from the slug 132 + const publicationRkey = getPublicationRkeyFromSlug(slug); 133 + 134 + if (!publicationRkey) { 135 + return new Response( 136 + `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, 137 + { 138 + status: 404, 139 + headers: { 140 + 'Content-Type': 'text/plain; charset=utf-8' 141 + } 142 + } 143 + ); 144 + } 145 + 136 146 // Validate TID format (AT Protocol record key) 137 147 const tidPattern = /^[a-zA-Z0-9]{12,16}$/; 138 148 ··· 146 156 } 147 157 148 158 // Detect platform and get appropriate URL 149 - const detection = await detectPostPlatform(rkey); 159 + const detection = await detectPostPlatform(rkey, publicationRkey); 150 160 151 161 let targetUrl: string | null = null; 152 162 let statusCode = 301; ··· 159 169 targetUrl = `${PUBLIC_BLOG_FALLBACK_URL}/${rkey}`; 160 170 } else { 161 171 // No fallback configured, return 404 162 - const blogPublicationNote = PUBLIC_LEAFLET_BLOG_PUBLICATION 163 - ? `\n\nNote: Only checking Leaflet publication: ${PUBLIC_LEAFLET_BLOG_PUBLICATION}` 164 - : ''; 172 + const publicationNote = `\n\nNote: Only checking Leaflet publication with rkey: ${publicationRkey}`; 165 173 const whiteWindNote = PUBLIC_ENABLE_WHITEWIND === 'true' 166 174 ? '\n- WhiteWind: https://whtwnd.com' 167 175 : ''; 168 176 169 177 return new Response( 170 - `Blog post not found: ${rkey} 178 + `Document not found: ${rkey} 171 179 172 - This post could not be found on Leaflet${PUBLIC_ENABLE_WHITEWIND === 'true' ? ' or WhiteWind' : ''} platform${PUBLIC_ENABLE_WHITEWIND === 'true' ? 's' : ''}.${blogPublicationNote} 180 + This document could not be found in the Leaflet publication for slug "${slug}"${PUBLIC_ENABLE_WHITEWIND === 'true' ? ' or WhiteWind' : ''}.${publicationNote} 173 181 174 182 Please check: 175 183 - Leaflet: https://leaflet.pub${whiteWindNote}`, ··· 194 202 'Cache-Control': 'public, max-age=31536000, immutable' 195 203 } 196 204 }); 197 - }; 205 + };
+20 -2
src/routes/blog/atom/+server.ts src/routes/[slug]/atom/+server.ts
··· 1 1 import type { RequestHandler } from '@sveltejs/kit'; 2 + import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 2 3 3 4 /** 4 5 * Deprecated Atom feed ··· 11 12 * - WhiteWind posts are included in our RSS feed 12 13 * - Maintaining both RSS and Atom adds unnecessary complexity 13 14 */ 14 - export const GET: RequestHandler = () => { 15 + export const GET: RequestHandler = ({ params }) => { 16 + const slug = params.slug; 17 + 18 + // Validate slug exists in config 19 + const publicationRkey = getPublicationRkeyFromSlug(slug); 20 + 21 + if (!publicationRkey) { 22 + return new Response( 23 + `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, 24 + { 25 + status: 404, 26 + headers: { 27 + 'Content-Type': 'text/plain; charset=utf-8' 28 + } 29 + } 30 + ); 31 + } 32 + 15 33 return new Response( 16 34 `Atom Feed Deprecated 17 35 18 36 This Atom feed is no longer available. Please use the RSS feed instead: 19 37 20 - RSS Feed: ${new URL('/blog/rss', 'http://localhost').pathname} 38 + RSS Feed: /${slug}/rss 21 39 22 40 For Leaflet posts with full content, the RSS feed will automatically redirect you to 23 41 Leaflet's native RSS feed which includes complete post content.
+43 -39
src/routes/blog/rss/+server.ts src/routes/[slug]/rss/+server.ts
··· 4 4 PUBLIC_SITE_TITLE, 5 5 PUBLIC_SITE_DESCRIPTION, 6 6 PUBLIC_SITE_URL, 7 - PUBLIC_LEAFLET_BASE_PATH, 8 - PUBLIC_LEAFLET_BLOG_PUBLICATION, 9 7 PUBLIC_ENABLE_WHITEWIND 10 8 } from '$env/static/public'; 11 9 import { fetchBlogPosts, fetchLeafletPublications } from '$lib/services/atproto'; 10 + import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 12 11 13 12 /** 14 - * RSS 2.0 feed for blog posts 13 + * RSS 2.0 feed for publications (accessed via /{slug}/rss) 15 14 * 16 15 * Strategy: 17 16 * 1. If WhiteWind is disabled or no WhiteWind posts exist, redirect to Leaflet RSS feed 18 17 * 2. If WhiteWind is enabled and WhiteWind posts exist, generate RSS with WhiteWind posts 19 18 * 3. If mixed content and WhiteWind is enabled, prioritize WhiteWind and generate RSS for those 20 19 */ 21 - export const GET: RequestHandler = async () => { 20 + export const GET: RequestHandler = async ({ params }) => { 21 + const slug = params.slug; 22 + 23 + // Get the publication rkey from the slug 24 + const publicationRkey = getPublicationRkeyFromSlug(slug); 25 + 26 + if (!publicationRkey) { 27 + return new Response( 28 + `Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`, 29 + { 30 + status: 404, 31 + headers: { 32 + 'Content-Type': 'text/plain; charset=utf-8' 33 + } 34 + } 35 + ); 36 + } 37 + 22 38 try { 23 39 const { posts } = await fetchBlogPosts(); 24 40 41 + // Filter posts for this specific publication 42 + const publicationPosts = posts.filter( 43 + p => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind' 44 + ); 45 + 25 46 // Separate WhiteWind and Leaflet posts 26 - const whiteWindPosts = posts.filter((p) => p.platform === 'WhiteWind'); 27 - const leafletPosts = posts.filter((p) => p.platform === 'leaflet'); 47 + const whiteWindPosts = publicationPosts.filter((p) => p.platform === 'WhiteWind'); 48 + const leafletPosts = publicationPosts.filter((p) => p.platform === 'leaflet'); 28 49 29 50 // If WhiteWind is enabled and we have WhiteWind posts, generate RSS for them 30 51 if (PUBLIC_ENABLE_WHITEWIND === 'true' && whiteWindPosts.length > 0) { 31 - return generateWhiteWindRSS(whiteWindPosts); 52 + return generateWhiteWindRSS(whiteWindPosts, slug); 32 53 } 33 54 34 55 // If WhiteWind is disabled or only Leaflet posts exist, redirect to Leaflet RSS feed 35 56 if (leafletPosts.length > 0) { 36 - return await redirectToLeafletRSS(); 57 + return await redirectToLeafletRSS(publicationRkey); 37 58 } 38 59 39 60 // No posts at all 40 - return new Response('No blog posts found', { 61 + return new Response(`No posts found for publication: ${slug}`, { 41 62 status: 404, 42 63 headers: { 43 64 'Content-Type': 'text/plain; charset=utf-8' ··· 57 78 /** 58 79 * Generate RSS feed for WhiteWind posts 59 80 */ 60 - function generateWhiteWindRSS(posts: Array<any>): Response { 81 + function generateWhiteWindRSS(posts: Array<any>, slug: string): Response { 61 82 const rss = `<?xml version="1.0" encoding="UTF-8"?> 62 83 <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> 63 84 <channel> 64 - <title>${escapeXml(PUBLIC_SITE_TITLE)} - Blog</title> 85 + <title>${escapeXml(PUBLIC_SITE_TITLE)} - ${slug}</title> 65 86 <link>${escapeXml(PUBLIC_SITE_URL)}</link> 66 87 <description>${escapeXml(PUBLIC_SITE_DESCRIPTION)}</description> 67 88 <language>en</language> 68 - <atom:link href="${escapeXml(PUBLIC_SITE_URL)}/blog/rss" rel="self" type="application/rss+xml" /> 89 + <atom:link href="${escapeXml(PUBLIC_SITE_URL)}/${slug}/rss" rel="self" type="application/rss+xml" /> 69 90 <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> 70 91 <generator>SvelteKit with AT Protocol</generator> 71 92 ${posts ··· 96 117 /** 97 118 * Redirect to Leaflet's native RSS feed 98 119 */ 99 - async function redirectToLeafletRSS(): Promise<Response> { 120 + async function redirectToLeafletRSS(publicationRkey: string): Promise<Response> { 100 121 try { 101 122 const { publications } = await fetchLeafletPublications(); 102 123 103 - // If a specific publication is configured, use that 104 - if (PUBLIC_LEAFLET_BLOG_PUBLICATION) { 105 - const publication = publications.find((p) => p.rkey === PUBLIC_LEAFLET_BLOG_PUBLICATION); 106 - if (publication) { 107 - const rssUrl = getLeafletRSSUrl(publication); 108 - return Response.redirect(rssUrl, 307); // Temporary redirect 109 - } 110 - } 111 - 112 - // If there's only one publication, redirect to it 113 - if (publications.length === 1) { 114 - const rssUrl = getLeafletRSSUrl(publications[0]); 124 + // Find the specific publication 125 + const publication = publications.find((p) => p.rkey === publicationRkey); 126 + 127 + if (publication) { 128 + const rssUrl = getLeafletRSSUrl(publication); 115 129 return Response.redirect(rssUrl, 307); // Temporary redirect 116 130 } 117 131 118 - // Multiple publications but no specific one configured 132 + // Publication not found 119 133 return new Response( 120 - `Multiple Leaflet publications found. Please configure PUBLIC_LEAFLET_BLOG_PUBLICATION in your .env file to specify which publication's RSS feed to use. 121 - 122 - Available publications: 123 - ${publications.map((p) => `- ${p.name} (rkey: ${p.rkey})`).join('\n')} 124 - 125 - Or visit the Leaflet RSS feeds directly: 126 - ${publications.map((p) => `- ${getLeafletRSSUrl(p)}`).join('\n')}`, 134 + `Leaflet publication not found for rkey: ${publicationRkey}`, 127 135 { 128 - status: 300, // Multiple Choices 136 + status: 404, 129 137 headers: { 130 138 'Content-Type': 'text/plain; charset=utf-8' 131 139 } ··· 146 154 * Get the RSS URL for a Leaflet publication 147 155 */ 148 156 function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string { 149 - if (PUBLIC_LEAFLET_BASE_PATH) { 150 - return `${PUBLIC_LEAFLET_BASE_PATH}/rss`; 151 - } 152 - 153 157 if (publication.basePath) { 154 158 // Ensure basePath is a complete URL 155 159 const basePath = publication.basePath.startsWith('http') ··· 169 173 .replace(/>/g, '&gt;') 170 174 .replace(/"/g, '&quot;') 171 175 .replace(/'/g, '&apos;'); 172 - } 176 + }