wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files draw
109 lines · 4.0 KB · ~1.2k tok Raw History
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
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts