my website at ewancroft.uk
6
fork

Configure Feed

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

feat(rss): add RSS feed utilities and bump version to 10.7.1

+253 -60
+9 -2
README.md
··· 371 371 ### RSS Feed Behavior 372 372 373 373 Generates an RSS 2.0 feed containing all documents from the specified publication: 374 + 374 375 - Includes title, link, publication date, and description 375 376 - Filtered by publication rkey 376 377 - Cached for 1 hour for performance ··· 454 455 ### Available Themes 455 456 456 457 **Neutral Themes** 458 + 457 459 - **Sage**: Calm green-blue 458 460 - **Monochrome**: Pure greyscale 459 461 - **Slate**: Blue-grey (default) 460 462 461 463 **Warm Themes** 464 + 462 465 - **Ruby**: Bold red 463 466 - **Coral**: Orange-pink 464 467 - **Sunset**: Warm orange 465 468 - **Amber**: Bright yellow 466 469 467 470 **Cool Themes** 471 + 468 472 - **Forest**: Natural green 469 473 - **Teal**: Blue-green 470 474 - **Ocean**: Deep blue 471 475 472 476 **Vibrant Themes** 477 + 473 478 - **Lavender**: Soft purple 474 479 - **Rose**: Pink-red 475 480 ··· 493 498 description: 'Custom colors', 494 499 color: 'oklch(80% 0.2 180)', 495 500 category: 'cool' 496 - }, 501 + } 497 502 // ... more themes 498 503 ]; 499 504 ``` ··· 593 598 ### Site Information (`uk.ewancroft.site.info`) 594 599 595 600 Store comprehensive site metadata: 601 + 596 602 - Technology stack 597 603 - Privacy statement 598 604 - Open-source information ··· 731 737 6. Icon placeholder displays if no artwork is found 732 738 733 739 The cascading fallback system tries multiple sources: 740 + 734 741 - MusicBrainz (with automatic search) 735 742 - iTunes 736 743 - Deezer ··· 800 807 801 808 Built with ❤️ using SvelteKit, AT Protocol, and open-source tools 802 809 803 - **Version**: 10.7.0 810 + **Version**: 10.7.1
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "10.7.0", 3 + "version": "10.7.1", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "10.7.0", 9 + "version": "10.7.1", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.1", 12 12 "@lucide/svelte": "^0.554.0",
+1 -1
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "10.7.0", 4 + "version": "10.7.1", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+2 -2
src/lib/components/layout/Footer.svelte
··· 97 97 type="button" 98 98 onclick={() => happyMacStore.incrementClick()} 99 99 class="cursor-default transition-colors select-none hover:text-ink-600 dark:hover:text-ink-300" 100 - aria-label="Version 10.7.0{showHint 100 + aria-label="Version 10.7.1{showHint 101 101 ? ` - ${$happyMacStore.clickCount} of 24 clicks` 102 102 : ''}" 103 103 title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 104 104 > 105 - v10.7.0{#if showHint}<span class="ml-1 text-xs opacity-60" 105 + v10.7.1{#if showHint}<span class="ml-1 text-xs opacity-60" 106 106 >({$happyMacStore.clickCount}/24)</span 107 107 >{/if} 108 108 </button>
+8 -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, fetchFn)) || undefined; 238 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || 239 + undefined; 239 240 } 240 241 241 242 // Priority 2: Fall back to track-based search if album search failed 242 243 if (!artworkUrl && trackName && artistName) { 243 244 console.info('[MusicStatus] Falling back to track-based artwork search'); 244 245 artworkUrl = 245 - (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 246 + (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || 247 + undefined; 246 248 } 247 249 248 250 // Priority 3: Final fallback to atproto blob if no external artwork found ··· 326 328 if (releaseName && artistName) { 327 329 console.info('[MusicStatus] Prioritizing album artwork search'); 328 330 artworkUrl = 329 - (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 331 + (await findArtwork(releaseName, artistName, releaseName, releaseMbId, fetchFn)) || 332 + undefined; 330 333 } 331 334 332 335 // Priority 2: Fall back to track-based search if album search failed 333 336 if (!artworkUrl && trackName && artistName) { 334 337 console.info('[MusicStatus] Falling back to track-based artwork search'); 335 338 artworkUrl = 336 - (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || undefined; 339 + (await findArtwork(trackName, artistName, releaseName, releaseMbId, fetchFn)) || 340 + undefined; 337 341 } 338 342 339 343 // Priority 3: Final fallback to atproto blob if no external artwork found
+3
src/lib/utils/index.ts
··· 14 14 15 15 // Validation and text processing utilities 16 16 export * from './validators'; 17 + 18 + // RSS feed generation utilities 19 + export * from './rss';
+201
src/lib/utils/rss.ts
··· 1 + /** 2 + * RSS Feed Generation Utilities 3 + */ 4 + 5 + export interface RSSChannelConfig { 6 + title: string; 7 + link: string; 8 + description: string; 9 + language?: string; 10 + selfLink?: string; 11 + copyright?: string; 12 + managingEditor?: string; 13 + webMaster?: string; 14 + generator?: string; 15 + ttl?: number; 16 + } 17 + 18 + export interface RSSItem { 19 + title: string; 20 + link: string; 21 + guid?: string; 22 + pubDate: Date | string; 23 + description?: string; 24 + content?: string; 25 + author?: string; 26 + categories?: string[]; 27 + enclosure?: { 28 + url: string; 29 + length?: number; 30 + type?: string; 31 + }; 32 + comments?: string; 33 + source?: { 34 + url: string; 35 + title: string; 36 + }; 37 + } 38 + 39 + /** 40 + * Escape XML special characters (minimal escaping for UTF-8 RSS feeds) 41 + * Only escapes characters that MUST be escaped in XML 42 + */ 43 + export function escapeXml(unsafe: string): string { 44 + return unsafe 45 + .replace(/&/g, '&amp;') 46 + .replace(/</g, '&lt;') 47 + .replace(/>/g, '&gt;'); 48 + } 49 + 50 + /** 51 + * Escape XML attributes (includes quotes) 52 + */ 53 + export function escapeXmlAttribute(unsafe: string): string { 54 + return unsafe 55 + .replace(/&/g, '&amp;') 56 + .replace(/</g, '&lt;') 57 + .replace(/>/g, '&gt;') 58 + .replace(/"/g, '&quot;'); 59 + } 60 + 61 + /** 62 + * Normalize special characters to their UTF-8 equivalents 63 + */ 64 + export function normalizeCharacters(text: string): string { 65 + return text 66 + // Smart quotes 67 + .replace(/\u2018|\u2019|\u201A|\u201B/g, "'") 68 + .replace(/\u201C|\u201D|\u201E|\u201F/g, '"') 69 + // Em and en dashes 70 + .replace(/\u2013/g, '-') 71 + .replace(/\u2014/g, '--') 72 + // Other special spaces and characters 73 + .replace(/\u00A0/g, ' ') // non-breaking space 74 + .replace(/\u2026/g, '...') // ellipsis 75 + .replace(/\u2022/g, '*') // bullet 76 + // HTML entities that might have been left in 77 + .replace(/&apos;/g, "'") 78 + .replace(/&quot;/g, '"') 79 + .replace(/&nbsp;/g, ' ') 80 + .replace(/&mdash;/g, '--') 81 + .replace(/&ndash;/g, '-') 82 + .replace(/&hellip;/g, '...') 83 + .replace(/&rsquo;/g, "'") 84 + .replace(/&lsquo;/g, "'") 85 + .replace(/&rdquo;/g, '"') 86 + .replace(/&ldquo;/g, '"'); 87 + } 88 + 89 + /** 90 + * Format a date for RSS (RFC 822 format) 91 + */ 92 + export function formatRSSDate(date: Date | string): string { 93 + const d = typeof date === 'string' ? new Date(date) : date; 94 + return d.toUTCString(); 95 + } 96 + 97 + /** 98 + * Generate an RSS item XML string 99 + */ 100 + export function generateRSSItem(item: RSSItem): string { 101 + const guid = item.guid || item.link; 102 + const pubDate = formatRSSDate(item.pubDate); 103 + 104 + // Normalize and escape text content 105 + const title = escapeXml(normalizeCharacters(item.title)); 106 + const description = item.description ? escapeXml(normalizeCharacters(item.description)) : ''; 107 + const content = item.content ? normalizeCharacters(item.content) : ''; 108 + const author = item.author ? escapeXml(normalizeCharacters(item.author)) : ''; 109 + 110 + const categories = 111 + item.categories?.map((cat) => ` <category>${escapeXml(normalizeCharacters(cat))}</category>`).join('\n') || ''; 112 + 113 + let enclosure = ''; 114 + if (item.enclosure) { 115 + const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : ''; 116 + const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : ''; 117 + enclosure = ` <enclosure url="${escapeXmlAttribute(item.enclosure.url)}"${length}${type} />`; 118 + } 119 + 120 + let source = ''; 121 + if (item.source) { 122 + source = ` <source url="${escapeXmlAttribute(item.source.url)}">${escapeXml(normalizeCharacters(item.source.title))}</source>`; 123 + } 124 + 125 + return ` <item> 126 + <title>${title}</title> 127 + <link>${escapeXmlAttribute(item.link)}</link> 128 + <guid isPermaLink="true">${escapeXmlAttribute(guid)}</guid> 129 + <pubDate>${pubDate}</pubDate>${description ? `\n <description>${description}</description>` : ''}${content ? `\n <content:encoded><![CDATA[${content}]]></content:encoded>` : ''}${author ? `\n <author>${author}</author>` : ''}${item.comments ? `\n <comments>${escapeXmlAttribute(item.comments)}</comments>` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''} 130 + </item>`; 131 + } 132 + 133 + /** 134 + * Generate a complete RSS 2.0 feed 135 + */ 136 + export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string { 137 + const language = config.language || 'en'; 138 + const generator = config.generator || 'SvelteKit with AT Protocol'; 139 + const lastBuildDate = formatRSSDate(new Date()); 140 + 141 + // Normalize and escape channel data 142 + const title = escapeXml(normalizeCharacters(config.title)); 143 + const link = escapeXmlAttribute(config.link); 144 + const description = escapeXml(normalizeCharacters(config.description)); 145 + const generatorText = escapeXml(normalizeCharacters(generator)); 146 + 147 + const atomLink = config.selfLink 148 + ? ` <atom:link href="${escapeXmlAttribute(config.selfLink)}" rel="self" type="application/rss+xml" />` 149 + : ''; 150 + 151 + const optionalFields = []; 152 + if (config.copyright) { 153 + optionalFields.push(` <copyright>${escapeXml(normalizeCharacters(config.copyright))}</copyright>`); 154 + } 155 + if (config.managingEditor) { 156 + optionalFields.push(` <managingEditor>${escapeXml(normalizeCharacters(config.managingEditor))}</managingEditor>`); 157 + } 158 + if (config.webMaster) { 159 + optionalFields.push(` <webMaster>${escapeXml(normalizeCharacters(config.webMaster))}</webMaster>`); 160 + } 161 + if (config.ttl) { 162 + optionalFields.push(` <ttl>${config.ttl}</ttl>`); 163 + } 164 + 165 + const itemsXml = items.map((item) => generateRSSItem(item)).join('\n'); 166 + 167 + return `<?xml version="1.0" encoding="UTF-8"?> 168 + <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 169 + <channel> 170 + <title>${title}</title> 171 + <link>${link}</link> 172 + <description>${description}</description> 173 + <language>${language}</language>${atomLink ? `\n${atomLink}` : ''} 174 + <lastBuildDate>${lastBuildDate}</lastBuildDate> 175 + <generator>${generatorText}</generator>${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''} 176 + ${itemsXml} 177 + </channel> 178 + </rss>`; 179 + } 180 + 181 + /** 182 + * Create an RSS Response object ready to be returned from a SvelteKit endpoint 183 + */ 184 + export function createRSSResponse( 185 + feed: string, 186 + options?: { 187 + cacheMaxAge?: number; 188 + status?: number; 189 + } 190 + ): Response { 191 + const cacheMaxAge = options?.cacheMaxAge ?? 3600; 192 + const status = options?.status ?? 200; 193 + 194 + return new Response(feed, { 195 + status, 196 + headers: { 197 + 'Content-Type': 'application/rss+xml; charset=utf-8', 198 + 'Cache-Control': `public, max-age=${cacheMaxAge}` 199 + } 200 + }); 201 + }
+27 -49
src/routes/[slug=slug]/rss/+server.ts
··· 7 7 } from '$env/static/public'; 8 8 import { fetchBlogPosts } from '$lib/services/atproto'; 9 9 import { getPublicationRkeyFromSlug } from '$lib/config/slugs'; 10 + import { generateRSSFeed, createRSSResponse, type RSSItem } from '$lib/utils/rss'; 10 11 11 12 /** 12 13 * RSS 2.0 feed for Standard.site publications (accessed via /{slug}/rss) ··· 49 50 50 51 // Generate RSS for Standard.site posts 51 52 if (publicationPosts.length > 0) { 52 - return generateRSS(publicationPosts, slug); 53 + // Convert posts to RSS items 54 + const items: RSSItem[] = publicationPosts.map((post) => ({ 55 + title: post.title, 56 + link: post.url, 57 + guid: post.url, 58 + pubDate: post.createdAt, 59 + description: post.description || 'Read this post on Standard.site', 60 + content: post.textContent || '', 61 + author: PUBLIC_SITE_TITLE, 62 + categories: post.tags 63 + })); 64 + 65 + // Generate RSS feed 66 + const feed = generateRSSFeed( 67 + { 68 + title: `${PUBLIC_SITE_TITLE} - ${slug}`, 69 + link: PUBLIC_SITE_URL, 70 + description: PUBLIC_SITE_DESCRIPTION, 71 + language: 'en', 72 + selfLink: `${PUBLIC_SITE_URL}/${slug}/rss`, 73 + generator: 'SvelteKit with AT Protocol' 74 + }, 75 + items 76 + ); 77 + 78 + return createRSSResponse(feed); 53 79 } 54 80 55 81 // No posts at all ··· 69 95 }); 70 96 } 71 97 }; 72 - 73 - /** 74 - * Generate RSS feed for Standard.site posts 75 - */ 76 - function generateRSS(posts: Array<any>, slug: string): Response { 77 - const rss = `<?xml version="1.0" encoding="UTF-8"?> 78 - <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> 79 - <channel> 80 - <title>${escapeXml(PUBLIC_SITE_TITLE)} - ${slug}</title> 81 - <link>${escapeXml(PUBLIC_SITE_URL)}</link> 82 - <description>${escapeXml(PUBLIC_SITE_DESCRIPTION)}</description> 83 - <language>en</language> 84 - <atom:link href="${escapeXml(PUBLIC_SITE_URL)}/${slug}/rss" rel="self" type="application/rss+xml" /> 85 - <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> 86 - <generator>SvelteKit with AT Protocol</generator> 87 - ${posts 88 - .map((post) => { 89 - const description = post.description || 'Read this post on Standard.site'; 90 - 91 - return ` <item> 92 - <title>${escapeXml(post.title)}</title> 93 - <link>${escapeXml(post.url)}</link> 94 - <guid isPermaLink="true">${escapeXml(post.url)}</guid> 95 - <pubDate>${new Date(post.createdAt).toUTCString()}</pubDate> 96 - <description>${escapeXml(description)}</description> 97 - <author>${escapeXml(PUBLIC_SITE_TITLE)}</author> 98 - </item>`; 99 - }) 100 - .join('\n')} 101 - </channel> 102 - </rss>`; 103 - 104 - return new Response(rss, { 105 - headers: { 106 - 'Content-Type': 'application/rss+xml; charset=utf-8', 107 - 'Cache-Control': 'public, max-age=3600' 108 - } 109 - }); 110 - } 111 - 112 - function escapeXml(unsafe: string): string { 113 - return unsafe 114 - .replace(/&/g, '&amp;') 115 - .replace(/</g, '&lt;') 116 - .replace(/>/g, '&gt;') 117 - .replace(/"/g, '&quot;') 118 - .replace(/'/g, '&apos;'); 119 - }