buninu-browser 0.1.0 → 0.2.1

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.
Files changed (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -4,6 +4,9 @@
4
4
  * model and parser here are project-owned and intentionally cover only the
5
5
  * properties the current pixel renderer consumes.
6
6
  */
7
+ import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
8
+ import { selectorKeys } from "./selector-keys.js";
9
+
7
10
 
8
11
  const BLOCK_TAGS = new Set([
9
12
  "ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "BODY", "DD", "DETAILS", "DIV", "DL", "DT",
@@ -12,8 +15,17 @@ const BLOCK_TAGS = new Set([
12
15
  ]);
13
16
  const NONE_TAGS = new Set(["HEAD", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE"]);
14
17
  const INHERITED = new Set([
15
- "color", "font-family", "font-size", "font-weight", "line-height", "list-style-type", "text-align", "white-space",
16
- "visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse",
18
+ "color", "color-scheme", "font-family", "font-size", "font-weight", "line-height", "list-style-type", "text-align", "white-space",
19
+ "visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse", "pointer-events",
20
+ ]);
21
+ // Inherited properties whose computed value is copied from the parent as is.
22
+ // line-height is not: a unitless factor resolves against each element's own
23
+ // font size, so it goes through its specified form (copyProperty).
24
+ const INHERITED_FIELDS = new Map([
25
+ ["color", "color"], ["font-family", "fontFamily"], ["font-size", "fontSize"], ["font-weight", "fontWeight"],
26
+ ["list-style-type", "listStyleType"], ["text-align", "textAlign"], ["white-space", "whiteSpace"],
27
+ ["visibility", "visibility"], ["overflow-wrap", "overflowWrap"], ["word-break", "wordBreak"],
28
+ ["border-spacing", "borderSpacing"], ["border-collapse", "borderCollapse"], ["pointer-events", "pointerEvents"],
17
29
  ]);
18
30
  const LIST_STYLE_TYPES = new Set([
19
31
  "none", "disc", "circle", "square", "decimal", "decimal-leading-zero",
@@ -53,6 +65,9 @@ const PROPERTY_FIELDS = Object.freeze({
53
65
  "grid-row": ["gridRowStart", "gridRowEnd"],
54
66
  "grid-column": ["gridColumnStart", "gridColumnEnd"],
55
67
  "grid-template": ["gridTemplateColumns", "gridTemplateRows"],
68
+ "place-content": ["alignContent", "justifyContent"],
69
+ "place-items": ["alignItems", "justifyItems"],
70
+ "place-self": ["alignSelf", "justifySelf"],
56
71
  "line-height": ["lineHeight", "lineHeightFactor"],
57
72
  "list-style": ["listStyleType"],
58
73
  "border-radius": ["borderRadius"],
@@ -71,6 +86,7 @@ const INITIAL = Object.freeze({
71
86
  textDecorationColor: null,
72
87
  textDecorations: null,
73
88
  color: "rgba(0, 0, 0, 1)",
89
+ colorScheme: "light",
74
90
  backgroundColor: "rgba(0, 0, 0, 0)",
75
91
  backgroundImage: null,
76
92
  backgroundClip: "border-box",
@@ -102,6 +118,7 @@ const INITIAL = Object.freeze({
102
118
  flexDirection: "row",
103
119
  flexWrap: "nowrap",
104
120
  justifyContent: "normal",
121
+ alignContent: "normal",
105
122
  alignItems: "normal",
106
123
  alignSelf: "auto",
107
124
  gap: 0,
@@ -136,6 +153,8 @@ const INITIAL = Object.freeze({
136
153
  tableLayout: "auto",
137
154
  verticalAlign: "baseline",
138
155
  visibility: "visible",
156
+ // Hit testing only (inherited): "none" lets clicks pass through.
157
+ pointerEvents: "auto",
139
158
  // Breaking inside words (CSS Text 3 §5): only with these set.
140
159
  overflowWrap: "normal",
141
160
  wordBreak: "normal",
@@ -229,14 +248,26 @@ export class StyleEngine {
229
248
  #styles = new WeakMap();
230
249
  #customProperties = new WeakMap();
231
250
  #pseudoStyles = new WeakMap();
251
+ #document = null;
252
+ #sheetChanges = null;
253
+ /** Elements computed by the last compute(), and whether it was incremental. */
254
+ restyled = 0;
255
+ incremental = false;
232
256
  // Parsed and indexed rules for the last style sheet list and viewport:
233
257
  // restyles after script changes reuse them instead of re-parsing.
234
258
  #ruleCache = { styleSheets: null, key: "", index: null };
259
+ // Parsed rules by sheet text, for the viewport in #parsedSheetsKey.
260
+ #parsedSheets = new Map();
261
+ #parsedSheetsKey = "";
235
262
 
236
- compute(document, styleSheets = [], viewport = {}) {
237
- this.#styles = new WeakMap();
238
- this.#customProperties = new WeakMap();
239
- this.#pseudoStyles = new WeakMap();
263
+ /**
264
+ * Computes the document's styles. With `dirty` (elements whose subtree's
265
+ * styles may have changed), only those subtrees are restyled and every
266
+ * other element keeps the style it had: unchanged rules and viewport, the
267
+ * same document, and a styled root are required, else it restyles fully.
268
+ * `restyled` afterwards counts the elements this call computed.
269
+ */
270
+ compute(document, styleSheets = [], viewport = {}, dirty = null, selfOnly = null) {
240
271
  const previous = environment;
241
272
  environment = {
242
273
  viewportWidth: viewport.width ?? DEFAULT_ENVIRONMENT.viewportWidth,
@@ -244,9 +275,55 @@ export class StyleEngine {
244
275
  rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize,
245
276
  mobile: viewport.mobile === true,
246
277
  };
278
+ this.restyled = 0;
247
279
  try {
280
+ const before = this.#ruleCache.index;
248
281
  const rules = this.#rulesFor(styleSheets);
249
- if (document.documentElement) this.#visit(document.documentElement, null, null, rules);
282
+ const root = document.documentElement;
283
+ if (dirty !== null && rules !== before) {
284
+ const affected = this.#elementsForSheetChanges(document);
285
+ dirty = affected ? new Set([...dirty, ...affected]) : null;
286
+ }
287
+ const incremental = dirty !== null && this.#document === document
288
+ && Boolean(root) && this.#styles.has(root) && !dirty.has(root);
289
+ this.incremental = incremental;
290
+ if (!incremental) {
291
+ this.#styles = new WeakMap();
292
+ this.#customProperties = new WeakMap();
293
+ this.#pseudoStyles = new WeakMap();
294
+ this.#document = document;
295
+ if (root) this.#visit(root, null, null, rules);
296
+ return this;
297
+ }
298
+ // rem units resolve against the root's font size, which is unchanged.
299
+ environment = { ...environment, rootFontSize: this.#styles.get(root).fontSize };
300
+ // Restyle from the nearest ancestor that has a style (new subtrees have
301
+ // none); many roots inside one new subtree share that start.
302
+ const starts = new Set();
303
+ for (const element of topmostConnected(dirty, root)) {
304
+ let start = element;
305
+ while (start !== root && !this.#styles.has(start.parentElement)) start = start.parentElement;
306
+ starts.add(start);
307
+ }
308
+ if (starts.has(root) || selfOnly?.has(root)) {
309
+ this.#visit(root, null, null, rules);
310
+ return this;
311
+ }
312
+ const topmost = topmostConnected(starts, root);
313
+ for (const start of topmost) {
314
+ const parent = start.parentElement;
315
+ const ancestors = this.#ancestorsOf(start);
316
+ this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
317
+ }
318
+ // Elements whose own matches may have changed (:empty, :first-child,
319
+ // ...) and that no restyled subtree covered.
320
+ const covered = new Set(topmost);
321
+ for (const element of selfOnly ?? []) {
322
+ if (!element || element.nodeType !== 1 || !root.contains(element) || !this.#styles.has(element.parentElement)) continue;
323
+ let inside = false;
324
+ for (let node = element; node && !inside; node = node.parentElement) inside = covered.has(node);
325
+ if (!inside) this.#restyleSelf(element, rules);
326
+ }
250
327
  } finally {
251
328
  environment = previous;
252
329
  }
@@ -256,15 +333,91 @@ export class StyleEngine {
256
333
  #rulesFor(styleSheets) {
257
334
  const key = `${environment.viewportWidth}x${environment.viewportHeight}${environment.mobile ? ":mobile" : ""}`;
258
335
  const cache = this.#ruleCache;
336
+ this.#sheetChanges = null;
259
337
  if (cache.styleSheets !== styleSheets || cache.key !== key || cache.length !== styleSheets.length) {
260
- const counter = { order: 0 };
261
- const rules = styleSheets.flatMap((source) => parseStyleSheet(source, environment, counter));
262
- if (counter.layers) rankLayers(counter.layers, rules);
263
- this.#ruleCache = { styleSheets, key, length: styleSheets.length, index: new RuleIndex(rules) };
338
+ const perSheet = this.#parseSheets(styleSheets, key);
339
+ const rules = perSheet.flat();
340
+ // Sheets added or removed since the last list (same viewport): only the
341
+ // elements their rules match can change style, every other element
342
+ // keeps the same rules in the same relative order.
343
+ if (cache.index && cache.key === key) this.#sheetChanges = diffSheets(cache, styleSheets, perSheet);
344
+ this.#ruleCache = { styleSheets, key, length: styleSheets.length, index: new RuleIndex(rules), perSheet };
264
345
  }
265
346
  return this.#ruleCache.index;
266
347
  }
267
348
 
349
+ /**
350
+ * Each sheet's rules, in cascade order. A sheet is parsed once per text
351
+ * (and viewport) with layers of its own; putting a list together only
352
+ * renumbers the rules and merges the sheets' layer trees in order, so a
353
+ * script adding one <style> does not re-parse every other sheet. Of
354
+ * identical sheets only the last copy's rules are kept, as each beats the
355
+ * same rule of an earlier copy (the earlier copy still declares its layers
356
+ * first); a sheet with anonymous layers is kept in every copy, since under
357
+ * !important an earlier anonymous layer wins.
358
+ */
359
+ #parseSheets(styleSheets, key) {
360
+ if (this.#parsedSheetsKey !== key) {
361
+ this.#parsedSheets = new Map();
362
+ this.#parsedSheetsKey = key;
363
+ }
364
+ const parsed = this.#parsedSheets;
365
+ const entries = styleSheets.map((source) => {
366
+ let entry = parsed.get(source);
367
+ if (entry) parsed.delete(source);
368
+ else {
369
+ const counter = { order: 0 };
370
+ const rules = parseStyleSheet(source, environment, counter);
371
+ entry = { rules, layers: counter.layers.children.size ? counter.layers : null };
372
+ entry.anonymousLayers = entry.layers !== null && hasAnonymousLayer(entry.layers);
373
+ }
374
+ parsed.set(source, entry); // most recently used last
375
+ return entry;
376
+ });
377
+ for (const source of parsed.keys()) {
378
+ if (parsed.size <= MAX_PARSED_SHEETS + styleSheets.length) break;
379
+ parsed.delete(source);
380
+ }
381
+ const last = new Map();
382
+ entries.forEach((entry, index) => last.set(entry, index));
383
+ const root = newLayer(null);
384
+ const layerOf = new Map();
385
+ let order = 0;
386
+ const perSheet = entries.map((entry, index) => {
387
+ if (entry.layers) mergeLayers(entry.layers, root, layerOf);
388
+ const lastCopy = last.get(entry) === index;
389
+ if (!lastCopy && !entry.anonymousLayers) return [];
390
+ // Each kept copy but the last gets rule objects of its own.
391
+ const rules = lastCopy ? entry.rules : entry.rules.map((rule) => rule.copy());
392
+ for (const rule of rules) {
393
+ rule.order = order++;
394
+ rule.layer = rule.layerNode ? layerOf.get(rule.layerNode) : Infinity;
395
+ }
396
+ return rules;
397
+ });
398
+ if (root.children.size) {
399
+ rankLayerTree(root);
400
+ for (const rules of perSheet) for (const rule of rules) if (rule.layerNode) rule.layer = rule.layer.rank;
401
+ }
402
+ return perSheet;
403
+ }
404
+
405
+ /** Elements matched by the rules of added or removed sheets, or null when that is unknown. */
406
+ #elementsForSheetChanges(document) {
407
+ const changes = this.#sheetChanges;
408
+ // Each selector is a document-wide search: past a few hundred, a full
409
+ // restyle is cheaper (a large stylesheet arriving late, for example).
410
+ if (!changes || changes.rules.length > MAX_SHEET_CHANGE_RULES) return null;
411
+ const elements = new Set();
412
+ const selectors = new Set(changes.rules.map((rule) => rule.selector));
413
+ for (const selector of selectors) {
414
+ let matched;
415
+ try { matched = document.querySelectorAll(selector); } catch { continue; } // invalid: matches nothing
416
+ for (const element of matched) elements.add(element);
417
+ }
418
+ return elements;
419
+ }
420
+
268
421
  get(element) {
269
422
  return this.#styles.get(element) ?? INITIAL;
270
423
  }
@@ -274,18 +427,26 @@ export class StyleEngine {
274
427
  return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
275
428
  }
276
429
 
277
- #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new Map()) {
278
- const keys = elementKeys(element);
430
+ /** Computes and stores one element's style and ::before/::after styles. */
431
+ #styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors) {
432
+ const previousStyle = this.#styles.get(element);
433
+ const previousCustom = this.#customProperties.get(element);
434
+ const previousPseudo = this.#pseudoStyles.get(element);
279
435
  // Inline style takes part in the cascade before custom properties are
280
436
  // resolved: style="--x: ..." must win over stylesheet values of --x.
281
437
  const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
282
- const customProperties = withCustomProperties(parentCustomProperties ?? EMPTY_MAP, declarations);
283
- const style = computeElementStyle(element, parentStyle, declarations, customProperties);
438
+ let customProperties = withCustomProperties(parentCustomProperties ?? EMPTY_MAP, declarations);
439
+ let style = computeElementStyle(element, parentStyle, declarations, customProperties);
440
+ // A descendant may be visited because an ancestor's selector state
441
+ // changed even though its final computed values did not. Preserve the
442
+ // immutable identities so retained render and layout subtrees survive.
443
+ if (sameValue(previousStyle, style)) style = previousStyle;
444
+ if (sameCustom(previousCustom, customProperties)) customProperties = previousCustom;
284
445
  // rem units resolve against the root element's computed font size.
285
446
  if (!parentStyle) environment = { ...environment, rootFontSize: style.fontSize };
286
447
  this.#styles.set(element, style);
287
448
  this.#customProperties.set(element, customProperties);
288
- const pseudoStyles = {};
449
+ let pseudoStyles = {};
289
450
  for (const pseudo of ["before", "after"]) {
290
451
  const pseudoDeclarations = cascadeDeclarations(element, rules, pseudo, keys, ancestors);
291
452
  if (!pseudoDeclarations.has("content")) continue;
@@ -294,20 +455,115 @@ export class StyleEngine {
294
455
  // `content: none | normal` generates no box (CSS Pseudo 4 §2.3).
295
456
  if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
296
457
  }
458
+ if (sameValue(previousPseudo ?? null, (pseudoStyles.before || pseudoStyles.after) ? pseudoStyles : null)) {
459
+ pseudoStyles = previousPseudo ?? {};
460
+ }
297
461
  if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
462
+ else this.#pseudoStyles.delete(element);
463
+ this.restyled++;
464
+ return { style, customProperties };
465
+ }
466
+
467
+ #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new AncestorFilter()) {
468
+ const keys = elementKeys(element);
469
+ const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
470
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
471
+ }
472
+
473
+ #visitChildren(element, keys, style, customProperties, rules, ancestors) {
298
474
  // The element is an ancestor of everything below it.
299
- const own = ancestorFilterKeys(keys);
300
- for (const key of own) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
475
+ const own = ancestors.push(keys);
301
476
  for (const child of element.children) this.#visit(child, style, customProperties, rules, ancestors);
302
- for (const key of own) {
303
- const count = ancestors.get(key) - 1;
304
- if (count) ancestors.set(key, count);
305
- else ancestors.delete(key);
477
+ ancestors.pop(own);
478
+ }
479
+
480
+ /** Ancestor filter counts for the elements above `element`. */
481
+ #ancestorsOf(element) {
482
+ const ancestors = new AncestorFilter();
483
+ for (let node = element.parentElement; node; node = node.parentElement) ancestors.push(elementKeys(node));
484
+ return ancestors;
485
+ }
486
+
487
+ /**
488
+ * Restyles one element; only when its style (or ::before/::after, or the
489
+ * custom properties it passes down) changed does its subtree follow.
490
+ */
491
+ #restyleSelf(element, rules) {
492
+ const parent = element.parentElement;
493
+ const before = { style: this.#styles.get(element), pseudo: this.#pseudoStyles.get(element), custom: this.#customProperties.get(element) };
494
+ const keys = elementKeys(element);
495
+ const ancestors = this.#ancestorsOf(element);
496
+ const { style, customProperties } = this.#styleOne(element, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, keys, ancestors);
497
+ const pseudo = this.#pseudoStyles.get(element);
498
+ if (sameValue(before.style, style) && sameValue(before.pseudo ?? null, pseudo ?? null) && sameCustom(before.custom, customProperties)) {
499
+ // Unchanged: keep the previous objects so later comparisons stay cheap.
500
+ this.#styles.set(element, before.style);
501
+ return;
306
502
  }
503
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
307
504
  }
308
505
  }
309
506
 
507
+ /** Structural equality of computed style values (frozen objects and arrays). */
508
+ function sameValue(left, right) {
509
+ if (left === right) return true;
510
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
511
+ const keys = Object.keys(left);
512
+ if (keys.length !== Object.keys(right).length) return false;
513
+ return keys.every((key) => sameValue(left[key], right[key]));
514
+ }
515
+
516
+ function sameCustom(left, right) {
517
+ if (left === right) return true;
518
+ if (!left || !right || left.size !== right.size) return false;
519
+ for (const [name, value] of left) if (right.get(name) !== value) return false;
520
+ return true;
521
+ }
522
+
310
523
  const EMPTY_MAP = new Map();
524
+ const MAX_SHEET_CHANGE_RULES = 300;
525
+ // Parsed sheets kept for reuse beyond those in use (sheets that come and go).
526
+ const MAX_PARSED_SHEETS = 64;
527
+
528
+ /**
529
+ * Rules from sheets present in only one of two sheet lists (by text), or null
530
+ * when such a change can reorder other rules: @layer declarations rank
531
+ * layers by first appearance and @property changes inheritance.
532
+ */
533
+ function diffSheets(previous, styleSheets, perSheet) {
534
+ const remaining = new Map();
535
+ previous.styleSheets.forEach((source, index) => {
536
+ const list = remaining.get(source);
537
+ if (list) list.push(previous.perSheet[index]); else remaining.set(source, [previous.perSheet[index]]);
538
+ });
539
+ const rules = [];
540
+ const risky = (source) => /@layer\b|@property\b/i.test(source);
541
+ for (let index = 0; index < styleSheets.length; index++) {
542
+ const source = styleSheets[index];
543
+ const list = remaining.get(source);
544
+ if (list?.length) { list.pop(); continue; }
545
+ if (risky(source)) return null;
546
+ rules.push(...perSheet[index]);
547
+ }
548
+ for (const [source, lists] of remaining) {
549
+ if (!lists.length) continue;
550
+ if (risky(source)) return null;
551
+ for (const list of lists) rules.push(...list);
552
+ }
553
+ return { rules };
554
+ }
555
+
556
+ /** The connected elements of `elements` that are not inside another of them. */
557
+ export function topmostConnected(elements, root) {
558
+ const result = [];
559
+ for (const element of elements) {
560
+ if (!element || element.nodeType !== 1 || !root.contains(element)) continue;
561
+ let covered = false;
562
+ for (let node = element.parentElement; node && !covered; node = node.parentElement) covered = elements.has(node);
563
+ if (!covered) result.push(element);
564
+ }
565
+ return result;
566
+ }
311
567
 
312
568
  /** The cascaded declarations with the element's style attribute applied on top. */
313
569
  function withInlineStyle(element, declarations) {
@@ -342,8 +598,12 @@ function withCustomProperties(inherited, declarations) {
342
598
 
343
599
  export function computeElementStyle(element, parentStyle = null, authorDeclarations = null, customProperties = new Map()) {
344
600
  const values = { ...INITIAL };
345
- for (const property of INHERITED) {
346
- if (parentStyle) copyProperty(values, property, parentStyle);
601
+ if (parentStyle) {
602
+ for (const property of INHERITED) {
603
+ const field = INHERITED_FIELDS.get(property);
604
+ if (field) values[field] = parentStyle[field];
605
+ else copyProperty(values, property, parentStyle);
606
+ }
347
607
  }
348
608
  // vertical-align is not inherited, except through the table UA rules' "inherit".
349
609
  values.verticalAlign = "baseline";
@@ -374,20 +634,19 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
374
634
  // font-size and the font shorthand wins the cascade.
375
635
  const fontSize = declarations.get("font-size");
376
636
  const font = declarations.get("font");
377
- const cascadeKey = (declaration) => ({ important: false, specificity: 0, order: 0, ...declaration, important: Boolean(declaration.important) });
378
- if (font && (!fontSize || compareCascade(cascadeKey(font), cascadeKey(fontSize)) > 0)) {
637
+ if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
379
638
  const parsed = parseFontShorthand(resolveVariables(font.value, customProperties));
380
639
  if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
381
640
  } else if (fontSize) {
382
641
  applyDeclaration(values, fontSize, customProperties, parentStyle);
383
642
  }
643
+ // light-dark() depends on the used scheme regardless of declaration order.
644
+ const colorScheme = declarations.get("color-scheme");
645
+ if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
384
646
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
385
- const ordered = [...declarations.values()].sort((left, right) => compareCascade(
386
- { important: false, specificity: 0, order: 0, ...left, important: Boolean(left.important) },
387
- { important: false, specificity: 0, order: 0, ...right, important: Boolean(right.important) },
388
- ));
647
+ const ordered = [...declarations.values()].sort(compareDeclarations);
389
648
  for (const declaration of ordered) {
390
- if (declaration.name.startsWith("--") || declaration.name === "font-size") continue;
649
+ if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
391
650
  // The shorthand's size was settled above; its other parts apply in order.
392
651
  applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
393
652
  }
@@ -506,6 +765,13 @@ class StyleRule {
506
765
  get hasCustomProperties() {
507
766
  return this.#block.hasCustomProperties;
508
767
  }
768
+
769
+ /** The same rule as a separate object (its own order and layer). */
770
+ copy() {
771
+ const rule = new StyleRule(this.selector, this.#selectorText, this.pseudo, this.#block, this.order);
772
+ rule.layerNode = this.layerNode;
773
+ return rule;
774
+ }
509
775
  }
510
776
 
511
777
  /**
@@ -544,7 +810,7 @@ export function parseStyleSheet(source, viewport = environment, counter = { orde
544
810
  if (name === "layer") {
545
811
  // A named layer block, or an anonymous one (a layer of its own).
546
812
  const parent = layer ?? counter.layers;
547
- const inner = condition ? declareLayer(parent, condition) : declareLayer(parent, `\0anonymous${counter.order}`);
813
+ const inner = condition ? declareLayer(parent, condition) : declareLayer(parent, `${ANONYMOUS_LAYER}${counter.order}`);
548
814
  parseBlock(open + 1, close, inner);
549
815
  } else if ((name === "media" && mediaQueryMatches(condition, viewport)) || name === "supports") {
550
816
  parseBlock(open + 1, close, layer);
@@ -571,6 +837,28 @@ function newLayer(parent) {
571
837
  return { parent, children: new Map(), rank: Infinity };
572
838
  }
573
839
 
840
+ const ANONYMOUS_LAYER = "\0anonymous";
841
+
842
+ function hasAnonymousLayer(node) {
843
+ for (const [name, child] of node.children) if (name.startsWith(ANONYMOUS_LAYER) || hasAnonymousLayer(child)) return true;
844
+ return false;
845
+ }
846
+
847
+ /**
848
+ * Adds one sheet's layer tree to the layers of a sheet list, in first-seen
849
+ * order; `layerOf` maps each of the sheet's layers to the list's. Every
850
+ * anonymous layer is a layer of its own.
851
+ */
852
+ function mergeLayers(node, target, layerOf) {
853
+ for (const [name, child] of node.children) {
854
+ const anonymous = name.startsWith(ANONYMOUS_LAYER);
855
+ let merged = anonymous ? null : target.children.get(name);
856
+ if (!merged) target.children.set(anonymous ? `${name}\0${target.children.size}` : name, merged = newLayer(target));
857
+ layerOf.set(child, merged);
858
+ mergeLayers(child, merged, layerOf);
859
+ }
860
+ }
861
+
574
862
  /** Finds or creates a (possibly dotted) layer name under `parent`, in first-seen order. */
575
863
  function declareLayer(parent, name) {
576
864
  let node = parent;
@@ -587,12 +875,16 @@ function declareLayer(parent, name) {
587
875
  * layer's sublayers before its own rules; unlayered rules rank highest.
588
876
  */
589
877
  export function rankLayers(root, rules) {
878
+ rankLayerTree(root);
879
+ for (const rule of rules) rule.layer = rule.layerNode ? rule.layerNode.rank : Infinity;
880
+ }
881
+
882
+ function rankLayerTree(root) {
590
883
  let next = 0;
591
884
  (function visit(node) {
592
885
  for (const child of node.children.values()) visit(child);
593
886
  if (node !== root) node.rank = next++;
594
887
  })(root);
595
- for (const rule of rules) rule.layer = rule.layerNode ? rule.layerNode.rank : Infinity;
596
888
  }
597
889
 
598
890
  /** Splits a `font` shorthand value into size, line height, weight and family. */
@@ -607,43 +899,54 @@ function parseFontShorthand(value) {
607
899
  weight = lower === "bolder" ? "700" : lower === "lighter" ? "300" : lower;
608
900
  continue;
609
901
  }
610
- // The size, optionally with /line-height, then the family list.
611
- const [size, lineHeight] = token.split("/");
612
- const family = tokens.slice(index + 1).join(" ").trim();
902
+ // The size, optionally with /line-height (spaces around "/" allowed),
903
+ // then the family list.
904
+ let [size, lineHeight] = token.split("/");
905
+ let rest = index + 1;
906
+ if (lineHeight === "") lineHeight = tokens[rest++]; // "14px/ 1.5"
907
+ else if (lineHeight === undefined && tokens[rest] === "/") { // "14px / 1.5"
908
+ lineHeight = tokens[rest + 1];
909
+ rest += 2;
910
+ } else if (lineHeight === undefined && tokens[rest]?.startsWith("/")) lineHeight = tokens[rest++].slice(1); // "14px /1.5"
911
+ const family = tokens.slice(rest).join(" ").trim();
613
912
  if (!family || !(FONT_SIZE_KEYWORDS[size] || /^[\d.]/.test(size) || /^(?:calc|clamp|min|max)\(/.test(size))) return null;
614
913
  return { size, lineHeight: lineHeight || null, weight, family };
615
914
  }
616
915
  return null;
617
916
  }
618
917
 
918
+ // Character codes for the scanners below, which run over whole style sheets.
919
+ const QUOTE = 0x22, APOSTROPHE = 0x27, BACKSLASH = 0x5c, OPEN_PAREN = 0x28, CLOSE_PAREN = 0x29, OPEN_BRACE = 0x7b, CLOSE_BRACE = 0x7d;
920
+
619
921
  function findTopLevel(source, character, start, end) {
620
- let quote = "";
922
+ const target = character.charCodeAt(0);
923
+ let quote = 0;
621
924
  let depth = 0;
622
925
  for (let index = start; index < end; index++) {
623
- const current = source[index];
926
+ const current = source.charCodeAt(index);
624
927
  if (quote) {
625
- if (current === "\\") index++;
626
- else if (current === quote) quote = "";
627
- } else if (current === '"' || current === "'") quote = current;
628
- else if (current === "(") depth++;
629
- else if (current === ")") depth = Math.max(0, depth - 1);
630
- else if (current === character && depth === 0) return index;
631
- else if (current === "{" || current === "}") return character === current ? index : -1;
928
+ if (current === BACKSLASH) index++;
929
+ else if (current === quote) quote = 0;
930
+ } else if (current === QUOTE || current === APOSTROPHE) quote = current;
931
+ else if (current === OPEN_PAREN) depth++;
932
+ else if (current === CLOSE_PAREN) depth = Math.max(0, depth - 1);
933
+ else if (current === target && depth === 0) return index;
934
+ else if (current === OPEN_BRACE || current === CLOSE_BRACE) return target === current ? index : -1;
632
935
  }
633
936
  return -1;
634
937
  }
635
938
 
636
939
  function matchingBrace(source, open, end) {
637
940
  let depth = 0;
638
- let quote = "";
941
+ let quote = 0;
639
942
  for (let index = open; index < end; index++) {
640
- const current = source[index];
943
+ const current = source.charCodeAt(index);
641
944
  if (quote) {
642
- if (current === "\\") index++;
643
- else if (current === quote) quote = "";
644
- } else if (current === '"' || current === "'") quote = current;
645
- else if (current === "{") depth++;
646
- else if (current === "}" && --depth === 0) return index;
945
+ if (current === BACKSLASH) index++;
946
+ else if (current === quote) quote = 0;
947
+ } else if (current === QUOTE || current === APOSTROPHE) quote = current;
948
+ else if (current === OPEN_BRACE) depth++;
949
+ else if (current === CLOSE_BRACE && --depth === 0) return index;
647
950
  }
648
951
  return end;
649
952
  }
@@ -751,6 +1054,13 @@ function setProperty(style, name, value, parentStyle = null) {
751
1054
  }
752
1055
  if (typeof value !== "string") return;
753
1056
  switch (name) {
1057
+ case "color-scheme": {
1058
+ const schemes = value.toLowerCase().split(/\s+/).filter((part) => part !== "only");
1059
+ if (schemes.every((part) => ["normal", "light", "dark"].includes(part))) {
1060
+ style.colorScheme = schemes.includes("light") || !schemes.includes("dark") ? "light" : "dark";
1061
+ }
1062
+ break;
1063
+ }
754
1064
  case "display": if (DISPLAYS.has(value)) style.display = value; break;
755
1065
  case "text-decoration-line": {
756
1066
  const lines = parseDecorationLines(value);
@@ -790,6 +1100,10 @@ function setProperty(style, name, value, parentStyle = null) {
790
1100
  case "visibility":
791
1101
  if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
792
1102
  break;
1103
+ // SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
1104
+ case "pointer-events":
1105
+ if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
1106
+ break;
793
1107
  case "opacity": {
794
1108
  const number = /^([+-]?(?:\d+\.?\d*|\.\d+))(%?)$/.exec(value);
795
1109
  if (number) style.opacity = Math.min(1, Math.max(0, Number(number[1]) / (number[2] ? 100 : 1)));
@@ -833,12 +1147,12 @@ function setProperty(style, name, value, parentStyle = null) {
833
1147
  break;
834
1148
  }
835
1149
  case "color": {
836
- const color = Bun.color(value, "rgba");
1150
+ const color = resolvedColor(value, style.colorScheme);
837
1151
  if (color) style.color = color;
838
1152
  break;
839
1153
  }
840
1154
  case "background-color": {
841
- const color = Bun.color(value, "rgba");
1155
+ const color = resolvedColor(value, style.colorScheme);
842
1156
  if (color) style.backgroundColor = color;
843
1157
  break;
844
1158
  }
@@ -847,7 +1161,7 @@ function setProperty(style, name, value, parentStyle = null) {
847
1161
  const layers = splitTopLevel(value, ",");
848
1162
  const images = layers.filter((layer) => GRADIENT.test(layer));
849
1163
  const color = splitTopLevel(layers.at(-1) ?? "", " ")
850
- .map((token) => Bun.color(token, "rgba"))
1164
+ .map((token) => resolvedColor(token, style.colorScheme))
851
1165
  .find(Boolean);
852
1166
  style.backgroundColor = color ?? INITIAL.backgroundColor;
853
1167
  style.backgroundImage = images.length ? images.join(", ") : null;
@@ -1097,6 +1411,24 @@ function setProperty(style, name, value, parentStyle = null) {
1097
1411
  if (["normal", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(justify)) style.justifyContent = justify;
1098
1412
  break;
1099
1413
  }
1414
+ case "align-content": {
1415
+ const alignment = FLEX_ALIGNMENT[value] ?? value;
1416
+ if (["normal", "stretch", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(alignment)) {
1417
+ style.alignContent = alignment;
1418
+ }
1419
+ break;
1420
+ }
1421
+ // CSS Box Alignment 3 §5.1/§6.1/§6.2: <align> [<justify>]; one value sets both.
1422
+ case "place-content":
1423
+ case "place-items":
1424
+ case "place-self": {
1425
+ const [align, justify = align] = splitTopLevel(value, " ").filter(Boolean);
1426
+ if (!align) break;
1427
+ const part = name.slice("place-".length);
1428
+ setProperty(style, `align-${part}`, align, parentStyle);
1429
+ setProperty(style, `justify-${part}`, justify, parentStyle);
1430
+ break;
1431
+ }
1100
1432
  case "align-items": {
1101
1433
  const alignment = FLEX_ALIGNMENT[value] ?? value;
1102
1434
  if (["normal", "stretch", "start", "end", "center", "baseline"].includes(alignment)) style.alignItems = alignment;
@@ -1216,10 +1548,14 @@ class RuleIndex {
1216
1548
  buckets = { id: new Map(), class: new Map(), attr: new Map(), tag: new Map(), universal: [] };
1217
1549
  this.#byPseudo.set(rule.pseudo, buckets);
1218
1550
  }
1219
- const parts = selectorParts(rule.selector);
1220
- rule.ancestorKeys = parts ? ancestorKeys(parts) : [];
1551
+ // Kept on the rule: a parsed sheet's rules are indexed again whenever the sheet list changes.
1552
+ if (rule.subjectKeys === undefined) {
1553
+ const { subject, ancestors } = selectorKeys(rule.selector);
1554
+ rule.ancestorIds = Int32Array.from(ancestors, filterKeyId);
1555
+ rule.subjectKeys = subject;
1556
+ }
1221
1557
  rule.seen = 0;
1222
- const keys = parts ? subjectKeys(parts.compounds.at(-1)) : null;
1558
+ const keys = rule.subjectKeys;
1223
1559
  if (!keys) {
1224
1560
  buckets.universal.push(rule);
1225
1561
  continue;
@@ -1236,7 +1572,7 @@ class RuleIndex {
1236
1572
  /**
1237
1573
  * Rules that may match `element` for `pseudo`, in cascade order.
1238
1574
  * @param {ReturnType<typeof elementKeys>} keys the element's own keys
1239
- * @param {Map<string, number>} ancestors keys present on its ancestors
1575
+ * @param {AncestorFilter} ancestors keys present on its ancestors
1240
1576
  */
1241
1577
  candidates(keys, pseudo, ancestors) {
1242
1578
  const buckets = this.#byPseudo.get(pseudo);
@@ -1254,7 +1590,7 @@ class RuleIndex {
1254
1590
  for (const rule of list) {
1255
1591
  if (rule.seen === stamp) continue;
1256
1592
  rule.seen = stamp;
1257
- if (rule.ancestorKeys.every((key) => ancestors.has(key))) result.push(rule);
1593
+ if (ancestors.hasAll(rule.ancestorIds)) result.push(rule);
1258
1594
  }
1259
1595
  }
1260
1596
  if (lists.length > 1) result.sort((left, right) => left.order - right.order);
@@ -1262,6 +1598,72 @@ class RuleIndex {
1262
1598
  }
1263
1599
  }
1264
1600
 
1601
+ /**
1602
+ * The elements that match each selector among `root`'s descendants and, for
1603
+ * an element, `root` itself (querySelectorAll() plus matches()), in one walk: selectors are bucketed by their subject's keys like
1604
+ * rules, so each element is only tested against selectors it may match.
1605
+ * Invalid selectors are left out of the result.
1606
+ * @param {Element | Document} root
1607
+ * @param {Iterable<string>} selectors
1608
+ * @param {Map<string, object | null>} [compiledSelectors] compiled selectors to reuse and add to
1609
+ * @returns {Map<string, Set<Element>>}
1610
+ */
1611
+ export function matchSelectors(root, selectors, compiledSelectors = new Map()) {
1612
+ const result = new Map();
1613
+ const buckets = { id: new Map(), class: new Map(), attr: new Map(), tag: new Map(), universal: [] };
1614
+ const sample = root.nodeType === 9 ? root.documentElement : root;
1615
+ for (const selector of selectors) {
1616
+ if (result.has(selector)) continue;
1617
+ // :scope means the root to querySelectorAll() but the element to matches().
1618
+ let compiled;
1619
+ if (sample && !selector.includes(":scope")) {
1620
+ compiled = compiledSelectors.get(selector);
1621
+ if (compiled === undefined) {
1622
+ compiled = compileSelector(sample, selector);
1623
+ if (compiled !== undefined) compiledSelectors.set(selector, compiled);
1624
+ }
1625
+ }
1626
+ if (compiled === null) continue;
1627
+ if (compiled === undefined) {
1628
+ try { result.set(selector, new Set(root.querySelectorAll(selector))); } catch { /* invalid */ }
1629
+ continue;
1630
+ }
1631
+ const entry = { compiled, matched: new Set(), seen: 0 };
1632
+ result.set(selector, entry.matched);
1633
+ const keys = selectorKeys(selector).subject;
1634
+ if (!keys) {
1635
+ buckets.universal.push(entry);
1636
+ continue;
1637
+ }
1638
+ for (const key of keys) {
1639
+ const list = buckets[key.kind].get(key.name);
1640
+ if (list) list.push(entry); else buckets[key.kind].set(key.name, [entry]);
1641
+ }
1642
+ }
1643
+ let stamp = 0;
1644
+ const test = (element, list) => {
1645
+ if (!list) return;
1646
+ for (const entry of list) {
1647
+ if (entry.seen === stamp) continue;
1648
+ entry.seen = stamp;
1649
+ if (matchesCompiled(element, entry.compiled)) entry.matched.add(element);
1650
+ }
1651
+ };
1652
+ const visit = (element) => {
1653
+ stamp++;
1654
+ const keys = elementKeys(element);
1655
+ test(element, buckets.universal);
1656
+ if (keys.id) test(element, buckets.id.get(keys.id));
1657
+ for (const name of keys.classes) test(element, buckets.class.get(name));
1658
+ for (const name of keys.attrs) test(element, buckets.attr.get(name));
1659
+ if (keys.tag) test(element, buckets.tag.get(keys.tag));
1660
+ for (const child of element.children) visit(child);
1661
+ };
1662
+ if (root.nodeType === 1) visit(root);
1663
+ else for (const child of root.children ?? []) visit(child);
1664
+ return result;
1665
+ }
1666
+
1265
1667
  /** The keys an element offers to selectors: id, classes, attribute names, type. */
1266
1668
  function elementKeys(element) {
1267
1669
  const id = element.getAttribute?.("id") || null;
@@ -1273,203 +1675,102 @@ function elementKeys(element) {
1273
1675
  return { id, classes, attrs, tag: tag ? String(tag).toLowerCase() : null };
1274
1676
  }
1275
1677
 
1276
- /** Ancestor filter keys for an element: "tag", "#id", ".class", "[attr". */
1277
- function ancestorFilterKeys(keys) {
1278
- const result = [];
1279
- if (keys.tag) result.push(keys.tag);
1280
- if (keys.id) result.push(`#${keys.id}`);
1281
- for (const name of keys.classes) result.push(`.${name}`);
1282
- for (const name of keys.attrs) result.push(`[${name}`);
1283
- return result;
1678
+ // Ancestor filter keys that some rule requires, numbered for AncestorFilter.
1679
+ const FILTER_KEY_IDS = new Map();
1680
+
1681
+ function filterKeyId(key) {
1682
+ let id = FILTER_KEY_IDS.get(key);
1683
+ if (id === undefined) FILTER_KEY_IDS.set(key, id = FILTER_KEY_IDS.size);
1684
+ return id;
1284
1685
  }
1285
1686
 
1286
1687
  /**
1287
- * Splits a complex selector into compounds and the combinators between them
1288
- * (" ", ">", "+", "~"), ignoring anything inside (), [] and quotes. Returns
1289
- * null for selectors this does not understand, which are then left unindexed.
1688
+ * Counts of the filter keys on the elements above the one being styled (a
1689
+ * counting filter, like Blink's ancestor Bloom filter but exact). Keys no
1690
+ * rule requires are not counted. Created after the rules are indexed.
1290
1691
  */
1291
- function selectorParts(selector) {
1292
- const compounds = [];
1293
- const combinators = [];
1294
- let current = "";
1295
- let pending = null;
1296
- let depth = 0;
1297
- for (let index = 0; index < selector.length; index++) {
1298
- const character = selector[index];
1299
- if (character === "\\") {
1300
- current += character + (selector[++index] ?? "");
1301
- continue;
1302
- }
1303
- if (character === "\"" || character === "'") {
1304
- const close = selector.indexOf(character, index + 1);
1305
- if (close < 0) return null;
1306
- current += selector.slice(index, close + 1);
1307
- index = close;
1308
- continue;
1309
- }
1310
- if (character === "(" || character === "[") depth++;
1311
- else if (character === ")" || character === "]") depth--;
1312
- if (depth === 0 && (character === " " || character === ">" || character === "+" || character === "~"
1313
- || character === "\t" || character === "\n" || character === "\r" || character === "\f")) {
1314
- if (current) {
1315
- compounds.push(current);
1316
- current = "";
1317
- pending = " ";
1318
- }
1319
- if (character === ">" || character === "+" || character === "~") {
1320
- if (pending === null && compounds.length === 0) return null;
1321
- pending = character;
1322
- }
1323
- continue;
1324
- }
1325
- if (pending !== null) {
1326
- combinators.push(pending);
1327
- pending = null;
1328
- }
1329
- current += character;
1330
- }
1331
- if (depth !== 0) return null;
1332
- if (current) compounds.push(current);
1333
- else if (pending !== null && compounds.length) return null;
1334
- return compounds.length ? { compounds, combinators } : null;
1335
- }
1692
+ class AncestorFilter {
1693
+ #counts = new Int32Array(Math.max(1, FILTER_KEY_IDS.size));
1336
1694
 
1337
- const IDENTIFIER = /^(?:[\w-]|\\[^0-9a-fA-F\s]|[^\x00-\x7f])+/;
1338
-
1339
- /** An identifier at the start of `text`, unescaped; null when absent or hex-escaped. */
1340
- function leadingIdentifier(text) {
1341
- const match = IDENTIFIER.exec(text);
1342
- if (!match || text[match[0].length] === "\\") return null;
1343
- return match[0].replace(/\\(.)/g, "$1");
1344
- }
1345
-
1346
- /** Keys of one compound selector, from its parts outside () (ids, classes, attribute names, type). */
1347
- function compoundKeys(compound) {
1348
- const keys = { ids: [], classes: [], attrs: [], tag: null, pseudo: [] };
1349
- if (compound.includes("|")) return keys;
1350
- const tag = /^[a-zA-Z][\w-]*/.exec(compound)?.[0];
1351
- if (tag) keys.tag = tag.toLowerCase();
1352
- let depth = 0;
1353
- for (let index = 0; index < compound.length; index++) {
1354
- const character = compound[index];
1355
- if (character === "\\") {
1356
- index++;
1357
- continue;
1358
- }
1359
- if (character === "(") {
1360
- depth++;
1361
- continue;
1362
- }
1363
- if (character === ")") {
1364
- depth--;
1365
- continue;
1366
- }
1367
- if (depth > 0) continue;
1368
- if (character === "[") {
1369
- const name = /^\s*([\w-]+)/.exec(compound.slice(index + 1))?.[1];
1370
- if (name) keys.attrs.push(name.toLowerCase());
1371
- // Skip to the closing bracket; quoted values may contain "]", "#" or ".".
1372
- let quote = null;
1373
- for (index++; index < compound.length; index++) {
1374
- const inner = compound[index];
1375
- if (inner === "\\") index++;
1376
- else if (quote) {
1377
- if (inner === quote) quote = null;
1378
- } else if (inner === "\"" || inner === "'") quote = inner;
1379
- else if (inner === "]") break;
1380
- }
1381
- } else if (character === "#" || character === ".") {
1382
- const name = leadingIdentifier(compound.slice(index + 1));
1383
- if (name && !(character === "." && /^\d/.test(name))) (character === "#" ? keys.ids : keys.classes).push(name);
1384
- } else if (character === ":") {
1385
- const name = /^:?([\w-]+)/.exec(compound.slice(index + 1))?.[1];
1386
- if (name) keys.pseudo.push({ name: name.toLowerCase(), start: index });
1695
+ /** Adds an element's keys; returns what pop() takes to remove them. */
1696
+ push(keys) {
1697
+ const ids = [];
1698
+ for (const key of ancestorFilterKeys(keys)) {
1699
+ const id = FILTER_KEY_IDS.get(key);
1700
+ if (id === undefined || id >= this.#counts.length) continue;
1701
+ this.#counts[id]++;
1702
+ ids.push(id);
1387
1703
  }
1704
+ return ids;
1388
1705
  }
1389
- return keys;
1390
- }
1391
1706
 
1392
- /**
1393
- * Bucket keys for the subject compound: one id, class, attribute or type key,
1394
- * or one per alternative of an :is()/:where() whose alternatives all have one.
1395
- */
1396
- function subjectKeys(compound) {
1397
- const keys = compoundKeys(compound);
1398
- if (keys.ids.length) return [{ kind: "id", name: keys.ids[0] }];
1399
- if (keys.classes.length) return [{ kind: "class", name: keys.classes[0] }];
1400
- if (keys.attrs.length) return [{ kind: "attr", name: keys.attrs[0] }];
1401
- if (keys.tag) return [{ kind: "tag", name: keys.tag }];
1402
- if (keys.pseudo.some(({ name }) => name === "root")) return [{ kind: "tag", name: "html" }];
1403
- for (const { name, start } of keys.pseudo) {
1404
- if (!["is", "where", "matches", "-webkit-any"].includes(name)) continue;
1405
- const open = compound.indexOf("(", start);
1406
- const close = open < 0 ? -1 : matchingParenthesis(compound, open);
1407
- if (close < 0) return null;
1408
- const alternatives = splitTopLevel(compound.slice(open + 1, close), ",").filter(Boolean);
1409
- const result = [];
1410
- for (const alternative of alternatives) {
1411
- // Only simple alternatives: complex ones inside :is() are left to the
1412
- // selector engine unindexed, so indexing never changes a result.
1413
- const parts = selectorParts(alternative.trim());
1414
- const inner = parts?.compounds.length === 1 ? subjectKeys(parts.compounds[0]) : null;
1415
- if (!inner) return null;
1416
- result.push(...inner);
1417
- }
1418
- return result.length ? result : null;
1707
+ pop(ids) {
1708
+ for (const id of ids) this.#counts[id]--;
1419
1709
  }
1420
- return null;
1421
- }
1422
1710
 
1423
- /** Keys that must be present on some ancestor for the selector to match. */
1424
- function ancestorKeys(parts) {
1425
- const keys = new Set();
1426
- for (let index = 0; index < parts.compounds.length - 1; index++) {
1427
- // Only compounds whose right-hand combinator is descendant or child are
1428
- // ancestors of the subject (a sibling's parent is the subject's parent).
1429
- if (parts.combinators[index] !== " " && parts.combinators[index] !== ">") continue;
1430
- const compound = compoundKeys(parts.compounds[index]);
1431
- if (compound.tag) keys.add(compound.tag);
1432
- for (const id of compound.ids) keys.add(`#${id}`);
1433
- for (const name of compound.classes) keys.add(`.${name}`);
1434
- for (const name of compound.attrs) keys.add(`[${name}`);
1711
+ hasAll(ids) {
1712
+ for (let index = 0; index < ids.length; index++) if (!(this.#counts[ids[index]] > 0)) return false;
1713
+ return true;
1435
1714
  }
1436
- return [...keys];
1437
1715
  }
1438
1716
 
1439
- function matchingParenthesis(text, open) {
1440
- let depth = 0;
1441
- for (let index = open; index < text.length; index++) {
1442
- if (text[index] === "\\") index++;
1443
- else if (text[index] === "(") depth++;
1444
- else if (text[index] === ")" && --depth === 0) return index;
1445
- }
1446
- return -1;
1717
+ /** Ancestor filter keys for an element: "tag", "#id", ".class", "[attr". */
1718
+ function ancestorFilterKeys(keys) {
1719
+ const result = [];
1720
+ if (keys.tag) result.push(keys.tag);
1721
+ if (keys.id) result.push(`#${keys.id}`);
1722
+ for (const name of keys.classes) result.push(`.${name}`);
1723
+ for (const name of keys.attrs) result.push(`[${name}`);
1724
+ return result;
1447
1725
  }
1448
1726
 
1449
- function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new Map()) {
1727
+ function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new AncestorFilter()) {
1450
1728
  const result = new Map();
1451
1729
  result.hasCustomProperties = false;
1452
1730
  for (const rule of rules.candidates(keys, pseudo, ancestors)) {
1453
1731
  if (!matchesSelector(element, rule)) continue;
1454
1732
  if (rule.hasCustomProperties) result.hasCustomProperties = true;
1733
+ const { specificity, order } = rule;
1734
+ const layer = rule.layer ?? Infinity;
1455
1735
  for (const declaration of rule.declarationList) {
1456
- const candidate = { ...declaration, specificity: rule.specificity, order: rule.order, layer: rule.layer ?? Infinity };
1457
- const current = result.get(candidate.name);
1458
- if (!current || compareCascade(candidate, current) >= 0) result.set(candidate.name, candidate);
1736
+ const current = result.get(declaration.name);
1737
+ if (current && compareCascadeParts(declaration.important, layer, specificity, order, current) < 0) continue;
1738
+ result.set(declaration.name, { name: declaration.name, value: declaration.value, important: declaration.important, specificity, order, layer });
1459
1739
  }
1460
1740
  }
1461
1741
  return result;
1462
1742
  }
1463
1743
 
1464
1744
  function compareCascade(left, right) {
1465
- if (left.important !== right.important) return left.important ? 1 : -1;
1745
+ return compareCascadeParts(left.important, left.layer ?? Infinity, left.specificity, left.order, right);
1746
+ }
1747
+
1748
+ /** compareCascade() for a left side given as its parts (no object for it). */
1749
+ function compareCascadeParts(important, layer, specificity, order, right) {
1750
+ if (important !== right.important) return important ? 1 : -1;
1466
1751
  // Cascade layers: later layers and unlayered styles win for normal
1467
1752
  // declarations; for !important the order is reversed.
1753
+ const rightLayer = right.layer ?? Infinity;
1754
+ if (layer !== rightLayer) return (layer > rightLayer ? 1 : -1) * (important ? -1 : 1);
1755
+ if (specificity !== right.specificity) return specificity - right.specificity;
1756
+ return order - right.order;
1757
+ }
1758
+
1759
+ /**
1760
+ * compareCascade() for declarations that may lack cascade fields (user-agent
1761
+ * and inline ones): absent means normal importance, specificity and order 0.
1762
+ */
1763
+ function compareDeclarations(left, right) {
1764
+ const leftImportant = Boolean(left.important);
1765
+ const rightImportant = Boolean(right.important);
1766
+ if (leftImportant !== rightImportant) return leftImportant ? 1 : -1;
1468
1767
  const leftLayer = left.layer ?? Infinity;
1469
1768
  const rightLayer = right.layer ?? Infinity;
1470
- if (leftLayer !== rightLayer) return (leftLayer > rightLayer ? 1 : -1) * (left.important ? -1 : 1);
1471
- if (left.specificity !== right.specificity) return left.specificity - right.specificity;
1472
- return left.order - right.order;
1769
+ if (leftLayer !== rightLayer) return (leftLayer > rightLayer ? 1 : -1) * (leftImportant ? -1 : 1);
1770
+ const leftSpecificity = "specificity" in left ? left.specificity : 0;
1771
+ const rightSpecificity = "specificity" in right ? right.specificity : 0;
1772
+ if (leftSpecificity !== rightSpecificity) return leftSpecificity - rightSpecificity;
1773
+ return ("order" in left ? left.order : 0) - ("order" in right ? right.order : 0);
1473
1774
  }
1474
1775
 
1475
1776
  const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
@@ -1480,9 +1781,14 @@ const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
1480
1781
  * pseudo-elements that are not rendered (::placeholder, ::selection, ...).
1481
1782
  */
1482
1783
  function splitPseudoElement(selectorText) {
1483
- const match = PSEUDO_ELEMENT.exec(selectorText);
1784
+ // A trailing pseudo-element starts at the last colon (or the one before
1785
+ // it): only the tail needs the regular expression.
1786
+ const colon = selectorText.lastIndexOf(":");
1787
+ if (colon < 0) return { selector: selectorText, pseudo: null };
1788
+ const offset = Math.max(0, colon - 1);
1789
+ const match = PSEUDO_ELEMENT.exec(offset ? selectorText.slice(offset) : selectorText);
1484
1790
  if (!match) return { selector: selectorText, pseudo: null };
1485
- const selector = selectorText.slice(0, match.index).trim() || "*";
1791
+ const selector = selectorText.slice(0, offset + match.index).trim() || "*";
1486
1792
  return { selector, pseudo: match[1] ? match[1].toLowerCase() : undefined };
1487
1793
  }
1488
1794
 
@@ -1544,7 +1850,18 @@ function addSpecificity(left, right) {
1544
1850
  function matchesSelector(element, rule) {
1545
1851
  if (rule.invalid || typeof element.matches !== "function") return false;
1546
1852
  try {
1547
- return element.matches(rule.selector);
1853
+ // Compiled once per rule; objects other than Happy DOM elements use matches().
1854
+ let compiled = rule.compiled;
1855
+ if (compiled === undefined) {
1856
+ compiled = compileSelector(element, rule.selector);
1857
+ if (compiled === undefined) return element.matches(rule.selector);
1858
+ rule.compiled = compiled;
1859
+ }
1860
+ if (compiled === null) {
1861
+ rule.invalid = true;
1862
+ return false;
1863
+ }
1864
+ return matchesCompiled(element, compiled);
1548
1865
  } catch {
1549
1866
  rule.invalid = true;
1550
1867
  return false;
@@ -1678,6 +1995,7 @@ function cssValue(style, name) {
1678
1995
  "line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
1679
1996
  : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
1680
1997
  "list-style-type": style.listStyleType,
1998
+ "pointer-events": style.pointerEvents,
1681
1999
  "text-align": style.textAlign,
1682
2000
  "white-space": style.whiteSpace,
1683
2001
  })[name] ?? style[name];
@@ -1988,6 +2306,17 @@ function flexShorthand(value, fontSize) {
1988
2306
  };
1989
2307
  }
1990
2308
 
2309
+ function resolvedColor(value, scheme) {
2310
+ const match = /^light-dark\((.*)\)$/is.exec(value.trim());
2311
+ if (match) {
2312
+ const choices = splitTopLevel(match[1], ",");
2313
+ if (choices.length !== 2) return null;
2314
+ return resolvedColor(choices[scheme === "dark" ? 1 : 0], scheme);
2315
+ }
2316
+ const color = Bun.color(value, "rgba");
2317
+ return /^rgba\(/.test(color ?? "") ? color : null;
2318
+ }
2319
+
1991
2320
  function splitTopLevel(source, separator) {
1992
2321
  const parts = [];
1993
2322
  let depth = 0;