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