my website at ewancroft.uk

Compare changes

Choose any two refs to compare.

+25
.env.example
··· 50 50 # Use "*" to allow all origins (not recommended for production) 51 51 # Example: https://example.com,https://app.example.com 52 52 PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com" 53 + 54 + # Cache TTL Configuration (optional) 55 + # Configure how long different types of data are cached (in minutes) 56 + # Longer TTLs reduce API calls and prevent timeouts, but data may be less fresh 57 + # Leave empty to use defaults (optimized for production) 58 + # Profile data (default: 5 min dev, 60 min prod) 59 + # CACHE_TTL_PROFILE=60 60 + # Site info (default: 5 min dev, 120 min prod) 61 + # CACHE_TTL_SITE_INFO=120 62 + # Links (default: 5 min dev, 60 min prod) 63 + # CACHE_TTL_LINKS=60 64 + # Music status (default: 2 min dev, 10 min prod) 65 + # CACHE_TTL_MUSIC_STATUS=10 66 + # Kibun status (default: 2 min dev, 15 min prod) 67 + # CACHE_TTL_KIBUN_STATUS=15 68 + # Tangled repos (default: 5 min dev, 60 min prod) 69 + # CACHE_TTL_TANGLED_REPOS=60 70 + # Blog posts (default: 5 min dev, 30 min prod) 71 + # CACHE_TTL_BLOG_POSTS=30 72 + # Publications (default: 5 min dev, 60 min prod) 73 + # CACHE_TTL_PUBLICATIONS=60 74 + # Individual posts (default: 5 min dev, 60 min prod) 75 + # CACHE_TTL_INDIVIDUAL_POST=60 76 + # Identity resolution (default: 30 min dev, 1440 min/24h prod) 77 + # CACHE_TTL_IDENTITY=1440
-80
git_diff.txt
··· 1 - diff --git a/src/lib/components/layout/main/card/ProfileCard.svelte b/src/lib/components/layout/main/card/ProfileCard.svelte 2 - index dc23db8..5d6f030 100644 3 - --- a/src/lib/components/layout/main/card/ProfileCard.svelte 4 - +++ b/src/lib/components/layout/main/card/ProfileCard.svelte 5 - @@ -112,6 +112,9 @@ 6 - {safeProfile.displayName || safeProfile.handle} 7 - </h2> 8 - <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 9 - + {#if safeProfile.pronouns} 10 - + <p class="text-sm italic text-ink-600 dark:text-ink-300">{safeProfile.pronouns}</p> 11 - + {/if} 12 - 13 - {#if safeProfile.description} 14 - <p 15 - diff --git a/src/lib/services/atproto/fetch.ts b/src/lib/services/atproto/fetch.ts 16 - index c719682..853a0c2 100644 17 - --- a/src/lib/services/atproto/fetch.ts 18 - +++ b/src/lib/services/atproto/fetch.ts 19 - @@ -40,6 +40,31 @@ export async function fetchProfile(fetchFn?: typeof fetch): Promise<ProfileData> 20 - fetchFn 21 - ); 22 - 23 - + // Fetch the actual profile record to get pronouns and other fields 24 - + // The profile view doesn't include pronouns, so we need the record 25 - + let pronouns: string | undefined; 26 - + try { 27 - + console.debug('[Profile] Attempting to fetch profile record for pronouns'); 28 - + const recordResponse = await withFallback( 29 - + PUBLIC_ATPROTO_DID, 30 - + async (agent) => { 31 - + const response = await agent.com.atproto.repo.getRecord({ 32 - + repo: PUBLIC_ATPROTO_DID, 33 - + collection: 'app.bsky.actor.profile', 34 - + rkey: 'self' 35 - + }); 36 - + return response.data; 37 - + }, 38 - + false, 39 - + fetchFn 40 - + ); 41 - + pronouns = (recordResponse.value as any).pronouns; 42 - + console.debug('[Profile] Successfully fetched pronouns:', pronouns); 43 - + } catch (error) { 44 - + console.debug('[Profile] Could not fetch profile record for pronouns:', error); 45 - + // Continue without pronouns if record fetch fails 46 - + } 47 - + 48 - const data: ProfileData = { 49 - did: profile.did, 50 - handle: profile.handle, 51 - @@ -49,7 +74,8 @@ export async function fetchProfile(fetchFn?: typeof fetch): Promise<ProfileData> 52 - banner: profile.banner, 53 - followersCount: profile.followersCount, 54 - followsCount: profile.followsCount, 55 - - postsCount: profile.postsCount 56 - + postsCount: profile.postsCount, 57 - + pronouns: pronouns 58 - }; 59 - 60 - console.info('[Profile] Successfully fetched profile data'); 61 - diff --git a/src/lib/services/atproto/types.ts b/src/lib/services/atproto/types.ts 62 - index f4d4b16..37440f6 100644 63 - --- a/src/lib/services/atproto/types.ts 64 - +++ b/src/lib/services/atproto/types.ts 65 - @@ -12,6 +12,7 @@ export interface ProfileData { 66 - followersCount?: number; 67 - followsCount?: number; 68 - postsCount?: number; 69 - + pronouns?: string; 70 - } 71 - 72 - export interface StatusData { 73 - @@ -150,6 +151,7 @@ export interface PostAuthor { 74 - handle: string; 75 - displayName?: string; 76 - avatar?: string; 77 - + pronouns?: string; 78 - } 79 - 80 - export interface BlueskyPost {
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "10.3.4", 3 + "version": "10.5.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "10.3.4", 9 + "version": "10.5.0", 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.3.4", 4 + "version": "10.5.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+23 -1
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 2 import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; 3 + import { HTTP_CACHE_HEADERS } from '$lib/config/cache.config'; 3 4 4 5 /** 5 6 * Global request handler with CORS support ··· 31 32 32 33 const response = await resolve(event, { 33 34 filterSerializedResponseHeaders: (name) => { 34 - return name === 'content-type' || name.startsWith('x-'); 35 + return name === 'content-type' || name === 'cache-control' || name.startsWith('x-'); 35 36 } 36 37 }); 38 + 39 + // Add HTTP caching headers for better performance and reduced timeouts 40 + // Layout data (root route) is cached aggressively since profile/site info changes infrequently 41 + if (!event.url.pathname.startsWith('/api/')) { 42 + // Root layout loads profile and site info - cache aggressively 43 + if (event.url.pathname === '/' || event.url.pathname === '') { 44 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 45 + } 46 + // Blog listing pages 47 + else if (event.url.pathname.startsWith('/blog') || event.url.pathname.startsWith('/archive')) { 48 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_LISTING); 49 + } 50 + // Individual blog post pages 51 + else if (event.url.pathname.match(/^\/[a-z0-9-]+$/)) { 52 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_POST); 53 + } 54 + // Other pages get moderate caching 55 + else { 56 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 57 + } 58 + } 37 59 38 60 // Add CORS headers for API routes 39 61 if (event.url.pathname.startsWith('/api/')) {
+26 -22
src/lib/components/layout/Footer.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 2 4 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; 3 5 import DecimalClock from './DecimalClock.svelte'; 4 6 import { happyMacStore } from '$lib/stores'; 5 7 6 - interface Props { 7 - profile?: ProfileData | null; 8 - siteInfo?: SiteInfoData | null; 9 - } 8 + let profile: ProfileData | null = $state(null); 9 + let siteInfo: SiteInfoData | null = $state(null); 10 + let loading = $state(true); 11 + let error: string | null = $state(null); 10 12 11 - let { profile = null, siteInfo = null }: Props = $props(); 12 - 13 - let loading = false; 14 - let error: string | null = null; 15 - 16 13 const currentYear = new Date().getFullYear(); 17 - 14 + 18 15 // Show click count hint after 3 clicks 19 16 let showHint = $derived($happyMacStore.clickCount >= 3 && $happyMacStore.clickCount < 24); 20 - 17 + 21 18 // Compute copyright text reactively 22 19 let copyrightText = $derived.by(() => { 23 - console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 24 20 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 25 - console.log('[Footer] Current year:', currentYear); 26 - console.log('[Footer] Birth year:', birthYear); 27 - console.log('[Footer] Birth year type:', typeof birthYear); 28 21 29 22 if (!birthYear || typeof birthYear !== 'number') { 30 - console.log('[Footer] Using current year (invalid/missing birth year)'); 31 23 return `${currentYear}`; 32 24 } else if (birthYear > currentYear) { 33 - console.log('[Footer] Using current year (birth year in future)'); 34 25 return `${currentYear}`; 35 26 } else if (birthYear === currentYear) { 36 - console.log('[Footer] Using current year (birth year equals current)'); 37 27 return `${currentYear}`; 38 28 } else { 39 - console.log('[Footer] Using year range'); 40 29 return `${birthYear} - ${currentYear}`; 41 30 } 42 31 }); 43 32 44 - // Data is provided by layout load; no client-side fetch here to avoid using window.fetch during navigation. 33 + // Fetch data client-side for non-blocking layout 34 + onMount(async () => { 35 + try { 36 + // Fetch both in parallel 37 + const [profileData, siteInfoData] = await Promise.all([ 38 + fetchProfile().catch(() => null), 39 + fetchSiteInfo().catch(() => null) 40 + ]); 41 + profile = profileData; 42 + siteInfo = siteInfoData; 43 + } catch (err) { 44 + error = err instanceof Error ? err.message : 'Failed to load footer data'; 45 + } finally { 46 + loading = false; 47 + } 48 + }); 45 49 </script> 46 50 47 51 <footer ··· 93 97 type="button" 94 98 onclick={() => happyMacStore.incrementClick()} 95 99 class="cursor-default select-none transition-colors hover:text-ink-600 dark:hover:text-ink-300" 96 - aria-label="Version 10.3.4{showHint ? ` - ${$happyMacStore.clickCount} of 24 clicks` : ''}" 100 + aria-label="Version 10.5.0{showHint ? ` - ${$happyMacStore.clickCount} of 24 clicks` : ''}" 97 101 title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 98 102 > 99 - v10.3.4{#if showHint}<span class="ml-1 text-xs opacity-60">({$happyMacStore.clickCount}/24)</span>{/if} 103 + v10.5.0{#if showHint}<span class="ml-1 text-xs opacity-60">({$happyMacStore.clickCount}/24)</span>{/if} 100 104 </button> 101 105 </div> 102 106 </div>
+95
src/lib/config/cache.config.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + /** 4 + * Cache configuration with environment-aware TTL values 5 + * 6 + * Development: Shorter TTLs for faster iteration 7 + * Production: Longer TTLs to reduce API calls and prevent timeouts 8 + */ 9 + 10 + // Parse environment variable or use default (in milliseconds) 11 + const getEnvTTL = (key: string, defaultMinutes: number): number => { 12 + if (typeof process !== 'undefined' && process.env?.[key]) { 13 + const minutes = parseInt(process.env[key], 10); 14 + return isNaN(minutes) ? defaultMinutes * 60 * 1000 : minutes * 60 * 1000; 15 + } 16 + return defaultMinutes * 60 * 1000; 17 + }; 18 + 19 + /** 20 + * Default TTL values (in minutes) for different data types 21 + * 22 + * Profile data changes infrequently, so we can cache it longer 23 + * Music and Kibun statuses change frequently, so shorter cache 24 + */ 25 + const DEFAULT_TTL = { 26 + // Profile data: 60 minutes (changes infrequently) 27 + PROFILE: dev ? 5 : 60, 28 + 29 + // Site info: 120 minutes (rarely changes) 30 + SITE_INFO: dev ? 5 : 120, 31 + 32 + // Links: 60 minutes (changes occasionally) 33 + LINKS: dev ? 5 : 60, 34 + 35 + // Music status: 10 minutes (changes frequently) 36 + MUSIC_STATUS: dev ? 2 : 10, 37 + 38 + // Kibun status: 15 minutes (changes occasionally) 39 + KIBUN_STATUS: dev ? 2 : 15, 40 + 41 + // Tangled repos: 60 minutes (changes occasionally) 42 + TANGLED_REPOS: dev ? 5 : 60, 43 + 44 + // Blog posts: 30 minutes (balance between freshness and performance) 45 + BLOG_POSTS: dev ? 5 : 30, 46 + 47 + // Publications: 60 minutes (rarely changes) 48 + PUBLICATIONS: dev ? 5 : 60, 49 + 50 + // Individual posts: 60 minutes (content doesn't change) 51 + INDIVIDUAL_POST: dev ? 5 : 60, 52 + 53 + // Identity resolution: 1440 minutes (24 hours - DIDs are stable) 54 + IDENTITY: dev ? 30 : 1440 55 + }; 56 + 57 + /** 58 + * Cache TTL configuration 59 + * Values are loaded from environment variables with fallbacks to defaults 60 + */ 61 + export const CACHE_TTL = { 62 + PROFILE: getEnvTTL('CACHE_TTL_PROFILE', DEFAULT_TTL.PROFILE), 63 + SITE_INFO: getEnvTTL('CACHE_TTL_SITE_INFO', DEFAULT_TTL.SITE_INFO), 64 + LINKS: getEnvTTL('CACHE_TTL_LINKS', DEFAULT_TTL.LINKS), 65 + MUSIC_STATUS: getEnvTTL('CACHE_TTL_MUSIC_STATUS', DEFAULT_TTL.MUSIC_STATUS), 66 + KIBUN_STATUS: getEnvTTL('CACHE_TTL_KIBUN_STATUS', DEFAULT_TTL.KIBUN_STATUS), 67 + TANGLED_REPOS: getEnvTTL('CACHE_TTL_TANGLED_REPOS', DEFAULT_TTL.TANGLED_REPOS), 68 + BLOG_POSTS: getEnvTTL('CACHE_TTL_BLOG_POSTS', DEFAULT_TTL.BLOG_POSTS), 69 + PUBLICATIONS: getEnvTTL('CACHE_TTL_PUBLICATIONS', DEFAULT_TTL.PUBLICATIONS), 70 + INDIVIDUAL_POST: getEnvTTL('CACHE_TTL_INDIVIDUAL_POST', DEFAULT_TTL.INDIVIDUAL_POST), 71 + IDENTITY: getEnvTTL('CACHE_TTL_IDENTITY', DEFAULT_TTL.IDENTITY) 72 + } as const; 73 + 74 + /** 75 + * HTTP Cache-Control header values for different routes 76 + * These tell browsers and CDNs how long to cache responses 77 + * 78 + * Format: max-age=X (browser cache), s-maxage=Y (CDN cache), stale-while-revalidate=Z 79 + */ 80 + export const HTTP_CACHE_HEADERS = { 81 + // Layout data (profile, site info) - cache aggressively 82 + LAYOUT: `public, max-age=${CACHE_TTL.PROFILE / 1000}, s-maxage=${CACHE_TTL.PROFILE / 1000}, stale-while-revalidate=${CACHE_TTL.PROFILE / 1000}`, 83 + 84 + // Blog posts listing - moderate caching 85 + BLOG_LISTING: `public, max-age=${CACHE_TTL.BLOG_POSTS / 1000}, s-maxage=${CACHE_TTL.BLOG_POSTS / 1000}, stale-while-revalidate=${CACHE_TTL.BLOG_POSTS / 1000}`, 86 + 87 + // Individual blog post - cache aggressively (content doesn't change) 88 + BLOG_POST: `public, max-age=${CACHE_TTL.INDIVIDUAL_POST / 1000}, s-maxage=${CACHE_TTL.INDIVIDUAL_POST / 1000}, stale-while-revalidate=${CACHE_TTL.INDIVIDUAL_POST / 1000}`, 89 + 90 + // Music status - short cache (changes frequently) 91 + MUSIC_STATUS: `public, max-age=${CACHE_TTL.MUSIC_STATUS / 1000}, s-maxage=${CACHE_TTL.MUSIC_STATUS / 1000}, stale-while-revalidate=${CACHE_TTL.MUSIC_STATUS / 1000}`, 92 + 93 + // API endpoints - moderate caching 94 + API: `public, max-age=300, s-maxage=300, stale-while-revalidate=600` 95 + } as const;
+1
src/lib/config/index.ts
··· 1 1 export * from './slugs'; 2 + export * from './cache.config';
+14
src/lib/services/atproto/agents.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { ResolvedIdentity } from './types'; 3 + import { cache } from './cache'; 3 4 4 5 /** 5 6 * Creates an AtpAgent with optional fetch function injection ··· 46 47 47 48 /** 48 49 * Resolves a DID to find its PDS endpoint using Slingshot. 50 + * Results are cached to reduce resolution calls. 49 51 */ 50 52 export async function resolveIdentity( 51 53 did: string, ··· 53 55 ): Promise<ResolvedIdentity> { 54 56 console.info(`[Identity] Resolving DID: ${did}`); 55 57 58 + // Check cache first 59 + const cacheKey = `identity:${did}`; 60 + const cached = cache.get<ResolvedIdentity>(cacheKey); 61 + if (cached) { 62 + console.info('[Identity] Using cached identity resolution'); 63 + return cached; 64 + } 65 + 56 66 // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 57 67 const _fetch = fetchFn ?? globalThis.fetch; 58 68 ··· 84 94 if (!data.did || !data.pds) { 85 95 throw new Error('Invalid response from identity resolver'); 86 96 } 97 + 98 + // Cache the resolved identity 99 + console.info('[Identity] Caching resolved identity'); 100 + cache.set(cacheKey, data); 87 101 88 102 return data; 89 103 }
+35 -9
src/lib/services/atproto/cache.ts
··· 1 1 import type { CacheEntry } from './types'; 2 + import { CACHE_TTL } from '$lib/config/cache.config'; 2 3 3 4 /** 4 - * Simple in-memory cache with TTL support 5 + * Simple in-memory cache with configurable TTL support 6 + * 7 + * TTL values are configured per data type in cache.config.ts 8 + * and can be overridden via environment variables 5 9 */ 6 10 export class ATProtoCache { 7 11 private cache = new Map<string, CacheEntry<any>>(); 8 - private readonly TTL = 5 * 60 * 1000; // 5 minutes 12 + 13 + /** 14 + * Get TTL for a cache key based on its prefix 15 + */ 16 + private getTTL(key: string): number { 17 + if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 18 + if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; 19 + if (key.startsWith('links:')) return CACHE_TTL.LINKS; 20 + if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 21 + if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 22 + if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 23 + if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS; 24 + if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS; 25 + if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST; 26 + if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 27 + 28 + // Default fallback (30 minutes) 29 + return 30 * 60 * 1000; 30 + } 9 31 10 32 get<T>(key: string): T | null { 11 - console.debug(`[Cache] Getting key: ${key}`); 33 + console.info(`[Cache] Getting key: ${key}`); 12 34 const entry = this.cache.get(key); 13 35 if (!entry) { 14 - console.debug(`[Cache] Cache miss for key: ${key}`); 36 + console.info(`[Cache] Cache miss for key: ${key}`); 15 37 return null; 16 38 } 17 39 18 - if (Date.now() - entry.timestamp > this.TTL) { 19 - console.debug(`[Cache] Entry expired for key: ${key}`); 40 + const ttl = this.getTTL(key); 41 + const age = Date.now() - entry.timestamp; 42 + 43 + if (age > ttl) { 44 + console.info(`[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 20 45 this.cache.delete(key); 21 46 return null; 22 47 } 23 48 24 - console.debug(`[Cache] Cache hit for key: ${key}`); 49 + console.info(`[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 25 50 return entry.data; 26 51 } 27 52 28 53 set<T>(key: string, data: T): void { 29 - console.debug(`[Cache] Setting key: ${key}`, data); 54 + const ttl = this.getTTL(key); 55 + console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`); 30 56 this.cache.set(key, { 31 57 data, 32 58 timestamp: Date.now() ··· 34 60 } 35 61 36 62 delete(key: string): void { 37 - console.debug(`[Cache] Deleting key: ${key}`); 63 + console.info(`[Cache] Deleting key: ${key}`); 38 64 this.cache.delete(key); 39 65 } 40 66
+1 -1
src/routes/+layout.svelte
··· 93 93 {@render children()} 94 94 </main> 95 95 96 - <Footer profile={data.profile} siteInfo={data.siteInfo} /> 96 + <Footer /> 97 97 98 98 <!-- Easter egg: Happy Mac walks across the screen (click version number 24 times!) --> 99 99 <HappyMacEasterEgg />
+13 -20
src/routes/+layout.ts
··· 1 1 import type { LayoutLoad } from './$types'; 2 2 import { createSiteMeta, type SiteMetadata, defaultSiteMeta } from '$lib/helper/siteMeta'; 3 - import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 4 3 5 - export const load: LayoutLoad = async ({ url, fetch }) => { 4 + /** 5 + * Non-blocking layout load 6 + * Returns immediately with default site metadata 7 + * All data fetching happens client-side in components for faster initial page load 8 + */ 9 + export const load: LayoutLoad = async ({ url }) => { 6 10 // Provide the default site metadata 7 11 const siteMeta: SiteMetadata = createSiteMeta({ 8 12 title: defaultSiteMeta.title, ··· 10 14 url: url.href // Include current URL for proper OG tags 11 15 }); 12 16 13 - // Fetch lightweight public data for layout using injected fetch 14 - let profile = null; 15 - let siteInfo = null; 16 - 17 - try { 18 - profile = await fetchProfile(fetch); 19 - } catch (err) { 20 - // Non-fatal: layout should still render even if profile fails 21 - console.warn('Layout: failed to fetch profile in load', err); 22 - } 23 - 24 - try { 25 - siteInfo = await fetchSiteInfo(fetch); 26 - } catch (err) { 27 - console.warn('Layout: failed to fetch siteInfo in load', err); 28 - } 29 - 30 - return { siteMeta, profile, siteInfo }; 17 + // Return immediately - no blocking data fetches 18 + // Components will fetch their own data client-side with skeletons 19 + return { 20 + siteMeta, 21 + profile: null, 22 + siteInfo: null 23 + }; 31 24 };