Files
xevion.dev/web/src/routes/internal/ogp/generate/+server.ts
Xevion 96595b073d 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
2026-01-05 15:15:08 -06:00

176 lines
4.7 KiB
TypeScript

import type { RequestHandler } from "./$types";
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", "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.
* Called by Rust server via POST with OGImageSpec JSON body.
*
* IMPORTANT: This endpoint should never be accessible externally.
* It's blocked by the Rust ISR handler's /internal/* check.
*/
export const POST: RequestHandler = async ({ request }) => {
let spec: OGImageSpec;
try {
spec = await request.json();
} catch {
logger.warn("Invalid JSON body received");
return new Response("Invalid JSON body", { status: 400 });
}
return await generateOGImage(spec);
};
async function generateOGImage(spec: OGImageSpec): Promise<Response> {
logger.info("Generating OG image", { spec });
try {
const templateData = await getTemplateData(spec);
logger.debug("Template data prepared", { templateData });
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,
});
// 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(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 });
}
}
async function getTemplateData(spec: OGImageSpec): Promise<{
title: string;
subtitle?: string;
description?: string;
image?: string;
color?: string;
type?: "default" | "project";
}> {
switch (spec.type) {
case "index":
return {
title: "Ryan Walters",
subtitle: "Full-Stack Software Engineer",
type: "default",
};
case "projects":
return {
title: "Projects",
subtitle: "created, maintained, or contributed to by me...",
type: "default",
};
case "project":
try {
const projects = await apiFetch<Project[]>("/api/projects");
const project = projects.find((p) => p.id === spec.id);
if (project) {
return {
title: project.name,
subtitle: project.shortDescription,
type: "project",
};
}
} catch (error) {
logger.error("Failed to fetch project", { id: spec.id, error });
}
return {
title: "Project",
subtitle: "View on xevion.dev",
type: "project",
};
}
}