refactor: replace sveltekit-og with native Satori implementation

- Remove @ethercorps/sveltekit-og and bits-ui dependencies
- Implement direct Satori + Resvg rendering pipeline
- Add OgImage.svelte component for template generation
- Create /internal/ogp preview page for development
- Load fonts from node_modules via fs for production compatibility
- Add 2s startup delay before OG image regeneration
This commit is contained in:
2026-01-05 14:38:52 -06:00
parent 81d9541b44
commit 96595b073d
15 changed files with 496 additions and 292 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
/* Font families */
--font-inter: "Inter Variable", sans-serif;
--font-hanken: "Hanken Grotesk", sans-serif;
--font-schibsted: "Schibsted Grotesk", sans-serif;
--font-schibsted: "Schibsted Grotesk Variable", sans-serif;
/* Background images */
--background-image-gradient-radial: radial-gradient(
+5
View File
@@ -365,6 +365,11 @@
let animationId: number;
function render() {
if (document.hidden) {
animationId = requestAnimationFrame(render);
return;
}
const time = ((Date.now() - startTime) / 1000) * timeScale;
uniforms.set("u_resolution", [canvas.width, canvas.height]);
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { OGImageSpec } from '$lib/og-types';
type Props = {
title: string;
subtitle?: string;
type: OGImageSpec['type'];
};
let { title, subtitle, type }: Props = $props();
// Calculate font size based on title length (matching original logic)
const fontSize = $derived(title.length > 40 ? '60px' : '72px');
</script>
<div
style="display: flex; width: 1200px; height: 630px; background-color: #000000; color: #fafafa; font-family: 'Schibsted Grotesk', sans-serif; padding: 60px 80px;"
>
<div
style="display: flex; flex-direction: column; justify-content: space-between; width: 100%; height: 100%;"
>
<!-- Content section -->
<div style="display: flex; flex-direction: column; flex: 1; justify-content: center;">
<h1
style="font-family: 'Hanken Grotesk', sans-serif; font-weight: 900; font-size: {fontSize}; line-height: 1.1; margin: 0; color: #ffffff;"
>
{title}
</h1>
{#if subtitle}
<p
style="font-family: 'Schibsted Grotesk', sans-serif; font-size: 36px; margin: 32px 0 0 0; color: #a1a1aa; line-height: 1.4;"
>
{subtitle}
</p>
{/if}
</div>
<!-- Footer -->
<div
style="display: flex; justify-content: space-between; align-items: flex-end; border-top: 2px solid #27272a; padding-top: 24px;"
>
<div style="font-size: 28px; color: #71717a; font-weight: 500;">xevion.dev</div>
{#if type === 'project'}
<div
style="font-size: 24px; color: #52525b; text-transform: uppercase; letter-spacing: 0.05em;"
>
PROJECT
</div>
{/if}
</div>
</div>
</div>
+8 -9
View File
@@ -27,29 +27,28 @@ export async function initLogger() {
const useJsonLogs =
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
const sinkName = useJsonLogs ? "json" : "console";
const sink = useJsonLogs
? (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
}
: getConsoleSink();
const jsonSink = (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
};
const consoleSink = getConsoleSink();
try {
await configure({
sinks: {
[sinkName]: sink,
json: useJsonLogs ? jsonSink : consoleSink,
console: useJsonLogs ? jsonSink : consoleSink,
},
filters: {},
loggers: [
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: [sinkName],
sinks: [useJsonLogs ? "json" : "console"],
},
{
category: [],
lowestLevel: "debug",
sinks: [sinkName],
sinks: [useJsonLogs ? "json" : "console"],
},
],
});
+49 -26
View File
@@ -1,39 +1,62 @@
import { read } from "$app/server";
import { CustomFont, resolveFonts } from "@ethercorps/sveltekit-og/fonts";
import HankenGrotesk900 from "@fontsource/hanken-grotesk/files/hanken-grotesk-latin-900-normal.woff?url";
import SchibstedGrotesk400 from "@fontsource/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff?url";
import Inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff?url";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { cwd } from "node:process";
import type { SatoriOptions } from "satori";
/**
* Load fonts for OG image generation.
* Fonts are sourced from @fontsource packages and imported directly from node_modules.
* Fonts are loaded directly from node_modules using fs.readFile for production compatibility.
* Must be called on each request (fonts can't be cached globally in server context).
*
* Note: Only WOFF/TTF/OTF formats are supported by Satori (not WOFF2).
*/
export async function loadOGFonts() {
const fonts = [
new CustomFont(
"Hanken Grotesk",
() => read(HankenGrotesk900).arrayBuffer(),
{
weight: 900,
style: "normal",
},
export async function loadOGFonts(): Promise<SatoriOptions["fonts"]> {
// In production, the server runs from web/build, so node_modules is at ../node_modules
// In dev, we're already in web/ directory
const workingDir = cwd();
const nodeModulesPath = workingDir.endsWith("/build")
? join(workingDir, "..", "node_modules")
: join(workingDir, "node_modules");
const [hankenGrotesk, schibstedGrotesk, inter] = await Promise.all([
readFile(
join(
nodeModulesPath,
"@fontsource/hanken-grotesk/files/hanken-grotesk-latin-900-normal.woff",
),
),
new CustomFont(
"Schibsted Grotesk",
() => read(SchibstedGrotesk400).arrayBuffer(),
{
weight: 400,
style: "normal",
},
readFile(
join(
nodeModulesPath,
"@fontsource/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff",
),
),
new CustomFont("Inter", () => read(Inter500).arrayBuffer(), {
readFile(
join(
nodeModulesPath,
"@fontsource/inter/files/inter-latin-500-normal.woff",
),
),
]);
return [
{
name: "Hanken Grotesk",
data: hankenGrotesk,
weight: 900,
style: "normal",
},
{
name: "Schibsted Grotesk",
data: schibstedGrotesk,
weight: 400,
style: "normal",
},
{
name: "Inter",
data: inter,
weight: 500,
style: "normal",
}),
},
];
return await resolveFonts(fonts);
}
-116
View File
@@ -1,116 +0,0 @@
/**
* Generate OG image HTML template matching xevion.dev dark aesthetic.
* Satori only supports flex layouts and subset of CSS.
*/
export function generateOGTemplate({
title,
subtitle,
type = "default",
}: {
title: string;
subtitle?: string;
type?: "default" | "project";
}): string {
return `
<div
style="
display: flex;
width: 1200px;
height: 630px;
background-color: #000000;
color: #fafafa;
font-family: 'Schibsted Grotesk', sans-serif;
padding: 60px 80px;
"
>
<div
style="
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 100%;
"
>
<!-- Main Content -->
<div style="display: flex; flex-direction: column; flex: 1; justify-content: center;">
<h1
style="
font-family: 'Hanken Grotesk', sans-serif;
font-weight: 900;
font-size: ${type === "project" ? "72px" : "96px"};
line-height: 1.1;
margin: 0;
color: #ffffff;
"
>
${escapeHtml(title)}
</h1>
${
subtitle
? `
<p
style="
font-family: 'Schibsted Grotesk', sans-serif;
font-size: 36px;
margin: 32px 0 0 0;
color: #a1a1aa;
line-height: 1.4;
"
>
${escapeHtml(subtitle)}
</p>
`
: ""
}
</div>
<!-- Footer -->
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-end;
border-top: 2px solid #27272a;
padding-top: 24px;
"
>
<div
style="
font-size: 28px;
color: #71717a;
font-weight: 500;
"
>
xevion.dev
</div>
${
type === "project"
? `
<div
style="
font-size: 24px;
color: #52525b;
text-transform: uppercase;
letter-spacing: 0.05em;
"
>
PROJECT
</div>
`
: ""
}
</div>
</div>
</div>
`;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+4 -4
View File
@@ -2,19 +2,19 @@
import "../app.css";
import "@fontsource-variable/inter";
import "@fontsource/hanken-grotesk/900.css";
import "@fontsource/schibsted-grotesk/400.css";
import "@fontsource/schibsted-grotesk/500.css";
import "@fontsource/schibsted-grotesk/600.css";
import "@fontsource-variable/schibsted-grotesk";
let { children, data } = $props();
const metadata = data?.metadata ?? {
const defaultMetadata = {
title: "Xevion.dev",
description:
"The personal website of Xevion, a full-stack software developer.",
ogImage: "/api/og/home.png",
url: "https://xevion.dev",
};
const metadata = $derived(data?.metadata ?? defaultMetadata);
</script>
<svelte:head>
@@ -0,0 +1,43 @@
import type { PageServerLoad } from "./$types";
import type { OGImageSpec } from "$lib/og-types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ url, parent }) => {
const parentData = await parent();
const type = url.searchParams.get("type");
if (!type) {
throw error(400, 'Missing "type" query parameter');
}
let spec: OGImageSpec;
let title: string;
switch (type) {
case "index":
spec = { type: "index" };
title = "Index Page";
break;
case "projects":
spec = { type: "projects" };
title = "Projects Page";
break;
case "project": {
const id = url.searchParams.get("id");
if (!id) {
throw error(400, 'Missing "id" query parameter for project type');
}
spec = { type: "project", id };
title = `Project: ${id}`;
break;
}
default:
throw error(400, `Invalid "type" query parameter: ${type}`);
}
return {
...parentData,
spec,
title,
};
};
+190
View File
@@ -0,0 +1,190 @@
<script lang="ts">
import { onMount } from "svelte";
import { browser } from "$app/environment";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
let imageKey = $state(0);
let loading = $state(false);
// Auto-reload image when HMR updates
onMount(() => {
if (!browser) return undefined;
// Trigger reload when HMR updates
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => {
imageKey++;
});
}
});
async function regenerate() {
loading = true;
imageKey++;
loading = false;
}
const imageUrl = $derived(
`/internal/ogp/generate?${new URLSearchParams(data.spec).toString()}&_=${imageKey}`,
);
</script>
<svelte:head>
<title>OG Image Preview - {data.title}</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>OG Image Preview</h1>
<div class="controls">
<button onclick={regenerate} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
<div class="info">
<p><strong>Type:</strong> {data.spec.type}</p>
{#if data.spec.type === "project"}
<p><strong>ID:</strong> {data.spec.id}</p>
{/if}
<p class="hint">
Image auto-reloads when server updates (HMR) or every 2 seconds
</p>
</div>
<div class="preview">
<img src={imageUrl} alt="Preview" width="1200" height="630" class:loading />
</div>
<div class="examples">
<h2>Example URLs:</h2>
<ul>
<li><a href="/internal/ogp?type=index">Index page</a></li>
<li><a href="/internal/ogp?type=projects">Projects page</a></li>
<li>
<a href="/internal/ogp?type=project&id=example-id"
>Project page (needs valid ID)</a
>
</li>
</ul>
</div>
</div>
<style>
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
}
.controls button {
padding: 0.5rem 1rem;
font-size: 1rem;
background: #000;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: opacity 0.2s;
}
.controls button:hover:not(:disabled) {
opacity: 0.8;
}
.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info {
background: #f5f5f5;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
}
.info p {
margin: 0.5rem 0;
}
.hint {
color: #666;
font-size: 0.875rem;
font-style: italic;
}
.preview {
border: 2px solid #e5e5e5;
border-radius: 0.5rem;
overflow: hidden;
background: #fafafa;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.preview img {
max-width: 100%;
height: auto;
display: block;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
transition: opacity 0.2s;
}
.preview img.loading {
opacity: 0.6;
}
.examples {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e5e5e5;
}
.examples h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.examples ul {
list-style: none;
padding: 0;
}
.examples li {
margin: 0.5rem 0;
}
.examples a {
color: #0066cc;
text-decoration: none;
}
.examples a:hover {
text-decoration: underline;
}
</style>
@@ -1,13 +1,58 @@
import { ImageResponse } from "@ethercorps/sveltekit-og";
import type { RequestHandler } from "./$types";
import { loadOGFonts } from "$lib/og-fonts";
import { generateOGTemplate } from "$lib/og-template";
import { apiFetch } from "$lib/api";
import type { Project } from "../../projects/+page.server";
import type { OGImageSpec } from "$lib/og-types";
import { loadOGFonts } from "$lib/og-fonts";
import { apiFetch } from "$lib/api";
import type { Project } from "../../../projects/+page.server";
import { getLogger } from "@logtape/logtape";
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import { render } from "svelte/server";
import { html } from "@xevion/satori-html";
import OgImage from "$lib/components/OgImage.svelte";
const logger = getLogger(["ssr", "routes", "internal", "ogp"]);
const logger = getLogger(["ssr", "routes", "internal", "ogp", "generate"]);
/**
* Generate endpoint for OG images.
* Parses query parameters and generates the image.
*/
export const GET: RequestHandler = async ({ url }) => {
const type = url.searchParams.get("type");
if (!type) {
logger.warn('Missing "type" query parameter');
return new Response('Missing "type" query parameter', { status: 400 });
}
let spec: OGImageSpec;
switch (type) {
case "index":
spec = { type: "index" };
break;
case "projects":
spec = { type: "projects" };
break;
case "project": {
const id = url.searchParams.get("id");
if (!id) {
logger.warn('Missing "id" query parameter for project type');
return new Response('Missing "id" query parameter for project type', {
status: 400,
});
}
spec = { type: "project", id };
break;
}
default:
logger.warn('Invalid "type" query parameter', { type });
return new Response(`Invalid "type" query parameter: ${type}`, {
status: 400,
});
}
return await generateOGImage(spec);
};
/**
* Internal endpoint for OG image generation.
@@ -29,75 +74,58 @@ export const POST: RequestHandler = async ({ request }) => {
return await generateOGImage(spec);
};
/**
* GET handler for OG image generation using query parameters.
* Supports: ?type=index, ?type=projects, ?type=project&id=<id>
*/
export const GET: RequestHandler = async ({ url }) => {
const type = url.searchParams.get("type");
if (!type) {
logger.warn("Missing 'type' query parameter");
return new Response("Missing 'type' query parameter", { status: 400 });
}
let spec: OGImageSpec;
switch (type) {
case "index":
spec = { type: "index" };
break;
case "projects":
spec = { type: "projects" };
break;
case "project": {
const id = url.searchParams.get("id");
if (!id) {
logger.warn("Missing 'id' query parameter for project type");
return new Response("Missing 'id' query parameter for project type", {
status: 400,
});
}
spec = { type: "project", id };
break;
}
default:
logger.warn("Invalid 'type' query parameter", { type });
return new Response(`Invalid 'type' query parameter: ${type}`, {
status: 400,
});
}
return await generateOGImage(spec);
};
async function generateOGImage(spec: OGImageSpec): Promise<Response> {
logger.info("Generating OG image", { spec });
const templateData = await getTemplateData(spec);
try {
const fonts = await loadOGFonts();
const html = generateOGTemplate(templateData);
const templateData = await getTemplateData(spec);
logger.debug("Template data prepared", { templateData });
const imageResponse = new ImageResponse(html, {
const fonts = await loadOGFonts();
logger.debug("Fonts loaded", { fontCount: fonts.length });
// Render Svelte component to HTML string
const { html: renderedHtml } = render(OgImage, {
props: {
title: templateData.title,
subtitle: templateData.subtitle,
type: spec.type,
},
});
// Convert HTML to Satori VNode
const vnode = html(renderedHtml);
// Generate SVG with satori
const svg = await satori(vnode, {
width: 1200,
height: 630,
fonts,
});
const imageBuffer = await imageResponse.arrayBuffer();
// Convert SVG to PNG with resvg
const resvg = new Resvg(svg, {
fitTo: {
mode: "width",
value: 1200,
},
});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
logger.info("OG image generated successfully", { spec });
return new Response(imageBuffer, {
status: 200,
headers: { "Content-Type": "image/png" },
return new Response(new Uint8Array(pngBuffer), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
} catch (error) {
logger.error("OG image generation failed", {
spec,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return new Response("Failed to generate image", { status: 500 });
}
@@ -106,6 +134,9 @@ async function generateOGImage(spec: OGImageSpec): Promise<Response> {
async function getTemplateData(spec: OGImageSpec): Promise<{
title: string;
subtitle?: string;
description?: string;
image?: string;
color?: string;
type?: "default" | "project";
}> {
switch (spec.type) {