@terpjs/contract 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/scripts/build-tokens.mjs +160 -25
- package/src/css-rules.js +107 -0
- package/src/css-rules.test.js +110 -0
- package/src/tokens.contrast.test.js +395 -0
- package/src/tokens.css +293 -14
- package/src/tokens.manifest.json +1416 -0
- package/src/tokens.manifest.test.js +166 -0
- package/src/tokens.themes.test.js +177 -0
- package/themes.json +43 -0
- package/token-pairs.json +252 -0
- package/tokens.contrast.json +70 -0
- package/tokens.dark.json +40 -4
- package/tokens.json +115 -7
- package/tokens.midnight.json +70 -0
- package/tokens.twilight.json +70 -0
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
8
|
"./schema": "./src/schema.d.ts",
|
|
9
9
|
"./openapi.json": "./openapi.json",
|
|
10
|
-
"./tokens.css": "./src/tokens.css"
|
|
10
|
+
"./tokens.css": "./src/tokens.css",
|
|
11
|
+
"./tokens.manifest.json": "./src/tokens.manifest.json"
|
|
11
12
|
},
|
|
12
13
|
"types": "./src/index.ts",
|
|
13
14
|
"bin": {
|
package/scripts/build-tokens.mjs
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Token build:
|
|
3
|
-
* tokens.
|
|
2
|
+
* Token build: the theme registry in `themes.json` plus one source per theme -> the compiled
|
|
3
|
+
* stylesheet `src/tokens.css` and the published `src/tokens.manifest.json` (design §7.1, item 2).
|
|
4
4
|
*
|
|
5
|
-
* The output is one stylesheet with
|
|
6
|
-
* 1. `:root` — the
|
|
7
|
-
* 2. `[data-theme=
|
|
8
|
-
* 3. `@media (prefers-color-scheme: dark)` scoped to `:root:not([data-theme
|
|
9
|
-
* — the OS preference
|
|
5
|
+
* The output is one stylesheet with a block per theme:
|
|
6
|
+
* 1. `:root` — the base theme, and the only block carrying geometry.
|
|
7
|
+
* 2. `[data-theme='<name>']` — one per non-base theme, colours only, an explicit choice.
|
|
8
|
+
* 3. `@media (prefers-color-scheme: dark)` scoped to `:root:not([data-theme])`
|
|
9
|
+
* — the OS preference selects the registry's `systemDark` theme when nothing is pinned.
|
|
10
|
+
*
|
|
11
|
+
* The media selector matches only an *unpinned* root. It used to be
|
|
12
|
+
* `:root:not([data-theme='light'])`, which was equivalent while `light` and `dark` were the
|
|
13
|
+
* only themes and became a defect the moment a third existed: it matches
|
|
14
|
+
* `[data-theme='contrast']`, outranks it on specificity (two compound parts against one), and
|
|
15
|
+
* so laid the dark colours over a theme the app had explicitly pinned whenever the OS
|
|
16
|
+
* preferred dark.
|
|
10
17
|
*
|
|
11
18
|
* Apps opt in/out per user via the `data-theme` attribute on <html> (react-core's
|
|
12
19
|
* `ThemeProvider` manages it); with no attribute the OS preference wins. Regenerate with
|
|
@@ -22,6 +29,32 @@ import StyleDictionary from "style-dictionary";
|
|
|
22
29
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
30
|
const buildDir = mkdtempSync(join(tmpdir(), "terp-tokens-"));
|
|
24
31
|
|
|
32
|
+
const read = (name) => JSON.parse(readFileSync(join(packageRoot, name), "utf8"));
|
|
33
|
+
|
|
34
|
+
const registry = read("themes.json");
|
|
35
|
+
const themes = registry.themes;
|
|
36
|
+
const base = themes.find((theme) => theme.name === registry.base);
|
|
37
|
+
const overlays = themes.filter((theme) => theme.name !== registry.base);
|
|
38
|
+
|
|
39
|
+
// The generator is the first thing to read the registry, so it is the right place to refuse a
|
|
40
|
+
// registry that cannot produce a coherent sheet. Each of these would otherwise emit something
|
|
41
|
+
// that looks fine and behaves wrongly in the browser.
|
|
42
|
+
if (!base) throw new Error(`themes.json: base theme "${registry.base}" is not in the list`);
|
|
43
|
+
if (!themes.some((theme) => theme.name === registry.systemDark)) {
|
|
44
|
+
throw new Error(`themes.json: systemDark "${registry.systemDark}" is not in the list`);
|
|
45
|
+
}
|
|
46
|
+
if (registry.systemDark === registry.base) {
|
|
47
|
+
throw new Error("themes.json: systemDark must not be the base theme");
|
|
48
|
+
}
|
|
49
|
+
for (const theme of themes) {
|
|
50
|
+
if (!/^[a-z][a-z0-9-]*$/.test(theme.name)) {
|
|
51
|
+
throw new Error(`themes.json: "${theme.name}" is not usable as a data-theme value`);
|
|
52
|
+
}
|
|
53
|
+
if (theme.appearance !== "light" && theme.appearance !== "dark") {
|
|
54
|
+
throw new Error(`themes.json: ${theme.name} appearance must be "light" or "dark"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
async function buildCss(sourceFile, outputFile) {
|
|
26
59
|
const sd = new StyleDictionary({
|
|
27
60
|
source: [join(packageRoot, sourceFile)],
|
|
@@ -51,36 +84,138 @@ function declarations(css) {
|
|
|
51
84
|
.join("\n");
|
|
52
85
|
}
|
|
53
86
|
|
|
54
|
-
|
|
55
|
-
const
|
|
87
|
+
/** Every theme's compiled declaration lines, keyed by theme name, in registry order. */
|
|
88
|
+
const compiled = new Map();
|
|
89
|
+
for (const theme of themes) {
|
|
90
|
+
compiled.set(
|
|
91
|
+
theme.name,
|
|
92
|
+
declarations(await buildCss(theme.source, `tokens.${theme.name}.css`)),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
56
95
|
rmSync(buildDir, { recursive: true, force: true });
|
|
57
96
|
|
|
97
|
+
const themeBlocks = overlays
|
|
98
|
+
.map(
|
|
99
|
+
(theme) => `
|
|
100
|
+
/* ${theme.label}: an explicit choice via <html data-theme="${theme.name}">.
|
|
101
|
+
${theme.description} */
|
|
102
|
+
[data-theme='${theme.name}'] {
|
|
103
|
+
color-scheme: ${theme.appearance};
|
|
104
|
+
${compiled.get(theme.name)}
|
|
105
|
+
}
|
|
106
|
+
`,
|
|
107
|
+
)
|
|
108
|
+
.join("");
|
|
109
|
+
|
|
110
|
+
const systemDark = themes.find((theme) => theme.name === registry.systemDark);
|
|
111
|
+
|
|
58
112
|
const output = `/**
|
|
59
113
|
* Do not edit directly, this file was auto-generated.
|
|
60
114
|
*/
|
|
61
115
|
|
|
62
116
|
:root {
|
|
63
117
|
/* Opts native chrome (scrollbars, the <select> option popup, form controls,
|
|
64
|
-
text-field carets) into the
|
|
65
|
-
OS
|
|
66
|
-
color-scheme:
|
|
67
|
-
${
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/* Dark theme: an explicit user/app choice via <html data-theme="dark">. */
|
|
71
|
-
[data-theme='dark'] {
|
|
72
|
-
color-scheme: dark;
|
|
73
|
-
${dark}
|
|
118
|
+
text-field carets) into the ${base.appearance} palette so it never renders as foreign
|
|
119
|
+
OS-${base.appearance === "light" ? "dark" : "light"} chrome. Each theme block below sets its own. */
|
|
120
|
+
color-scheme: ${base.appearance};
|
|
121
|
+
${compiled.get(base.name)}
|
|
74
122
|
}
|
|
75
|
-
|
|
76
|
-
/*
|
|
123
|
+
${themeBlocks}
|
|
124
|
+
/* ${systemDark.label}: the OS preference, unless the app pinned any theme explicitly. */
|
|
77
125
|
@media (prefers-color-scheme: dark) {
|
|
78
|
-
:root:not([data-theme
|
|
79
|
-
color-scheme:
|
|
80
|
-
${
|
|
126
|
+
:root:not([data-theme]) {
|
|
127
|
+
color-scheme: ${systemDark.appearance};
|
|
128
|
+
${compiled.get(systemDark.name).replace(/^ {2}/gm, " ")}
|
|
81
129
|
}
|
|
82
130
|
}
|
|
83
131
|
`;
|
|
84
132
|
|
|
85
133
|
writeFileSync(join(packageRoot, "src", "tokens.css"), output);
|
|
86
|
-
console.log(
|
|
134
|
+
console.log(`wrote src/tokens.css (${themes.length} themes)`);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The manifest: the same tokens as machine-readable data, so a consumer does not have to
|
|
138
|
+
* parse a stylesheet to find out what exists.
|
|
139
|
+
*
|
|
140
|
+
* Three consumers need it and none of them could get it before. A theme editor had to
|
|
141
|
+
* hard-code its own token list, because `tokens.json` is Style-Dictionary-shaped and is not
|
|
142
|
+
* exported from the package — only the compiled CSS is. An agent editing a theme by hand had
|
|
143
|
+
* to infer names from whatever it found in `node_modules`, with no way to tell which tokens
|
|
144
|
+
* are safe to theme or which must stay legible against which. And a human had no list at all.
|
|
145
|
+
*
|
|
146
|
+
* Everything here is derived, never restated: the token set comes from the theme sources, the
|
|
147
|
+
* category from the token's own path, `themeable` from whether any non-base theme overrides it,
|
|
148
|
+
* and the pairings from `token-pairs.json` — the same file the contrast gate reads. Nothing in
|
|
149
|
+
* this file can disagree with the stylesheet beside it, because both are generated from the
|
|
150
|
+
* same input in the same run.
|
|
151
|
+
*/
|
|
152
|
+
function cssName(path) {
|
|
153
|
+
return `--${path.join("-")}`.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every leaf `{ value }` in a token tree, as `[cssName, { value, category }]`.
|
|
158
|
+
*
|
|
159
|
+
* The category is the source tree's own top-level family, not a re-split of the CSS name:
|
|
160
|
+
* `zIndex.base` is in the `zIndex` family even though it flattens to `--z-index-base`, and
|
|
161
|
+
* splitting the CSS name would have called that family `z`.
|
|
162
|
+
*/
|
|
163
|
+
function flatten(node, path = []) {
|
|
164
|
+
if (node && typeof node === "object" && "value" in node) {
|
|
165
|
+
return [[cssName(path), { value: String(node.value), category: path[0] }]];
|
|
166
|
+
}
|
|
167
|
+
if (!node || typeof node !== "object") return [];
|
|
168
|
+
return Object.entries(node).flatMap(([key, child]) => flatten(child, [...path, key]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sources = new Map(
|
|
172
|
+
themes.map((theme) => [theme.name, new Map(flatten(read(theme.source)))]),
|
|
173
|
+
);
|
|
174
|
+
const baseTokens = sources.get(base.name);
|
|
175
|
+
const pairs = read("token-pairs.json");
|
|
176
|
+
|
|
177
|
+
const manifest = {
|
|
178
|
+
$comment:
|
|
179
|
+
"Generated by scripts/build-tokens.mjs from themes.json, the per-theme token sources and " +
|
|
180
|
+
"token-pairs.json. Do not edit directly; regenerate with `npm run -w @terpjs/contract " +
|
|
181
|
+
"tokens`. CI fails on drift.",
|
|
182
|
+
// The themes a `values` key can name, so a consumer can build a theme picker from this file
|
|
183
|
+
// alone rather than hard-coding the list it happens to know about.
|
|
184
|
+
base: registry.base,
|
|
185
|
+
systemDark: registry.systemDark,
|
|
186
|
+
themes: themes.map(({ name, label, appearance, description }) => ({
|
|
187
|
+
name,
|
|
188
|
+
label,
|
|
189
|
+
appearance,
|
|
190
|
+
description,
|
|
191
|
+
})),
|
|
192
|
+
tokens: [...baseTokens.entries()].map(([name, token]) => ({
|
|
193
|
+
name,
|
|
194
|
+
category: token.category,
|
|
195
|
+
// Only the themes that actually declare the token, base always included. A theme absent
|
|
196
|
+
// here inherits the base value — the cascade the sheet performs, stated as data. A token
|
|
197
|
+
// present under the base alone is theme-invariant by design (space, radius, font, motion,
|
|
198
|
+
// z-index): declared once and inherited. Saying so is what stops an editor from offering a
|
|
199
|
+
// per-theme control that would have no effect.
|
|
200
|
+
values: Object.fromEntries(
|
|
201
|
+
themes
|
|
202
|
+
.map((theme) => [theme.name, sources.get(theme.name).get(name)?.value])
|
|
203
|
+
.filter(([, value]) => value !== undefined),
|
|
204
|
+
),
|
|
205
|
+
themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
|
|
206
|
+
})),
|
|
207
|
+
textPairs: pairs.textPairs,
|
|
208
|
+
// Both sections, because a consumer that can only see the text pairings would read the
|
|
209
|
+
// absence of a boundary pairing as "no requirement" rather than "held elsewhere".
|
|
210
|
+
nonTextPairs: pairs.nonTextPairs,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
writeFileSync(
|
|
214
|
+
join(packageRoot, "src", "tokens.manifest.json"),
|
|
215
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
216
|
+
);
|
|
217
|
+
console.log(
|
|
218
|
+
`wrote src/tokens.manifest.json (${manifest.tokens.length} tokens, ` +
|
|
219
|
+
`${manifest.themes.length} themes, ${manifest.textPairs.length} text pairs, ` +
|
|
220
|
+
`${manifest.nonTextPairs.length} non-text pairs)`,
|
|
221
|
+
);
|
package/src/css-rules.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A minimal CSS rule reader for the token-sheet gates.
|
|
2
|
+
//
|
|
3
|
+
// Not a general parser and not exported from the package (`exports` in package.json
|
|
4
|
+
// publishes only the entry, the schema, the OpenAPI document and the sheet itself). It
|
|
5
|
+
// exists because two gates read `tokens.css` and a naive "slice to the next `}`" scan is
|
|
6
|
+
// wrong in a way that passes: a value containing a brace, or an `@media` wrapper, ends the
|
|
7
|
+
// block early and the gate then asserts about a fragment.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every `selector { … }` rule in `css`, flattened.
|
|
11
|
+
*
|
|
12
|
+
* An `@media` (or any other conditional group) contributes its inner rules rather than
|
|
13
|
+
* itself, so a nested `:root` is reachable by selector. A statement at-rule — `@charset`,
|
|
14
|
+
* `@import`, `@layer a, b;` — declares no block and is skipped; without that, its trailing
|
|
15
|
+
* `;` would let the scan run on to the *next* rule's brace and silently merge two rules.
|
|
16
|
+
*
|
|
17
|
+
* Each rule carries its custom properties as `declarations` and *every* declaration —
|
|
18
|
+
* standard properties included — as `properties`. The two are separate because the token
|
|
19
|
+
* gates want only custom properties nearly everywhere (a theme block is a set of tokens),
|
|
20
|
+
* and exactly one gate wants a standard one: `color-scheme`, which every theme block must
|
|
21
|
+
* declare and which is invisible to a custom-property reader.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} css
|
|
24
|
+
* @returns {{ selector: string, declarations: Map<string, string>, properties: Map<string, string> }[]}
|
|
25
|
+
*/
|
|
26
|
+
export function parseRules(css) {
|
|
27
|
+
const source = stripComments(css);
|
|
28
|
+
const rules = [];
|
|
29
|
+
let index = 0;
|
|
30
|
+
while (index < source.length) {
|
|
31
|
+
const open = source.indexOf("{", index);
|
|
32
|
+
if (open === -1) break;
|
|
33
|
+
// A `;` before the next `{` means the run of text is a statement at-rule, not a
|
|
34
|
+
// selector: consume it and carry on rather than treating the following block as its own.
|
|
35
|
+
const statementEnd = source.indexOf(";", index);
|
|
36
|
+
if (statementEnd !== -1 && statementEnd < open) {
|
|
37
|
+
index = statementEnd + 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const selector = source.slice(index, open).trim();
|
|
41
|
+
let depth = 1;
|
|
42
|
+
let cursor = open + 1;
|
|
43
|
+
// Quote-aware: a brace inside a quoted value (a font stack, a `url()`, a `content`
|
|
44
|
+
// string) is data, not structure. Counting it would end the block early and leave every
|
|
45
|
+
// later declaration in the rule unread — silently, because the fragment still parses.
|
|
46
|
+
let quote = "";
|
|
47
|
+
while (cursor < source.length && depth > 0) {
|
|
48
|
+
const character = source[cursor];
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (character === "\\") cursor += 1;
|
|
51
|
+
else if (character === quote) quote = "";
|
|
52
|
+
} else if (character === '"' || character === "'") {
|
|
53
|
+
quote = character;
|
|
54
|
+
} else if (character === "{") depth += 1;
|
|
55
|
+
else if (character === "}") depth -= 1;
|
|
56
|
+
cursor += 1;
|
|
57
|
+
}
|
|
58
|
+
const body = source.slice(open + 1, cursor - 1);
|
|
59
|
+
if (selector.startsWith("@")) {
|
|
60
|
+
rules.push(...parseRules(body));
|
|
61
|
+
} else if (selector) {
|
|
62
|
+
rules.push({
|
|
63
|
+
selector,
|
|
64
|
+
declarations: parseDeclarations(body),
|
|
65
|
+
properties: parseProperties(body),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
index = cursor;
|
|
69
|
+
}
|
|
70
|
+
return rules;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `--token: value` custom properties declared directly in a rule body, in source order.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} body
|
|
77
|
+
* @returns {Map<string, string>}
|
|
78
|
+
*/
|
|
79
|
+
export function parseDeclarations(body) {
|
|
80
|
+
const declarations = new Map();
|
|
81
|
+
for (const match of body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
|
82
|
+
declarations.set(match[1], match[2].trim());
|
|
83
|
+
}
|
|
84
|
+
return declarations;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every declaration directly in a rule body, standard properties included, in source order.
|
|
89
|
+
*
|
|
90
|
+
* Only `color-scheme` is read through this today, but a reader that special-cased that one
|
|
91
|
+
* name would quietly stop working the moment a second standard property mattered.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} body
|
|
94
|
+
* @returns {Map<string, string>}
|
|
95
|
+
*/
|
|
96
|
+
export function parseProperties(body) {
|
|
97
|
+
const properties = new Map();
|
|
98
|
+
for (const match of body.matchAll(/([a-zA-Z-][\w-]*)\s*:\s*([^;]+);/g)) {
|
|
99
|
+
properties.set(match[1], match[2].trim());
|
|
100
|
+
}
|
|
101
|
+
return properties;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `css` with `/* … *\/` comments removed. */
|
|
105
|
+
export function stripComments(css) {
|
|
106
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
107
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
parseDeclarations,
|
|
5
|
+
parseProperties,
|
|
6
|
+
parseRules,
|
|
7
|
+
stripComments,
|
|
8
|
+
} from "./css-rules.js";
|
|
9
|
+
|
|
10
|
+
// The reader the two token gates are built on. Every case here is a shape that made an
|
|
11
|
+
// earlier, naive version of this code assert about a fragment of a rule while still
|
|
12
|
+
// reporting green — which is the only failure mode that matters for a gate.
|
|
13
|
+
|
|
14
|
+
describe("parseRules", () => {
|
|
15
|
+
it("reads a flat rule's custom properties", () => {
|
|
16
|
+
const rules = parseRules(":root { --a: 1; --b: 2; }");
|
|
17
|
+
expect(rules).toHaveLength(1);
|
|
18
|
+
expect(rules[0].selector).toBe(":root");
|
|
19
|
+
expect([...rules[0].declarations]).toEqual([
|
|
20
|
+
["--a", "1"],
|
|
21
|
+
["--b", "2"],
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("flattens a conditional group to its inner rules", () => {
|
|
26
|
+
// The sheet's OS-preference dark theme is a `:root:not(…)` nested in `@media`. A reader
|
|
27
|
+
// that returned the `@media` itself would find no declarations and the completeness
|
|
28
|
+
// gate would pass by asserting about nothing.
|
|
29
|
+
const rules = parseRules(
|
|
30
|
+
"@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --a: 9; } }",
|
|
31
|
+
);
|
|
32
|
+
expect(rules.map((rule) => rule.selector)).toEqual([":root:not([data-theme='light'])"]);
|
|
33
|
+
expect(rules[0].declarations.get("--a")).toBe("9");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("skips a statement at-rule instead of swallowing the next rule", () => {
|
|
37
|
+
// `@layer tokens;` declares no block. Scanning to the next `{` would make the selector
|
|
38
|
+
// `@layer tokens; :root`, which starts with `@`, so the reader would recurse into
|
|
39
|
+
// `:root`'s body and drop the rule — `:root` then does not exist and every downstream
|
|
40
|
+
// assertion is about a sheet with no light theme.
|
|
41
|
+
const rules = parseRules("@layer tokens;\n:root { --a: 1; }");
|
|
42
|
+
expect(rules.map((rule) => rule.selector)).toEqual([":root"]);
|
|
43
|
+
expect(rules[0].declarations.get("--a")).toBe("1");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("skips a charset or import line the same way", () => {
|
|
47
|
+
const rules = parseRules('@charset "utf-8";\n@import "x.css";\n:root { --a: 1; }');
|
|
48
|
+
expect(rules.map((rule) => rule.selector)).toEqual([":root"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not end a block on a brace inside a value", () => {
|
|
52
|
+
// Not hypothetical for a generated sheet: a font stack or a `url()` can carry one.
|
|
53
|
+
const rules = parseRules(':root { --a: "}"; --b: 2; }');
|
|
54
|
+
expect(rules).toHaveLength(1);
|
|
55
|
+
expect(rules[0].declarations.get("--b")).toBe("2");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores commented-out declarations", () => {
|
|
59
|
+
const rules = parseRules(":root { /* --a: 1; */ --b: 2; }");
|
|
60
|
+
expect([...rules[0].declarations.keys()]).toEqual(["--b"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns nothing for a sheet with no rules", () => {
|
|
64
|
+
expect(parseRules("")).toEqual([]);
|
|
65
|
+
expect(parseRules("/* just a comment */")).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("parseProperties", () => {
|
|
70
|
+
it("reads standard properties alongside custom ones", () => {
|
|
71
|
+
// `color-scheme` is the reason this exists: every theme block must declare it, and a
|
|
72
|
+
// custom-property reader cannot see it, so the gate that checks it would pass vacuously.
|
|
73
|
+
const properties = parseProperties("color-scheme: dark; --a: 1;");
|
|
74
|
+
expect([...properties]).toEqual([
|
|
75
|
+
["color-scheme", "dark"],
|
|
76
|
+
["--a", "1"],
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not read a property name out of a value", () => {
|
|
81
|
+
// A value containing a colon — a `url(https://…)`, a media condition — would otherwise
|
|
82
|
+
// register as a second property and the caller would be reading noise.
|
|
83
|
+
expect([...parseProperties("background: url(https://x/y.png);").keys()]).toEqual([
|
|
84
|
+
"background",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is exposed on every rule the reader returns", () => {
|
|
89
|
+
const [rule] = parseRules(":root { color-scheme: light; --a: 1; }");
|
|
90
|
+
expect([...rule.declarations.keys()]).toEqual(["--a"]);
|
|
91
|
+
expect(rule.properties.get("color-scheme")).toBe("light");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("parseDeclarations", () => {
|
|
96
|
+
it("keeps the last value when a property repeats", () => {
|
|
97
|
+
// The cascade's own behaviour: a duplicated property in one block resolves to the last.
|
|
98
|
+
expect(parseDeclarations("--a: 1; --a: 2;").get("--a")).toBe("2");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores ordinary properties", () => {
|
|
102
|
+
expect([...parseDeclarations("color: red; --a: 1;").keys()]).toEqual(["--a"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("stripComments", () => {
|
|
107
|
+
it("removes multi-line comments", () => {
|
|
108
|
+
expect(stripComments("a/* x\ny */b")).toBe("ab");
|
|
109
|
+
});
|
|
110
|
+
});
|