Skip to the content

Capsid menu

components/theme-switch/theme-server.ts

48 lines and 5 functions in DrDustinEdwards/capsomer, at commit d44446f on 2026-10-25. This file on GitHub.

Functions

The functions of theme-server.ts
FunctionLinesComplexityCoveredCRAPMutation
isThemeChoice16 to 182100%OkCRAP 2No data: mutation testing has not run on this repo yet
readThemeCookie22 to 369100%WatchCRAP 9No data: mutation testing has not run on this repo yet
themeCookie40 to 432100%OkCRAP 2No data: mutation testing has not run on this repo yet
colorSchemeContent47 to 49233%OkCRAP 3.2No data: mutation testing has not run on this repo yet
themeScript54 to 561100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. Imported by components/theme-switch/theme-switch.ts.

Source

// The theme switch's server half: what a server-rendered site needs so the first byte already carries the person's
// theme. Read the cookie, write it, and the one-line pre-paint script for a site that remembers the choice in
// localStorage instead. No DOM and no framework, so it runs in a Worker, in Node and in the browser alike.
// dustinedwards.info, Foxhound and txasm each wrote this for themselves; it lives here once.

export type ThemeChoice = "light" | "dark";

/** The localStorage key the theme switch remembers the choice under, unless a switch names its own (data-cap-pref). */
export const THEME_PREF = "cap-theme";

/** The cookie a site remembers the choice in, unless it has its own (txasm's "txasm_theme", Foxhound's "fh_theme"). */
export const THEME_COOKIE = "theme";
/** A year: the choice should outlast any visit gap a person would expect it to survive. */
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;

export function isThemeChoice(value: unknown): value is ThemeChoice {
  return value === "light" || value === "dark";
}

// The person's remembered choice from a Cookie header (or the request carrying one), or null when they never chose,
// so the page follows the system. Anything unreadable is null too: a bad cookie costs the default theme, never a 500.
export function readThemeCookie(source: Request | string | null | undefined, name = THEME_COOKIE): ThemeChoice | null {
  const header = typeof source === "string" || source == null ? source : source.headers.get("cookie");
  if (!header) return null;
  for (const part of header.split(";")) {
    const at = part.indexOf("=");
    if (at < 0 || part.slice(0, at).trim() !== name) continue;
    try {
      const value = decodeURIComponent(part.slice(at + 1).trim());
      return isThemeChoice(value) ? value : null;
    } catch {
      return null;
    }
  }
  return null;
}

// The Set-Cookie value that remembers a choice: a year, the whole site, SameSite=Lax and Secure. A local http dev
// server passes secure: false.
export function themeCookie(choice: ThemeChoice, opts: { name?: string; maxAge?: number; secure?: boolean } = {}): string {
  const { name = THEME_COOKIE, maxAge = THEME_COOKIE_MAX_AGE, secure = true } = opts;
  return `${name}=${choice}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secure ? "; Secure" : ""}`;
}

// The content of <meta name="color-scheme">: the choice, or "light dark" with none. data-theme means nothing until
// the CSS parses, so without this the canvas the browser paints between pages is light.
export function colorSchemeContent(choice: ThemeChoice | null): "light" | "dark" | "light dark" {
  return choice ?? "light dark";
}

// The pre-paint script for a site that remembers the choice in localStorage: run inline in the head, before the
// stylesheet, or the page flashes the other theme. A site with a Content Security Policy allows it by its hash, so
// the text must be exactly this.
export function themeScript(key = THEME_PREF): string {
  return `try{var t=localStorage.getItem(${JSON.stringify(key)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
}