pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files draw
| 1 | /** |
| 2 | * The look, compiled. `design/tokens.json` is the app's design tokens in the |
| 3 | * W3C format — groups, `$value`, a `$type` a group hands down — and this |
| 4 | * turns them into custom properties once, at boot: `color.bg` becomes |
| 5 | * `--color-bg`. A colour's `$value` is the light theme and |
| 6 | * `$extensions.mode.dark` the dark one; dark follows the system unless |
| 7 | * `<html data-theme="light|dark">` decides. The page links the result as |
| 8 | * `/tokens.css`, and every other stylesheet speaks only in `var(--…)`. |
| 9 | * |
| 10 | * Tokens that do not read are not a warning. The app does not start. |
| 11 | */ |
| 12 | |
| 13 | import { createHash } from "node:crypto"; |
| 14 | import { readFileSync } from "node:fs"; |
| 15 | |
| 16 | export interface Token { |
| 17 | /** The custom property: `--color-bg`. */ |
| 18 | readonly name: string; |
| 19 | readonly type: string; |
| 20 | readonly light: string; |
| 21 | readonly dark?: string; |
| 22 | } |
| 23 | |
| 24 | const isGroup = (v: unknown): v is Record<string, unknown> => |
| 25 | typeof v === "object" && v !== null && !Array.isArray(v); |
| 26 | |
| 27 | /** Every token in the tree, in order, with its type and both themes' values. */ |
| 28 | export function flatten( |
| 29 | tree: unknown, |
| 30 | path: string[] = [], |
| 31 | type = "", |
| 32 | ): Token[] { |
| 33 | if (!isGroup(tree)) |
| 34 | throw new Error(`${path.join(".") || "tokens"}: not a group`); |
| 35 | const here = typeof tree.$type === "string" ? tree.$type : type; |
| 36 | if ("$value" in tree) { |
| 37 | const name = `--${path.join("-")}`; |
| 38 | if (!/^--[a-z0-9-]+$/.test(name)) |
| 39 | throw new Error(`${path.join(".")}: not a name CSS can carry`); |
| 40 | if (here === "") throw new Error(`${path.join(".")}: no $type`); |
| 41 | const dark = |
| 42 | isGroup(tree.$extensions) && isGroup(tree.$extensions.mode) |
| 43 | ? tree.$extensions.mode.dark |
| 44 | : undefined; |
| 45 | return [ |
| 46 | { |
| 47 | name, |
| 48 | type: here, |
| 49 | light: css(tree.$value, path), |
| 50 | ...(dark === undefined ? {} : { dark: css(dark, path) }), |
| 51 | }, |
| 52 | ]; |
| 53 | } |
| 54 | return Object.entries(tree) |
| 55 | .filter(([key]) => !key.startsWith("$")) |
| 56 | .flatMap(([key, child]) => flatten(child, [...path, key], here)); |
| 57 | } |
| 58 | |
| 59 | // A value is a string, a number, or a font stack; nothing in it may close |
| 60 | // the declaration it sits in. |
| 61 | function css(value: unknown, path: string[]): string { |
| 62 | const text = Array.isArray(value) |
| 63 | ? value |
| 64 | .map((f) => |
| 65 | /^[\w-]+$/.test(String(f)) ? String(f) : JSON.stringify(String(f)), |
| 66 | ) |
| 67 | .join(", ") |
| 68 | : typeof value === "string" || typeof value === "number" |
| 69 | ? String(value) |
| 70 | : ""; |
| 71 | if (text === "" || /[;{}<]/.test(text)) |
| 72 | throw new Error(`${path.join(".")}: $value is not a CSS value`); |
| 73 | return text; |
| 74 | } |
| 75 | |
| 76 | /** The stylesheet: every token on `:root`, the dark ones twice — for the system's choice and for the page's. */ |
| 77 | export function compile(tokens: readonly Token[]): string { |
| 78 | const block = (selector: string, lines: string[]): string => |
| 79 | `${selector} {\n${lines.map((l) => ` ${l}\n`).join("")}}\n`; |
| 80 | const light = tokens.map((t) => `${t.name}: ${t.light};`); |
| 81 | const dark = tokens.flatMap((t) => |
| 82 | t.dark === undefined ? [] : [`${t.name}: ${t.dark};`], |
| 83 | ); |
| 84 | return [ |
| 85 | block(":root", ["color-scheme: light dark;", ...light]), |
| 86 | block(':root[data-theme="light"]', ["color-scheme: light;"]), |
| 87 | `@media (prefers-color-scheme: dark) {\n${block(':root:not([data-theme="light"])', dark)}}\n`, |
| 88 | block(':root[data-theme="dark"]', ["color-scheme: dark;", ...dark]), |
| 89 | ].join("\n"); |
| 90 | } |
| 91 | |
| 92 | export const tokens: readonly Token[] = flatten( |
| 93 | JSON.parse( |
| 94 | readFileSync(new URL("../design/tokens.json", import.meta.url), "utf8"), |
| 95 | ), |
| 96 | ); |
| 97 | export const tokensCss: string = compile(tokens); |
| 98 | /** `/tokens.css`, addressed by its content, so it can be cached for a year. */ |
| 99 | export const tokensHref = `/tokens.css?v=${createHash("sha256").update(tokensCss).digest("hex").slice(0, 8)}`; |
| 100 | |
| 101 | /** One token's value, for the few places a colour must be spelled out (the browser's own chrome). */ |
| 102 | export const token = ( |
| 103 | name: string, |
| 104 | mode: "light" | "dark" = "light", |
| 105 | ): string => { |
| 106 | const found = tokens.find((t) => t.name === `--${name}`); |
| 107 | if (found === undefined) throw new Error(`no token ${name}`); |
| 108 | return mode === "dark" ? (found.dark ?? found.light) : found.light; |
| 109 | }; |