Skip to content

The browser-safe core

buildSvg builds a string: it takes props, a config and a size, and returns an SVG document. Everything in this package that needs Node sits on one side of it or the other, so the middle of it will run in a browser or anywhere else without a filesystem, such as a worker or a request handler.

import { buildSvg, resolveConfig } from "@kensio/colophon/core";
const config = resolveConfig({
colors: { brand: "#0d9488" },
fonts: [{ family: "Inter", data: fontBytes }],
});
const svg = await buildSvg(
{ template: "card", title: "Rendered anywhere" },
config,
{ width: 1200, height: 630 },
);

It is the same code the root entry point runs, and imported from Node it behaves identically. Imported into a bundle it swaps two modules, through package.json’s browser field, for versions that cannot touch a filesystem or a native binary.

Finding posts needs a filesystem, but understanding one does not. extractProps takes a parsed frontmatter object and returns the props a template renders from, so something handed a post rather than going and looking for one gets the same reading of meta_img_props that a build does:

import { extractProps } from "@kensio/colophon/core";
const props = extractProps(frontmatter, { defaultTemplate: "card" });

It returns undefined for a post that should not have an image, which is the same signal the props mapper gives. The rest of the content layer, which walks a tree, reads files and derives slugs, is on @kensio/colophon/content and stays in Node.

resolveConfig validates what it is given and throws, which is what a build wants: there is nowhere to put a problem but the end of the run. Somewhere with a place to put them, such as an editor rendering a config as it is typed, wants the list instead:

import { configProblems } from "@kensio/colophon/core";
for (const problem of configProblems(config)) {
// 'Unknown option "colors.forground". Did you mean "foreground"?'
}

An empty array means the config is one resolveConfig will accept.

Fonts and images have to be bytes. There is no filesystem to resolve a path against, so { data } works and { path } does not. It says so when the config is resolved rather than later:

fonts[0]: cannot read the font file at "./Inter.ttf" here, since there is no
filesystem. Supply the bytes as "data" instead.

The fonts that ship with the package are files, so they are not here either: the core measures by estimate and leaves the drawing to whatever the host has, as it did before there were bundled fonts. Inlining them would put half a megabyte of font into every bundle that imports the core, whether or not it draws any text. Pass the bytes yourself where the widths matter.

There is no rasteriser. resvg is a native module, so a browser build does not include it, and nothing here turns the SVG into pixels. Two ways on:

  • Take the SVG as it is, since browsers draw it, and it is often what was wanted anyway.
  • Set config.rasteriser to something that runs where you are, such as a wasm build of resvg. It returns a Uint8Array, so a backend with no Buffer to hand back still works.

Asking for pixels without one is an error rather than a blank image.

About 10 MB bundled, of which 9.5 MB is Shiki’s grammars and themes. The templates, the layout toolkit and the measuring are the remaining half a megabyte.

That is the price of the code template rendering any language in any theme. A build that does not need it, or needs two languages rather than every language, should use Shiki’s own fine-grained bundle to narrow what is included.

Themes on a site that already highlights code

Section titled “Themes on a site that already highlights code”

Colophon resolves code.theme through a registry of its own rather than through Shiki’s. That looks like duplication, so it is worth saying why it is there.

A tool that highlights a site’s own code blocks may narrow what Shiki bundles by rewriting Shiki’s modules during the build. Expressive Code, which a Starlight site runs by default, does that under an option called removeUnusedThemes: it strips every theme from Shiki’s theme module except the ones its own configuration names. The rewrite applies to the file rather than to whichever importer asked for it, so a second Shiki caller in the same build, which is what Colophon is on a site rendering images in the browser, was left with no themes at all and rejected every theme name, the default included.

Colophon’s own registry is not affected by that rewrite, and there is nothing to configure. Languages are still Shiki’s, so a site that has narrowed those the same way through shiki.bundledLangs will find a language Colophon does not have falls back to plain text, which is what an unrecognised language has always done.

Emitting meta tags needs none of this: the @kensio/colophon/meta subpath bundles to about 4 KB and depends on nothing.

An endpoint that turns a query string into an image will render whatever anyone who finds it asks for, on your domain and at your expense, so the parameters need signing.

import { signedQuery } from "@kensio/colophon/core";
// In the page, where the secret lives:
const query = await signedQuery({ title: post.title }, SECRET);
const url = `https://example.com/og?${query}`;
// In the handler:
import {
buildSvg,
resolveConfig,
verifySignedQuery,
} from "@kensio/colophon/core";
export default async function handler(request) {
const { searchParams } = new URL(request.url);
if (!(await verifySignedQuery(searchParams, SECRET))) {
return new Response("Not found", { status: 404 });
}
const svg = await buildSvg(
{ template: "card", title: searchParams.get("title") ?? "" },
resolveConfig({ fonts: [{ family: "Inter", data: await fontBytes() }] }),
{ width: 1200, height: 630 },
);
return new Response(svg, {
headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=31536000, immutable",
},
});
}

The signature covers the parameters and nothing else, so anything that must not be tampered with has to be one of them. A template name or a size left outside the parameters is one that anyone can change. The signature is HMAC-SHA256 over the parameters sorted by name, so the order they arrive in does not matter, and the check is crypto.subtle.verify rather than a string comparison that would stop at the first wrong byte.

A query string that repeats a parameter is refused rather than resolved. URLSearchParams.get takes the first value of a repeated key and building an object from the pairs takes the last, so appending a second copy of a key to a signed URL is how a check like this is usually got round. A legitimate signed URL has no reason to repeat one.

signParams and verifyParams are there for a URL shape of your own.

The same handler, with the two things a worker does differently: the font is imported as bytes rather than read, and the secret comes from the environment.

import fontData from "./Inter.ttf";
export default {
async fetch(request, env) {
const { searchParams } = new URL(request.url);
if (!(await verifySignedQuery(searchParams, env.COLOPHON_SECRET))) {
return new Response("Not found", { status: 404 });
}
// ...as above, with `fonts: [{ family: "Inter", data: new Uint8Array(fontData) }]`
},
};

wrangler.toml needs a rule to import the font as bytes:

rules = [{ type = "Data", globs = ["**/*.ttf"] }]

Caching matters more here than in a build: a signed URL is stable, so the immutable header above means each image is rendered once per edge location rather than once per request.

None of this replaces the CLI or generate. Rendering at build time is cheaper, since the rebuild stamps mean most images are not rendered again at all, and it produces files a CDN can serve without running anything. On-demand rendering is for pages that do not exist until somebody asks for them.