buninu-browser 0.2.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.
- package/lib/cdp-server.js +3 -1
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +57 -12
- package/lib/layout/text-layout.js +179 -15
- package/lib/network/network-service.js +176 -43
- package/lib/paint/display-list.js +23 -8
- package/lib/render-tree/index.js +45 -0
- package/lib/renderer/bindings.js +4 -4
- package/lib/renderer/host.js +1 -1
- package/lib/renderer/page-realm.js +2 -2
- package/lib/renderer/page-renderer.js +166 -204
- package/lib/style/computed-style.js +301 -220
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/package.json +1 -1
- package/test/computed-style.test.js +61 -1
- package/test/headless-shell.test.js +29 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +49 -0
- package/test/render-tree.test.js +22 -1
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +69 -1
|
@@ -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",
|
|
@@ -253,6 +256,9 @@ export class StyleEngine {
|
|
|
253
256
|
// Parsed and indexed rules for the last style sheet list and viewport:
|
|
254
257
|
// restyles after script changes reuse them instead of re-parsing.
|
|
255
258
|
#ruleCache = { styleSheets: null, key: "", index: null };
|
|
259
|
+
// Parsed rules by sheet text, for the viewport in #parsedSheetsKey.
|
|
260
|
+
#parsedSheets = new Map();
|
|
261
|
+
#parsedSheetsKey = "";
|
|
256
262
|
|
|
257
263
|
/**
|
|
258
264
|
* Computes the document's styles. With `dirty` (elements whose subtree's
|
|
@@ -306,10 +312,7 @@ export class StyleEngine {
|
|
|
306
312
|
const topmost = topmostConnected(starts, root);
|
|
307
313
|
for (const start of topmost) {
|
|
308
314
|
const parent = start.parentElement;
|
|
309
|
-
const ancestors =
|
|
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
|
-
}
|
|
315
|
+
const ancestors = this.#ancestorsOf(start);
|
|
313
316
|
this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
|
|
314
317
|
}
|
|
315
318
|
// Elements whose own matches may have changed (:empty, :first-child,
|
|
@@ -332,10 +335,8 @@ export class StyleEngine {
|
|
|
332
335
|
const cache = this.#ruleCache;
|
|
333
336
|
this.#sheetChanges = null;
|
|
334
337
|
if (cache.styleSheets !== styleSheets || cache.key !== key || cache.length !== styleSheets.length) {
|
|
335
|
-
const
|
|
336
|
-
const perSheet = styleSheets.map((source) => parseStyleSheet(source, environment, counter));
|
|
338
|
+
const perSheet = this.#parseSheets(styleSheets, key);
|
|
337
339
|
const rules = perSheet.flat();
|
|
338
|
-
if (counter.layers) rankLayers(counter.layers, rules);
|
|
339
340
|
// Sheets added or removed since the last list (same viewport): only the
|
|
340
341
|
// elements their rules match can change style, every other element
|
|
341
342
|
// keeps the same rules in the same relative order.
|
|
@@ -345,6 +346,62 @@ export class StyleEngine {
|
|
|
345
346
|
return this.#ruleCache.index;
|
|
346
347
|
}
|
|
347
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
|
+
|
|
348
405
|
/** Elements matched by the rules of added or removed sheets, or null when that is unknown. */
|
|
349
406
|
#elementsForSheetChanges(document) {
|
|
350
407
|
const changes = this.#sheetChanges;
|
|
@@ -372,16 +429,24 @@ export class StyleEngine {
|
|
|
372
429
|
|
|
373
430
|
/** Computes and stores one element's style and ::before/::after styles. */
|
|
374
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);
|
|
375
435
|
// Inline style takes part in the cascade before custom properties are
|
|
376
436
|
// resolved: style="--x: ..." must win over stylesheet values of --x.
|
|
377
437
|
const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
|
|
378
|
-
|
|
379
|
-
|
|
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;
|
|
380
445
|
// rem units resolve against the root element's computed font size.
|
|
381
446
|
if (!parentStyle) environment = { ...environment, rootFontSize: style.fontSize };
|
|
382
447
|
this.#styles.set(element, style);
|
|
383
448
|
this.#customProperties.set(element, customProperties);
|
|
384
|
-
|
|
449
|
+
let pseudoStyles = {};
|
|
385
450
|
for (const pseudo of ["before", "after"]) {
|
|
386
451
|
const pseudoDeclarations = cascadeDeclarations(element, rules, pseudo, keys, ancestors);
|
|
387
452
|
if (!pseudoDeclarations.has("content")) continue;
|
|
@@ -390,13 +455,16 @@ export class StyleEngine {
|
|
|
390
455
|
// `content: none | normal` generates no box (CSS Pseudo 4 §2.3).
|
|
391
456
|
if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
|
|
392
457
|
}
|
|
458
|
+
if (sameValue(previousPseudo ?? null, (pseudoStyles.before || pseudoStyles.after) ? pseudoStyles : null)) {
|
|
459
|
+
pseudoStyles = previousPseudo ?? {};
|
|
460
|
+
}
|
|
393
461
|
if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
|
|
394
462
|
else this.#pseudoStyles.delete(element);
|
|
395
463
|
this.restyled++;
|
|
396
464
|
return { style, customProperties };
|
|
397
465
|
}
|
|
398
466
|
|
|
399
|
-
#visit(element, parentStyle, parentCustomProperties, rules, ancestors = new
|
|
467
|
+
#visit(element, parentStyle, parentCustomProperties, rules, ancestors = new AncestorFilter()) {
|
|
400
468
|
const keys = elementKeys(element);
|
|
401
469
|
const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
|
|
402
470
|
this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
|
|
@@ -404,22 +472,15 @@ export class StyleEngine {
|
|
|
404
472
|
|
|
405
473
|
#visitChildren(element, keys, style, customProperties, rules, ancestors) {
|
|
406
474
|
// The element is an ancestor of everything below it.
|
|
407
|
-
const own =
|
|
408
|
-
for (const key of own) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
|
|
475
|
+
const own = ancestors.push(keys);
|
|
409
476
|
for (const child of element.children) this.#visit(child, style, customProperties, rules, ancestors);
|
|
410
|
-
|
|
411
|
-
const count = ancestors.get(key) - 1;
|
|
412
|
-
if (count) ancestors.set(key, count);
|
|
413
|
-
else ancestors.delete(key);
|
|
414
|
-
}
|
|
477
|
+
ancestors.pop(own);
|
|
415
478
|
}
|
|
416
479
|
|
|
417
480
|
/** Ancestor filter counts for the elements above `element`. */
|
|
418
481
|
#ancestorsOf(element) {
|
|
419
|
-
const ancestors = new
|
|
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
|
-
}
|
|
482
|
+
const ancestors = new AncestorFilter();
|
|
483
|
+
for (let node = element.parentElement; node; node = node.parentElement) ancestors.push(elementKeys(node));
|
|
423
484
|
return ancestors;
|
|
424
485
|
}
|
|
425
486
|
|
|
@@ -461,6 +522,8 @@ function sameCustom(left, right) {
|
|
|
461
522
|
|
|
462
523
|
const EMPTY_MAP = new Map();
|
|
463
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;
|
|
464
527
|
|
|
465
528
|
/**
|
|
466
529
|
* Rules from sheets present in only one of two sheet lists (by text), or null
|
|
@@ -571,8 +634,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
|
|
|
571
634
|
// font-size and the font shorthand wins the cascade.
|
|
572
635
|
const fontSize = declarations.get("font-size");
|
|
573
636
|
const font = declarations.get("font");
|
|
574
|
-
|
|
575
|
-
if (font && (!fontSize || compareCascade(cascadeKey(font), cascadeKey(fontSize)) > 0)) {
|
|
637
|
+
if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
|
|
576
638
|
const parsed = parseFontShorthand(resolveVariables(font.value, customProperties));
|
|
577
639
|
if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
|
|
578
640
|
} else if (fontSize) {
|
|
@@ -582,10 +644,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
|
|
|
582
644
|
const colorScheme = declarations.get("color-scheme");
|
|
583
645
|
if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
|
|
584
646
|
// Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
|
|
585
|
-
const ordered = [...declarations.values()].sort(
|
|
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
|
-
));
|
|
647
|
+
const ordered = [...declarations.values()].sort(compareDeclarations);
|
|
589
648
|
for (const declaration of ordered) {
|
|
590
649
|
if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
|
|
591
650
|
// The shorthand's size was settled above; its other parts apply in order.
|
|
@@ -706,6 +765,13 @@ class StyleRule {
|
|
|
706
765
|
get hasCustomProperties() {
|
|
707
766
|
return this.#block.hasCustomProperties;
|
|
708
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
|
+
}
|
|
709
775
|
}
|
|
710
776
|
|
|
711
777
|
/**
|
|
@@ -744,7 +810,7 @@ export function parseStyleSheet(source, viewport = environment, counter = { orde
|
|
|
744
810
|
if (name === "layer") {
|
|
745
811
|
// A named layer block, or an anonymous one (a layer of its own).
|
|
746
812
|
const parent = layer ?? counter.layers;
|
|
747
|
-
const inner = condition ? declareLayer(parent, condition) : declareLayer(parent,
|
|
813
|
+
const inner = condition ? declareLayer(parent, condition) : declareLayer(parent, `${ANONYMOUS_LAYER}${counter.order}`);
|
|
748
814
|
parseBlock(open + 1, close, inner);
|
|
749
815
|
} else if ((name === "media" && mediaQueryMatches(condition, viewport)) || name === "supports") {
|
|
750
816
|
parseBlock(open + 1, close, layer);
|
|
@@ -771,6 +837,28 @@ function newLayer(parent) {
|
|
|
771
837
|
return { parent, children: new Map(), rank: Infinity };
|
|
772
838
|
}
|
|
773
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
|
+
|
|
774
862
|
/** Finds or creates a (possibly dotted) layer name under `parent`, in first-seen order. */
|
|
775
863
|
function declareLayer(parent, name) {
|
|
776
864
|
let node = parent;
|
|
@@ -787,12 +875,16 @@ function declareLayer(parent, name) {
|
|
|
787
875
|
* layer's sublayers before its own rules; unlayered rules rank highest.
|
|
788
876
|
*/
|
|
789
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) {
|
|
790
883
|
let next = 0;
|
|
791
884
|
(function visit(node) {
|
|
792
885
|
for (const child of node.children.values()) visit(child);
|
|
793
886
|
if (node !== root) node.rank = next++;
|
|
794
887
|
})(root);
|
|
795
|
-
for (const rule of rules) rule.layer = rule.layerNode ? rule.layerNode.rank : Infinity;
|
|
796
888
|
}
|
|
797
889
|
|
|
798
890
|
/** Splits a `font` shorthand value into size, line height, weight and family. */
|
|
@@ -823,34 +915,38 @@ function parseFontShorthand(value) {
|
|
|
823
915
|
return null;
|
|
824
916
|
}
|
|
825
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
|
+
|
|
826
921
|
function findTopLevel(source, character, start, end) {
|
|
827
|
-
|
|
922
|
+
const target = character.charCodeAt(0);
|
|
923
|
+
let quote = 0;
|
|
828
924
|
let depth = 0;
|
|
829
925
|
for (let index = start; index < end; index++) {
|
|
830
|
-
const current = source
|
|
926
|
+
const current = source.charCodeAt(index);
|
|
831
927
|
if (quote) {
|
|
832
|
-
if (current ===
|
|
833
|
-
else if (current === quote) quote =
|
|
834
|
-
} else if (current ===
|
|
835
|
-
else if (current ===
|
|
836
|
-
else if (current ===
|
|
837
|
-
else if (current ===
|
|
838
|
-
else if (current ===
|
|
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;
|
|
839
935
|
}
|
|
840
936
|
return -1;
|
|
841
937
|
}
|
|
842
938
|
|
|
843
939
|
function matchingBrace(source, open, end) {
|
|
844
940
|
let depth = 0;
|
|
845
|
-
let quote =
|
|
941
|
+
let quote = 0;
|
|
846
942
|
for (let index = open; index < end; index++) {
|
|
847
|
-
const current = source
|
|
943
|
+
const current = source.charCodeAt(index);
|
|
848
944
|
if (quote) {
|
|
849
|
-
if (current ===
|
|
850
|
-
else if (current === quote) quote =
|
|
851
|
-
} else if (current ===
|
|
852
|
-
else if (current ===
|
|
853
|
-
else if (current ===
|
|
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;
|
|
854
950
|
}
|
|
855
951
|
return end;
|
|
856
952
|
}
|
|
@@ -1452,10 +1548,14 @@ class RuleIndex {
|
|
|
1452
1548
|
buckets = { id: new Map(), class: new Map(), attr: new Map(), tag: new Map(), universal: [] };
|
|
1453
1549
|
this.#byPseudo.set(rule.pseudo, buckets);
|
|
1454
1550
|
}
|
|
1455
|
-
|
|
1456
|
-
rule.
|
|
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
|
+
}
|
|
1457
1557
|
rule.seen = 0;
|
|
1458
|
-
const keys =
|
|
1558
|
+
const keys = rule.subjectKeys;
|
|
1459
1559
|
if (!keys) {
|
|
1460
1560
|
buckets.universal.push(rule);
|
|
1461
1561
|
continue;
|
|
@@ -1472,7 +1572,7 @@ class RuleIndex {
|
|
|
1472
1572
|
/**
|
|
1473
1573
|
* Rules that may match `element` for `pseudo`, in cascade order.
|
|
1474
1574
|
* @param {ReturnType<typeof elementKeys>} keys the element's own keys
|
|
1475
|
-
* @param {
|
|
1575
|
+
* @param {AncestorFilter} ancestors keys present on its ancestors
|
|
1476
1576
|
*/
|
|
1477
1577
|
candidates(keys, pseudo, ancestors) {
|
|
1478
1578
|
const buckets = this.#byPseudo.get(pseudo);
|
|
@@ -1490,7 +1590,7 @@ class RuleIndex {
|
|
|
1490
1590
|
for (const rule of list) {
|
|
1491
1591
|
if (rule.seen === stamp) continue;
|
|
1492
1592
|
rule.seen = stamp;
|
|
1493
|
-
if (
|
|
1593
|
+
if (ancestors.hasAll(rule.ancestorIds)) result.push(rule);
|
|
1494
1594
|
}
|
|
1495
1595
|
}
|
|
1496
1596
|
if (lists.length > 1) result.sort((left, right) => left.order - right.order);
|
|
@@ -1498,6 +1598,72 @@ class RuleIndex {
|
|
|
1498
1598
|
}
|
|
1499
1599
|
}
|
|
1500
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
|
+
|
|
1501
1667
|
/** The keys an element offers to selectors: id, classes, attribute names, type. */
|
|
1502
1668
|
function elementKeys(element) {
|
|
1503
1669
|
const id = element.getAttribute?.("id") || null;
|
|
@@ -1509,203 +1675,102 @@ function elementKeys(element) {
|
|
|
1509
1675
|
return { id, classes, attrs, tag: tag ? String(tag).toLowerCase() : null };
|
|
1510
1676
|
}
|
|
1511
1677
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
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;
|
|
1520
1685
|
}
|
|
1521
1686
|
|
|
1522
1687
|
/**
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
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.
|
|
1526
1691
|
*/
|
|
1527
|
-
|
|
1528
|
-
|
|
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])+/;
|
|
1574
|
-
|
|
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
|
-
}
|
|
1692
|
+
class AncestorFilter {
|
|
1693
|
+
#counts = new Int32Array(Math.max(1, FILTER_KEY_IDS.size));
|
|
1581
1694
|
|
|
1582
|
-
/**
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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 });
|
|
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);
|
|
1623
1703
|
}
|
|
1704
|
+
return ids;
|
|
1624
1705
|
}
|
|
1625
|
-
return keys;
|
|
1626
|
-
}
|
|
1627
1706
|
|
|
1628
|
-
|
|
1629
|
-
|
|
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;
|
|
1707
|
+
pop(ids) {
|
|
1708
|
+
for (const id of ids) this.#counts[id]--;
|
|
1655
1709
|
}
|
|
1656
|
-
return null;
|
|
1657
|
-
}
|
|
1658
1710
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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}`);
|
|
1711
|
+
hasAll(ids) {
|
|
1712
|
+
for (let index = 0; index < ids.length; index++) if (!(this.#counts[ids[index]] > 0)) return false;
|
|
1713
|
+
return true;
|
|
1671
1714
|
}
|
|
1672
|
-
return [...keys];
|
|
1673
1715
|
}
|
|
1674
1716
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
}
|
|
1682
|
-
return
|
|
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;
|
|
1683
1725
|
}
|
|
1684
1726
|
|
|
1685
|
-
function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new
|
|
1727
|
+
function cascadeDeclarations(element, rules, pseudo = null, keys = elementKeys(element), ancestors = new AncestorFilter()) {
|
|
1686
1728
|
const result = new Map();
|
|
1687
1729
|
result.hasCustomProperties = false;
|
|
1688
1730
|
for (const rule of rules.candidates(keys, pseudo, ancestors)) {
|
|
1689
1731
|
if (!matchesSelector(element, rule)) continue;
|
|
1690
1732
|
if (rule.hasCustomProperties) result.hasCustomProperties = true;
|
|
1733
|
+
const { specificity, order } = rule;
|
|
1734
|
+
const layer = rule.layer ?? Infinity;
|
|
1691
1735
|
for (const declaration of rule.declarationList) {
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
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 });
|
|
1695
1739
|
}
|
|
1696
1740
|
}
|
|
1697
1741
|
return result;
|
|
1698
1742
|
}
|
|
1699
1743
|
|
|
1700
1744
|
function compareCascade(left, right) {
|
|
1701
|
-
|
|
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;
|
|
1702
1751
|
// Cascade layers: later layers and unlayered styles win for normal
|
|
1703
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;
|
|
1704
1767
|
const leftLayer = left.layer ?? Infinity;
|
|
1705
1768
|
const rightLayer = right.layer ?? Infinity;
|
|
1706
|
-
if (leftLayer !== rightLayer) return (leftLayer > rightLayer ? 1 : -1) * (
|
|
1707
|
-
|
|
1708
|
-
|
|
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);
|
|
1709
1774
|
}
|
|
1710
1775
|
|
|
1711
1776
|
const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
|
|
@@ -1716,9 +1781,14 @@ const PSEUDO_ELEMENT = /(?:::?(before|after)|::([\w-]+))$/i;
|
|
|
1716
1781
|
* pseudo-elements that are not rendered (::placeholder, ::selection, ...).
|
|
1717
1782
|
*/
|
|
1718
1783
|
function splitPseudoElement(selectorText) {
|
|
1719
|
-
|
|
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);
|
|
1720
1790
|
if (!match) return { selector: selectorText, pseudo: null };
|
|
1721
|
-
const selector = selectorText.slice(0, match.index).trim() || "*";
|
|
1791
|
+
const selector = selectorText.slice(0, offset + match.index).trim() || "*";
|
|
1722
1792
|
return { selector, pseudo: match[1] ? match[1].toLowerCase() : undefined };
|
|
1723
1793
|
}
|
|
1724
1794
|
|
|
@@ -1780,7 +1850,18 @@ function addSpecificity(left, right) {
|
|
|
1780
1850
|
function matchesSelector(element, rule) {
|
|
1781
1851
|
if (rule.invalid || typeof element.matches !== "function") return false;
|
|
1782
1852
|
try {
|
|
1783
|
-
|
|
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);
|
|
1784
1865
|
} catch {
|
|
1785
1866
|
rule.invalid = true;
|
|
1786
1867
|
return false;
|