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.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- 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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
954
|
+
const current = source.charCodeAt(index);
|
|
831
955
|
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 ===
|
|
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
|
|
971
|
+
const current = source.charCodeAt(index);
|
|
848
972
|
if (quote) {
|
|
849
|
-
if (current ===
|
|
850
|
-
else if (current === quote) quote =
|
|
851
|
-
} else if (current ===
|
|
852
|
-
else if (current ===
|
|
853
|
-
else if (current ===
|
|
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
|
-
//
|
|
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
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
1065
|
-
const layers = splitTopLevel(value, ",");
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
.
|
|
1069
|
-
.
|
|
1070
|
-
|
|
1071
|
-
style.backgroundImage =
|
|
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, ",").
|
|
1078
|
-
if (images.
|
|
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 :
|
|
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
|
|
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
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1456
|
-
rule.
|
|
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 =
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
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
|
-
|
|
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])+/;
|
|
1748
|
+
class AncestorFilter {
|
|
1749
|
+
#counts = new Int32Array(Math.max(1, FILTER_KEY_IDS.size));
|
|
1574
1750
|
|
|
1575
|
-
/**
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
|
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
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
}
|
|
1682
|
-
return
|
|
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
|
|
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
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
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) * (
|
|
1707
|
-
|
|
1708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|