my website at ewancroft.uk
6
fork

Configure Feed

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

refactor(config): centralise external URLs and env bridging

+70 -31
+2 -1
.env.example
··· 9 9 10 10 # Ko-fi Supporters (optional) 11 11 # PDS and DID are resolved automatically from PUBLIC_ATPROTO_DID above. 12 - # The only secrets needed are the Ko-fi verification token and a PDS app password. 12 + # PUBLIC_KOFI_PAGE_ID=your-kofi-page-id 13 13 # KOFI_VERIFICATION_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 14 14 # ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 15 15 16 16 # GitHub Sponsors (optional) 17 + # PUBLIC_GITHUB_USERNAME=your-github-username 17 18 # Set this to the secret you configure when registering the webhook on GitHub. 18 19 # Webhook URL: https://your-domain.com/webhook/github 19 20 # Event: Sponsorship only — content type: application/json
+4 -2
src/lib/components/layout/Footer.svelte
··· 4 4 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; 5 5 import DecimalClock from './DecimalClock.svelte'; 6 6 import { happyMacStore } from '$lib/stores'; 7 + import { witchskyProfileUrl } from '$lib/config/urls'; 8 + import { PUBLIC_KOFI_PAGE_ID } from '$env/static/public'; 7 9 import { Code } from '@lucide/svelte'; 8 10 9 11 let profile: ProfileData | null = $state(null); ··· 65 67 <span role="status" aria-live="polite">Loading profile…</span> 66 68 {:else if profile} 67 69 <a 68 - href="https://witchsky.app/profile/{profile.did}" 70 + href={witchskyProfileUrl(profile.did)} 69 71 class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400" 70 72 target="_blank" 71 73 rel="noopener noreferrer" 72 74 aria-label="Visit {profile.handle}'s Bluesky profile">@{profile.handle}</a 73 75 > 74 76 <a 75 - href="https://ko-fi.com/ewancroft" 77 + href="https://ko-fi.com/{PUBLIC_KOFI_PAGE_ID}" 76 78 target="_blank" 77 79 rel="noopener noreferrer" 78 80 class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400"
+7 -14
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { Card, NoiseImage } from '$lib/components/ui'; 3 3 import { fetchLatestBlueskyPost, type BlueskyPost } from '$lib/services/atproto'; 4 + import { getUserLocale } from '$lib/utils/locale'; 5 + import { witchskyProfileUrl, witchskyPostUrl, witchskyHashtagUrl } from '$lib/config/urls'; 4 6 import { formatRelativeTime } from '$lib/utils/formatDate'; 5 7 import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 8 import { Heart, Repeat2, MessageCircle, ExternalLink, X } from '@lucide/svelte'; ··· 18 20 let lightboxImage = $state<{ url: string; alt: string } | null>(null); 19 21 let videoElements = new Map<string, { element: HTMLVideoElement; hls: Hls | null }>(); 20 22 21 - // Detect system locale, fallback to en-GB 22 - const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 23 + const locale = getUserLocale(); 23 24 24 25 // Poll interval in milliseconds (2 minutes) 25 26 const POLL_INTERVAL = 2 * 60 * 1000; ··· 72 73 }; 73 74 }); 74 75 75 - function getPostUrl(uri: string): string { 76 - const parts = uri.split('/'); 77 - const did = parts[2]; 78 - const rkey = parts[4]; 79 - return `https://witchsky.app/profile/${did}/post/${rkey}`; 80 - } 81 - 82 - function getProfileUrl(handle: string): string { 83 - return `https://witchsky.app/profile/${handle}`; 84 - } 76 + const getPostUrl = witchskyPostUrl; 77 + const getProfileUrl = witchskyProfileUrl; 85 78 86 79 function openLightbox(url: string, alt: string) { 87 80 lightboxImage = { url, alt }; ··· 123 116 if (feature.$type === 'app.bsky.richtext.facet#link') { 124 117 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline">${escapeHtml(facetText)}</a>`; 125 118 } else if (feature.$type === 'app.bsky.richtext.facet#mention') { 126 - result += `<a href="https://witchsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 119 + result += `<a href="${witchskyProfileUrl(escapeHtml(feature.did))}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 127 120 } else if (feature.$type === 'app.bsky.richtext.facet#tag') { 128 - result += `<a href="https://witchsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 121 + result += `<a href="${witchskyHashtagUrl(escapeHtml(feature.tag))}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">${escapeHtml(facetText)}</a>`; 129 122 } else { 130 123 result += escapeHtml(facetText); 131 124 }
+4 -3
src/lib/components/layout/main/card/ProfileCard.svelte
··· 3 3 import type { ProfileData } from '$lib/services/atproto'; 4 4 import LinkCard from './LinkCard.svelte'; 5 5 import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 + import { getUserLocale } from '$lib/utils/locale'; 7 + import { witchskyProfileUrl } from '$lib/config/urls'; 6 8 7 9 interface Props { 8 10 profile?: ProfileData | null; ··· 10 12 11 13 let { profile = null }: Props = $props(); 12 14 13 - // Detect system locale, fallback to en-GB 14 - const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 15 + const locale = getUserLocale(); 15 16 </script> 16 17 17 18 <div class="mx-auto w-full max-w-2xl"> ··· 119 120 120 121 <div class="mt-4"> 121 122 <LinkCard 122 - url="https://witchsky.app/profile/{safeProfile.did}" 123 + url={witchskyProfileUrl(safeProfile.did)} 123 124 title="View on Bluesky" 124 125 variant="button" 125 126 />
+7 -2
src/lib/components/layout/main/card/SupportersCard.svelte
··· 2 2 import { Heart, ExternalLink } from '@lucide/svelte'; 3 3 import { Card, NoiseImage } from '$lib/components/ui'; 4 4 import type { UnifiedSupportEvent, KofiEventType, GitHubSponsorshipAction } from '$lib/services/atproto'; 5 + import { PUBLIC_KOFI_PAGE_ID, PUBLIC_GITHUB_USERNAME } from '$env/static/public'; 5 6 6 7 interface Props { 7 8 supporters?: UnifiedSupportEvent[] | null; ··· 102 103 {/each} 103 104 </ol> 104 105 <div class="mt-4 flex flex-wrap gap-x-4 gap-y-2 border-t border-canvas-200 pt-4 dark:border-canvas-700"> 106 + {#if PUBLIC_KOFI_PAGE_ID} 105 107 <a 106 - href="https://ko-fi.com/ewancroft" 108 + href="https://ko-fi.com/{PUBLIC_KOFI_PAGE_ID}" 107 109 target="_blank" 108 110 rel="noopener noreferrer" 109 111 class="inline-flex items-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" ··· 111 113 Support me on Ko-fi 112 114 <ExternalLink class="h-3.5 w-3.5" aria-hidden="true" /> 113 115 </a> 116 + {/if} 117 + {#if PUBLIC_GITHUB_USERNAME} 114 118 <a 115 - href="https://github.com/sponsors/ewanc26" 119 + href="https://github.com/sponsors/{PUBLIC_GITHUB_USERNAME}" 116 120 target="_blank" 117 121 rel="noopener noreferrer" 118 122 class="inline-flex items-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" ··· 120 124 Sponsor me on GitHub 121 125 <ExternalLink class="h-3.5 w-3.5" aria-hidden="true" /> 122 126 </a> 127 + {/if} 123 128 </div> 124 129 {/snippet} 125 130 </Card>
+1
src/lib/config/index.ts
··· 1 1 export * from './slugs'; 2 2 export * from './cache.config'; 3 + export * from './urls';
+27
src/lib/config/urls.ts
··· 1 + /** 2 + * External service URLs used across the application. 3 + * 4 + * Centralised here so changes to a service's web client only need 5 + * to be made in one place. 6 + */ 7 + 8 + /** Bluesky web client base URL. */ 9 + export const WITCHSKY_BASE_URL = 'https://witchsky.app'; 10 + 11 + /** Build a witchsky profile URL from a DID or handle. */ 12 + export function witchskyProfileUrl(identifier: string): string { 13 + return `${WITCHSKY_BASE_URL}/profile/${identifier}`; 14 + } 15 + 16 + /** Build a witchsky post URL from an AT-URI (`at://did/.../rkey`). */ 17 + export function witchskyPostUrl(atUri: string): string { 18 + const parts = atUri.split('/'); 19 + const did = parts[2]; 20 + const rkey = parts[4]; 21 + return `${WITCHSKY_BASE_URL}/profile/${did}/post/${rkey}`; 22 + } 23 + 24 + /** Build a witchsky hashtag URL. */ 25 + export function witchskyHashtagUrl(tag: string): string { 26 + return `${WITCHSKY_BASE_URL}/hashtag/${tag}`; 27 + }
+13
src/lib/server/bridgeAtprotoEnv.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 3 + 4 + /** 5 + * Bridges SvelteKit's private env into process.env for the @ewanc26/supporters package, 6 + * which reads credentials via process.env rather than SvelteKit's env system. 7 + * 8 + * Call this once at the top of any server route that writes to the PDS. 9 + */ 10 + export function bridgeAtprotoEnv(): void { 11 + process.env.ATPROTO_DID = PUBLIC_ATPROTO_DID; 12 + process.env.ATPROTO_APP_PASSWORD = env.ATPROTO_APP_PASSWORD; 13 + }
+1 -1
src/lib/utils/formatDate.ts
··· 1 - export { formatRelativeTime } from '@ewanc26/utils'; 1 + export { formatRelativeTime, formatLocalizedDate, getUserLocale } from './locale';
+2 -4
src/routes/webhook/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import { env } from '$env/dynamic/private'; 3 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 4 3 import { parseWebhook, WebhookError, appendEvent } from '@ewanc26/supporters'; 4 + import { bridgeAtprotoEnv } from '$lib/server/bridgeAtprotoEnv'; 5 5 import type { RequestHandler } from './$types'; 6 6 7 7 export const POST: RequestHandler = async ({ request }) => { ··· 10 10 userAgent: request.headers.get('user-agent') 11 11 }); 12 12 13 - // Bridge SvelteKit's private env into process.env for the supporters package. 14 - process.env.ATPROTO_DID = PUBLIC_ATPROTO_DID; 15 - process.env.ATPROTO_APP_PASSWORD = env.ATPROTO_APP_PASSWORD; 13 + bridgeAtprotoEnv(); 16 14 17 15 let payload; 18 16 try {
+2 -4
src/routes/webhook/github/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import { env } from '$env/dynamic/private'; 3 - import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 4 3 import { parseGitHubSponsorsWebhook, GitHubWebhookError, appendSponsorEvent } from '@ewanc26/supporters'; 4 + import { bridgeAtprotoEnv } from '$lib/server/bridgeAtprotoEnv'; 5 5 import type { RequestHandler } from './$types'; 6 6 7 7 export const POST: RequestHandler = async ({ request }) => { ··· 10 10 delivery: request.headers.get('x-github-delivery') 11 11 }); 12 12 13 - // Bridge SvelteKit's private env into process.env for the supporters package. 14 - process.env.ATPROTO_DID = PUBLIC_ATPROTO_DID; 15 - process.env.ATPROTO_APP_PASSWORD = env.ATPROTO_APP_PASSWORD; 13 + bridgeAtprotoEnv(); 16 14 17 15 let payload; 18 16 try {