# Enable Open Graph images
Generates a share card for every page from a Svelte component: files at build time for prerendered pages, on request for the rest.
Tags: sveltekit, open-graph, og-image, seo, takumi, velastack

$ vela enable og

## src/lib/og/card.svelte

```svelte
<!--
	The share card: what a link to this site looks like in a chat, a feed or a
	search result. It is rendered to a 1200x630 PNG by `src/lib/server/og.ts`,
	never in a browser, so it is plain markup and CSS and has no interactivity.

	Lay it out with flexbox. Tailwind classes and `app.css` do not apply here;
	only this file's <style> block reaches the renderer.
-->
<svelte:options css="injected" />

<script lang="ts">
  let {
    title,
    description,
    siteName,
    accent = "#4f46e5",
  }: {
    title: string;
    description?: string;
    siteName: string;
    accent?: string;
  } = $props();

  // A long title steps down in size so it stays within three lines.
  const titleSize = $derived(
    title.length > 70 ? 52 : title.length > 40 ? 64 : 76,
  );
</script>

<div class="card" style:--accent={accent}>
  <div class="bar"></div>
  <div class="body">
    {#if title !== siteName}<div class="site">{siteName}</div>{/if}
    <div class="title" style:font-size="{titleSize}px">{title}</div>
    {#if description}<div class="description">{description}</div>{/if}
  </div>
</div>

<style>
  .card {
    display: flex;
    width: 1200px;
    height: 630px;
    background: linear-gradient(135deg, #0b1020, #1b2140);
    color: white;
  }
  .bar {
    width: 16px;
    background: var(--accent);
  }
  .body {
    display: flex;
    flex: 1;
    flex-direction: column;
    justify-content: center;
    gap: 24px;
    padding: 72px;
  }
  .site {
    font-size: 28px;
    opacity: 0.7;
  }
  .title {
    font-weight: 700;
    line-height: 1.05;
    letter-spacing: -0.02em;
  }
  .description {
    display: -webkit-box;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    font-size: 32px;
    line-height: 1.3;
    opacity: 0.8;
  }
</style>
```

## src/lib/og/index.ts

```ts
import type { MetaTagsProps } from "svelte-meta-tags";
import { site } from "#lib/site.js";

/** Where a page's share card is served: `/` is `/og/index.png`, `/about` is `/og/about.png`. */
export function cardPath(pathname: string): string {
  const path = pathname.replace(/\/+$/, "");
  return `/og${path || "/index"}.png`;
}

/**
 * Points a page's Open Graph image at its generated share card.
 *
 * A page that sets its own image keeps it: only the site-wide default
 * (`/og.jpg`) or a missing image is replaced. The `image_src` link is how
 * prerendering finds the card, since it only follows links on its own origin
 * and the Open Graph image is an absolute URL on `site.url`.
 */
export function withCard(
  tags: MetaTagsProps,
  url: Pick<URL, "pathname">,
): MetaTagsProps {
  const image = tags.openGraph?.images?.[0];
  if (image && !image.url.endsWith("/og.jpg")) return tags;

  const path = cardPath(url.pathname);
  return {
    ...tags,
    openGraph: {
      ...tags.openGraph,
      images: [
        { url: `${site.url}${path}`, alt: site.name, width: 1200, height: 630 },
      ],
    },
    twitter: { cardType: "summary_large_image", ...tags.twitter },
    additionalLinkTags: [
      ...(tags.additionalLinkTags ?? []),
      { rel: "image_src", href: path },
    ],
  };
}
```

## src/lib/server/og.ts

```ts
import type { ComponentProps } from "svelte";
import { render } from "svelte/server";
import { Renderer } from "@takumi-rs/core";
import Card from "#lib/og/card.svelte";

/**
 * One renderer for the life of the server. It draws text in its built-in font
 * (Geist). To use another, register the font file once, here, and name its
 * family in the card's CSS:
 *
 *   import font from './my-font.woff2?inline';
 *   await renderer.registerFont(Buffer.from(font.split(',')[1], 'base64'));
 */
const renderer = new Renderer();

/** Renders the share card to a 1200x630 PNG. */
export async function renderCard(props: ComponentProps<typeof Card>) {
  const { head, body } = render(Card, { props });
  const css = [...head.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map(
    (match) => match[1],
  );
  return renderer.render(body, {
    width: 1200,
    height: 630,
    format: "png",
    css,
  });
}
```

## src/routes/og/[...path].png/+server.ts

```ts
import { renderCard } from "#lib/server/og.js";
import { site } from "#lib/site.js";
import type { RequestHandler } from "./$types";

/**
 * A share card for every page, at the page's own path: `/og/about.png` is the
 * card for `/about`, and `/og/index.png` the one for `/`.
 *
 * Prerendered pages get their card written as a file at build time; on a
 * server the rest are rendered on request.
 */
export const prerender = "auto";

const ENTITIES: Record<string, string> = {
  "&amp;": "&",
  "&quot;": '"',
  "&#39;": "'",
  "&lt;": "<",
  "&gt;": ">",
};

/** Drops the ` | Site name` the root layout's title template adds. */
function withoutSiteName(title: string | undefined) {
  if (!title?.endsWith(site.name)) return title;
  return (
    title.slice(0, -site.name.length).replace(/\s*[|·–—-]\s*$/, "") || title
  );
}

export const GET: RequestHandler = async ({ params, fetch }) => {
  // The page itself says what its card reads: whatever it set as its title
  // and description is in the meta tags it renders.
  // Redirects are not followed: a page behind sign-in has no card, where
  // following would hand back the sign-in page's.
  const page = await fetch(params.path === "index" ? "/" : `/${params.path}`, {
    redirect: "manual",
  });
  if (!page.ok) return new Response("Not found", { status: 404 });

  const html = await page.text();
  const meta = (property: string) =>
    html
      .match(new RegExp(`<meta property="${property}" content="([^"]*)"`))?.[1]
      ?.replace(/&(amp|quot|#39|lt|gt);/g, (entity) => ENTITIES[entity]);

  const png = await renderCard({
    title: withoutSiteName(meta("og:title")) || site.name,
    description: meta("og:description"),
    siteName: site.name,
  });

  return new Response(png, {
    headers: {
      "content-type": "image/png",
      "cache-control": "public, max-age=86400",
    },
  });
};
```
