buninu-browser 0.2.0 → 0.2.2

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 (80) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +19 -12
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/happy-dom/selector-matcher.js +83 -0
  11. package/lib/headless-shell.js +120 -28
  12. package/lib/input/hit-test.js +25 -7
  13. package/lib/layout/text-layout.js +365 -54
  14. package/lib/log.js +5 -2
  15. package/lib/media/byte-pipe.js +82 -0
  16. package/lib/media/position-audio.js +42 -0
  17. package/lib/network/network-service.js +326 -46
  18. package/lib/paint/display-list.js +322 -21
  19. package/lib/paint/gradient.js +11 -5
  20. package/lib/render-tree/index.js +62 -2
  21. package/lib/render-tree/svg.js +2 -1
  22. package/lib/renderer/bindings.js +989 -72
  23. package/lib/renderer/canvas-2d.js +92 -0
  24. package/lib/renderer/host.js +159 -9
  25. package/lib/renderer/interfaces.js +215 -0
  26. package/lib/renderer/main.js +160 -12
  27. package/lib/renderer/media-source.js +819 -0
  28. package/lib/renderer/messaging.js +411 -0
  29. package/lib/renderer/native-functions.js +113 -0
  30. package/lib/renderer/page-realm.js +1407 -200
  31. package/lib/renderer/page-renderer.js +1899 -394
  32. package/lib/renderer/rejections.js +31 -0
  33. package/lib/renderer/rewrite-worker.js +22 -0
  34. package/lib/renderer/script-rewrite.js +805 -57
  35. package/lib/renderer/trusted-types.js +129 -0
  36. package/lib/renderer/workers.js +327 -0
  37. package/lib/screenshot-cli.js +2 -0
  38. package/lib/style/computed-style.js +463 -242
  39. package/lib/style/selector-keys.js +302 -0
  40. package/lib/style/selector-traits.js +240 -0
  41. package/lib/user-agent.js +14 -2
  42. package/package.json +4 -1
  43. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  44. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  45. package/patches/happy-dom/README.md +26 -0
  46. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  47. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  48. package/patches/ses/README.md +4 -0
  49. package/test/canvas-2d.test.js +55 -0
  50. package/test/compositor.test.js +3 -1
  51. package/test/computed-style.test.js +98 -1
  52. package/test/display-list.test.js +74 -1
  53. package/test/download.test.js +35 -0
  54. package/test/fixtures/long-video.mp4 +0 -0
  55. package/test/fixtures/media-sync.js +57 -0
  56. package/test/happy-dom-platform-adapters.test.js +25 -0
  57. package/test/headless-shell.test.js +29 -0
  58. package/test/hit-test.test.js +25 -1
  59. package/test/log.test.js +4 -1
  60. package/test/media-source.test.js +156 -0
  61. package/test/media-sync.test.js +12 -0
  62. package/test/mobile.test.js +2 -2
  63. package/test/navigation.test.js +61 -0
  64. package/test/network-service.test.js +81 -0
  65. package/test/page-scripts.test.js +1094 -7
  66. package/test/position-audio.test.js +67 -0
  67. package/test/positioning.test.js +95 -1
  68. package/test/pulse-client.test.js +69 -0
  69. package/test/render-tree.test.js +22 -1
  70. package/test/renderer-host.test.js +5 -2
  71. package/test/screenshot-cli.test.js +25 -1
  72. package/test/script-rewrite.test.js +111 -6
  73. package/test/selector-keys.test.js +39 -0
  74. package/test/selector-traits.test.js +19 -0
  75. package/test/setup.js +5 -0
  76. package/test/text-layout.test.js +69 -1
  77. package/test/user-agent.test.js +60 -4
  78. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  79. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  80. package/vendor/ses/dist/ses.mjs +52 -6
@@ -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",
@@ -47,13 +50,21 @@ const VERTICAL_ALIGNS = new Set(["baseline", "top", "middle", "bottom", "text-to
47
50
  const AUTO_TRACK = Object.freeze({ min: "auto", max: "auto" });
48
51
  const FLEX_ALIGNMENT = Object.freeze({ "flex-start": "start", "flex-end": "end", left: "start", right: "end" });
49
52
  const GRADIENT = /^\s*(?:repeating-)?(?:linear|radial)-gradient\(/i;
53
+ // A background layer's image: a URL or a gradient.
54
+ const BACKGROUND_IMAGE = /^(?:url\(|(?:repeating-)?(?:linear|radial)-gradient\()/i;
55
+ const BACKGROUND_REPEATS = new Set(["repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round"]);
56
+ const BACKGROUND_BOXES = new Set(["border-box", "padding-box", "content-box"]);
57
+ const BACKGROUND_ATTACHMENTS = new Set(["scroll", "fixed", "local"]);
50
58
  const BOX_SIDES = Object.freeze(["top", "right", "bottom", "left"]);
51
59
  const WHITE_SPACES = new Set(["normal", "nowrap", "pre", "pre-wrap"]);
52
60
  // CSS Cascade 4 §7.3 CSS-wide keywords; revert is treated as unset (no user/UA origin split).
53
61
  const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
54
62
  // Computed-style fields a shorthand (or a property not named after its field) covers.
55
63
  const PROPERTY_FIELDS = Object.freeze({
56
- background: ["backgroundColor", "backgroundImage"],
64
+ overflow: ["overflowX", "overflowY", "overflowScrollX", "overflowScrollY"],
65
+ "overflow-x": ["overflowX", "overflowScrollX"],
66
+ "overflow-y": ["overflowY", "overflowScrollY"],
67
+ background: ["backgroundColor", "backgroundImage", "backgroundPosition", "backgroundSize", "backgroundRepeat"],
57
68
  flex: ["flexGrow", "flexShrink", "flexBasis"],
58
69
  "flex-flow": ["flexDirection", "flexWrap"],
59
70
  gap: ["rowGap", "columnGap", "gap"],
@@ -86,6 +97,10 @@ const INITIAL = Object.freeze({
86
97
  colorScheme: "light",
87
98
  backgroundColor: "rgba(0, 0, 0, 0)",
88
99
  backgroundImage: null,
100
+ // Comma-separated, one entry per background-image layer (repeated as needed); null is the initial value.
101
+ backgroundPosition: null,
102
+ backgroundSize: null,
103
+ backgroundRepeat: null,
89
104
  backgroundClip: "border-box",
90
105
  boxShadow: null,
91
106
  fontFamily: Object.freeze(["sans-serif"]),
@@ -130,6 +145,8 @@ const INITIAL = Object.freeze({
130
145
  inset: Object.freeze(["auto", "auto", "auto", "auto"]),
131
146
  zIndex: "auto",
132
147
  transform: null,
148
+ // Raw text (default "50% 50%"); paint resolves it against the border box.
149
+ transformOrigin: null,
133
150
  justifyItems: "normal",
134
151
  justifySelf: "auto",
135
152
  gridTemplateColumns: null,
@@ -158,6 +175,10 @@ const INITIAL = Object.freeze({
158
175
  opacity: 1,
159
176
  overflowX: "visible",
160
177
  overflowY: "visible",
178
+ // Scroll containers (CSS Overflow 3 §3): "auto" for auto/scroll/overlay,
179
+ // which the user scrolls; "hidden", which only scripts scroll; else null.
180
+ overflowScrollX: null,
181
+ overflowScrollY: null,
161
182
  textOverflow: "clip",
162
183
  clip: null,
163
184
  clipPath: null,
@@ -184,6 +205,7 @@ const UA_DECLARATIONS = new Map(Object.entries({
184
205
  FIGURE: "margin: 1em 40px",
185
206
  PRE: "margin: 1em 0; font-family: monospace",
186
207
  CODE: "font-family: monospace",
208
+ IFRAME: "border: 2px inset",
187
209
  KBD: "font-family: monospace",
188
210
  SAMP: "font-family: monospace",
189
211
  TT: "font-family: monospace",
@@ -234,6 +256,8 @@ export function controlKind(element) {
234
256
  }
235
257
 
236
258
  /** HTML §15.3.4: :link (visited links are not tracked). */
259
+ // <iframe frameborder="0"> (or "no"): no border in place of the default one.
260
+ const UA_FRAMELESS_DECLARATIONS = parseDeclarations("border: none");
237
261
  const UA_LINK_DECLARATIONS = parseDeclarations("color: #0000EE; text-decoration: underline; cursor: pointer");
238
262
  const DECORATION_LINES = new Set(["underline", "overline", "line-through"]);
239
263
  const DECORATION_STYLES = new Set(["solid", "double", "dotted", "dashed", "wavy"]);
@@ -253,6 +277,9 @@ export class StyleEngine {
253
277
  // Parsed and indexed rules for the last style sheet list and viewport:
254
278
  // restyles after script changes reuse them instead of re-parsing.
255
279
  #ruleCache = { styleSheets: null, key: "", index: null };
280
+ // Parsed rules by sheet text, for the viewport in #parsedSheetsKey.
281
+ #parsedSheets = new Map();
282
+ #parsedSheetsKey = "";
256
283
 
257
284
  /**
258
285
  * Computes the document's styles. With `dirty` (elements whose subtree's
@@ -306,10 +333,7 @@ export class StyleEngine {
306
333
  const topmost = topmostConnected(starts, root);
307
334
  for (const start of topmost) {
308
335
  const parent = start.parentElement;
309
- const ancestors = new Map();
310
- for (let node = parent; node; node = node.parentElement) {
311
- for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
312
- }
336
+ const ancestors = this.#ancestorsOf(start);
313
337
  this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
314
338
  }
315
339
  // Elements whose own matches may have changed (:empty, :first-child,
@@ -332,10 +356,8 @@ export class StyleEngine {
332
356
  const cache = this.#ruleCache;
333
357
  this.#sheetChanges = null;
334
358
  if (cache.styleSheets !== styleSheets || cache.key !== key || cache.length !== styleSheets.length) {
335
- const counter = { order: 0 };
336
- const perSheet = styleSheets.map((source) => parseStyleSheet(source, environment, counter));
359
+ const perSheet = this.#parseSheets(styleSheets, key);
337
360
  const rules = perSheet.flat();
338
- if (counter.layers) rankLayers(counter.layers, rules);
339
361
  // Sheets added or removed since the last list (same viewport): only the
340
362
  // elements their rules match can change style, every other element
341
363
  // keeps the same rules in the same relative order.
@@ -345,6 +367,62 @@ export class StyleEngine {
345
367
  return this.#ruleCache.index;
346
368
  }
347
369
 
370
+ /**
371
+ * Each sheet's rules, in cascade order. A sheet is parsed once per text
372
+ * (and viewport) with layers of its own; putting a list together only
373
+ * renumbers the rules and merges the sheets' layer trees in order, so a
374
+ * script adding one <style> does not re-parse every other sheet. Of
375
+ * identical sheets only the last copy's rules are kept, as each beats the
376
+ * same rule of an earlier copy (the earlier copy still declares its layers
377
+ * first); a sheet with anonymous layers is kept in every copy, since under
378
+ * !important an earlier anonymous layer wins.
379
+ */
380
+ #parseSheets(styleSheets, key) {
381
+ if (this.#parsedSheetsKey !== key) {
382
+ this.#parsedSheets = new Map();
383
+ this.#parsedSheetsKey = key;
384
+ }
385
+ const parsed = this.#parsedSheets;
386
+ const entries = styleSheets.map((source) => {
387
+ let entry = parsed.get(source);
388
+ if (entry) parsed.delete(source);
389
+ else {
390
+ const counter = { order: 0 };
391
+ const rules = parseStyleSheet(source, environment, counter);
392
+ entry = { rules, layers: counter.layers.children.size ? counter.layers : null };
393
+ entry.anonymousLayers = entry.layers !== null && hasAnonymousLayer(entry.layers);
394
+ }
395
+ parsed.set(source, entry); // most recently used last
396
+ return entry;
397
+ });
398
+ for (const source of parsed.keys()) {
399
+ if (parsed.size <= MAX_PARSED_SHEETS + styleSheets.length) break;
400
+ parsed.delete(source);
401
+ }
402
+ const last = new Map();
403
+ entries.forEach((entry, index) => last.set(entry, index));
404
+ const root = newLayer(null);
405
+ const layerOf = new Map();
406
+ let order = 0;
407
+ const perSheet = entries.map((entry, index) => {
408
+ if (entry.layers) mergeLayers(entry.layers, root, layerOf);
409
+ const lastCopy = last.get(entry) === index;
410
+ if (!lastCopy && !entry.anonymousLayers) return [];
411
+ // Each kept copy but the last gets rule objects of its own.
412
+ const rules = lastCopy ? entry.rules : entry.rules.map((rule) => rule.copy());
413
+ for (const rule of rules) {
414
+ rule.order = order++;
415
+ rule.layer = rule.layerNode ? layerOf.get(rule.layerNode) : Infinity;
416
+ }
417
+ return rules;
418
+ });
419
+ if (root.children.size) {
420
+ rankLayerTree(root);
421
+ for (const rules of perSheet) for (const rule of rules) if (rule.layerNode) rule.layer = rule.layer.rank;
422
+ }
423
+ return perSheet;
424
+ }
425
+
348
426
  /** Elements matched by the rules of added or removed sheets, or null when that is unknown. */
349
427
  #elementsForSheetChanges(document) {
350
428
  const changes = this.#sheetChanges;
@@ -365,6 +443,11 @@ export class StyleEngine {
365
443
  return this.#styles.get(element) ?? INITIAL;
366
444
  }
367
445
 
446
+ /** An element's custom properties (--*), name to value text, inherited ones included. */
447
+ customPropertiesOf(element) {
448
+ return this.#customProperties.get(element) ?? EMPTY_MAP;
449
+ }
450
+
368
451
  /** The computed style of an element's ::before/::after box, or null when it generates none. */
369
452
  getPseudo(element, pseudo) {
370
453
  return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
@@ -372,16 +455,24 @@ export class StyleEngine {
372
455
 
373
456
  /** Computes and stores one element's style and ::before/::after styles. */
374
457
  #styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors) {
458
+ const previousStyle = this.#styles.get(element);
459
+ const previousCustom = this.#customProperties.get(element);
460
+ const previousPseudo = this.#pseudoStyles.get(element);
375
461
  // Inline style takes part in the cascade before custom properties are
376
462
  // resolved: style="--x: ..." must win over stylesheet values of --x.
377
463
  const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
378
- const customProperties = withCustomProperties(parentCustomProperties ?? EMPTY_MAP, declarations);
379
- const style = computeElementStyle(element, parentStyle, declarations, customProperties);
464
+ let customProperties = withCustomProperties(parentCustomProperties ?? EMPTY_MAP, declarations);
465
+ let style = computeElementStyle(element, parentStyle, declarations, customProperties);
466
+ // A descendant may be visited because an ancestor's selector state
467
+ // changed even though its final computed values did not. Preserve the
468
+ // immutable identities so retained render and layout subtrees survive.
469
+ if (sameValue(previousStyle, style)) style = previousStyle;
470
+ if (sameCustom(previousCustom, customProperties)) customProperties = previousCustom;
380
471
  // rem units resolve against the root element's computed font size.
381
472
  if (!parentStyle) environment = { ...environment, rootFontSize: style.fontSize };
382
473
  this.#styles.set(element, style);
383
474
  this.#customProperties.set(element, customProperties);
384
- const pseudoStyles = {};
475
+ let pseudoStyles = {};
385
476
  for (const pseudo of ["before", "after"]) {
386
477
  const pseudoDeclarations = cascadeDeclarations(element, rules, pseudo, keys, ancestors);
387
478
  if (!pseudoDeclarations.has("content")) continue;
@@ -390,13 +481,16 @@ export class StyleEngine {
390
481
  // `content: none | normal` generates no box (CSS Pseudo 4 §2.3).
391
482
  if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
392
483
  }
484
+ if (sameValue(previousPseudo ?? null, (pseudoStyles.before || pseudoStyles.after) ? pseudoStyles : null)) {
485
+ pseudoStyles = previousPseudo ?? {};
486
+ }
393
487
  if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
394
488
  else this.#pseudoStyles.delete(element);
395
489
  this.restyled++;
396
490
  return { style, customProperties };
397
491
  }
398
492
 
399
- #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new Map()) {
493
+ #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new AncestorFilter()) {
400
494
  const keys = elementKeys(element);
401
495
  const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
402
496
  this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
@@ -404,22 +498,15 @@ export class StyleEngine {
404
498
 
405
499
  #visitChildren(element, keys, style, customProperties, rules, ancestors) {
406
500
  // The element is an ancestor of everything below it.
407
- const own = ancestorFilterKeys(keys);
408
- for (const key of own) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
501
+ const own = ancestors.push(keys);
409
502
  for (const child of element.children) this.#visit(child, style, customProperties, rules, ancestors);
410
- for (const key of own) {
411
- const count = ancestors.get(key) - 1;
412
- if (count) ancestors.set(key, count);
413
- else ancestors.delete(key);
414
- }
503
+ ancestors.pop(own);
415
504
  }
416
505
 
417
506
  /** Ancestor filter counts for the elements above `element`. */
418
507
  #ancestorsOf(element) {
419
- const ancestors = new Map();
420
- for (let node = element.parentElement; node; node = node.parentElement) {
421
- for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
422
- }
508
+ const ancestors = new AncestorFilter();
509
+ for (let node = element.parentElement; node; node = node.parentElement) ancestors.push(elementKeys(node));
423
510
  return ancestors;
424
511
  }
425
512
 
@@ -461,6 +548,8 @@ function sameCustom(left, right) {
461
548
 
462
549
  const EMPTY_MAP = new Map();
463
550
  const MAX_SHEET_CHANGE_RULES = 300;
551
+ // Parsed sheets kept for reuse beyond those in use (sheets that come and go).
552
+ const MAX_PARSED_SHEETS = 64;
464
553
 
465
554
  /**
466
555
  * Rules from sheets present in only one of two sheet lists (by text), or null
@@ -555,7 +644,9 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
555
644
  const isLink = (element.tagName === "A" || element.tagName === "AREA") && element.hasAttribute("href");
556
645
  const control = controlKind(element);
557
646
  if (control === "hidden") values.display = "none";
558
- for (const declaration of [...(UA_DECLARATIONS.get(element.tagName)?.values() ?? []),
647
+ const frameless = element.tagName === "IFRAME" && ["0", "no"].includes(element.getAttribute("frameborder")?.trim().toLowerCase());
648
+ const uaDeclarations = frameless ? UA_FRAMELESS_DECLARATIONS : UA_DECLARATIONS.get(element.tagName);
649
+ for (const declaration of [...(uaDeclarations?.values() ?? []),
559
650
  ...(isLink ? UA_LINK_DECLARATIONS.values() : []), ...(UA_CONTROL_DECLARATIONS[control]?.values() ?? [])]) {
560
651
  if (!declarations.has(declaration.name)) {
561
652
  declarations.set(declaration.name, { ...declaration, specificity: -1, order: -1, layer: -Infinity });
@@ -571,8 +662,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
571
662
  // font-size and the font shorthand wins the cascade.
572
663
  const fontSize = declarations.get("font-size");
573
664
  const font = declarations.get("font");
574
- const cascadeKey = (declaration) => ({ important: false, specificity: 0, order: 0, ...declaration, important: Boolean(declaration.important) });
575
- if (font && (!fontSize || compareCascade(cascadeKey(font), cascadeKey(fontSize)) > 0)) {
665
+ if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
576
666
  const parsed = parseFontShorthand(resolveVariables(font.value, customProperties));
577
667
  if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
578
668
  } else if (fontSize) {
@@ -582,10 +672,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
582
672
  const colorScheme = declarations.get("color-scheme");
583
673
  if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
584
674
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
585
- const ordered = [...declarations.values()].sort((left, right) => compareCascade(
586
- { important: false, specificity: 0, order: 0, ...left, important: Boolean(left.important) },
587
- { important: false, specificity: 0, order: 0, ...right, important: Boolean(right.important) },
588
- ));
675
+ const ordered = [...declarations.values()].sort(compareDeclarations);
589
676
  for (const declaration of ordered) {
590
677
  if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
591
678
  // The shorthand's size was settled above; its other parts apply in order.
@@ -706,6 +793,13 @@ class StyleRule {
706
793
  get hasCustomProperties() {
707
794
  return this.#block.hasCustomProperties;
708
795
  }
796
+
797
+ /** The same rule as a separate object (its own order and layer). */
798
+ copy() {
799
+ const rule = new StyleRule(this.selector, this.#selectorText, this.pseudo, this.#block, this.order);
800
+ rule.layerNode = this.layerNode;
801
+ return rule;
802
+ }
709
803
  }
710
804
 
711
805
  /**
@@ -744,7 +838,7 @@ export function parseStyleSheet(source, viewport = environment, counter = { orde
744
838
  if (name === "layer") {
745
839
  // A named layer block, or an anonymous one (a layer of its own).
746
840
  const parent = layer ?? counter.layers;
747
- const inner = condition ? declareLayer(parent, condition) : declareLayer(parent, `\0anonymous${counter.order}`);
841
+ const inner = condition ? declareLayer(parent, condition) : declareLayer(parent, `${ANONYMOUS_LAYER}${counter.order}`);
748
842
  parseBlock(open + 1, close, inner);
749
843
  } else if ((name === "media" && mediaQueryMatches(condition, viewport)) || name === "supports") {
750
844
  parseBlock(open + 1, close, layer);
@@ -771,6 +865,28 @@ function newLayer(parent) {
771
865
  return { parent, children: new Map(), rank: Infinity };
772
866
  }
773
867
 
868
+ const ANONYMOUS_LAYER = "\0anonymous";
869
+
870
+ function hasAnonymousLayer(node) {
871
+ for (const [name, child] of node.children) if (name.startsWith(ANONYMOUS_LAYER) || hasAnonymousLayer(child)) return true;
872
+ return false;
873
+ }
874
+
875
+ /**
876
+ * Adds one sheet's layer tree to the layers of a sheet list, in first-seen
877
+ * order; `layerOf` maps each of the sheet's layers to the list's. Every
878
+ * anonymous layer is a layer of its own.
879
+ */
880
+ function mergeLayers(node, target, layerOf) {
881
+ for (const [name, child] of node.children) {
882
+ const anonymous = name.startsWith(ANONYMOUS_LAYER);
883
+ let merged = anonymous ? null : target.children.get(name);
884
+ if (!merged) target.children.set(anonymous ? `${name}\0${target.children.size}` : name, merged = newLayer(target));
885
+ layerOf.set(child, merged);
886
+ mergeLayers(child, merged, layerOf);
887
+ }
888
+ }
889
+
774
890
  /** Finds or creates a (possibly dotted) layer name under `parent`, in first-seen order. */
775
891
  function declareLayer(parent, name) {
776
892
  let node = parent;
@@ -787,12 +903,16 @@ function declareLayer(parent, name) {
787
903
  * layer's sublayers before its own rules; unlayered rules rank highest.
788
904
  */
789
905
  export function rankLayers(root, rules) {
906
+ rankLayerTree(root);
907
+ for (const rule of rules) rule.layer = rule.layerNode ? rule.layerNode.rank : Infinity;
908
+ }
909
+
910
+ function rankLayerTree(root) {
790
911
  let next = 0;
791
912
  (function visit(node) {
792
913
  for (const child of node.children.values()) visit(child);
793
914
  if (node !== root) node.rank = next++;
794
915
  })(root);
795
- for (const rule of rules) rule.layer = rule.layerNode ? rule.layerNode.rank : Infinity;
796
916
  }
797
917
 
798
918
  /** Splits a `font` shorthand value into size, line height, weight and family. */
@@ -823,34 +943,38 @@ function parseFontShorthand(value) {
823
943
  return null;
824
944
  }
825
945
 
946
+ // Character codes for the scanners below, which run over whole style sheets.
947
+ const QUOTE = 0x22, APOSTROPHE = 0x27, BACKSLASH = 0x5c, OPEN_PAREN = 0x28, CLOSE_PAREN = 0x29, OPEN_BRACE = 0x7b, CLOSE_BRACE = 0x7d;
948
+
826
949
  function findTopLevel(source, character, start, end) {
827
- let quote = "";
950
+ const target = character.charCodeAt(0);
951
+ let quote = 0;
828
952
  let depth = 0;
829
953
  for (let index = start; index < end; index++) {
830
- const current = source[index];
954
+ const current = source.charCodeAt(index);
831
955
  if (quote) {
832
- if (current === "\\") index++;
833
- else if (current === quote) quote = "";
834
- } else if (current === '"' || current === "'") quote = current;
835
- else if (current === "(") depth++;
836
- else if (current === ")") depth = Math.max(0, depth - 1);
837
- else if (current === character && depth === 0) return index;
838
- else if (current === "{" || current === "}") return character === current ? index : -1;
956
+ if (current === BACKSLASH) index++;
957
+ else if (current === quote) quote = 0;
958
+ } else if (current === QUOTE || current === APOSTROPHE) quote = current;
959
+ else if (current === OPEN_PAREN) depth++;
960
+ else if (current === CLOSE_PAREN) depth = Math.max(0, depth - 1);
961
+ else if (current === target && depth === 0) return index;
962
+ else if (current === OPEN_BRACE || current === CLOSE_BRACE) return target === current ? index : -1;
839
963
  }
840
964
  return -1;
841
965
  }
842
966
 
843
967
  function matchingBrace(source, open, end) {
844
968
  let depth = 0;
845
- let quote = "";
969
+ let quote = 0;
846
970
  for (let index = open; index < end; index++) {
847
- const current = source[index];
971
+ const current = source.charCodeAt(index);
848
972
  if (quote) {
849
- if (current === "\\") index++;
850
- else if (current === quote) quote = "";
851
- } else if (current === '"' || current === "'") quote = current;
852
- else if (current === "{") depth++;
853
- else if (current === "}" && --depth === 0) return index;
973
+ if (current === BACKSLASH) index++;
974
+ else if (current === quote) quote = 0;
975
+ } else if (current === QUOTE || current === APOSTROPHE) quote = current;
976
+ else if (current === OPEN_BRACE) depth++;
977
+ else if (current === CLOSE_BRACE && --depth === 0) return index;
854
978
  }
855
979
  return end;
856
980
  }
@@ -1016,13 +1140,22 @@ function setProperty(style, name, value, parentStyle = null) {
1016
1140
  case "overflow":
1017
1141
  case "overflow-x":
1018
1142
  case "overflow-y": {
1019
- // Scrolling boxes are not implemented: every non-visible value clips.
1143
+ // Every non-visible value clips; all but clip also make a scroll container.
1020
1144
  const parts = value.split(/\s+/);
1021
1145
  const clips = (part) => (["hidden", "clip", "auto", "scroll", "overlay"].includes(part) ? "clip" : part === "visible" ? "visible" : null);
1022
- const x = clips(parts[0]);
1023
- const y = clips(parts[1] ?? parts[0]);
1024
- if (name !== "overflow-y" && x) style.overflowX = x;
1025
- if (name !== "overflow-x" && (name === "overflow" ? y : x)) style.overflowY = name === "overflow" ? y : x;
1146
+ const scrolls = (part) => (["auto", "scroll", "overlay"].includes(part) ? "auto" : part === "hidden" ? "hidden" : null);
1147
+ const xPart = parts[0];
1148
+ const yPart = name === "overflow" ? parts[1] ?? parts[0] : parts[0];
1149
+ const x = clips(xPart);
1150
+ const y = clips(yPart);
1151
+ if (name !== "overflow-y" && x) {
1152
+ style.overflowX = x;
1153
+ style.overflowScrollX = scrolls(xPart);
1154
+ }
1155
+ if (name !== "overflow-x" && y) {
1156
+ style.overflowY = y;
1157
+ style.overflowScrollY = scrolls(yPart);
1158
+ }
1026
1159
  break;
1027
1160
  }
1028
1161
  case "text-overflow":
@@ -1061,24 +1194,37 @@ function setProperty(style, name, value, parentStyle = null) {
1061
1194
  break;
1062
1195
  }
1063
1196
  case "background": {
1064
- // The shorthand resets both longhands; only the final layer may carry a color.
1065
- const layers = splitTopLevel(value, ",");
1066
- const images = layers.filter((layer) => GRADIENT.test(layer));
1067
- const color = splitTopLevel(layers.at(-1) ?? "", " ")
1068
- .map((token) => resolvedColor(token, style.colorScheme))
1069
- .find(Boolean);
1070
- style.backgroundColor = color ?? INITIAL.backgroundColor;
1071
- style.backgroundImage = images.length ? images.join(", ") : null;
1197
+ // The shorthand resets the longhands; only the final layer may carry a color.
1198
+ const layers = splitTopLevel(value, ",").map(parseBackgroundLayer);
1199
+ if (layers.some((layer) => !layer)) break;
1200
+ style.backgroundColor = layers.at(-1).color
1201
+ ? resolvedColor(layers.at(-1).color, style.colorScheme) ?? INITIAL.backgroundColor
1202
+ : INITIAL.backgroundColor;
1203
+ const hasImage = layers.some((layer) => layer.image !== "none");
1204
+ style.backgroundImage = hasImage ? layers.map((layer) => layer.image).join(", ") : null;
1205
+ const list = (field, initial) => layers.some((layer) => layer[field] !== initial)
1206
+ ? layers.map((layer) => layer[field]).join(", ")
1207
+ : null;
1208
+ style.backgroundPosition = hasImage ? list("position", "0% 0%") : null;
1209
+ style.backgroundSize = hasImage ? list("size", "auto") : null;
1210
+ style.backgroundRepeat = hasImage ? list("repeat", "repeat") : null;
1072
1211
  break;
1073
1212
  }
1074
1213
  case "background-image": {
1075
1214
  if (value === "none") style.backgroundImage = null;
1076
1215
  else {
1077
- const images = splitTopLevel(value, ",").filter((layer) => GRADIENT.test(layer));
1078
- if (images.length) style.backgroundImage = images.join(", ");
1216
+ const images = splitTopLevel(value, ",").map((layer) => BACKGROUND_IMAGE.test(layer) || layer === "none" ? layer : null);
1217
+ if (images.every(Boolean) && images.some((layer) => layer !== "none")) style.backgroundImage = images.join(", ");
1079
1218
  }
1080
1219
  break;
1081
1220
  }
1221
+ case "background-position":
1222
+ case "background-size":
1223
+ case "background-repeat": {
1224
+ const field = { "background-position": "backgroundPosition", "background-size": "backgroundSize", "background-repeat": "backgroundRepeat" }[name];
1225
+ style[field] = value.toLowerCase().replace(/\s+/g, " ");
1226
+ break;
1227
+ }
1082
1228
  case "box-shadow": {
1083
1229
  const shadows = value === "none" ? null : parseBoxShadows(value, style.fontSize);
1084
1230
  if (value === "none" || shadows) style.boxShadow = shadows;
@@ -1133,10 +1279,13 @@ function setProperty(style, name, value, parentStyle = null) {
1133
1279
  break;
1134
1280
  }
1135
1281
  case "transform": {
1136
- const transform = value === "none" ? null : parseTranslate(value, style.fontSize);
1282
+ const transform = value === "none" ? null : parseTransform(value, style.fontSize);
1137
1283
  if (transform !== undefined) style.transform = transform;
1138
1284
  break;
1139
1285
  }
1286
+ case "transform-origin":
1287
+ style.transformOrigin = value.toLowerCase().replace(/\s+/g, " ");
1288
+ break;
1140
1289
  case "content": {
1141
1290
  const content = parseContent(value);
1142
1291
  if (content !== undefined) style.content = content;
@@ -1200,12 +1349,15 @@ function setProperty(style, name, value, parentStyle = null) {
1200
1349
  if (value === "normal") {
1201
1350
  style.lineHeight = null;
1202
1351
  style.lineHeightFactor = null;
1203
- } else if (Number.isFinite(numeric) && numeric > 0) {
1352
+ } else if (value !== "" && Number.isFinite(numeric) && numeric >= 0) {
1204
1353
  style.lineHeight = numeric * style.fontSize;
1205
1354
  style.lineHeightFactor = numeric;
1206
1355
  } else {
1207
- const length = computedLength(value, style.fontSize);
1208
- if (length > 0) {
1356
+ // Non-negative, so 0 is valid (icon buttons set line-height: 0); a
1357
+ // percentage computes to a length against this element's font size.
1358
+ const percentage = /^(\d+\.?\d*|\.\d+)%$/.exec(value);
1359
+ const length = percentage ? Number(percentage[1]) / 100 * style.fontSize : computedLength(value, style.fontSize);
1360
+ if (length >= 0) {
1209
1361
  style.lineHeight = length;
1210
1362
  style.lineHeightFactor = null;
1211
1363
  }
@@ -1452,10 +1604,14 @@ class RuleIndex {
1452
1604
  buckets = { id: new Map(), class: new Map(), attr: new Map(), tag: new Map(), universal: [] };
1453
1605
  this.#byPseudo.set(rule.pseudo, buckets);
1454
1606
  }
1455
- const parts = selectorParts(rule.selector);
1456
- rule.ancestorKeys = parts ? ancestorKeys(parts) : [];
1607
+ // Kept on the rule: a parsed sheet's rules are indexed again whenever the sheet list changes.
1608
+ if (rule.subjectKeys === undefined) {
1609
+ const { subject, ancestors } = selectorKeys(rule.selector);
1610
+ rule.ancestorIds = Int32Array.from(ancestors, filterKeyId);
1611
+ rule.subjectKeys = subject;
1612
+ }
1457
1613
  rule.seen = 0;
1458
- const keys = parts ? subjectKeys(parts.compounds.at(-1)) : null;
1614
+ const keys = rule.subjectKeys;
1459
1615
  if (!keys) {
1460
1616
  buckets.universal.push(rule);
1461
1617
  continue;
@@ -1472,7 +1628,7 @@ class RuleIndex {
1472
1628
  /**
1473
1629
  * Rules that may match `element` for `pseudo`, in cascade order.
1474
1630
  * @param {ReturnType<typeof elementKeys>} keys the element's own keys
1475
- * @param {Map<string, number>} ancestors keys present on its ancestors
1631
+ * @param {AncestorFilter} ancestors keys present on its ancestors
1476
1632
  */
1477
1633
  candidates(keys, pseudo, ancestors) {
1478
1634
  const buckets = this.#byPseudo.get(pseudo);
@@ -1490,7 +1646,7 @@ class RuleIndex {
1490
1646
  for (const rule of list) {
1491
1647
  if (rule.seen === stamp) continue;
1492
1648
  rule.seen = stamp;
1493
- if (rule.ancestorKeys.every((key) => ancestors.has(key))) result.push(rule);
1649
+ if (ancestors.hasAll(rule.ancestorIds)) result.push(rule);
1494
1650
  }
1495
1651
  }
1496
1652
  if (lists.length > 1) result.sort((left, right) => left.order - right.order);
@@ -1498,6 +1654,72 @@ class RuleIndex {
1498
1654
  }
1499
1655
  }
1500
1656
 
1657
+ /**
1658
+ * The elements that match each selector among `root`'s descendants and, for
1659
+ * an element, `root` itself (querySelectorAll() plus matches()), in one walk: selectors are bucketed by their subject's keys like
1660
+ * rules, so each element is only tested against selectors it may match.
1661
+ * Invalid selectors are left out of the result.
1662
+ * @param {Element | Document} root
1663
+ * @param {Iterable<string>} selectors
1664
+ * @param {Map<string, object | null>} [compiledSelectors] compiled selectors to reuse and add to
1665
+ * @returns {Map<string, Set<Element>>}
1666
+ */
1667
+ export function matchSelectors(root, selectors, compiledSelectors = new Map()) {
1668
+ const result = new Map();
1669
+ const buckets = { id: new Map(), class: new Map(), attr: new Map(), tag: new Map(), universal: [] };
1670
+ const sample = root.nodeType === 9 ? root.documentElement : root;
1671
+ for (const selector of selectors) {
1672
+ if (result.has(selector)) continue;
1673
+ // :scope means the root to querySelectorAll() but the element to matches().
1674
+ let compiled;
1675
+ if (sample && !selector.includes(":scope")) {
1676
+ compiled = compiledSelectors.get(selector);
1677
+ if (compiled === undefined) {
1678
+ compiled = compileSelector(sample, selector);
1679
+ if (compiled !== undefined) compiledSelectors.set(selector, compiled);
1680
+ }
1681
+ }
1682
+ if (compiled === null) continue;
1683
+ if (compiled === undefined) {
1684
+ try { result.set(selector, new Set(root.querySelectorAll(selector))); } catch { /* invalid */ }
1685
+ continue;
1686
+ }
1687
+ const entry = { compiled, matched: new Set(), seen: 0 };
1688
+ result.set(selector, entry.matched);
1689
+ const keys = selectorKeys(selector).subject;
1690
+ if (!keys) {
1691
+ buckets.universal.push(entry);
1692
+ continue;
1693
+ }
1694
+ for (const key of keys) {
1695
+ const list = buckets[key.kind].get(key.name);
1696
+ if (list) list.push(entry); else buckets[key.kind].set(key.name, [entry]);
1697
+ }
1698
+ }
1699
+ let stamp = 0;
1700
+ const test = (element, list) => {
1701
+ if (!list) return;
1702
+ for (const entry of list) {
1703
+ if (entry.seen === stamp) continue;
1704
+ entry.seen = stamp;
1705
+ if (matchesCompiled(element, entry.compiled)) entry.matched.add(element);
1706
+ }
1707
+ };
1708
+ const visit = (element) => {
1709
+ stamp++;
1710
+ const keys = elementKeys(element);
1711
+ test(element, buckets.universal);
1712
+ if (keys.id) test(element, buckets.id.get(keys.id));
1713
+ for (const name of keys.classes) test(element, buckets.class.get(name));
1714
+ for (const name of keys.attrs) test(element, buckets.attr.get(name));
1715
+ if (keys.tag) test(element, buckets.tag.get(keys.tag));
1716
+ for (const child of element.children) visit(child);
1717
+ };
1718
+ if (root.nodeType === 1) visit(root);
1719
+ else for (const child of root.children ?? []) visit(child);
1720
+ return result;
1721
+ }
1722
+
1501
1723
  /** The keys an element offers to selectors: id, classes, attribute names, type. */
1502
1724
  function elementKeys(element) {
1503
1725
  const id = element.getAttribute?.("id") || null;
@@ -1509,203 +1731,102 @@ function elementKeys(element) {
1509
1731
  return { id, classes, attrs, tag: tag ? String(tag).toLowerCase() : null };
1510
1732
  }
1511
1733
 
1512
- /** Ancestor filter keys for an element: "tag", "#id", ".class", "[attr". */
1513
- function ancestorFilterKeys(keys) {
1514
- const result = [];
1515
- if (keys.tag) result.push(keys.tag);
1516
- if (keys.id) result.push(`#${keys.id}`);
1517
- for (const name of keys.classes) result.push(`.${name}`);
1518
- for (const name of keys.attrs) result.push(`[${name}`);
1519
- return result;
1734
+ // Ancestor filter keys that some rule requires, numbered for AncestorFilter.
1735
+ const FILTER_KEY_IDS = new Map();
1736
+
1737
+ function filterKeyId(key) {
1738
+ let id = FILTER_KEY_IDS.get(key);
1739
+ if (id === undefined) FILTER_KEY_IDS.set(key, id = FILTER_KEY_IDS.size);
1740
+ return id;
1520
1741
  }
1521
1742
 
1522
1743
  /**
1523
- * Splits a complex selector into compounds and the combinators between them
1524
- * (" ", ">", "+", "~"), ignoring anything inside (), [] and quotes. Returns
1525
- * null for selectors this does not understand, which are then left unindexed.
1744
+ * Counts of the filter keys on the elements above the one being styled (a
1745
+ * counting filter, like Blink's ancestor Bloom filter but exact). Keys no
1746
+ * rule requires are not counted. Created after the rules are indexed.
1526
1747
  */
1527
- function selectorParts(selector) {
1528
- const compounds = [];
1529
- const combinators = [];
1530
- let current = "";
1531
- let pending = null;
1532
- let depth = 0;
1533
- for (let index = 0; index < selector.length; index++) {
1534
- const character = selector[index];
1535
- if (character === "\\") {
1536
- current += character + (selector[++index] ?? "");
1537
- continue;
1538
- }
1539
- if (character === "\"" || character === "'") {
1540
- const close = selector.indexOf(character, index + 1);
1541
- if (close < 0) return null;
1542
- current += selector.slice(index, close + 1);
1543
- index = close;
1544
- continue;
1545
- }
1546
- if (character === "(" || character === "[") depth++;
1547
- else if (character === ")" || character === "]") depth--;
1548
- if (depth === 0 && (character === " " || character === ">" || character === "+" || character === "~"
1549
- || character === "\t" || character === "\n" || character === "\r" || character === "\f")) {
1550
- if (current) {
1551
- compounds.push(current);
1552
- current = "";
1553
- pending = " ";
1554
- }
1555
- if (character === ">" || character === "+" || character === "~") {
1556
- if (pending === null && compounds.length === 0) return null;
1557
- pending = character;
1558
- }
1559
- continue;
1560
- }
1561
- if (pending !== null) {
1562
- combinators.push(pending);
1563
- pending = null;
1564
- }
1565
- current += character;
1566
- }
1567
- if (depth !== 0) return null;
1568
- if (current) compounds.push(current);
1569
- else if (pending !== null && compounds.length) return null;
1570
- return compounds.length ? { compounds, combinators } : null;
1571
- }
1572
-
1573
- const IDENTIFIER = /^(?:[\w-]|\\[^0-9a-fA-F\s]|[^\x00-\x7f])+/;
1748
+ class AncestorFilter {
1749
+ #counts = new Int32Array(Math.max(1, FILTER_KEY_IDS.size));
1574
1750
 
1575
- /** An identifier at the start of `text`, unescaped; null when absent or hex-escaped. */
1576
- function leadingIdentifier(text) {
1577
- const match = IDENTIFIER.exec(text);
1578
- if (!match || text[match[0].length] === "\\") return null;
1579
- return match[0].replace(/\\(.)/g, "$1");
1580
- }
1581
-
1582
- /** Keys of one compound selector, from its parts outside () (ids, classes, attribute names, type). */
1583
- function compoundKeys(compound) {
1584
- const keys = { ids: [], classes: [], attrs: [], tag: null, pseudo: [] };
1585
- if (compound.includes("|")) return keys;
1586
- const tag = /^[a-zA-Z][\w-]*/.exec(compound)?.[0];
1587
- if (tag) keys.tag = tag.toLowerCase();
1588
- let depth = 0;
1589
- for (let index = 0; index < compound.length; index++) {
1590
- const character = compound[index];
1591
- if (character === "\\") {
1592
- index++;
1593
- continue;
1594
- }
1595
- if (character === "(") {
1596
- depth++;
1597
- continue;
1598
- }
1599
- if (character === ")") {
1600
- depth--;
1601
- continue;
1602
- }
1603
- if (depth > 0) continue;
1604
- if (character === "[") {
1605
- const name = /^\s*([\w-]+)/.exec(compound.slice(index + 1))?.[1];
1606
- if (name) keys.attrs.push(name.toLowerCase());
1607
- // Skip to the closing bracket; quoted values may contain "]", "#" or ".".
1608
- let quote = null;
1609
- for (index++; index < compound.length; index++) {
1610
- const inner = compound[index];
1611
- if (inner === "\\") index++;
1612
- else if (quote) {
1613
- if (inner === quote) quote = null;
1614
- } else if (inner === "\"" || inner === "'") quote = inner;
1615
- else if (inner === "]") break;
1616
- }
1617
- } else if (character === "#" || character === ".") {
1618
- const name = leadingIdentifier(compound.slice(index + 1));
1619
- if (name && !(character === "." && /^\d/.test(name))) (character === "#" ? keys.ids : keys.classes).push(name);
1620
- } else if (character === ":") {
1621
- const name = /^:?([\w-]+)/.exec(compound.slice(index + 1))?.[1];
1622
- if (name) keys.pseudo.push({ name: name.toLowerCase(), start: index });
1751
+ /** Adds an element's keys; returns what pop() takes to remove them. */
1752
+ push(keys) {
1753
+ const ids = [];
1754
+ for (const key of ancestorFilterKeys(keys)) {
1755
+ const id = FILTER_KEY_IDS.get(key);
1756
+ if (id === undefined || id >= this.#counts.length) continue;
1757
+ this.#counts[id]++;
1758
+ ids.push(id);
1623
1759
  }
1760
+ return ids;
1624
1761
  }
1625
- return keys;
1626
- }
1627
1762
 
1628
- /**
1629
- * Bucket keys for the subject compound: one id, class, attribute or type key,
1630
- * or one per alternative of an :is()/:where() whose alternatives all have one.
1631
- */
1632
- function subjectKeys(compound) {
1633
- const keys = compoundKeys(compound);
1634
- if (keys.ids.length) return [{ kind: "id", name: keys.ids[0] }];
1635
- if (keys.classes.length) return [{ kind: "class", name: keys.classes[0] }];
1636
- if (keys.attrs.length) return [{ kind: "attr", name: keys.attrs[0] }];
1637
- if (keys.tag) return [{ kind: "tag", name: keys.tag }];
1638
- if (keys.pseudo.some(({ name }) => name === "root")) return [{ kind: "tag", name: "html" }];
1639
- for (const { name, start } of keys.pseudo) {
1640
- if (!["is", "where", "matches", "-webkit-any"].includes(name)) continue;
1641
- const open = compound.indexOf("(", start);
1642
- const close = open < 0 ? -1 : matchingParenthesis(compound, open);
1643
- if (close < 0) return null;
1644
- const alternatives = splitTopLevel(compound.slice(open + 1, close), ",").filter(Boolean);
1645
- const result = [];
1646
- for (const alternative of alternatives) {
1647
- // Only simple alternatives: complex ones inside :is() are left to the
1648
- // selector engine unindexed, so indexing never changes a result.
1649
- const parts = selectorParts(alternative.trim());
1650
- const inner = parts?.compounds.length === 1 ? subjectKeys(parts.compounds[0]) : null;
1651
- if (!inner) return null;
1652
- result.push(...inner);
1653
- }
1654
- return result.length ? result : null;
1763
+ pop(ids) {
1764
+ for (const id of ids) this.#counts[id]--;
1655
1765
  }
1656
- return null;
1657
- }
1658
1766
 
1659
- /** Keys that must be present on some ancestor for the selector to match. */
1660
- function ancestorKeys(parts) {
1661
- const keys = new Set();
1662
- for (let index = 0; index < parts.compounds.length - 1; index++) {
1663
- // Only compounds whose right-hand combinator is descendant or child are
1664
- // ancestors of the subject (a sibling's parent is the subject's parent).
1665
- if (parts.combinators[index] !== " " && parts.combinators[index] !== ">") continue;
1666
- const compound = compoundKeys(parts.compounds[index]);
1667
- if (compound.tag) keys.add(compound.tag);
1668
- for (const id of compound.ids) keys.add(`#${id}`);
1669
- for (const name of compound.classes) keys.add(`.${name}`);
1670
- for (const name of compound.attrs) keys.add(`[${name}`);
1767
+ hasAll(ids) {
1768
+ for (let index = 0; index < ids.length; index++) if (!(this.#counts[ids[index]] > 0)) return false;
1769
+ return true;
1671
1770
  }
1672
- return [...keys];
1673
1771
  }
1674
1772
 
1675
- function matchingParenthesis(text, open) {
1676
- let depth = 0;
1677
- for (let index = open; index < text.length; index++) {
1678
- if (text[index] === "\\") index++;
1679
- else if (text[index] === "(") depth++;
1680
- else if (text[index] === ")" && --depth === 0) return index;
1681
- }
1682
- return -1;
1773
+ /** Ancestor filter keys for an element: "tag", "#id", ".class", "[attr". */
1774
+ function ancestorFilterKeys(keys) {
1775
+ const result = [];
1776
+ if (keys.tag) result.push(keys.tag);
1777
+ if (keys.id) result.push(`#${keys.id}`);
1778
+ for (const name of keys.classes) result.push(`.${name}`);
1779
+ for (const name of keys.attrs) result.push(`[${name}`);
1780
+ return result;
1683
1781
  }
1684
1782
 
1685
- function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new Map()) {
1783
+ function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new AncestorFilter()) {
1686
1784
  const result = new Map();
1687
1785
  result.hasCustomProperties = false;
1688
1786
  for (const rule of rules.candidates(keys, pseudo, ancestors)) {
1689
1787
  if (!matchesSelector(element, rule)) continue;
1690
1788
  if (rule.hasCustomProperties) result.hasCustomProperties = true;
1789
+ const { specificity, order } = rule;
1790
+ const layer = rule.layer ?? Infinity;
1691
1791
  for (const declaration of rule.declarationList) {
1692
- const candidate = { ...declaration, specificity: rule.specificity, order: rule.order, layer: rule.layer ?? Infinity };
1693
- const current = result.get(candidate.name);
1694
- if (!current || compareCascade(candidate, current) >= 0) result.set(candidate.name, candidate);
1792
+ const current = result.get(declaration.name);
1793
+ if (current && compareCascadeParts(declaration.important, layer, specificity, order, current) < 0) continue;
1794
+ result.set(declaration.name, { name: declaration.name, value: declaration.value, important: declaration.important, specificity, order, layer });
1695
1795
  }
1696
1796
  }
1697
1797
  return result;
1698
1798
  }
1699
1799
 
1700
1800
  function compareCascade(left, right) {
1701
- if (left.important !== right.important) return left.important ? 1 : -1;
1801
+ return compareCascadeParts(left.important, left.layer ?? Infinity, left.specificity, left.order, right);
1802
+ }
1803
+
1804
+ /** compareCascade() for a left side given as its parts (no object for it). */
1805
+ function compareCascadeParts(important, layer, specificity, order, right) {
1806
+ if (important !== right.important) return important ? 1 : -1;
1702
1807
  // Cascade layers: later layers and unlayered styles win for normal
1703
1808
  // declarations; for !important the order is reversed.
1809
+ const rightLayer = right.layer ?? Infinity;
1810
+ if (layer !== rightLayer) return (layer > rightLayer ? 1 : -1) * (important ? -1 : 1);
1811
+ if (specificity !== right.specificity) return specificity - right.specificity;
1812
+ return order - right.order;
1813
+ }
1814
+
1815
+ /**
1816
+ * compareCascade() for declarations that may lack cascade fields (user-agent
1817
+ * and inline ones): absent means normal importance, specificity and order 0.
1818
+ */
1819
+ function compareDeclarations(left, right) {
1820
+ const leftImportant = Boolean(left.important);
1821
+ const rightImportant = Boolean(right.important);
1822
+ if (leftImportant !== rightImportant) return leftImportant ? 1 : -1;
1704
1823
  const leftLayer = left.layer ?? Infinity;
1705
1824
  const rightLayer = right.layer ?? Infinity;
1706
- if (leftLayer !== rightLayer) return (leftLayer > rightLayer ? 1 : -1) * (left.important ? -1 : 1);
1707
- if (left.specificity !== right.specificity) return left.specificity - right.specificity;
1708
- return left.order - right.order;
1825
+ if (leftLayer !== rightLayer) return (leftLayer > rightLayer ? 1 : -1) * (leftImportant ? -1 : 1);
1826
+ const leftSpecificity = "specificity" in left ? left.specificity : 0;
1827
+ const rightSpecificity = "specificity" in right ? right.specificity : 0;
1828
+ if (leftSpecificity !== rightSpecificity) return leftSpecificity - rightSpecificity;
1829
+ return ("order" in left ? left.order : 0) - ("order" in right ? right.order : 0);
1709
1830
  }
1710
1831
 
1711
1832
  const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
@@ -1716,9 +1837,14 @@ const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
1716
1837
  * pseudo-elements that are not rendered (::placeholder, ::selection, ...).
1717
1838
  */
1718
1839
  function splitPseudoElement(selectorText) {
1719
- const match = PSEUDO_ELEMENT.exec(selectorText);
1840
+ // A trailing pseudo-element starts at the last colon (or the one before
1841
+ // it): only the tail needs the regular expression.
1842
+ const colon = selectorText.lastIndexOf(":");
1843
+ if (colon < 0) return { selector: selectorText, pseudo: null };
1844
+ const offset = Math.max(0, colon - 1);
1845
+ const match = PSEUDO_ELEMENT.exec(offset ? selectorText.slice(offset) : selectorText);
1720
1846
  if (!match) return { selector: selectorText, pseudo: null };
1721
- const selector = selectorText.slice(0, match.index).trim() || "*";
1847
+ const selector = selectorText.slice(0, offset + match.index).trim() || "*";
1722
1848
  return { selector, pseudo: match[1] ? match[1].toLowerCase() : undefined };
1723
1849
  }
1724
1850
 
@@ -1780,7 +1906,18 @@ function addSpecificity(left, right) {
1780
1906
  function matchesSelector(element, rule) {
1781
1907
  if (rule.invalid || typeof element.matches !== "function") return false;
1782
1908
  try {
1783
- return element.matches(rule.selector);
1909
+ // Compiled once per rule; objects other than Happy DOM elements use matches().
1910
+ let compiled = rule.compiled;
1911
+ if (compiled === undefined) {
1912
+ compiled = compileSelector(element, rule.selector);
1913
+ if (compiled === undefined) return element.matches(rule.selector);
1914
+ rule.compiled = compiled;
1915
+ }
1916
+ if (compiled === null) {
1917
+ rule.invalid = true;
1918
+ return false;
1919
+ }
1920
+ return matchesCompiled(element, compiled);
1784
1921
  } catch {
1785
1922
  rule.invalid = true;
1786
1923
  return false;
@@ -1797,6 +1934,32 @@ function pseudoElement(pseudo) {
1797
1934
  * percentages of the element's own border box). Other functions are ignored
1798
1935
  * until transforms are painted with matrices.
1799
1936
  */
1937
+ /**
1938
+ * The translation (layout moves the box) and scale (paint scales it about
1939
+ * transform-origin) of a transform list; other functions are ignored.
1940
+ */
1941
+ function parseTransform(value, fontSize) {
1942
+ const translation = parseTranslate(value, fontSize);
1943
+ if (translation === undefined) return undefined;
1944
+ let scaleX = 1;
1945
+ let scaleY = 1;
1946
+ let found = false;
1947
+ for (const match of value.matchAll(/\b(scale|scaleX|scaleY|scale3d)\(([^)]*)\)/gi)) {
1948
+ const args = splitTopLevel(match[2], ",").map((part) => part.endsWith("%") ? Number(part.slice(0, -1)) / 100 : Number(part));
1949
+ if (!args.length || args.some((part) => !Number.isFinite(part))) return undefined;
1950
+ const kind = match[1].toLowerCase();
1951
+ if (kind === "scalex") scaleX *= args[0];
1952
+ else if (kind === "scaley") scaleY *= args[0];
1953
+ else {
1954
+ scaleX *= args[0];
1955
+ scaleY *= args[1] ?? args[0];
1956
+ }
1957
+ found = true;
1958
+ }
1959
+ if (!found || (scaleX === 1 && scaleY === 1)) return translation;
1960
+ return Object.freeze({ x: translation?.x ?? 0, y: translation?.y ?? 0, scaleX, scaleY });
1961
+ }
1962
+
1800
1963
  function parseTranslate(value, fontSize) {
1801
1964
  let x = 0;
1802
1965
  let y = 0;
@@ -1835,7 +1998,7 @@ function parseContent(value) {
1835
1998
  * undefined property without a fallback leaves nothing (the declaration then
1836
1999
  * has no effect).
1837
2000
  */
1838
- function resolveVariables(value, customProperties, seen = new Set()) {
2001
+ export function resolveVariables(value, customProperties, seen = new Set()) {
1839
2002
  const text = String(value);
1840
2003
  // Most values use no custom properties; skip the parsing.
1841
2004
  if (!text.includes("var(")) return text.trim();
@@ -2236,6 +2399,64 @@ function resolvedColor(value, scheme) {
2236
2399
  return /^rgba\(/.test(color ?? "") ? color : null;
2237
2400
  }
2238
2401
 
2402
+ /**
2403
+ * One layer of the background shorthand, its parts as text: the image, the
2404
+ * position (before a slash) and size (after it), the repeat style and a color.
2405
+ * Null when a token fits none of them.
2406
+ */
2407
+ function parseBackgroundLayer(layer) {
2408
+ const tokens = [];
2409
+ let depth = 0;
2410
+ let token = "";
2411
+ for (const character of layer) {
2412
+ if (depth === 0 && (/\s/.test(character) || character === "/")) {
2413
+ if (token) tokens.push(token);
2414
+ if (character === "/") tokens.push("/");
2415
+ token = "";
2416
+ continue;
2417
+ }
2418
+ if (character === "(") depth++;
2419
+ else if (character === ")") depth--;
2420
+ token += character;
2421
+ }
2422
+ if (token) tokens.push(token);
2423
+ const result = { image: "none", position: "0% 0%", size: "auto", repeat: "repeat", color: null };
2424
+ const position = [];
2425
+ const size = [];
2426
+ const repeat = [];
2427
+ let afterSlash = false;
2428
+ for (const raw of tokens) {
2429
+ const lower = raw.toLowerCase();
2430
+ if (raw === "/") afterSlash = true;
2431
+ else if (BACKGROUND_IMAGE.test(raw)) result.image = raw;
2432
+ else if (lower === "none") result.image = "none";
2433
+ else if (BACKGROUND_REPEATS.has(lower)) repeat.push(lower);
2434
+ else if (BACKGROUND_BOXES.has(lower) || BACKGROUND_ATTACHMENTS.has(lower)) continue;
2435
+ else if (afterSlash && (lower === "cover" || lower === "contain" || lower === "auto" || /^-?[\d.]/.test(lower) || lower.startsWith("calc("))) size.push(lower);
2436
+ else if (["left", "right", "top", "bottom", "center"].includes(lower) || /^-?[\d.]/.test(lower) || lower.startsWith("calc(")) {
2437
+ afterSlash = false;
2438
+ position.push(lower);
2439
+ } else if (resolvedColor(raw, "light")) result.color = raw;
2440
+ else return null;
2441
+ }
2442
+ if (position.length) result.position = position.join(" ");
2443
+ if (size.length) result.size = size.join(" ");
2444
+ if (repeat.length) result.repeat = repeat.join(" ");
2445
+ return result;
2446
+ }
2447
+
2448
+ /** A style sheet with each relative url() made absolute against the sheet's own URL. */
2449
+ export function absolutizeStyleSheetUrls(text, base) {
2450
+ return text.replace(/url\(\s*(["']?)([^"')]*)\1\s*\)/gi, (whole, quote, url) => {
2451
+ if (!url || /^(?:[a-z][a-z\d+.-]*:|#)/i.test(url)) return whole;
2452
+ try {
2453
+ return `url(${quote}${new URL(url, base).href}${quote})`;
2454
+ } catch {
2455
+ return whole;
2456
+ }
2457
+ });
2458
+ }
2459
+
2239
2460
  function splitTopLevel(source, separator) {
2240
2461
  const parts = [];
2241
2462
  let depth = 0;