/** * The look, compiled. `design/tokens.json` is the app's design tokens in the * W3C format — groups, `$value`, a `$type` a group hands down — and this * turns them into custom properties once, at boot: `color.bg` becomes * `--color-bg`. A colour's `$value` is the light theme and * `$extensions.mode.dark` the dark one; dark follows the system unless * `` decides. The page links the result as * `/tokens.css`, and every other stylesheet speaks only in `var(--…)`. * * Tokens that do not read are not a warning. The app does not start. */ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; export interface Token { /** The custom property: `--color-bg`. */ readonly name: string; readonly type: string; readonly light: string; readonly dark?: string; } const isGroup = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); /** Every token in the tree, in order, with its type and both themes' values. */ export function flatten( tree: unknown, path: string[] = [], type = "", ): Token[] { if (!isGroup(tree)) throw new Error(`${path.join(".") || "tokens"}: not a group`); const here = typeof tree.$type === "string" ? tree.$type : type; if ("$value" in tree) { const name = `--${path.join("-")}`; if (!/^--[a-z0-9-]+$/.test(name)) throw new Error(`${path.join(".")}: not a name CSS can carry`); if (here === "") throw new Error(`${path.join(".")}: no $type`); const dark = isGroup(tree.$extensions) && isGroup(tree.$extensions.mode) ? tree.$extensions.mode.dark : undefined; return [ { name, type: here, light: css(tree.$value, path), ...(dark === undefined ? {} : { dark: css(dark, path) }), }, ]; } return Object.entries(tree) .filter(([key]) => !key.startsWith("$")) .flatMap(([key, child]) => flatten(child, [...path, key], here)); } // A value is a string, a number, or a font stack; nothing in it may close // the declaration it sits in. function css(value: unknown, path: string[]): string { const text = Array.isArray(value) ? value .map((f) => /^[\w-]+$/.test(String(f)) ? String(f) : JSON.stringify(String(f)), ) .join(", ") : typeof value === "string" || typeof value === "number" ? String(value) : ""; if (text === "" || /[;{}<]/.test(text)) throw new Error(`${path.join(".")}: $value is not a CSS value`); return text; } /** The stylesheet: every token on `:root`, the dark ones twice — for the system's choice and for the page's. */ export function compile(tokens: readonly Token[]): string { const block = (selector: string, lines: string[]): string => `${selector} {\n${lines.map((l) => ` ${l}\n`).join("")}}\n`; const light = tokens.map((t) => `${t.name}: ${t.light};`); const dark = tokens.flatMap((t) => t.dark === undefined ? [] : [`${t.name}: ${t.dark};`], ); return [ block(":root", ["color-scheme: light dark;", ...light]), block(':root[data-theme="light"]', ["color-scheme: light;"]), `@media (prefers-color-scheme: dark) {\n${block(':root:not([data-theme="light"])', dark)}}\n`, block(':root[data-theme="dark"]', ["color-scheme: dark;", ...dark]), ].join("\n"); } export const tokens: readonly Token[] = flatten( JSON.parse( readFileSync(new URL("../design/tokens.json", import.meta.url), "utf8"), ), ); export const tokensCss: string = compile(tokens); /** `/tokens.css`, addressed by its content, so it can be cached for a year. */ export const tokensHref = `/tokens.css?v=${createHash("sha256").update(tokensCss).digest("hex").slice(0, 8)}`; /** One token's value, for the few places a colour must be spelled out (the browser's own chrome). */ export const token = ( name: string, mode: "light" | "dark" = "light", ): string => { const found = tokens.find((t) => t.name === `--${name}`); if (found === undefined) throw new Error(`no token ${name}`); return mode === "dark" ? (found.dark ?? found.light) : found.light; };