@cssints/devtools 0.0.0-stage → 0.1.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 CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@cssints/devtools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A dev overlay inspector for cssints: an element's classes by the call that made them, the cascade of the sheet, checked against the browser",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/alexeiaccio/css-in-ts-compiler.git",
9
+ "directory": "packages/devtools"
10
+ },
11
+ "homepage": "https://cssints.dev",
12
+ "files": [
13
+ "dist",
14
+ "src/core"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ "./overlay": "./dist/overlay.js",
20
+ "./core": {
21
+ "types": "./src/core/index.ts",
22
+ "default": "./src/core/index.ts"
23
+ },
24
+ "./model": {
25
+ "types": "./src/core/model.ts",
26
+ "default": "./src/core/model.ts"
27
+ },
28
+ "./inject": "./src/core/inject.ts"
29
+ },
30
+ "dependencies": {
31
+ "@cssints/css-grammar": "^0.1.0",
32
+ "effect": "4.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "cssints": "^0.1.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,379 @@
1
+ // The cascade of cssints' sheet for one element (docs/research/devtools.md "Our devtools"): the rules that are the
2
+ // element's, which of them apply now (the page decides the at-rules and the states), the winner per longhand by layer
3
+ // rank, specificity and the sheet's order, and each winner checked against the browser's computed style.
4
+ import { longhands } from "@cssints/css-grammar/longhands";
5
+ import { Effect } from "effect";
6
+
7
+ import { cmpSpec, type Decl, specificity, splitTop, stripStates, type SheetRule } from "./css.ts";
8
+ import { type Observed, Page, type Probe } from "./page.ts";
9
+ import type { Model, Site } from "./table.ts";
10
+
11
+ export type Spec = [number, number, number];
12
+
13
+ export interface Condition {
14
+ text: string;
15
+ holds: boolean;
16
+ }
17
+
18
+ /** One declaration of a rule that is the element's. */
19
+ export interface Candidate {
20
+ rule: number;
21
+ selector: string;
22
+ layer: string;
23
+ wrap: readonly string[];
24
+ specificity: Spec;
25
+ decl: Decl;
26
+ /** the at-rules, then the states of the selector */
27
+ conditions: Condition[];
28
+ applies: boolean;
29
+ }
30
+
31
+ export interface Verdict {
32
+ prop: string;
33
+ status: "ok" | "mismatch" | "unverified";
34
+ computed: string;
35
+ expected: string;
36
+ reason?: string;
37
+ }
38
+
39
+ /** The longhands one declaration wins, with what it beat and what does not apply now. */
40
+ export interface Row {
41
+ /** the winner's property, or the property of declarations of which none applies */
42
+ prop: string;
43
+ longhands: string[];
44
+ winner?: Candidate;
45
+ /** longhands of the winner's property that another declaration wins */
46
+ lost: { prop: string; by: Candidate }[];
47
+ overridden: Candidate[];
48
+ inactive: Candidate[];
49
+ verdicts: Verdict[];
50
+ }
51
+
52
+ export interface ClassRow {
53
+ cls: string;
54
+ kind: "class" | "marker" | "group" | "other";
55
+ plugin?: string;
56
+ layer?: string;
57
+ decls: Decl[];
58
+ conditions: Condition[];
59
+ active: boolean;
60
+ tokens: readonly string[];
61
+ }
62
+
63
+ export interface Group {
64
+ site?: Site;
65
+ classes: ClassRow[];
66
+ }
67
+
68
+ export interface TokenRow {
69
+ name: string;
70
+ path?: string;
71
+ declared?: string;
72
+ computed: string;
73
+ /** has an `@property` rule: with no source, its value is the initial one */
74
+ registered: boolean;
75
+ /** the rule that sets it on the element or the nearest ancestor */
76
+ source?: { selector: string; layer: string; wrap: readonly string[]; on: string };
77
+ }
78
+
79
+ export interface Inspection {
80
+ element: string;
81
+ root: string;
82
+ groups: Group[];
83
+ rows: Row[];
84
+ tokens: TokenRow[];
85
+ }
86
+
87
+ const lh = (prop: string): readonly string[] => longhands[prop] ?? [prop];
88
+ const isScope = (w: string) => /^@scope\b/i.test(w);
89
+ const CLASSY = /\._[\w-]/;
90
+ const UNLAYERED = 1e9;
91
+ // Computed values of these depend on the element's content, which the clone does not have.
92
+ const LAYOUT =
93
+ /^(width|height|inline-size|block-size|min-width|min-height|max-width|max-height|flex-basis|grid-template-columns|grid-template-rows)$/;
94
+
95
+ const layerKey = (model: Model, layer: string) =>
96
+ layer === "" ? UNLAYERED : (model.rank.get(layer) ?? model.rank.get(layer.split(".")[0]!) ?? -1);
97
+
98
+ // Whether `a` beats `b` in the cascade: importance, then the layer (reversed for `!important`), specificity, order.
99
+ export const beats = (model: Model, a: Candidate, b: Candidate): boolean => {
100
+ if (a.decl.important !== b.decl.important) return a.decl.important;
101
+ const la = layerKey(model, a.layer);
102
+ const lb = layerKey(model, b.layer);
103
+ const byLayer = a.decl.important ? lb - la : la - lb;
104
+ return (byLayer || cmpSpec(a.specificity, b.specificity) || a.rule - b.rule) > 0;
105
+ };
106
+
107
+ // The declarations of the rules that are the element's (all, or those that set `only`), with whether each applies now.
108
+ const candidates = Effect.fnUntraced(function* (model: Model, el: Element, only?: string) {
109
+ const page = yield* Page;
110
+ const rules = model.sheet.rules.filter(
111
+ (r) => r.decls.length && (!only || r.decls.some((d) => d.prop === only)),
112
+ );
113
+ const items = (r: SheetRule) =>
114
+ splitTop(r.selector, ",").map((s) => ({
115
+ full: s,
116
+ ...(CLASSY.test(s) ? stripStates(s) : { base: s, states: [] as string[] }),
117
+ }));
118
+ // which rules are the element's: the selector without its states matches (inside its @scope, if any)
119
+ const scoped = rules.filter((r) => r.wrap.some(isScope));
120
+ const scopedProbes: Probe[] = scoped.flatMap((r) => {
121
+ const wrap = r.wrap.filter(isScope);
122
+ return [
123
+ {
124
+ wrap,
125
+ selector: items(r)
126
+ .map((x) => x.base)
127
+ .join(", "),
128
+ },
129
+ { wrap, selector: r.selector },
130
+ ];
131
+ });
132
+ const scopedHits = yield* page.probe(el, scopedProbes);
133
+ const mine: { rule: SheetRule; specificity: Spec; matched: boolean; states: string[] }[] = [];
134
+ for (const r of rules) {
135
+ const its = items(r);
136
+ const s = scoped.indexOf(r);
137
+ if (s >= 0) {
138
+ if (!scopedHits[2 * s]) continue;
139
+ mine.push({
140
+ rule: r,
141
+ specificity: its
142
+ .map((x) => specificity(x.full))
143
+ .reduce((m, x) => (cmpSpec(x, m) > 0 ? x : m)),
144
+ matched: !!scopedHits[2 * s + 1],
145
+ states: its.flatMap((x) => x.states),
146
+ });
147
+ continue;
148
+ }
149
+ const base: typeof its = [];
150
+ for (const x of its) if (yield* page.matches(el, x.base)) base.push(x);
151
+ if (!base.length) continue;
152
+ const full: typeof its = [];
153
+ for (const x of base) if (yield* page.matches(el, x.full)) full.push(x);
154
+ const specs = (full.length ? full : base).map((x) => specificity(x.full));
155
+ mine.push({
156
+ rule: r,
157
+ specificity: specs.reduce((m, x) => (cmpSpec(x, m) > 0 ? x : m)),
158
+ matched: full.length > 0,
159
+ states: base.flatMap((x) => x.states),
160
+ });
161
+ }
162
+ // the at-rules, each on its own, for this element
163
+ const preludes = [...new Set(mine.flatMap((m) => m.rule.wrap.filter((w) => !isScope(w))))];
164
+ const held = yield* page.probe(
165
+ el,
166
+ preludes.map((w) => ({ wrap: [w] })),
167
+ );
168
+ const holds = new Map(preludes.map((w, i) => [w, !!held[i]]));
169
+ return mine.flatMap(({ rule, specificity: spec, matched, states }) => {
170
+ const conditions: Condition[] = [
171
+ ...rule.wrap.map((w) => ({ text: w, holds: isScope(w) || !!holds.get(w) })),
172
+ ...[...new Set(states)].map((text) => ({ text, holds: matched })),
173
+ ];
174
+ const applies = matched && conditions.every((c) => c.holds);
175
+ return rule.decls.flatMap((decl) =>
176
+ only && decl.prop !== only
177
+ ? []
178
+ : [
179
+ {
180
+ rule: rule.index,
181
+ selector: rule.selector,
182
+ layer: rule.layer,
183
+ wrap: rule.wrap,
184
+ specificity: spec,
185
+ decl,
186
+ conditions,
187
+ applies,
188
+ } satisfies Candidate,
189
+ ],
190
+ );
191
+ });
192
+ });
193
+
194
+ const verdictOf = (o: Observed, winner: Candidate, root: string): Verdict => {
195
+ const base = { prop: o.prop, computed: o.computed, expected: o.expected };
196
+ if (o.computed === o.expected) return { ...base, status: "ok" };
197
+ if (o.inline)
198
+ return {
199
+ ...base,
200
+ status: "mismatch",
201
+ reason: `the element's style attribute sets ${o.prop}: ${o.inline}`,
202
+ };
203
+ const foreign =
204
+ o.foreign.find((f) => f.important) ?? o.foreign.find((f) => f.layer === "") ?? o.foreign[0];
205
+ if (foreign)
206
+ return {
207
+ ...base,
208
+ status: "mismatch",
209
+ reason: `${foreign.selector} { ${o.prop}: ${foreign.value}${foreign.important ? " !important" : ""} } in ${foreign.source.replace(`${root}/`, "")} beats it: ${
210
+ foreign.layer
211
+ ? `layer ${foreign.layer}`
212
+ : "a rule outside every layer beats every layer, cssints' too"
213
+ }`,
214
+ };
215
+ if (o.animated)
216
+ return {
217
+ ...base,
218
+ status: "mismatch",
219
+ reason: "an animation or a transition is running on the element",
220
+ };
221
+ if (LAYOUT.test(o.prop))
222
+ return {
223
+ ...base,
224
+ status: "unverified",
225
+ reason: `${o.prop} depends on the element's content and layout, which the probe does not have`,
226
+ };
227
+ return {
228
+ ...base,
229
+ status: "mismatch",
230
+ reason: `the browser computes ${o.computed}; ${winner.selector} would give ${o.expected}`,
231
+ };
232
+ };
233
+
234
+ // The winner of one property on one element, among the rules that set it.
235
+ const winnerOf = Effect.fnUntraced(function* (model: Model, el: Element, prop: string) {
236
+ let best: Candidate | undefined;
237
+ for (const c of yield* candidates(model, el, prop))
238
+ if (c.applies && (!best || beats(model, c, best))) best = c;
239
+ return best;
240
+ });
241
+
242
+ // The rule that gives the element a custom property: on it, or inherited from the nearest ancestor that has one.
243
+ const sourceOf = Effect.fnUntraced(function* (model: Model, el: Element, name: string) {
244
+ const page = yield* Page;
245
+ const inherits = model.sheet.inherits.get(name) ?? true;
246
+ let node: Element | undefined = el;
247
+ for (let depth = 0; node && depth < 64; depth++) {
248
+ const w = yield* winnerOf(model, node, name);
249
+ if (w)
250
+ return { selector: w.selector, layer: w.layer, wrap: w.wrap, on: yield* page.label(node) };
251
+ if (!inherits) return undefined;
252
+ node = yield* page.parent(node);
253
+ }
254
+ return undefined;
255
+ });
256
+
257
+ const VAR = /var\(\s*(--[\w-]+)/g;
258
+
259
+ /** The cssints classes of an element grouped by the call that made them, the cascade per property, and its tokens. */
260
+ export const inspect = Effect.fn("devtools.inspect")(function* (model: Model, el: Element) {
261
+ const page = yield* Page;
262
+ const { table } = model;
263
+ const all = yield* candidates(model, el);
264
+ const applying = all.filter((c) => c.applies);
265
+ const waiting = all.filter((c) => !c.applies);
266
+
267
+ // per longhand, the winner
268
+ const winners = new Map<string, Candidate>();
269
+ for (const c of applying)
270
+ for (const l of lh(c.decl.prop)) {
271
+ const w = winners.get(l);
272
+ if (!w || beats(model, c, w)) winners.set(l, c);
273
+ }
274
+ const won = new Map<Candidate, string[]>();
275
+ for (const [l, c] of winners) (won.get(c) ?? won.set(c, []).get(c)!).push(l);
276
+ const touches = (c: Candidate, ls: readonly string[]) =>
277
+ lh(c.decl.prop).some((l) => ls.includes(l));
278
+ const rows: Row[] = [];
279
+ for (const [c, ls] of won) {
280
+ const verdicts = (yield* page.verify(el, c.decl.prop, c.selector, c.decl.value, ls)).map((o) =>
281
+ verdictOf(o, c, table.root),
282
+ );
283
+ rows.push({
284
+ prop: c.decl.prop,
285
+ longhands: ls,
286
+ winner: c,
287
+ lost: lh(c.decl.prop).flatMap((l) => {
288
+ const by = winners.get(l);
289
+ return by && by !== c ? [{ prop: l, by }] : [];
290
+ }),
291
+ overridden: applying.filter((o) => o !== c && touches(o, ls)),
292
+ inactive: waiting.filter((o) => touches(o, ls)),
293
+ verdicts,
294
+ });
295
+ }
296
+ // declarations of which none applies now (a hover colour with no base colour)
297
+ const idle = new Map<string, Candidate[]>();
298
+ for (const c of waiting)
299
+ if (!lh(c.decl.prop).some((l) => winners.has(l)))
300
+ (idle.get(c.decl.prop) ?? idle.set(c.decl.prop, []).get(c.decl.prop)!).push(c);
301
+ for (const [prop, inactive] of idle)
302
+ rows.push({ prop, longhands: [...lh(prop)], lost: [], overridden: [], inactive, verdicts: [] });
303
+ rows.sort(
304
+ (a, b) =>
305
+ Number(a.prop.startsWith("--")) - Number(b.prop.startsWith("--")) ||
306
+ (a.prop < b.prop ? -1 : a.prop > b.prop ? 1 : 0),
307
+ );
308
+
309
+ // the classes, grouped by the call that made them
310
+ const classes = yield* page.classes(el);
311
+ const byRule = new Map(all.map((c) => [c.rule, c]));
312
+ const classRow = (cls: string): ClassRow => {
313
+ const rec = table.classes[cls];
314
+ if (!rec) return { cls, kind: "other", decls: [], conditions: [], active: true, tokens: [] };
315
+ const own = rec.kind === "class" ? (model.rulesOf.get(cls) ?? []) : [];
316
+ const seen = own.flatMap((i) => byRule.get(i) ?? []);
317
+ const first = own[0] === undefined ? undefined : model.sheet.rules[own[0]];
318
+ const states = first ? splitTop(first.selector, ",").flatMap((s) => stripStates(s).states) : [];
319
+ return {
320
+ cls,
321
+ kind: rec.kind,
322
+ ...(rec.plugin ? { plugin: rec.plugin } : {}),
323
+ layer: rec.layer,
324
+ decls: own.flatMap((i) => model.sheet.rules[i]?.decls ?? []),
325
+ conditions: seen[0]?.conditions ?? [
326
+ ...rec.wrap.map((text) => ({ text, holds: false })),
327
+ ...states.map((text) => ({ text, holds: false })),
328
+ ],
329
+ active: rec.kind !== "class" || seen.some((c) => c.applies),
330
+ tokens: rec.tokens,
331
+ };
332
+ };
333
+ const groups: Group[] = [];
334
+ let left = classes.filter((c) => table.classes[c]);
335
+ while (left.length) {
336
+ let best: { site: Site; n: number } | undefined;
337
+ for (const c of left)
338
+ for (const i of table.classes[c]!.sites) {
339
+ const site = table.sites[i]!;
340
+ const n = site.classes.filter((x) => left.includes(x)).length;
341
+ if (!best || n > best.n || (n === best.n && site.classes.length < best.site.classes.length))
342
+ best = { site, n };
343
+ }
344
+ if (!best) break;
345
+ const site = best.site;
346
+ groups.push({ site, classes: left.filter((c) => site.classes.includes(c)).map(classRow) });
347
+ left = left.filter((c) => !site.classes.includes(c));
348
+ }
349
+ const rest = classes.filter((c) => !groups.some((g) => g.classes.some((r) => r.cls === c)));
350
+ if (rest.length) groups.push({ classes: rest.map(classRow) });
351
+
352
+ // the tokens the element's rules read
353
+ const names = [
354
+ ...new Set([
355
+ ...all.flatMap((c) => [...c.decl.value.matchAll(VAR)].map((m) => m[1]!)),
356
+ ...classes.flatMap((c) => table.classes[c]?.tokens ?? []),
357
+ ]),
358
+ ].sort();
359
+ const computed = yield* page.custom(el, names);
360
+ const tokens: TokenRow[] = [];
361
+ for (const [i, name] of names.entries()) {
362
+ const def = table.tokens[name];
363
+ const source = yield* sourceOf(model, el, name);
364
+ tokens.push({
365
+ name,
366
+ ...(def ? { path: def.path, declared: def.value } : {}),
367
+ computed: computed[i] ?? "",
368
+ registered: model.sheet.inherits.has(name),
369
+ ...(source ? { source } : {}),
370
+ });
371
+ }
372
+ return {
373
+ element: yield* page.label(el),
374
+ root: table.root,
375
+ groups,
376
+ rows,
377
+ tokens,
378
+ } satisfies Inspection;
379
+ });
@@ -0,0 +1,219 @@
1
+ // The sheet as cssints prints it, read back into rules: layer, at-rules, selector, declarations, and the sheet's index.
2
+ // Plus what the cascade needs of a selector: its specificity, and the selector without its states. Plain code, called per
3
+ // rule inside the Effect boundary of `parseSheet` and `inspect` (the hot path of an inspection).
4
+ import { Effect } from "effect";
5
+
6
+ export interface Decl {
7
+ prop: string;
8
+ value: string;
9
+ important: boolean;
10
+ }
11
+
12
+ export interface SheetRule {
13
+ /** the place in the sheet: later wins at equal layer and specificity */
14
+ index: number;
15
+ /** the top-level layer path (`_.b`, `viewer`); "" outside every layer */
16
+ layer: string;
17
+ /** the at-rule preludes around the rule, outermost first, @layer excluded */
18
+ wrap: string[];
19
+ selector: string;
20
+ decls: Decl[];
21
+ }
22
+
23
+ export interface Sheet {
24
+ rules: SheetRule[];
25
+ /** custom properties registered by `@property`, and whether they inherit */
26
+ inherits: Map<string, boolean>;
27
+ }
28
+
29
+ const WRAPS = /^@(media|supports|container|scope|starting-style)\b/i;
30
+
31
+ // The index after the bracket that closes the one at `i` (`{`, `(` or `[`), skipping strings.
32
+ const close = (s: string, i: number) => {
33
+ let depth = 0;
34
+ for (let j = i; j < s.length; j++) {
35
+ const ch = s[j]!;
36
+ if (ch === '"' || ch === "'") j = skipString(s, j);
37
+ else if (ch === "{" || ch === "(" || ch === "[") depth++;
38
+ else if (ch === "}" || ch === ")" || ch === "]") if (--depth === 0) return j + 1;
39
+ }
40
+ return s.length;
41
+ };
42
+ const skipString = (s: string, i: number) => {
43
+ for (let j = i + 1; j < s.length; j++) {
44
+ if (s[j] === "\\") j++;
45
+ else if (s[j] === s[i]) return j;
46
+ }
47
+ return s.length;
48
+ };
49
+
50
+ // Splits at `sep` outside brackets and strings.
51
+ export const splitTop = (s: string, sep: string): string[] => {
52
+ const out: string[] = [];
53
+ let depth = 0;
54
+ let from = 0;
55
+ for (let j = 0; j < s.length; j++) {
56
+ const ch = s[j]!;
57
+ if (ch === '"' || ch === "'") j = skipString(s, j);
58
+ else if (ch === "(" || ch === "[" || ch === "{") depth++;
59
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
60
+ else if (ch === sep && depth === 0) {
61
+ out.push(s.slice(from, j));
62
+ from = j + 1;
63
+ }
64
+ }
65
+ out.push(s.slice(from));
66
+ return out.flatMap((x) => (x.trim() ? [x.trim()] : []));
67
+ };
68
+
69
+ export const parseDecls = (body: string): Decl[] =>
70
+ splitTop(body, ";").flatMap((d) => {
71
+ const colon = d.indexOf(":");
72
+ if (colon <= 0) return [];
73
+ const name = d.slice(0, colon).trim();
74
+ let value = d.slice(colon + 1).trim();
75
+ const important = /!\s*important$/i.test(value);
76
+ if (important) value = value.replace(/\s*!\s*important$/i, "");
77
+ return [{ prop: name.startsWith("--") ? name : name.toLowerCase(), value, important }];
78
+ });
79
+
80
+ const parseBlock = (s: string, layer: string, wrap: string[], out: Sheet): void => {
81
+ let i = 0;
82
+ while (i < s.length) {
83
+ while (i < s.length && /\s/.test(s[i]!)) i++;
84
+ if (i >= s.length) return;
85
+ // the prelude: up to `{` or `;` at depth 0
86
+ let j = i;
87
+ while (j < s.length && s[j] !== "{" && s[j] !== ";") {
88
+ const ch = s[j]!;
89
+ j =
90
+ ch === '"' || ch === "'"
91
+ ? skipString(s, j) + 1
92
+ : ch === "(" || ch === "["
93
+ ? close(s, j)
94
+ : j + 1;
95
+ }
96
+ const prelude = s.slice(i, j).trim();
97
+ if (j >= s.length || s[j] === ";") {
98
+ i = j + 1;
99
+ continue;
100
+ }
101
+ const end = close(s, j);
102
+ const body = s.slice(j + 1, end - 1);
103
+ i = end;
104
+ if (/^@layer\b/i.test(prelude)) {
105
+ const name = prelude.slice(6).trim();
106
+ parseBlock(body, layer ? `${layer}.${name}` : name, wrap, out);
107
+ } else if (WRAPS.test(prelude)) parseBlock(body, layer, [...wrap, prelude], out);
108
+ else if (/^@property\b/i.test(prelude)) {
109
+ const inherits = parseDecls(body).find((d) => d.prop === "inherits");
110
+ out.inherits.set(prelude.slice(9).trim(), inherits?.value.trim() !== "false");
111
+ } else if (prelude.startsWith("@")) continue;
112
+ else {
113
+ // a style rule; nested rules in it (CSS nesting) are not read: cssints prints flat selectors
114
+ const flat = body.replace(/[^;{}]*\{[^]*\}/g, "");
115
+ out.rules.push({
116
+ index: out.rules.length,
117
+ layer,
118
+ wrap,
119
+ selector: prelude,
120
+ decls: parseDecls(flat),
121
+ });
122
+ }
123
+ }
124
+ };
125
+
126
+ /** The rules of a sheet in its order, with their layers and at-rules, and its `@property` registrations. */
127
+ export const parseSheet = Effect.fn("devtools.parseSheet")(function* (text: string) {
128
+ return yield* Effect.sync(() => {
129
+ const out: Sheet = { rules: [], inherits: new Map() };
130
+ parseBlock(text.replace(/\/\*[^]*?\*\//g, ""), "", [], out);
131
+ return out;
132
+ });
133
+ });
134
+
135
+ // ---------- selectors
136
+
137
+ type Spec = [number, number, number];
138
+ const ZERO: Spec = [0, 0, 0];
139
+ const add = (a: Spec, b: Spec): Spec => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
140
+ export const cmpSpec = (a: Spec, b: Spec): number => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
141
+ const max = (specs: Spec[]) => specs.reduce((m, x) => (cmpSpec(x, m) > 0 ? x : m), ZERO);
142
+
143
+ const IDENT = /^-?[\w-]+/;
144
+ const NAMED_ARGS = /^(is|not|has|matches)$/i;
145
+
146
+ // Specificity of one complex selector (not a list): `:where()` is 0, `:is()`, `:not()`, `:has()` their most specific argument.
147
+ export const specificity = (sel: string): Spec => {
148
+ let spec: Spec = [0, 0, 0];
149
+ for (let i = 0; i < sel.length; ) {
150
+ const ch = sel[i]!;
151
+ const rest = sel.slice(i + 1);
152
+ if (ch === "#") {
153
+ spec = add(spec, [1, 0, 0]);
154
+ i += 1 + (IDENT.exec(rest)?.[0].length ?? 0);
155
+ } else if (ch === ".") {
156
+ spec = add(spec, [0, 1, 0]);
157
+ i += 1 + (IDENT.exec(rest)?.[0].length ?? 0);
158
+ } else if (ch === "[") {
159
+ spec = add(spec, [0, 1, 0]);
160
+ i = close(sel, i);
161
+ } else if (ch === ":") {
162
+ const element = sel[i + 1] === ":";
163
+ const at = i + (element ? 2 : 1);
164
+ const name = IDENT.exec(sel.slice(at))?.[0] ?? "";
165
+ let next = at + name.length;
166
+ const args = sel[next] === "(" ? sel.slice(next + 1, close(sel, next) - 1) : undefined;
167
+ if (args !== undefined) next = close(sel, next);
168
+ if (element) spec = add(spec, [0, 0, 1]);
169
+ else if (/^where$/i.test(name)) spec = add(spec, ZERO);
170
+ else if (NAMED_ARGS.test(name) && args !== undefined)
171
+ spec = add(spec, max(splitTop(args, ",").map(specificity)));
172
+ else spec = add(spec, [0, 1, 0]);
173
+ i = next;
174
+ } else if (/[a-zA-Z]/.test(ch)) {
175
+ spec = add(spec, [0, 0, 1]);
176
+ i += IDENT.exec(sel.slice(i))?.[0].length ?? 1;
177
+ } else i++;
178
+ }
179
+ return spec;
180
+ };
181
+
182
+ // The pseudo-classes that say where an element is, not what state it is in: kept by `stripStates`.
183
+ const STRUCTURE =
184
+ /^(where|is|not|has|root|scope|host|first-child|last-child|only-child|first-of-type|last-of-type|only-of-type|empty|defined|nth-child|nth-last-child|nth-of-type|nth-last-of-type)$/i;
185
+
186
+ // A selector of a class rule without its states, and the states: `._x:hover` is `._x` and `:hover`,
187
+ // `:where(._g:hover) ._x` is `:where(._g) ._x` and `within :hover`, `._x[aria-current="page"]` is `._x` and the attribute.
188
+ export const stripStates = (sel: string, within = false): { base: string; states: string[] } => {
189
+ let base = "";
190
+ const states: string[] = [];
191
+ for (let i = 0; i < sel.length; ) {
192
+ const ch = sel[i]!;
193
+ if (ch === "[") {
194
+ const end = close(sel, i);
195
+ states.push(`${within ? "within " : ""}${sel.slice(i, end)}`);
196
+ i = end;
197
+ } else if (ch === ":" && sel[i + 1] !== ":") {
198
+ const name = IDENT.exec(sel.slice(i + 1))?.[0] ?? "";
199
+ const next = i + 1 + name.length;
200
+ const hasArgs = sel[next] === "(";
201
+ const end = hasArgs ? close(sel, next) : next;
202
+ if (STRUCTURE.test(name)) {
203
+ if (hasArgs && /^(where|is|not|has)$/i.test(name)) {
204
+ const inner = splitTop(sel.slice(next + 1, end - 1), ",").map((a) =>
205
+ stripStates(a, within || /^where$/i.test(name)),
206
+ );
207
+ inner.forEach((x) => states.push(...x.states));
208
+ base += `:${name}(${inner.map((x) => x.base || "*").join(", ")})`;
209
+ } else base += sel.slice(i, end);
210
+ } else states.push(`${within ? "within " : ""}${sel.slice(i, end)}`);
211
+ i = end;
212
+ } else {
213
+ base += ch;
214
+ i++;
215
+ }
216
+ }
217
+ base = base.trim();
218
+ return { base: /(^|[\s>+~])$/.test(base) ? `${base}*` : base, states };
219
+ };