diff --git a/src/lib/components/SiteHead.svelte b/src/lib/components/SiteHead.svelte index fd8682f..95ae3a1 100644 --- a/src/lib/components/SiteHead.svelte +++ b/src/lib/components/SiteHead.svelte @@ -62,14 +62,17 @@ // OG Image generation with fallback to absolute static asset if needed const ogImage = $derived.by(() => { - if (image) return image; + if (image) return new URL(image, page.url.origin).href; const params = new URLSearchParams(); if (title) params.set('title', title); if (ogType) params.set('type', ogType); if (ogImageSubtitle) params.set('subtitle', ogImageSubtitle); + params.set('slug', page.url.pathname); return new URL(`/api/og/generate?${params.toString()}`, page.url.origin).href; }); - const ogImageAlt = $derived(title ? `OpenGraph image for ${title}` : `OpenGraph image for ${SITE.ogTitle}`); + const ogImageAlt = $derived( + `Social preview for ${title ?? SITE.title}${ogType ? `, labelled ${ogType.replaceAll('_', ' ').toLowerCase()}` : ''}.` + ); @@ -91,12 +94,13 @@ + {#if ogImage.startsWith('https://')}{/if} - + {#if type === 'article'} diff --git a/src/lib/og.ts b/src/lib/og.ts index 1e9a301..f59ba34 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -15,6 +15,51 @@ export type OgEntry = { }; }; +export const OG_DEFAULT_TITLES: Record = { + BLOG: "Blog", + ARTICLE: "Article", + ABOUT: "About", + SUPPORT: "Support", + SUBSCRIPTIONS: "Subscriptions", + SITE_META: "Site Metadata", + DESIGN: "Design", +}; + +const MAX_TITLE_LENGTH = 180; +const MAX_SUBTITLE_LENGTH = 180; + +export const cleanOgText = ( + value: string | null | undefined, + maxLength: number, +): string | null => { + if (!value) return null; + const cleaned = value + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) return null; + return cleaned.length > maxLength + ? `${cleaned.slice(0, maxLength - 1).trimEnd()}\u2026` + : cleaned; +}; + +export const normalizeOgType = ( + value: string | null | undefined, +): string | null => { + const cleaned = cleanOgText(value, 32); + if (!cleaned) return null; + return cleaned.toUpperCase().replace(/[-_\s]+/g, " "); +}; + +export const getDefaultOgTitle = (value: string | null): string | null => { + if (!value) return null; + const key = value + .trim() + .toUpperCase() + .replace(/[-\s]+/g, "_"); + return OG_DEFAULT_TITLES[key] ?? null; +}; + /** * Derive font size directly from the canvas geometry rather than bucketing. * @@ -69,46 +114,57 @@ const getTitleFontSize = (title: string): number => const getSubtitleFontSize = (subtitle: string): number => dynamicFontSize(subtitle, 2, 24, 40); +const displayPath = (slug: string): string => { + const path = cleanOgText(slug, 80) ?? "/"; + if (path === "/") return "ewancroft.uk"; + const compactPath = path.length > 48 ? `${path.slice(0, 47)}\u2026` : path; + return `ewancroft.uk${compactPath.startsWith("/") ? "" : "/"}${compactPath}`; +}; + // Satori uses a JSX-like object structure for defining the layout export const getOgTemplate = (entry: OgEntry) => { - const { title, subtitle, type, theme } = entry; + const title = cleanOgText(entry.title, MAX_TITLE_LENGTH); + const subtitle = cleanOgText(entry.subtitle, MAX_SUBTITLE_LENGTH); + const type = normalizeOgType(entry.type); + const { theme } = entry; const children = []; - // Conditionally render type badge - if (type && type !== "TECHNICAL SPEC") { + if (type) { children.push({ type: "div", props: { style: { - fontSize: "20px", + fontSize: "19px", color: theme.typeFg, fontFamily: "JetBrains Mono", textTransform: "uppercase", - letterSpacing: "0.1em", - marginBottom: "20px", + letterSpacing: "0", + marginBottom: "24px", }, - children: type, + children: `// ${type}`, }, }); } // Only render title when provided if (title) { + const titleFontSize = getTitleFontSize(title); + const displayTitle = truncateToFit(title, titleFontSize, 3); children.push({ type: "h1", props: { style: { - fontSize: `${getTitleFontSize(title)}px`, + fontSize: `${titleFontSize}px`, fontWeight: 800, - marginBottom: "20px", + margin: "0 0 20px 0", display: "-webkit-box", "-webkit-line-clamp": "3", "-webkit-box-orient": "vertical", overflow: "hidden", lineHeight: 1.15, }, - children: title, + children: displayTitle, }, }); } @@ -122,7 +178,8 @@ export const getOgTemplate = (entry: OgEntry) => { props: { style: { fontSize: `${subtitleFontSize}px`, - color: theme.accent, + color: theme.typeFg, + margin: "0", display: "-webkit-box", "-webkit-line-clamp": "2", "-webkit-box-orient": "vertical", @@ -144,21 +201,21 @@ export const getOgTemplate = (entry: OgEntry) => { fontFamily: "JetBrains Mono", color: theme.fg, }, - children: "ewancroft.uk", + children: displayPath(entry.slug), }, }, // Pentacle Icon { type: "svg", props: { - width: "100", - height: "100", + width: "160", + height: "160", viewBox: "0 0 12 12", style: { position: "absolute", - bottom: "80px", - right: "80px", - opacity: "0.1", + bottom: "54px", + right: "64px", + opacity: "0.13", }, children: [ { @@ -186,12 +243,27 @@ export const getOgTemplate = (entry: OgEntry) => { width: "100%", height: "100%", backgroundColor: theme.bg, - padding: "80px", + padding: "72px 80px 64px", justifyContent: "center", color: theme.fg, position: "relative", }, - children, + children: [ + { + type: "div", + props: { + style: { + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "8px", + backgroundColor: theme.accent, + }, + }, + }, + ...children, + ], }, }; }; diff --git a/src/routes/api/og/generate/+server.ts b/src/routes/api/og/generate/+server.ts index e841181..68dc17c 100644 --- a/src/routes/api/og/generate/+server.ts +++ b/src/routes/api/og/generate/+server.ts @@ -1,33 +1,34 @@ import type { RequestHandler } from "./$types"; import { Resvg, initWasm } from "@resvg/resvg-wasm"; import satori from "satori"; -import { getOgTemplate } from "$lib/og"; +import { cleanOgText, getDefaultOgTitle, getOgTemplate } from "$lib/og"; import { read } from "$app/server"; import { getOgThemeColors } from "$lib/server/theme"; -import wasmModule from "@resvg/resvg-wasm/index_bg.wasm?module"; +import wasmUrl from "@resvg/resvg-wasm/index_bg.wasm?url"; -let wasmInitialized = false; -async function ensureWasm() { - if (wasmInitialized) return; - try { - if (typeof wasmModule === "string") { - // Strip any query parameters (like ?module or ?url) that Vite might append - const assetPath = wasmModule.split("?")[0]; - const response = await read(assetPath); - await initWasm(await response.arrayBuffer()); - } else { - await initWasm(wasmModule as unknown as WebAssembly.Module); - } - wasmInitialized = true; - } catch (err: any) { - if (err.message?.includes("Already initialized")) { - wasmInitialized = true; - return; - } - console.error("Failed to initialize WASM at path:", wasmModule, err); - throw err; - } -} +let wasmInitialization: Promise | undefined; +const ensureWasm = () => { + wasmInitialization ??= read(wasmUrl) + .arrayBuffer() + .then(async (buffer) => { + try { + await initWasm(buffer); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Already initialized") + ) { + return; + } + throw error; + } + }) + .catch((error) => { + wasmInitialization = undefined; + throw error; + }); + return wasmInitialization; +}; import interUrl from "$lib/fonts/Inter-ExtraBold.ttf"; import monoUrl from "$lib/fonts/JetBrainsMono-Regular.ttf"; @@ -42,58 +43,35 @@ const loadFont = async (url: string) => { } }; +let fonts: Promise<[ArrayBuffer, ArrayBuffer]> | undefined; +const loadFonts = () => + (fonts ??= Promise.all([loadFont(interUrl), loadFont(monoUrl)]).catch( + (error) => { + fonts = undefined; + throw error; + }, + )); + import { SITE } from "$lib/config"; export const GET: RequestHandler = async ({ url, setHeaders }) => { try { await ensureWasm(); - const [interFont, monoFont] = await Promise.all([ - loadFont(interUrl), - loadFont(monoUrl), - ]); + const [interFont, monoFont] = await loadFonts(); const theme = getOgThemeColors(); - const title = url.searchParams.get("title"); - const subtitle = url.searchParams.get("subtitle"); + const title = cleanOgText(url.searchParams.get("title"), 180); + const subtitle = cleanOgText(url.searchParams.get("subtitle"), 180); const type = url.searchParams.get("type"); - - let finalTitle = title; - if (!finalTitle && type) { - switch (type.toUpperCase()) { - case "HOME": - finalTitle = SITE.title; - break; - case "BLOG": - finalTitle = "Blog"; - break; - case "ABOUT": - finalTitle = "About"; - break; - case "SUPPORT": - finalTitle = "Support"; - break; - case "SUBSCRIPTIONS": - finalTitle = "Subscriptions"; - break; - case "SITE_META": - finalTitle = "Site Metadata"; - break; - case "TECHNICAL SPEC": - finalTitle = "Technical Specification"; - break; - case "DESIGN": - finalTitle = "Design"; - break; - } - } + const finalTitle = title ?? getDefaultOgTitle(type) ?? SITE.title; const svg = await satori( getOgTemplate({ title: finalTitle, subtitle, - slug: url.searchParams.get("slug") ?? "/", + slug: cleanOgText(url.searchParams.get("slug"), 100) ?? "/", type, theme, }), @@ -127,10 +105,10 @@ export const GET: RequestHandler = async ({ url, setHeaders }) => { "Content-Type": "image/png", "Cache-Control": import.meta.env.DEV ? "no-store" - : "public, max-age=86400, s-maxage=31536000, stale-while-revalidate", + : "public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800", }); - return new Response(pngData.asPng()); + return new Response(Uint8Array.from(pngData.asPng()).buffer); } catch (e) { console.error("OG Generation Error:", e); return new Response("Error generating image", { status: 500 }); diff --git a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte index 0badd48..175323e 100644 --- a/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte +++ b/src/routes/blog/[year]/[month]/[day]/[slug]/+page.svelte @@ -21,7 +21,7 @@ description={data.post.metaDescription} ogSubtitle={data.post.description} type="article" - ogType="BLOG" + ogType="ARTICLE" publishedTime={data.post.createdAt} tags={data.post.tags} author="https://ewancroft.uk/about" diff --git a/src/routes/site/design/+page.svelte b/src/routes/site/design/+page.svelte index f67301e..a9403f9 100644 --- a/src/routes/site/design/+page.svelte +++ b/src/routes/site/design/+page.svelte @@ -169,10 +169,10 @@ usage: 'Browser tab, bookmarks, shortcuts', }, ogImage: { - endpoint: '/og/generate', + endpoint: '/api/og/generate', size: '1200×630', - format: 'SVG (dynamic)', - params: ['title', 'type'], + format: 'PNG (dynamic)', + params: ['title', 'subtitle', 'type', 'slug'], usage: 'Open Graph, Twitter cards', }, };