buninu-browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. package/vendor/ses/dist/ses.mjs +2 -0
@@ -0,0 +1,61 @@
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { createBrowser } from "./headless-shell.js";
4
+
5
+ export function screenshotTargetUrl(input, { platform = process.platform, resolvePath = resolve, fileURL = pathToFileURL } = {}) {
6
+ const windowsPath = platform === "win32" && (/^[a-z]:/i.test(input) || input.startsWith("\\\\") || input.startsWith("//"));
7
+ if (windowsPath) return fileURL(resolvePath(input)).href;
8
+ if (/^[a-z][a-z\d+.-]*:/i.test(input)) return input;
9
+ if (input.includes("/") || input.startsWith(".") || (platform === "win32" && input.includes("\\"))) {
10
+ return fileURL(resolvePath(input)).href;
11
+ }
12
+ return `https://${input}`;
13
+ }
14
+
15
+ /** Chromium-style one-shot PNG capture, using the same renderer as CDP. */
16
+ export async function runScreenshot(args) {
17
+ const screenshot = args.find((arg) => arg === "--screenshot" || arg.startsWith("--screenshot="));
18
+ const filename = screenshot === "--screenshot" ? "screenshot.png" : screenshot.slice("--screenshot=".length);
19
+ const extension = filename.match(/\.(png|jpe?g|webp)$/i)?.[1]?.toLowerCase();
20
+ if (!extension) throw new Error("--screenshot filename must end in .png, .jpg, .jpeg, or .webp");
21
+ const format = extension === "jpg" ? "jpeg" : extension;
22
+
23
+ const size = args.find((arg) => arg.startsWith("--window-size="))?.slice("--window-size=".length);
24
+ const waitArg = args.find((arg) => arg.startsWith("--screenshot-wait="))?.slice("--screenshot-wait=".length);
25
+ const waitMs = waitArg === undefined ? 0 : Number(waitArg);
26
+ if (waitArg !== undefined && (!/^\d+$/.test(waitArg) || !Number.isSafeInteger(waitMs))) {
27
+ throw new Error("--screenshot-wait must be a nonnegative number of milliseconds");
28
+ }
29
+ const match = size?.match(/^(\d+)[,x](\d+)$/i);
30
+ if (size && (!match || !Number.isSafeInteger(Number(match[1])) || !Number.isSafeInteger(Number(match[2]))
31
+ || Number(match[1]) < 1 || Number(match[2]) < 1)) {
32
+ throw new Error("--window-size must be WIDTH,HEIGHT with positive integers");
33
+ }
34
+
35
+ const urlArg = args.find((arg) => !arg.startsWith("-"));
36
+ if (!urlArg) throw new Error("--screenshot requires a URL");
37
+ const url = screenshotTargetUrl(urlArg);
38
+
39
+ const browser = await createBrowser({
40
+ allowHostJs: args.includes("--dangerously-allow-host-js"),
41
+ mobile: args.includes("--mobile"),
42
+ spareRenderer: false,
43
+ });
44
+ try {
45
+ if (match) await browser.context.resize(Number(match[1]), Number(match[2]));
46
+ await browser.context.navigate(url);
47
+ if (!browser.context.lifecycleState().includes("load")) {
48
+ await new Promise((done) => {
49
+ const unsubscribe = browser.context.onLifecycle((name) => {
50
+ if (name === "load") { unsubscribe(); done(); }
51
+ });
52
+ });
53
+ }
54
+ if (waitMs) await Bun.sleep(waitMs);
55
+ const data = await browser.context.screenshot({ format });
56
+ await Bun.write(filename, Buffer.from(data, "base64"));
57
+ console.error(`Screenshot saved to ${resolve(filename)}`);
58
+ } finally {
59
+ browser.close();
60
+ }
61
+ }
@@ -12,8 +12,17 @@ const BLOCK_TAGS = new Set([
12
12
  ]);
13
13
  const NONE_TAGS = new Set(["HEAD", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE"]);
14
14
  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",
15
+ "color", "color-scheme", "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", "pointer-events",
17
+ ]);
18
+ // Inherited properties whose computed value is copied from the parent as is.
19
+ // line-height is not: a unitless factor resolves against each element's own
20
+ // font size, so it goes through its specified form (copyProperty).
21
+ const INHERITED_FIELDS = new Map([
22
+ ["color", "color"], ["font-family", "fontFamily"], ["font-size", "fontSize"], ["font-weight", "fontWeight"],
23
+ ["list-style-type", "listStyleType"], ["text-align", "textAlign"], ["white-space", "whiteSpace"],
24
+ ["visibility", "visibility"], ["overflow-wrap", "overflowWrap"], ["word-break", "wordBreak"],
25
+ ["border-spacing", "borderSpacing"], ["border-collapse", "borderCollapse"], ["pointer-events", "pointerEvents"],
17
26
  ]);
18
27
  const LIST_STYLE_TYPES = new Set([
19
28
  "none", "disc", "circle", "square", "decimal", "decimal-leading-zero",
@@ -53,6 +62,9 @@ const PROPERTY_FIELDS = Object.freeze({
53
62
  "grid-row": ["gridRowStart", "gridRowEnd"],
54
63
  "grid-column": ["gridColumnStart", "gridColumnEnd"],
55
64
  "grid-template": ["gridTemplateColumns", "gridTemplateRows"],
65
+ "place-content": ["alignContent", "justifyContent"],
66
+ "place-items": ["alignItems", "justifyItems"],
67
+ "place-self": ["alignSelf", "justifySelf"],
56
68
  "line-height": ["lineHeight", "lineHeightFactor"],
57
69
  "list-style": ["listStyleType"],
58
70
  "border-radius": ["borderRadius"],
@@ -71,6 +83,7 @@ const INITIAL = Object.freeze({
71
83
  textDecorationColor: null,
72
84
  textDecorations: null,
73
85
  color: "rgba(0, 0, 0, 1)",
86
+ colorScheme: "light",
74
87
  backgroundColor: "rgba(0, 0, 0, 0)",
75
88
  backgroundImage: null,
76
89
  backgroundClip: "border-box",
@@ -102,6 +115,7 @@ const INITIAL = Object.freeze({
102
115
  flexDirection: "row",
103
116
  flexWrap: "nowrap",
104
117
  justifyContent: "normal",
118
+ alignContent: "normal",
105
119
  alignItems: "normal",
106
120
  alignSelf: "auto",
107
121
  gap: 0,
@@ -136,6 +150,8 @@ const INITIAL = Object.freeze({
136
150
  tableLayout: "auto",
137
151
  verticalAlign: "baseline",
138
152
  visibility: "visible",
153
+ // Hit testing only (inherited): "none" lets clicks pass through.
154
+ pointerEvents: "auto",
139
155
  // Breaking inside words (CSS Text 3 §5): only with these set.
140
156
  overflowWrap: "normal",
141
157
  wordBreak: "normal",
@@ -229,14 +245,23 @@ export class StyleEngine {
229
245
  #styles = new WeakMap();
230
246
  #customProperties = new WeakMap();
231
247
  #pseudoStyles = new WeakMap();
248
+ #document = null;
249
+ #sheetChanges = null;
250
+ /** Elements computed by the last compute(), and whether it was incremental. */
251
+ restyled = 0;
252
+ incremental = false;
232
253
  // Parsed and indexed rules for the last style sheet list and viewport:
233
254
  // restyles after script changes reuse them instead of re-parsing.
234
255
  #ruleCache = { styleSheets: null, key: "", index: null };
235
256
 
236
- compute(document, styleSheets = [], viewport = {}) {
237
- this.#styles = new WeakMap();
238
- this.#customProperties = new WeakMap();
239
- this.#pseudoStyles = new WeakMap();
257
+ /**
258
+ * Computes the document's styles. With `dirty` (elements whose subtree's
259
+ * styles may have changed), only those subtrees are restyled and every
260
+ * other element keeps the style it had: unchanged rules and viewport, the
261
+ * same document, and a styled root are required, else it restyles fully.
262
+ * `restyled` afterwards counts the elements this call computed.
263
+ */
264
+ compute(document, styleSheets = [], viewport = {}, dirty = null, selfOnly = null) {
240
265
  const previous = environment;
241
266
  environment = {
242
267
  viewportWidth: viewport.width ?? DEFAULT_ENVIRONMENT.viewportWidth,
@@ -244,9 +269,58 @@ export class StyleEngine {
244
269
  rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize,
245
270
  mobile: viewport.mobile === true,
246
271
  };
272
+ this.restyled = 0;
247
273
  try {
274
+ const before = this.#ruleCache.index;
248
275
  const rules = this.#rulesFor(styleSheets);
249
- if (document.documentElement) this.#visit(document.documentElement, null, null, rules);
276
+ const root = document.documentElement;
277
+ if (dirty !== null && rules !== before) {
278
+ const affected = this.#elementsForSheetChanges(document);
279
+ dirty = affected ? new Set([...dirty, ...affected]) : null;
280
+ }
281
+ const incremental = dirty !== null && this.#document === document
282
+ && Boolean(root) && this.#styles.has(root) && !dirty.has(root);
283
+ this.incremental = incremental;
284
+ if (!incremental) {
285
+ this.#styles = new WeakMap();
286
+ this.#customProperties = new WeakMap();
287
+ this.#pseudoStyles = new WeakMap();
288
+ this.#document = document;
289
+ if (root) this.#visit(root, null, null, rules);
290
+ return this;
291
+ }
292
+ // rem units resolve against the root's font size, which is unchanged.
293
+ environment = { ...environment, rootFontSize: this.#styles.get(root).fontSize };
294
+ // Restyle from the nearest ancestor that has a style (new subtrees have
295
+ // none); many roots inside one new subtree share that start.
296
+ const starts = new Set();
297
+ for (const element of topmostConnected(dirty, root)) {
298
+ let start = element;
299
+ while (start !== root && !this.#styles.has(start.parentElement)) start = start.parentElement;
300
+ starts.add(start);
301
+ }
302
+ if (starts.has(root) || selfOnly?.has(root)) {
303
+ this.#visit(root, null, null, rules);
304
+ return this;
305
+ }
306
+ const topmost = topmostConnected(starts, root);
307
+ for (const start of topmost) {
308
+ const parent = start.parentElement;
309
+ const ancestors = new Map();
310
+ for (let node = parent; node; node = node.parentElement) {
311
+ for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
312
+ }
313
+ this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
314
+ }
315
+ // Elements whose own matches may have changed (:empty, :first-child,
316
+ // ...) and that no restyled subtree covered.
317
+ const covered = new Set(topmost);
318
+ for (const element of selfOnly ?? []) {
319
+ if (!element || element.nodeType !== 1 || !root.contains(element) || !this.#styles.has(element.parentElement)) continue;
320
+ let inside = false;
321
+ for (let node = element; node && !inside; node = node.parentElement) inside = covered.has(node);
322
+ if (!inside) this.#restyleSelf(element, rules);
323
+ }
250
324
  } finally {
251
325
  environment = previous;
252
326
  }
@@ -256,15 +330,37 @@ export class StyleEngine {
256
330
  #rulesFor(styleSheets) {
257
331
  const key = `${environment.viewportWidth}x${environment.viewportHeight}${environment.mobile ? ":mobile" : ""}`;
258
332
  const cache = this.#ruleCache;
333
+ this.#sheetChanges = null;
259
334
  if (cache.styleSheets !== styleSheets || cache.key !== key || cache.length !== styleSheets.length) {
260
335
  const counter = { order: 0 };
261
- const rules = styleSheets.flatMap((source) => parseStyleSheet(source, environment, counter));
336
+ const perSheet = styleSheets.map((source) => parseStyleSheet(source, environment, counter));
337
+ const rules = perSheet.flat();
262
338
  if (counter.layers) rankLayers(counter.layers, rules);
263
- this.#ruleCache = { styleSheets, key, length: styleSheets.length, index: new RuleIndex(rules) };
339
+ // Sheets added or removed since the last list (same viewport): only the
340
+ // elements their rules match can change style, every other element
341
+ // keeps the same rules in the same relative order.
342
+ if (cache.index && cache.key === key) this.#sheetChanges = diffSheets(cache, styleSheets, perSheet);
343
+ this.#ruleCache = { styleSheets, key, length: styleSheets.length, index: new RuleIndex(rules), perSheet };
264
344
  }
265
345
  return this.#ruleCache.index;
266
346
  }
267
347
 
348
+ /** Elements matched by the rules of added or removed sheets, or null when that is unknown. */
349
+ #elementsForSheetChanges(document) {
350
+ const changes = this.#sheetChanges;
351
+ // Each selector is a document-wide search: past a few hundred, a full
352
+ // restyle is cheaper (a large stylesheet arriving late, for example).
353
+ if (!changes || changes.rules.length > MAX_SHEET_CHANGE_RULES) return null;
354
+ const elements = new Set();
355
+ const selectors = new Set(changes.rules.map((rule) => rule.selector));
356
+ for (const selector of selectors) {
357
+ let matched;
358
+ try { matched = document.querySelectorAll(selector); } catch { continue; } // invalid: matches nothing
359
+ for (const element of matched) elements.add(element);
360
+ }
361
+ return elements;
362
+ }
363
+
268
364
  get(element) {
269
365
  return this.#styles.get(element) ?? INITIAL;
270
366
  }
@@ -274,8 +370,8 @@ export class StyleEngine {
274
370
  return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
275
371
  }
276
372
 
277
- #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new Map()) {
278
- const keys = elementKeys(element);
373
+ /** Computes and stores one element's style and ::before/::after styles. */
374
+ #styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors) {
279
375
  // Inline style takes part in the cascade before custom properties are
280
376
  // resolved: style="--x: ..." must win over stylesheet values of --x.
281
377
  const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
@@ -295,6 +391,18 @@ export class StyleEngine {
295
391
  if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
296
392
  }
297
393
  if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
394
+ else this.#pseudoStyles.delete(element);
395
+ this.restyled++;
396
+ return { style, customProperties };
397
+ }
398
+
399
+ #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new Map()) {
400
+ const keys = elementKeys(element);
401
+ const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
402
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
403
+ }
404
+
405
+ #visitChildren(element, keys, style, customProperties, rules, ancestors) {
298
406
  // The element is an ancestor of everything below it.
299
407
  const own = ancestorFilterKeys(keys);
300
408
  for (const key of own) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
@@ -305,9 +413,94 @@ export class StyleEngine {
305
413
  else ancestors.delete(key);
306
414
  }
307
415
  }
416
+
417
+ /** Ancestor filter counts for the elements above `element`. */
418
+ #ancestorsOf(element) {
419
+ const ancestors = new Map();
420
+ for (let node = element.parentElement; node; node = node.parentElement) {
421
+ for (const key of ancestorFilterKeys(elementKeys(node))) ancestors.set(key, (ancestors.get(key) ?? 0) + 1);
422
+ }
423
+ return ancestors;
424
+ }
425
+
426
+ /**
427
+ * Restyles one element; only when its style (or ::before/::after, or the
428
+ * custom properties it passes down) changed does its subtree follow.
429
+ */
430
+ #restyleSelf(element, rules) {
431
+ const parent = element.parentElement;
432
+ const before = { style: this.#styles.get(element), pseudo: this.#pseudoStyles.get(element), custom: this.#customProperties.get(element) };
433
+ const keys = elementKeys(element);
434
+ const ancestors = this.#ancestorsOf(element);
435
+ const { style, customProperties } = this.#styleOne(element, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, keys, ancestors);
436
+ const pseudo = this.#pseudoStyles.get(element);
437
+ if (sameValue(before.style, style) && sameValue(before.pseudo ?? null, pseudo ?? null) && sameCustom(before.custom, customProperties)) {
438
+ // Unchanged: keep the previous objects so later comparisons stay cheap.
439
+ this.#styles.set(element, before.style);
440
+ return;
441
+ }
442
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
443
+ }
444
+ }
445
+
446
+ /** Structural equality of computed style values (frozen objects and arrays). */
447
+ function sameValue(left, right) {
448
+ if (left === right) return true;
449
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
450
+ const keys = Object.keys(left);
451
+ if (keys.length !== Object.keys(right).length) return false;
452
+ return keys.every((key) => sameValue(left[key], right[key]));
453
+ }
454
+
455
+ function sameCustom(left, right) {
456
+ if (left === right) return true;
457
+ if (!left || !right || left.size !== right.size) return false;
458
+ for (const [name, value] of left) if (right.get(name) !== value) return false;
459
+ return true;
308
460
  }
309
461
 
310
462
  const EMPTY_MAP = new Map();
463
+ const MAX_SHEET_CHANGE_RULES = 300;
464
+
465
+ /**
466
+ * Rules from sheets present in only one of two sheet lists (by text), or null
467
+ * when such a change can reorder other rules: @layer declarations rank
468
+ * layers by first appearance and @property changes inheritance.
469
+ */
470
+ function diffSheets(previous, styleSheets, perSheet) {
471
+ const remaining = new Map();
472
+ previous.styleSheets.forEach((source, index) => {
473
+ const list = remaining.get(source);
474
+ if (list) list.push(previous.perSheet[index]); else remaining.set(source, [previous.perSheet[index]]);
475
+ });
476
+ const rules = [];
477
+ const risky = (source) => /@layer\b|@property\b/i.test(source);
478
+ for (let index = 0; index < styleSheets.length; index++) {
479
+ const source = styleSheets[index];
480
+ const list = remaining.get(source);
481
+ if (list?.length) { list.pop(); continue; }
482
+ if (risky(source)) return null;
483
+ rules.push(...perSheet[index]);
484
+ }
485
+ for (const [source, lists] of remaining) {
486
+ if (!lists.length) continue;
487
+ if (risky(source)) return null;
488
+ for (const list of lists) rules.push(...list);
489
+ }
490
+ return { rules };
491
+ }
492
+
493
+ /** The connected elements of `elements` that are not inside another of them. */
494
+ export function topmostConnected(elements, root) {
495
+ const result = [];
496
+ for (const element of elements) {
497
+ if (!element || element.nodeType !== 1 || !root.contains(element)) continue;
498
+ let covered = false;
499
+ for (let node = element.parentElement; node && !covered; node = node.parentElement) covered = elements.has(node);
500
+ if (!covered) result.push(element);
501
+ }
502
+ return result;
503
+ }
311
504
 
312
505
  /** The cascaded declarations with the element's style attribute applied on top. */
313
506
  function withInlineStyle(element, declarations) {
@@ -342,8 +535,12 @@ function withCustomProperties(inherited, declarations) {
342
535
 
343
536
  export function computeElementStyle(element, parentStyle = null, authorDeclarations = null, customProperties = new Map()) {
344
537
  const values = { ...INITIAL };
345
- for (const property of INHERITED) {
346
- if (parentStyle) copyProperty(values, property, parentStyle);
538
+ if (parentStyle) {
539
+ for (const property of INHERITED) {
540
+ const field = INHERITED_FIELDS.get(property);
541
+ if (field) values[field] = parentStyle[field];
542
+ else copyProperty(values, property, parentStyle);
543
+ }
347
544
  }
348
545
  // vertical-align is not inherited, except through the table UA rules' "inherit".
349
546
  values.verticalAlign = "baseline";
@@ -381,13 +578,16 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
381
578
  } else if (fontSize) {
382
579
  applyDeclaration(values, fontSize, customProperties, parentStyle);
383
580
  }
581
+ // light-dark() depends on the used scheme regardless of declaration order.
582
+ const colorScheme = declarations.get("color-scheme");
583
+ if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
384
584
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
385
585
  const ordered = [...declarations.values()].sort((left, right) => compareCascade(
386
586
  { important: false, specificity: 0, order: 0, ...left, important: Boolean(left.important) },
387
587
  { important: false, specificity: 0, order: 0, ...right, important: Boolean(right.important) },
388
588
  ));
389
589
  for (const declaration of ordered) {
390
- if (declaration.name.startsWith("--") || declaration.name === "font-size") continue;
590
+ if (declaration.name.startsWith("--") || declaration.name === "font-size" || declaration.name === "color-scheme") continue;
391
591
  // The shorthand's size was settled above; its other parts apply in order.
392
592
  applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
393
593
  }
@@ -607,9 +807,16 @@ function parseFontShorthand(value) {
607
807
  weight = lower === "bolder" ? "700" : lower === "lighter" ? "300" : lower;
608
808
  continue;
609
809
  }
610
- // The size, optionally with /line-height, then the family list.
611
- const [size, lineHeight] = token.split("/");
612
- const family = tokens.slice(index + 1).join(" ").trim();
810
+ // The size, optionally with /line-height (spaces around "/" allowed),
811
+ // then the family list.
812
+ let [size, lineHeight] = token.split("/");
813
+ let rest = index + 1;
814
+ if (lineHeight === "") lineHeight = tokens[rest++]; // "14px/ 1.5"
815
+ else if (lineHeight === undefined && tokens[rest] === "/") { // "14px / 1.5"
816
+ lineHeight = tokens[rest + 1];
817
+ rest += 2;
818
+ } else if (lineHeight === undefined && tokens[rest]?.startsWith("/")) lineHeight = tokens[rest++].slice(1); // "14px /1.5"
819
+ const family = tokens.slice(rest).join(" ").trim();
613
820
  if (!family || !(FONT_SIZE_KEYWORDS[size] || /^[\d.]/.test(size) || /^(?:calc|clamp|min|max)\(/.test(size))) return null;
614
821
  return { size, lineHeight: lineHeight || null, weight, family };
615
822
  }
@@ -751,6 +958,13 @@ function setProperty(style, name, value, parentStyle = null) {
751
958
  }
752
959
  if (typeof value !== "string") return;
753
960
  switch (name) {
961
+ case "color-scheme": {
962
+ const schemes = value.toLowerCase().split(/\s+/).filter((part) => part !== "only");
963
+ if (schemes.every((part) => ["normal", "light", "dark"].includes(part))) {
964
+ style.colorScheme = schemes.includes("light") || !schemes.includes("dark") ? "light" : "dark";
965
+ }
966
+ break;
967
+ }
754
968
  case "display": if (DISPLAYS.has(value)) style.display = value; break;
755
969
  case "text-decoration-line": {
756
970
  const lines = parseDecorationLines(value);
@@ -790,6 +1004,10 @@ function setProperty(style, name, value, parentStyle = null) {
790
1004
  case "visibility":
791
1005
  if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
792
1006
  break;
1007
+ // SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
1008
+ case "pointer-events":
1009
+ if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
1010
+ break;
793
1011
  case "opacity": {
794
1012
  const number = /^([+-]?(?:\d+\.?\d*|\.\d+))(%?)$/.exec(value);
795
1013
  if (number) style.opacity = Math.min(1, Math.max(0, Number(number[1]) / (number[2] ? 100 : 1)));
@@ -833,12 +1051,12 @@ function setProperty(style, name, value, parentStyle = null) {
833
1051
  break;
834
1052
  }
835
1053
  case "color": {
836
- const color = Bun.color(value, "rgba");
1054
+ const color = resolvedColor(value, style.colorScheme);
837
1055
  if (color) style.color = color;
838
1056
  break;
839
1057
  }
840
1058
  case "background-color": {
841
- const color = Bun.color(value, "rgba");
1059
+ const color = resolvedColor(value, style.colorScheme);
842
1060
  if (color) style.backgroundColor = color;
843
1061
  break;
844
1062
  }
@@ -847,7 +1065,7 @@ function setProperty(style, name, value, parentStyle = null) {
847
1065
  const layers = splitTopLevel(value, ",");
848
1066
  const images = layers.filter((layer) => GRADIENT.test(layer));
849
1067
  const color = splitTopLevel(layers.at(-1) ?? "", " ")
850
- .map((token) => Bun.color(token, "rgba"))
1068
+ .map((token) => resolvedColor(token, style.colorScheme))
851
1069
  .find(Boolean);
852
1070
  style.backgroundColor = color ?? INITIAL.backgroundColor;
853
1071
  style.backgroundImage = images.length ? images.join(", ") : null;
@@ -1097,6 +1315,24 @@ function setProperty(style, name, value, parentStyle = null) {
1097
1315
  if (["normal", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(justify)) style.justifyContent = justify;
1098
1316
  break;
1099
1317
  }
1318
+ case "align-content": {
1319
+ const alignment = FLEX_ALIGNMENT[value] ?? value;
1320
+ if (["normal", "stretch", "start", "end", "center", "space-between", "space-around", "space-evenly"].includes(alignment)) {
1321
+ style.alignContent = alignment;
1322
+ }
1323
+ break;
1324
+ }
1325
+ // CSS Box Alignment 3 §5.1/§6.1/§6.2: <align> [<justify>]; one value sets both.
1326
+ case "place-content":
1327
+ case "place-items":
1328
+ case "place-self": {
1329
+ const [align, justify = align] = splitTopLevel(value, " ").filter(Boolean);
1330
+ if (!align) break;
1331
+ const part = name.slice("place-".length);
1332
+ setProperty(style, `align-${part}`, align, parentStyle);
1333
+ setProperty(style, `justify-${part}`, justify, parentStyle);
1334
+ break;
1335
+ }
1100
1336
  case "align-items": {
1101
1337
  const alignment = FLEX_ALIGNMENT[value] ?? value;
1102
1338
  if (["normal", "stretch", "start", "end", "center", "baseline"].includes(alignment)) style.alignItems = alignment;
@@ -1678,6 +1914,7 @@ function cssValue(style, name) {
1678
1914
  "line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
1679
1915
  : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
1680
1916
  "list-style-type": style.listStyleType,
1917
+ "pointer-events": style.pointerEvents,
1681
1918
  "text-align": style.textAlign,
1682
1919
  "white-space": style.whiteSpace,
1683
1920
  })[name] ?? style[name];
@@ -1988,6 +2225,17 @@ function flexShorthand(value, fontSize) {
1988
2225
  };
1989
2226
  }
1990
2227
 
2228
+ function resolvedColor(value, scheme) {
2229
+ const match = /^light-dark\((.*)\)$/is.exec(value.trim());
2230
+ if (match) {
2231
+ const choices = splitTopLevel(match[1], ",");
2232
+ if (choices.length !== 2) return null;
2233
+ return resolvedColor(choices[scheme === "dark" ? 1 : 0], scheme);
2234
+ }
2235
+ const color = Bun.color(value, "rgba");
2236
+ return /^rgba\(/.test(color ?? "") ? color : null;
2237
+ }
2238
+
1991
2239
  function splitTopLevel(source, separator) {
1992
2240
  const parts = [];
1993
2241
  let depth = 0;
@@ -0,0 +1,12 @@
1
+ // Chrome 154 stable for desktop (2026-09-22). Keep CDP discovery, page JS, and
2
+ // HTTP requests on the same advertised version; casty reads /json/version.
3
+ export const CHROMIUM_VERSION = "154.0.8037.57";
4
+
5
+ export function desktopUserAgent(platform = process.platform, arch = process.arch) {
6
+ const system = platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7"
7
+ : platform === "win32" ? "Windows NT 10.0; Win64; x64"
8
+ : `X11; Linux ${arch === "arm64" ? "aarch64" : "x86_64"}`;
9
+ return `Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Safari/537.36`;
10
+ }
11
+
12
+ export const DESKTOP_USER_AGENT = desktopUserAgent();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -0,0 +1,40 @@
1
+ diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
2
+ index f439646..ce2dbe5 100644
3
+ --- a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
4
+ +++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
5
+ @@ -566,16 +566,7 @@ export default class HTMLScriptElement extends HTMLElement {
6
+ browserSettings &&
7
+ (browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
8
+ ) {
9
+ - if (browserSettings.handleDisabledFileLoadingAsSuccess) {
10
+ - this.dispatchEvent(new Event('load'));
11
+ - } else {
12
+ - const error = new window.DOMException(
13
+ - `Failed to load module "${url}". JavaScript file loading is disabled.`,
14
+ - DOMExceptionNameEnum.notSupportedError
15
+ - );
16
+ - browserFrame.page.console.error(error);
17
+ - this.dispatchEvent(new Event('error'));
18
+ - }
19
+ + // The renderer owns script fetching and dispatches load/error after execution.
20
+ return;
21
+ }
22
+
23
+ @@ -647,16 +638,7 @@ export default class HTMLScriptElement extends HTMLElement {
24
+ browserSettings &&
25
+ (browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)
26
+ ) {
27
+ - if (browserSettings.handleDisabledFileLoadingAsSuccess) {
28
+ - this.dispatchEvent(new Event('load'));
29
+ - } else {
30
+ - const error = new window.DOMException(
31
+ - `Failed to load script "${absoluteURL}". JavaScript file loading is disabled.`,
32
+ - DOMExceptionNameEnum.notSupportedError
33
+ - );
34
+ - browserFrame.page.console.error(error);
35
+ - this.dispatchEvent(new Event('error'));
36
+ - }
37
+ + // The renderer owns script fetching and dispatches load/error after execution.
38
+ return;
39
+ }
40
+
@@ -0,0 +1,19 @@
1
+ diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
2
+ --- a/packages/happy-dom/src/nodes/element/Element.ts
3
+ +++ b/packages/happy-dom/src/nodes/element/Element.ts
4
+ @@ -706,12 +706,9 @@
5
+ * @param text HTML string to insert.
6
+ */
7
+ public insertAdjacentHTML(position: InsertAdjacentPosition, text: string): void {
8
+ - const childNodes = new HTMLParser(this[PropertySymbol.window]).parse(text)[
9
+ - PropertySymbol.nodeArray
10
+ - ];
11
+ - while (childNodes.length) {
12
+ - this.insertAdjacentElement(position, childNodes[0]);
13
+ - }
14
+ + // Insert the parsed fragment as a whole: inserting its nodes one at a
15
+ + // time at "afterbegin" or "afterend" reversed their order.
16
+ + this.insertAdjacentElement(position, new HTMLParser(this[PropertySymbol.window]).parse(text));
17
+ }
18
+
19
+ /**
@@ -0,0 +1,43 @@
1
+ diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts
2
+ --- a/packages/happy-dom/src/query-selector/QuerySelector.ts
3
+ +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts
4
+ @@ -373,7 +373,10 @@ export default class QuerySelector {
5
+ );
6
+ }
7
+
8
+ - const cachedResult = element[PropertySymbol.cache].matches.get(selector);
9
+ + // A :has() match depends on the whole subtree, which no affected-cache
10
+ + // registration covers, so it is not cached.
11
+ + const cacheable = !selector.includes(':has(');
12
+ + const cachedResult = cacheable ? element[PropertySymbol.cache].matches.get(selector) : null;
13
+
14
+ if (cachedResult) {
15
+ if (cachedResult.result !== null) {
16
+ @@ -386,9 +389,11 @@ export default class QuerySelector {
17
+ result: { match: null }
18
+ };
19
+
20
+ - element[PropertySymbol.cache].matches.set(selector, cachedItem);
21
+ + if (cacheable) {
22
+ + element[PropertySymbol.cache].matches.set(selector, cachedItem);
23
+ + }
24
+
25
+ - if (element[PropertySymbol.isConnected]) {
26
+ + if (cacheable && element[PropertySymbol.isConnected]) {
27
+ // Document is affected for the ":target" selector
28
+ (element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push(
29
+ cachedItem
30
+ @@ -462,6 +467,13 @@ export default class QuerySelector {
31
+ return null;
32
+ }
33
+
34
+ + // Structural pseudo-classes and sibling combinators read the parent's
35
+ + // child list, whose changes are reported on the parent.
36
+ + const parent = element.parentNode;
37
+ + if (parent) {
38
+ + this.affectsCache(<Element>parent, cachedItem);
39
+ + }
40
+ +
41
+ const result = selectorItem.match(scope, element, ignoreErrors);
42
+
43
+ if (result) {