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 @@
-
+
+
+
+
-
-
+

-
-
+
+
-
{profile?.displayName}
+
+
+ {profile?.displayName || 'Anonymous'}
+
+
+
-
- {profile?.did}
+ {#if profile?.handle}
+
+ @{profile.handle}
+
+ {:else if profile?.did}
+
+ {profile.did}
+
+ {:else}
+ Unknown user
+ {/if}
+
+
+ {#if profile?.did}
+
+
+ {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 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