diff --git a/src/lib/components/profile/Profile.svelte b/src/lib/components/profile/Profile.svelte index bd76b31..69bfabc 100644 --- a/src/lib/components/profile/Profile.svelte +++ b/src/lib/components/profile/Profile.svelte @@ -1,52 +1,89 @@ - + + + +
{#if profile} -
-
- + {profile?.displayName || 'User'}'s avatar - - + +
-

{profile?.displayName}

+ +

+ {profile?.displayName || 'Anonymous'} +

+ +
- @{profile?.handle} -
-
- + {#if profile?.handle} + + @{profile.handle} + + {:else if profile?.did} + + {profile.did} + + {:else} + Unknown user + {/if}
+ + + {#if profile?.did} +
+ +
+ {/if}
- - + + {#if profile?.description}
-

{profile?.description}

+

+ {profile.description} +

{/if} - - + +
@@ -54,16 +91,16 @@
{:else} - +

- create a `app.bsky.actor.profile` record at https://bsky.app/ + create a `app.bsky.actor.profile` record at + + https://bsky.app/ +

{/if} \ No newline at end of file diff --git a/src/lib/server/ogImage.ts b/src/lib/server/ogImage.ts index a4d7679..e2871d9 100644 --- a/src/lib/server/ogImage.ts +++ b/src/lib/server/ogImage.ts @@ -2,177 +2,200 @@ import satori from 'satori'; import { Resvg } from '@resvg/resvg-js'; import { dev } from '$app/environment'; -// Font URLs for production (served from static folder) const FONT_BASE_URL = '/fonts/ArrowType-Recursive-1.085/Recursive_Desktop/separate_statics/TTF'; const FONT_FILES = { regular: 'RecursiveSansCslSt-Regular.ttf', bold: 'RecursiveSansCslSt-Bold.ttf', - italic: 'RecursiveSansCslSt-Italic.ttf' + italic: 'RecursiveSansCslSt-Italic.ttf', }; -// Preload fonts (cache in memory for performance) let fontCache: { regular?: Buffer; bold?: Buffer; italic?: Buffer } = {}; +/** + * Load a single font file. Dev: read from fs (static folder). Prod: fetch from baseUrl + path. + */ async function loadSingleFont(fileName: string, baseUrl?: string): Promise { - try { - if (dev) { - // In development, try to load from filesystem first - const fs = await import('fs/promises'); - const path = await import('path'); - const fontPath = path.resolve(`static${FONT_BASE_URL}/${fileName}`); - console.log(`Loading font from filesystem: ${fontPath}`); - return await fs.readFile(fontPath); - } else { - // In production, fetch from the served static files - const fontUrl = `${baseUrl || ''}${FONT_BASE_URL}/${fileName}`; - console.log(`Fetching font from URL: ${fontUrl}`); - - const response = await fetch(fontUrl); - if (!response.ok) { - throw new Error(`Failed to fetch font: ${response.status} ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); - } - } catch (error) { - console.error(`Failed to load font ${fileName}:`, error); - throw error; + if (dev) { + const fs = await import('fs/promises'); + const path = await import('path'); + const fontPath = path.resolve(`static${FONT_BASE_URL}/${fileName}`); + return await fs.readFile(fontPath); + } else { + const fontUrl = `${baseUrl || ''}${FONT_BASE_URL}/${fileName}`; + const res = await fetch(fontUrl); + if (!res.ok) throw new Error(`Failed to fetch font: ${res.status} ${res.statusText}`); + const ab = await res.arrayBuffer(); + return Buffer.from(ab); } } +/** + * Load all fonts (cached). + */ async function loadFonts(baseUrl?: string) { - try { - // Load fonts if not already cached - if (!fontCache.regular) { - fontCache.regular = await loadSingleFont(FONT_FILES.regular, baseUrl); - } - if (!fontCache.bold) { - fontCache.bold = await loadSingleFont(FONT_FILES.bold, baseUrl); - } - if (!fontCache.italic) { - fontCache.italic = await loadSingleFont(FONT_FILES.italic, baseUrl); - } + if (!fontCache.regular) fontCache.regular = await loadSingleFont(FONT_FILES.regular, baseUrl); + if (!fontCache.bold) fontCache.bold = await loadSingleFont(FONT_FILES.bold, baseUrl); + if (!fontCache.italic) fontCache.italic = await loadSingleFont(FONT_FILES.italic, baseUrl); - // Defensive: throw if any font is missing - if (!fontCache.regular || !fontCache.bold || !fontCache.italic) { - throw new Error('Failed to load all required font files for OG image'); - } - - return fontCache as { regular: Buffer; bold: Buffer; italic: Buffer }; - } catch (error) { - console.error('Font loading error:', error); - throw error; + if (!fontCache.regular || !fontCache.bold || !fontCache.italic) { + throw new Error('Failed to load required fonts'); } + return fontCache as { regular: Buffer; bold: Buffer; italic: Buffer }; } +/** + * Load a fallback SVG (dev: read from static/fallback/, prod: fetch from baseUrl + /fallback/). + * Returns a data URL string (data:image/svg+xml;base64,...), which Satori accepts. + */ +async function loadFallbackSvg(fileName: string, baseUrl?: string): Promise { + if (dev) { + const fs = await import('fs/promises'); + const path = await import('path'); + const filePath = path.resolve(`static/fallback/${fileName}`); + const svg = await fs.readFile(filePath, 'utf-8'); + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + } else { + const url = `${baseUrl || ''}/fallback/${fileName}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch fallback SVG: ${res.status} ${res.statusText}`); + const svgText = await res.text(); + return `data:image/svg+xml;base64,${Buffer.from(svgText).toString('base64')}`; + } +} + +/** + * Resolve an arbitrary image src (author.avatar or banner) into a Satori-friendly absolute/data URL. + * - If src is falsy => return fallback data URL loaded via loadFallbackSvg + * - If src starts with 'data:' => pass through + * - If src is absolute (http/https) => pass through + * - If src is a relative path (starts with '/') => in dev we read and base64 it, in prod we fetch baseUrl+path and base64 it + */ +async function resolveImageSrc(src: string | undefined | null, fallbackFileName: string, baseUrl?: string): Promise { + if (!src) { + return await loadFallbackSvg(fallbackFileName, baseUrl); + } + + const trimmed = src.trim(); + if (trimmed.startsWith('data:')) return trimmed; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + + // relative path -> load as data URL + if (dev) { + const fs = await import('fs/promises'); + const path = await import('path'); + // allow both /fallback/foo.svg and fallback/foo.svg + const rel = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; + const filePath = path.resolve(`static/${rel}`); + const svg = await fs.readFile(filePath); + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + } else { + const url = `${baseUrl || ''}${trimmed.startsWith('/') ? trimmed : `/${trimmed}`}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch image ${url}: ${res.status}`); + const text = await res.text(); + return `data:image/svg+xml;base64,${Buffer.from(text).toString('base64')}`; + } +} + +/** + * Type definitions + */ export interface OgImageOptions { title: string; subtitle?: string; metaLine?: string; author?: { - name: string; + name?: string; handle?: string; + did?: string; avatar?: string; }; - // For blog posts: wordCount, readingTime, date, etc. - extraMeta?: string[]; - // For custom layouts + extraMeta?: string[]; // first element can be the date string/ISO + banner?: string; customChildren?: any; } /** - * Calculate optimal font size based on text length and available space + * Helpers to calculate font sizes and estimate heights */ -function calculateTitleFontSize(title: string, maxWidth: number = 1000): number { +function calculateTitleFontSize(title: string, maxWidth = 1000) { const baseSize = 64; const charThreshold = 45; const minSize = 36; - - if (title.length <= charThreshold) { - return baseSize; - } - + if (title.length <= charThreshold) return baseSize; const scaleFactor = Math.max(0.5, 1 - ((title.length - charThreshold) * 0.012)); return Math.max(minSize, Math.floor(baseSize * scaleFactor)); } -/** - * Calculate optimal subtitle font size - */ -function calculateSubtitleFontSize(subtitle: string, titleSize: number): number { +function calculateSubtitleFontSize(subtitle: string, titleSize: number) { const baseRatio = 0.57; const charThreshold = 80; const minSize = 20; - let size = Math.floor(titleSize * baseRatio); - if (subtitle.length > charThreshold) { const scaleFactor = Math.max(0.7, 1 - ((subtitle.length - charThreshold) * 0.01)); size = Math.floor(size * scaleFactor); } - return Math.max(minSize, size); } -/** - * Estimate content height to ensure it fits - */ -function estimateContentHeight(options: OgImageOptions): { - titleHeight: number; - subtitleHeight: number; - metaHeight: number; - authorHeight: number; - totalContentHeight: number; -} { +function estimateContentHeight(options: OgImageOptions) { const titleFontSize = calculateTitleFontSize(options.title); const titleLines = Math.ceil(options.title.length / 40); const titleHeight = titleLines * titleFontSize * 1.25 + 32; - + let subtitleHeight = 0; if (options.subtitle) { const subtitleFontSize = calculateSubtitleFontSize(options.subtitle, titleFontSize); const subtitleLines = Math.ceil(options.subtitle.length / 60); subtitleHeight = subtitleLines * subtitleFontSize * 1.2 + 24; } - + let metaHeight = 0; if (options.metaLine || (options.extraMeta && options.extraMeta.length > 0)) { metaHeight = 32 + 24; } - - let authorHeight = options.author ? 120 : 28; - + + const authorHeight = options.author ? 120 : 28; + return { titleHeight, subtitleHeight, metaHeight, authorHeight, - totalContentHeight: titleHeight + subtitleHeight + metaHeight + authorHeight + totalContentHeight: titleHeight + subtitleHeight + metaHeight + authorHeight, }; } /** - * Generates a PNG buffer for an OG image with unified styling. - * @param options OgImageOptions - * @param baseUrl Base URL for fetching fonts in production (e.g., 'https://ewancroft.uk') - * @returns Buffer (PNG) + * Utility: estimate text width (px) without DOM. + * charWidthFactor: average char width relative to fontSize (0.5-0.7). */ -export async function generateOgImage(options: OgImageOptions, baseUrl?: string): Promise { +function estimateTextWidth(text: string, fontSize: number, charWidthFactor = 0.55) { + return Math.ceil(text.length * fontSize * charWidthFactor); +} + +/** + * Main: generates PNG as Uint8Array + */ +export async function generateOgImage(options: OgImageOptions, baseUrl?: string): Promise { + // load fonts const fonts = await loadFonts(baseUrl); - - // Calculate optimal sizing + + // Resolve fallback images to data URLs (if needed) + const avatarDataUrl = await resolveImageSrc(options.author?.avatar || null, 'profile.svg', baseUrl); + const bannerDataUrl = await resolveImageSrc(options.banner || null, 'banner.svg', baseUrl); + + // sizes const titleFontSize = calculateTitleFontSize(options.title); const subtitleFontSize = options.subtitle ? calculateSubtitleFontSize(options.subtitle, titleFontSize) : 0; - - // Estimate if content will fit and adjust if needed + + // height estimate const contentEstimate = estimateContentHeight(options); const availableHeight = 630 - 96; - let titleMarginBottom = 32; let subtitleMarginBottom = 24; let metaMarginBottom = 24; - if (contentEstimate.totalContentHeight > availableHeight) { const compressionRatio = availableHeight / contentEstimate.totalContentHeight; titleMarginBottom = Math.max(8, Math.floor(titleMarginBottom * compressionRatio)); @@ -180,12 +203,11 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) metaMarginBottom = Math.max(8, Math.floor(metaMarginBottom * compressionRatio)); } - // Extract and format date for UK timezone + // Date let dateString: string | null = null; if (options.extraMeta && options.extraMeta.length > 0) { try { - const utcDate = new Date(options.extraMeta[0]); // parse UTC string - // Format in UK timezone + const utcDate = new Date(options.extraMeta[0]); dateString = new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'short', @@ -193,35 +215,26 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) hour: '2-digit', minute: '2-digit', timeZone: 'Europe/London', - hour12: false + hour12: false, }).format(utcDate); - } catch (err) { - console.warn('Failed to parse date for OG image:', err); - dateString = options.extraMeta[0]; // fallback + } catch { + dateString = options.extraMeta[0]; } } - // Layout children - let children: any[] = []; + // Build children (title/subtitle/meta/custom) + const children: any[] = []; - // Title with dynamic sizing children.push({ type: 'h1', props: { style: { fontSize: `${titleFontSize}px`, fontWeight: 700, - fontStyle: 'normal', margin: `0 0 ${titleMarginBottom}px 0`, textAlign: 'center', lineHeight: 1.25, maxWidth: '1000px', - overflow: 'hidden', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - textWrap: 'balance', - hyphens: 'auto', paddingLeft: '48px', paddingRight: '48px', }, @@ -229,7 +242,6 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) }, }); - // Subtitle/description with dynamic sizing if (options.subtitle) { children.push({ type: 'div', @@ -242,7 +254,6 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) opacity: 0.8, maxWidth: '900px', lineHeight: 1.3, - textWrap: 'balance', paddingLeft: '48px', paddingRight: '48px', }, @@ -251,7 +262,6 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) }); } - // Meta line (e.g., reading time, word count) - exclude date from this if (options.metaLine) { children.push({ type: 'div', @@ -261,23 +271,15 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) opacity: 0.75, margin: `0 0 ${metaMarginBottom}px 0`, display: 'flex', - flexDirection: 'row', - alignItems: 'center', justifyContent: 'center', - flexWrap: 'wrap', - gap: '8px', }, children: options.metaLine, }, }); } - // Custom children (for advanced layouts) - if (options.customChildren) { - children.push(options.customChildren); - } + if (options.customChildren) children.push(options.customChildren); - // Decorative line (optional, for index pages) - only if no author and no date if (!options.author && !dateString && !options.customChildren) { children.push({ type: 'div', @@ -288,151 +290,216 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) background: '#2d4839', borderRadius: '2px', margin: '0 auto', - flexShrink: 0, }, }, }); } - // Create footer with split layout (author left, date right) - let footerContent = null; - if (options.author || dateString) { - footerContent = { - type: 'div', + // Build footer (author + date) with fallbacks and DID collision check + const safeAuthor = options.author ?? { name: 'Anonymous' }; + + // If author was supplied but missing both handle & did -> throw (you asked for this) + if (options.author && !options.author.handle && !options.author.did) { + throw new Error('OG image generation error: author is missing both handle and DID.'); + } + + // Resolve identifier node: handle -> did (code) -> null + let identifierNode: any = null; + if (safeAuthor.handle) { + identifierNode = { + type: 'span', props: { style: { - width: '100%', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingLeft: '48px', - paddingRight: '48px', - opacity: 0.85, - flexShrink: 0, + fontSize: '16px', + color: '#8fd0a0', + fontWeight: 400, + fontStyle: 'italic', }, - children: [ - // Author info - left side - options.author ? { - type: 'div', - props: { - style: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: '18px', - textAlign: 'left', - }, - children: [ - options.author.avatar && { - type: 'img', - props: { - src: options.author.avatar, - width: 64, - height: 64, - style: { - borderRadius: '50%', - border: '3px solid #2d4839', - boxShadow: '0 2px 12px rgba(0,0,0,0.18)', - background: '#1e2c23', - flexShrink: 0, - }, + children: '@' + safeAuthor.handle, + }, + }; + } else if (safeAuthor.did) { + // decide whether to truncate DID based on estimated collision with date block + const containerWidth = 1200; + const paddingTotal = 48 + 48; // left + right padding in container + const avatarWidth = 64; + const avatarGap = 18; + const nameFontSize = 22; + const didFontSize = 15; + const dateFontSize = 18; + const safetyBuffer = 8; + + const nameText = safeAuthor.name || 'Anonymous'; + const nameWidth = estimateTextWidth(nameText, nameFontSize, 0.56); + const leftBase = avatarWidth + avatarGap + Math.max(120, nameWidth); // baseline left width + + const dateBlockWidth = dateString + ? estimateTextWidth(dateString, dateFontSize, 0.55) + estimateTextWidth('Published', 14, 0.55) + 24 + : 0; + + const monoCharFactor = 0.6; + const didFullWidth = estimateTextWidth(safeAuthor.did, didFontSize, monoCharFactor); + + const availableForLeft = containerWidth - paddingTotal - dateBlockWidth - safetyBuffer; + const leftNeededIfFull = leftBase + 12 + didFullWidth; // 12 for code padding + + let didToRender = safeAuthor.did; + if (leftNeededIfFull > availableForLeft) { + // truncation logic (head … tail) + const allowedDidWidth = Math.max(24, availableForLeft - leftBase - 12); + const avgCharPx = didFontSize * monoCharFactor; + let allowedChars = Math.floor(allowedDidWidth / avgCharPx); + + if (allowedChars <= 6) { + const head = safeAuthor.did.slice(0, 4); + const tail = safeAuthor.did.slice(-1); + didToRender = `${head}…${tail}`; + } else { + const headLen = Math.ceil((allowedChars - 1) / 2); + const tailLen = Math.floor((allowedChars - 1) / 2); + const head = safeAuthor.did.slice(0, headLen); + const tail = safeAuthor.did.slice(-tailLen); + didToRender = `${head}…${tail}`; + } + } + + identifierNode = { + type: 'code', + props: { + style: { + fontSize: `${didFontSize}px`, + fontFamily: 'monospace', + background: '#1e2c23', + color: '#8fd0a0', + padding: '2px 6px', + borderRadius: '6px', + }, + children: didToRender, + }, + }; + } + + // Ensure avatarDataUrl exists (we already pre-resolved above) + const avatarSrc = avatarDataUrl; + + const footerContent = + options.author || dateString + ? { + type: 'div', + props: { + style: { + width: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: '48px', + paddingRight: '48px', + opacity: 0.85, + flexShrink: 0, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '18px', + textAlign: 'left', }, - }, - { - type: 'div', - props: { - style: { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: '2px', - }, - children: [ - { - type: 'span', - props: { - style: { - fontSize: '22px', - fontWeight: 700, - fontStyle: 'normal', - lineHeight: 1.1, - }, - children: options.author.name, + children: [ + { + type: 'img', + props: { + src: avatarSrc, + width: 64, + height: 64, + style: { + borderRadius: '50%', + border: '3px solid #2d4839', + boxShadow: '0 2px 12px rgba(0,0,0,0.18)', + background: '#1e2c23', + flexShrink: 0, }, }, - options.author.handle && { - type: 'span', - props: { - style: { - fontSize: '16px', - color: '#8fd0a0', - fontWeight: 400, - fontStyle: 'italic', - }, - children: '@' + options.author.handle, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '2px', }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '22px', + fontWeight: 700, + lineHeight: 1.1, + }, + children: safeAuthor.name || 'Anonymous', + }, + }, + identifierNode, + ].filter(Boolean), }, - ].filter(Boolean), - }, - }, - ].filter(Boolean), - }, - } : { - type: 'div', // Empty placeholder for left side - props: { style: {} }, - }, - - // Date info - right side - dateString ? { - type: 'div', - props: { - style: { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-end', - textAlign: 'right', - gap: '4px', - }, - children: [ - { - type: 'span', - props: { - style: { - fontSize: '18px', - fontWeight: 600, - fontStyle: 'normal', - color: '#d8e8d8', - lineHeight: 1.2, }, - children: dateString, - }, + ], }, - { - type: 'span', - props: { - style: { - fontSize: '14px', - color: '#8fd0a0', - fontWeight: 400, - fontStyle: 'italic', - opacity: 0.8, + }, + dateString + ? { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + textAlign: 'right', + gap: '4px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: '#d8e8d8', + lineHeight: 1.2, + }, + children: dateString, + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '14px', + color: '#8fd0a0', + fontWeight: 400, + fontStyle: 'italic', + opacity: 0.8, + }, + children: 'Published', + }, + }, + ], }, - children: 'Published', - }, - }, - ], - }, - } : { - type: 'div', // Empty placeholder for right side - props: { style: {} }, + } + : { type: 'div', props: {} }, + ], }, - ], - }, - }; - } + } + : null; - // Create main container with efficient spacing + // Main container (use bannerDataUrl in backgroundImage) const mainContainer = { type: 'div', props: { @@ -444,6 +511,9 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) alignItems: 'center', justifyContent: 'space-between', background: '#121c17', + backgroundImage: `url(${bannerDataUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', color: '#d8e8d8', fontFamily: 'Recursive', position: 'relative', @@ -451,7 +521,6 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) boxSizing: 'border-box', }, children: [ - // Main content container - takes up most space { type: 'div', props: { @@ -466,41 +535,26 @@ export async function generateOgImage(options: OgImageOptions, baseUrl?: string) children, }, }, - - // Footer with split layout footerContent, ].filter(Boolean), }, }; - // Compose SVG + // Render SVG via Satori const svg = await satori(mainContainer, { width: 1200, height: 630, fonts: [ - { - name: 'Recursive', - data: fonts.regular, - weight: 400, - style: 'normal', - }, - { - name: 'Recursive', - data: fonts.bold, - weight: 700, - style: 'normal', - }, - { - name: 'Recursive', - data: fonts.italic, - weight: 400, - style: 'italic', - }, + { name: 'Recursive', data: fonts.regular, weight: 400, style: 'normal' }, + { name: 'Recursive', data: fonts.bold, weight: 700, style: 'normal' }, + { name: 'Recursive', data: fonts.italic, weight: 400, style: 'italic' }, ], }); - // Convert SVG to PNG + // Convert SVG to PNG with resvg const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }); - const png = resvg.render(); - return png.asPng(); + const pngRender = resvg.render(); + const pngBuffer = pngRender.asPng(); // Buffer-like + // Return a Uint8Array for portability + return Uint8Array.from(pngBuffer); } \ No newline at end of file diff --git a/src/routes/api/og/test.png/+server.ts b/src/routes/api/og/test.png/+server.ts new file mode 100644 index 0000000..b2a824c --- /dev/null +++ b/src/routes/api/og/test.png/+server.ts @@ -0,0 +1,38 @@ +// Go to http://localhost:5173/api/og/test.png to see the result. +// Note: In production, this would be at https://ewancroft.uk/api/og/test.png +// This is primarily for testing the OG image generation locally and in production. Particularly since this is entirely dynamic and not based on any content. + +import { generateOgImage } from '$lib/server/ogImage'; +import { dev } from '$app/environment'; + +export const GET = async () => { + if (!dev) { + // In production, redirect to the index page + return new Response(null, { + status: 302, + headers: { Location: '/' } + }); + } + + // Dev: generate test OG image + const png = await generateOgImage({ + title: 'Test OG Image', + subtitle: 'This is a subtitle to check sizing and wrapping.', + metaLine: '5 min read · 1200 words', + author: { + name: 'ewan', + handle: 'ewancroft.uk', + did: 'did:plc:ofrbh253gwicbkc5nktqepol', + avatar: '', // force fallback + }, + extraMeta: [new Date().toISOString()], + banner: '', // force fallback + }); + + return new Response(new Uint8Array(png), { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'no-store', + }, + }); +}; \ No newline at end of file diff --git a/static/fallback/banner.svg b/static/fallback/banner.svg new file mode 100644 index 0000000..a3deedc --- /dev/null +++ b/static/fallback/banner.svg @@ -0,0 +1,19 @@ + + + + + + No Banner + + + + + + + + \ No newline at end of file diff --git a/static/fallback/profile.svg b/static/fallback/profile.svg new file mode 100644 index 0000000..eb51c7a --- /dev/null +++ b/static/fallback/profile.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file