| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
import { createHash } from "node:crypto"; |
| 14 |
import { readFileSync } from "node:fs"; |
| 15 |
|
| 16 |
export interface Token { |
| 17 |
|
| 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 |
|
| 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 |
|
| 60 |
|
| 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 |
|
| 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 |
|
| 99 |
export const tokensHref = `/tokens.css?v=${createHash("sha256").update(tokensCss).digest("hex").slice(0, 8)}`; |
| 100 |
|
| 101 |
|
| 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 |
}; |