buninu-browser 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +19 -12
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/happy-dom/selector-matcher.js +83 -0
  11. package/lib/headless-shell.js +120 -28
  12. package/lib/input/hit-test.js +25 -7
  13. package/lib/layout/text-layout.js +365 -54
  14. package/lib/log.js +5 -2
  15. package/lib/media/byte-pipe.js +82 -0
  16. package/lib/media/position-audio.js +42 -0
  17. package/lib/network/network-service.js +326 -46
  18. package/lib/paint/display-list.js +322 -21
  19. package/lib/paint/gradient.js +11 -5
  20. package/lib/render-tree/index.js +62 -2
  21. package/lib/render-tree/svg.js +2 -1
  22. package/lib/renderer/bindings.js +989 -72
  23. package/lib/renderer/canvas-2d.js +92 -0
  24. package/lib/renderer/host.js +159 -9
  25. package/lib/renderer/interfaces.js +215 -0
  26. package/lib/renderer/main.js +160 -12
  27. package/lib/renderer/media-source.js +819 -0
  28. package/lib/renderer/messaging.js +411 -0
  29. package/lib/renderer/native-functions.js +113 -0
  30. package/lib/renderer/page-realm.js +1407 -200
  31. package/lib/renderer/page-renderer.js +1899 -394
  32. package/lib/renderer/rejections.js +31 -0
  33. package/lib/renderer/rewrite-worker.js +22 -0
  34. package/lib/renderer/script-rewrite.js +805 -57
  35. package/lib/renderer/trusted-types.js +129 -0
  36. package/lib/renderer/workers.js +327 -0
  37. package/lib/screenshot-cli.js +2 -0
  38. package/lib/style/computed-style.js +463 -242
  39. package/lib/style/selector-keys.js +302 -0
  40. package/lib/style/selector-traits.js +240 -0
  41. package/lib/user-agent.js +14 -2
  42. package/package.json +4 -1
  43. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  44. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  45. package/patches/happy-dom/README.md +26 -0
  46. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  47. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  48. package/patches/ses/README.md +4 -0
  49. package/test/canvas-2d.test.js +55 -0
  50. package/test/compositor.test.js +3 -1
  51. package/test/computed-style.test.js +98 -1
  52. package/test/display-list.test.js +74 -1
  53. package/test/download.test.js +35 -0
  54. package/test/fixtures/long-video.mp4 +0 -0
  55. package/test/fixtures/media-sync.js +57 -0
  56. package/test/happy-dom-platform-adapters.test.js +25 -0
  57. package/test/headless-shell.test.js +29 -0
  58. package/test/hit-test.test.js +25 -1
  59. package/test/log.test.js +4 -1
  60. package/test/media-source.test.js +156 -0
  61. package/test/media-sync.test.js +12 -0
  62. package/test/mobile.test.js +2 -2
  63. package/test/navigation.test.js +61 -0
  64. package/test/network-service.test.js +81 -0
  65. package/test/page-scripts.test.js +1094 -7
  66. package/test/position-audio.test.js +67 -0
  67. package/test/positioning.test.js +95 -1
  68. package/test/pulse-client.test.js +69 -0
  69. package/test/render-tree.test.js +22 -1
  70. package/test/renderer-host.test.js +5 -2
  71. package/test/screenshot-cli.test.js +25 -1
  72. package/test/script-rewrite.test.js +111 -6
  73. package/test/selector-keys.test.js +39 -0
  74. package/test/selector-traits.test.js +19 -0
  75. package/test/setup.js +5 -0
  76. package/test/text-layout.test.js +69 -1
  77. package/test/user-agent.test.js +60 -4
  78. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  79. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  80. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The keys a stylesheet selector is indexed by (see RuleIndex in
3
+ * computed-style.js): the subject compound's bucket keys, and the keys its
4
+ * ancestors must carry for the ancestor filter.
5
+ *
6
+ * Selectors are scanned by character code and position: names are cut out
7
+ * of the selector only for the keys returned, without regular expressions or
8
+ * intermediate substrings, as tens of thousands of selectors are indexed for
9
+ * every new document.
10
+ */
11
+
12
+ const BACKSLASH = 0x5c, QUOTE = 0x22, APOSTROPHE = 0x27, OPEN_PAREN = 0x28, CLOSE_PAREN = 0x29;
13
+ const OPEN_BRACKET = 0x5b, CLOSE_BRACKET = 0x5d, SPACE = 0x20, TAB = 0x09, LINE_FEED = 0x0a, CARRIAGE_RETURN = 0x0d;
14
+ const FORM_FEED = 0x0c, GREATER = 0x3e, PLUS = 0x2b, TILDE = 0x7e, HASH = 0x23, PERIOD = 0x2e, COLON = 0x3a;
15
+ const COMMA = 0x2c, PIPE = 0x7c, HYPHEN = 0x2d, UNDERSCORE = 0x5f;
16
+
17
+ // The selector being analyzed; the functions below take positions in it.
18
+ let text = "";
19
+
20
+ // Scratch results, reused instead of allocated per selector and compound:
21
+ // one per level of :is() nesting, as an outer result stays in use while the
22
+ // alternatives inside it are analyzed.
23
+ const partsPool = [];
24
+ const keysPool = [];
25
+
26
+ function scratchParts(level) {
27
+ const parts = partsPool[level] ??= { starts: [], ends: [], combinators: [] };
28
+ parts.starts.length = parts.ends.length = parts.combinators.length = 0;
29
+ return parts;
30
+ }
31
+
32
+ function scratchKeys(level) {
33
+ const keys = keysPool[level] ??= { ids: [], classes: [], attrs: [], tag: [], pseudo: [] };
34
+ keys.ids.length = keys.classes.length = keys.attrs.length = keys.tag.length = keys.pseudo.length = 0;
35
+ return keys;
36
+ }
37
+
38
+ /**
39
+ * A selector's index keys. `subject` is null when the selector is not
40
+ * understood or its subject has no key (the rule then goes to the universal
41
+ * bucket); `ancestors` lists filter keys: "tag", "#id", ".class", "[attr".
42
+ * @returns {{ subject: { kind: "id" | "class" | "attr" | "tag", name: string }[] | null, ancestors: string[] }}
43
+ */
44
+ export function selectorKeys(selector) {
45
+ text = selector;
46
+ const parts = selectorParts(0, selector.length, 0);
47
+ if (!parts) return { subject: null, ancestors: [] };
48
+ const last = parts.starts.length - 1;
49
+ const subject = subjectKeys(parts.starts[last], parts.ends[last], 1);
50
+ const ancestors = new Set();
51
+ for (let index = 0; index < last; index++) {
52
+ // Only compounds whose right-hand combinator is descendant or child are
53
+ // ancestors of the subject (a sibling's parent is the subject's parent).
54
+ const combinator = parts.combinators[index];
55
+ if (combinator !== SPACE && combinator !== GREATER) continue;
56
+ const keys = compoundKeys(parts.starts[index], parts.ends[index], 0);
57
+ if (keys.tag.length) ancestors.add(name(keys.tag, true));
58
+ for (let at = 0; at < keys.ids.length; at += 3) ancestors.add(`#${name(keys.ids, false, at)}`);
59
+ for (let at = 0; at < keys.classes.length; at += 3) ancestors.add(`.${name(keys.classes, false, at)}`);
60
+ for (let at = 0; at < keys.attrs.length; at += 3) ancestors.add(`[${name(keys.attrs, true, at)}`);
61
+ }
62
+ return { subject, ancestors: [...ancestors] };
63
+ }
64
+
65
+ /**
66
+ * Splits [start, end) into compounds and the combinators between them (" ",
67
+ * ">", "+", "~"), ignoring anything inside (), [] and quotes. Returns null
68
+ * for selectors this does not understand. As before this module existed, an
69
+ * escape or a quoted string does not take a pending combinator, so
70
+ * `combinators` may be shorter than `starts` minus one.
71
+ */
72
+ function selectorParts(start, end, level) {
73
+ const parts = scratchParts(level);
74
+ const { starts, ends, combinators } = parts;
75
+ let current = -1;
76
+ let pending = 0;
77
+ let depth = 0;
78
+ for (let index = start; index < end; index++) {
79
+ const code = text.charCodeAt(index);
80
+ if (code === BACKSLASH) {
81
+ if (current < 0) current = index;
82
+ index++;
83
+ continue;
84
+ }
85
+ if (code === QUOTE || code === APOSTROPHE) {
86
+ const close = text.indexOf(text[index], index + 1);
87
+ if (close < 0 || close >= end) return null;
88
+ if (current < 0) current = index;
89
+ index = close;
90
+ continue;
91
+ }
92
+ if (code === OPEN_PAREN || code === OPEN_BRACKET) depth++;
93
+ else if (code === CLOSE_PAREN || code === CLOSE_BRACKET) depth--;
94
+ if (depth === 0 && (code === SPACE || code === GREATER || code === PLUS || code === TILDE
95
+ || code === TAB || code === LINE_FEED || code === CARRIAGE_RETURN || code === FORM_FEED)) {
96
+ if (current >= 0) {
97
+ starts.push(current);
98
+ ends.push(index);
99
+ current = -1;
100
+ pending = SPACE;
101
+ }
102
+ if (code === GREATER || code === PLUS || code === TILDE) {
103
+ if (pending === 0 && starts.length === 0) return null;
104
+ pending = code;
105
+ }
106
+ continue;
107
+ }
108
+ if (pending !== 0) {
109
+ combinators.push(pending);
110
+ pending = 0;
111
+ }
112
+ if (current < 0) current = index;
113
+ }
114
+ if (depth !== 0) return null;
115
+ if (current >= 0) {
116
+ starts.push(current);
117
+ ends.push(end);
118
+ } else if (pending !== 0 && starts.length) return null;
119
+ return starts.length ? parts : null;
120
+ }
121
+
122
+ /**
123
+ * Keys of the compound [start, end), from its parts outside (): ids,
124
+ * classes and attribute names as flat [start, end, escaped] triples, the
125
+ * type as one (empty when none), and pseudo-classes as [name start, name
126
+ * end, colon] triples.
127
+ */
128
+ function compoundKeys(start, end, level) {
129
+ const keys = scratchKeys(level);
130
+ const pipe = text.indexOf("|", start);
131
+ if (pipe !== -1 && pipe < end) return keys;
132
+ if (start < end && isAlpha(text.charCodeAt(start))) {
133
+ let tagEnd = start + 1;
134
+ while (tagEnd < end && isNameCode(text.charCodeAt(tagEnd))) tagEnd++;
135
+ keys.tag.push(start, tagEnd, 0);
136
+ }
137
+ let depth = 0;
138
+ for (let index = start; index < end; index++) {
139
+ const code = text.charCodeAt(index);
140
+ if (code === BACKSLASH) {
141
+ index++;
142
+ continue;
143
+ }
144
+ if (code === OPEN_PAREN) {
145
+ depth++;
146
+ continue;
147
+ }
148
+ if (code === CLOSE_PAREN) {
149
+ depth--;
150
+ continue;
151
+ }
152
+ if (depth > 0) continue;
153
+ if (code === OPEN_BRACKET) {
154
+ let nameStart = index + 1;
155
+ while (nameStart < end && isSpace(text.charCodeAt(nameStart))) nameStart++;
156
+ let nameEnd = nameStart;
157
+ while (nameEnd < end && isNameCode(text.charCodeAt(nameEnd))) nameEnd++;
158
+ if (nameEnd > nameStart) keys.attrs.push(nameStart, nameEnd, 0);
159
+ // Skip to the closing bracket; quoted values may contain "]", "#" or ".".
160
+ let quote = 0;
161
+ for (index++; index < end; index++) {
162
+ const inner = text.charCodeAt(index);
163
+ if (inner === BACKSLASH) index++;
164
+ else if (quote) {
165
+ if (inner === quote) quote = 0;
166
+ } else if (inner === QUOTE || inner === APOSTROPHE) quote = inner;
167
+ else if (inner === CLOSE_BRACKET) break;
168
+ }
169
+ } else if (code === HASH || code === PERIOD) {
170
+ const identifier = leadingIdentifier(index + 1, end);
171
+ const first = text.charCodeAt(index + 1);
172
+ // A class name cannot start with a digit; an id may.
173
+ if (identifier && !(code === PERIOD && first >= 0x30 && first <= 0x39)) {
174
+ (code === HASH ? keys.ids : keys.classes).push(index + 1, identifier.end, identifier.escaped);
175
+ }
176
+ } else if (code === COLON) {
177
+ let nameStart = index + 1;
178
+ if (nameStart < end && text.charCodeAt(nameStart) === COLON) nameStart++;
179
+ let nameEnd = nameStart;
180
+ while (nameEnd < end && isNameCode(text.charCodeAt(nameEnd))) nameEnd++;
181
+ if (nameEnd > nameStart) keys.pseudo.push(nameStart, nameEnd, index);
182
+ }
183
+ }
184
+ return keys;
185
+ }
186
+
187
+ /**
188
+ * The identifier at `start` (word characters, "-", non-ASCII, and escapes
189
+ * other than hex ones), or null when absent or followed by a hex escape.
190
+ */
191
+ function leadingIdentifier(start, end) {
192
+ let index = start;
193
+ let escaped = 0;
194
+ while (index < end) {
195
+ const code = text.charCodeAt(index);
196
+ if (isNameCode(code) || code >= 0x80) index++;
197
+ else if (code === BACKSLASH && index + 1 < end && !isHexDigit(text.charCodeAt(index + 1)) && !isSpace(text.charCodeAt(index + 1))) {
198
+ index += 2;
199
+ escaped = 1;
200
+ } else break;
201
+ }
202
+ if (index === start || (index < end && text.charCodeAt(index) === BACKSLASH)) return null;
203
+ return { end: index, escaped };
204
+ }
205
+
206
+ /**
207
+ * Bucket keys for the subject compound: one id, class, attribute or type key,
208
+ * or one per alternative of an :is()/:where() whose alternatives all have one.
209
+ */
210
+ function subjectKeys(start, end, level) {
211
+ const keys = compoundKeys(start, end, level);
212
+ if (keys.ids.length) return [{ kind: "id", name: name(keys.ids, false) }];
213
+ if (keys.classes.length) return [{ kind: "class", name: name(keys.classes, false) }];
214
+ if (keys.attrs.length) return [{ kind: "attr", name: name(keys.attrs, true) }];
215
+ if (keys.tag.length) return [{ kind: "tag", name: name(keys.tag, true) }];
216
+ const pseudo = keys.pseudo;
217
+ for (let at = 0; at < pseudo.length; at += 3) {
218
+ if (equalsLowerCase(pseudo[at], pseudo[at + 1], "root")) return [{ kind: "tag", name: "html" }];
219
+ }
220
+ for (let at = 0; at < pseudo.length; at += 3) {
221
+ const nameStart = pseudo[at];
222
+ const nameEnd = pseudo[at + 1];
223
+ if (!equalsLowerCase(nameStart, nameEnd, "is") && !equalsLowerCase(nameStart, nameEnd, "where")
224
+ && !equalsLowerCase(nameStart, nameEnd, "matches") && !equalsLowerCase(nameStart, nameEnd, "-webkit-any")) continue;
225
+ const open = text.indexOf("(", pseudo[at + 2]);
226
+ const close = open < 0 || open >= end ? -1 : matchingParenthesis(open, end);
227
+ if (close < 0) return null;
228
+ const result = [];
229
+ let depth = 0;
230
+ let from = open + 1;
231
+ for (let index = open + 1; index <= close; index++) {
232
+ // The closing parenthesis ends the last alternative.
233
+ const code = index < close ? text.charCodeAt(index) : COMMA;
234
+ if (index < close && code === OPEN_PAREN) depth++;
235
+ else if (index < close && code === CLOSE_PAREN) depth--;
236
+ else if (code === COMMA && (depth === 0 || index === close)) {
237
+ let alternativeStart = from;
238
+ let alternativeEnd = index;
239
+ from = index + 1;
240
+ while (alternativeStart < alternativeEnd && isSpace(text.charCodeAt(alternativeStart))) alternativeStart++;
241
+ while (alternativeEnd > alternativeStart && isSpace(text.charCodeAt(alternativeEnd - 1))) alternativeEnd--;
242
+ if (alternativeStart === alternativeEnd) continue;
243
+ // Only simple alternatives: complex ones inside :is() are left to the
244
+ // selector engine unindexed, so indexing never changes a result.
245
+ const parts = selectorParts(alternativeStart, alternativeEnd, level);
246
+ const inner = parts?.starts.length === 1 ? subjectKeys(parts.starts[0], parts.ends[0], level + 1) : null;
247
+ if (!inner) return null;
248
+ result.push(...inner);
249
+ }
250
+ }
251
+ return result.length ? result : null;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function matchingParenthesis(open, end) {
257
+ let depth = 0;
258
+ for (let index = open; index < end; index++) {
259
+ const code = text.charCodeAt(index);
260
+ if (code === BACKSLASH) index++;
261
+ else if (code === OPEN_PAREN) depth++;
262
+ else if (code === CLOSE_PAREN && --depth === 0) return index;
263
+ }
264
+ return -1;
265
+ }
266
+
267
+ /** The name of a [start, end, escaped] triple at `at`: unescaped, lowercased for types and attributes. */
268
+ function name(triple, lowerCase, at = 0) {
269
+ let value = text.slice(triple[at], triple[at + 1]);
270
+ if (triple[at + 2]) value = value.replace(/\\(.)/g, "$1");
271
+ return lowerCase ? value.toLowerCase() : value;
272
+ }
273
+
274
+ function equalsLowerCase(start, end, expected) {
275
+ if (end - start !== expected.length) return false;
276
+ for (let index = 0; index < expected.length; index++) {
277
+ let code = text.charCodeAt(start + index);
278
+ if (code >= 0x41 && code <= 0x5a) code += 0x20;
279
+ if (code !== expected.charCodeAt(index)) return false;
280
+ }
281
+ return true;
282
+ }
283
+
284
+ function isAlpha(code) {
285
+ return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a);
286
+ }
287
+
288
+ /** [\w-]: ASCII letters, digits, "_" and "-". */
289
+ function isNameCode(code) {
290
+ return isAlpha(code) || (code >= 0x30 && code <= 0x39) || code === UNDERSCORE || code === HYPHEN;
291
+ }
292
+
293
+ function isHexDigit(code) {
294
+ return (code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66) || (code >= 0x41 && code <= 0x46);
295
+ }
296
+
297
+ /** JavaScript white space: what /\s/ matches and trim() removes. */
298
+ function isSpace(code) {
299
+ return code === SPACE || (code >= TAB && code <= CARRIAGE_RETURN) || code === 0xa0 || code === 0x1680
300
+ || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f
301
+ || code === 0x205f || code === 0x3000 || code === 0xfeff;
302
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * What a style sheet's selectors make sensitive to DOM changes, for the
3
+ * renderer's style invalidation (see selectorTraitsFor in page-renderer.js).
4
+ */
5
+
6
+ /** One sheet's selector traits (sibling combinators, :has(), structural pseudo-classes, invalidation features). */
7
+ export function sheetTraits(sheet) {
8
+ const text = String(sheet).replace(/\/\*[\s\S]*?\*\//g, "");
9
+ const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^}]*\}/g, "{}");
10
+ const sibling = /[^\s~|^$*=\[]\s*[+~]\s*[^\s=]/.test(plain);
11
+ const hasCompounds = new Set();
12
+ let hasSiblings = false;
13
+ const selectorsOnly = text.replace(/\{[^}]*\}/g, "{}");
14
+ for (const block of hasBlocks(selectorsOnly)) {
15
+ for (const selector of splitTopLevelText(block.slice(0, -1), ",")) {
16
+ for (const compound of compoundsOf(selector)) {
17
+ if (!compound.includes(":has(")) continue;
18
+ hasCompounds.add(compound.replace(/::[\w-]+(\([^)]*\))?$/, ""));
19
+ if (/:has\(\s*[+~]/.test(compound)) hasSiblings = true;
20
+ }
21
+ }
22
+ }
23
+ // Structural pseudo-classes: which siblings a child list change can
24
+ // affect, and the compounds where they are not the subject (".a:last-child .b"),
25
+ // whose matches decide whether a sibling's subtree must restyle too.
26
+ const structural = /:(?:nth-child|nth-last-child|first-child|last-child|only-child|nth-of-type|nth-last-of-type|first-of-type|last-of-type|only-of-type)\b/;
27
+ const structuralCompounds = new Set();
28
+ for (const block of selectorsOnly.matchAll(/[^{}]*\{/g)) {
29
+ if (!structural.test(block[0])) continue;
30
+ for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
31
+ const compounds = compoundsOf(selector);
32
+ for (const compound of compounds.slice(0, -1)) if (structural.test(compound)) structuralCompounds.add(compound);
33
+ }
34
+ }
35
+ // Invalidation features (class ".x", id "#x", attribute "[x", tag "t:x",
36
+ // state ":state", anything "*") of compounds that are not a selector's
37
+ // subject: left of a descendant/child combinator, a change to them can
38
+ // restyle the element's subtree; left of + / ~, its later siblings.
39
+ const descendantFeatures = new Set();
40
+ const siblingFeatures = new Set();
41
+ const descendantStateCompounds = [];
42
+ // Selectors ending in a compound right of + / ~ that is not the subject
43
+ // (".a + .b" of ".a + .b .c"): a later sibling's subtree restyles only
44
+ // when its match of one of them changes.
45
+ const siblingTargetCompounds = new Set();
46
+ for (const block of selectorsOnly.matchAll(/[^{}@]*\{/g)) {
47
+ for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
48
+ const parts = complexParts(selector);
49
+ parts.forEach(({ compound, combinator }, index) => {
50
+ const nested = /\([^)]*[\s>+~][^)]*\)/.test(compound); // :is(.a .b) hides combinators
51
+ const previous = parts[index - 1]?.combinator;
52
+ if ((previous === "+" || previous === "~") && index < parts.length - 1) {
53
+ // With what is left of it ("li:first-child + li"): the relation to
54
+ // the earlier sibling can end while the compound still matches.
55
+ const left = parts.slice(0, index).map((part) => `${part.compound} ${part.combinator === " " ? "" : part.combinator + " "}`).join("");
56
+ siblingTargetCompounds.add(left + compound);
57
+ }
58
+ if (index === parts.length - 1 && !nested) return;
59
+ const features = compoundFeatures(compound);
60
+ if (nested || combinator === " " || combinator === ">") {
61
+ for (const feature of features) descendantFeatures.add(feature);
62
+ const states = features.filter((feature) => feature.startsWith(":"));
63
+ if (states.length) descendantStateCompounds.push({ states, required: positiveCompoundFeatures(compound) });
64
+ }
65
+ if (nested || combinator === "+" || combinator === "~") for (const feature of features) siblingFeatures.add(feature);
66
+ });
67
+ }
68
+ }
69
+ return {
70
+ sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, descendantStateCompounds, siblingTargetCompounds,
71
+ forward: /:(?:nth-child|nth-of-type|first-of-type|only-of-type)\b/.test(selectorsOnly),
72
+ firstChild: /:(?:first-child|only-child)\b/.test(selectorsOnly),
73
+ backward: /:(?:nth-last-child|nth-last-of-type|last-of-type|only-of-type)\b/.test(selectorsOnly),
74
+ lastChild: /:(?:last-child|only-child)\b/.test(selectorsOnly),
75
+ defined: text.includes(":defined"), target: /:target\b/.test(text),
76
+ };
77
+ }
78
+
79
+ const HAS_BLOCK = /[^{}]*:has\([^{]*\{/y;
80
+
81
+ /**
82
+ * The matches of /[^{}]*:has\([^{]*\{/g in `text`. Searched globally, that
83
+ * expression retries at every position of every brace-free run; a match can
84
+ * only start where such a run starts (after a brace, where the previous
85
+ * match ended) and contains ":has(", so only those runs are tried.
86
+ */
87
+ function* hasBlocks(text) {
88
+ let end = 0;
89
+ for (let at = text.indexOf(":has("); at !== -1; at = text.indexOf(":has(", Math.max(at + 1, end))) {
90
+ const start = Math.max(text.lastIndexOf("{", at), text.lastIndexOf("}", at)) + 1;
91
+ if (start < end) continue;
92
+ HAS_BLOCK.lastIndex = start;
93
+ const match = HAS_BLOCK.exec(text);
94
+ if (!match) continue;
95
+ end = HAS_BLOCK.lastIndex;
96
+ yield match[0];
97
+ }
98
+ }
99
+
100
+ /** Splits at top-level `separator` (outside (), [] and quotes). */
101
+ function splitTopLevelText(text, separator) {
102
+ const parts = [];
103
+ let depth = 0;
104
+ let quote = "";
105
+ let start = 0;
106
+ for (let index = 0; index < text.length; index++) {
107
+ const character = text[index];
108
+ if (quote) { if (character === "\\") index++; else if (character === quote) quote = ""; continue; }
109
+ if (character === '"' || character === "'") quote = character;
110
+ else if (character === "\\") index++;
111
+ else if (character === "(" || character === "[") depth++;
112
+ else if (character === ")" || character === "]") depth--;
113
+ else if (character === separator && depth === 0) { parts.push(text.slice(start, index).trim()); start = index + 1; }
114
+ }
115
+ parts.push(text.slice(start).trim());
116
+ return parts.filter(Boolean);
117
+ }
118
+
119
+ const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)\b/g;
120
+
121
+ /** CSS escapes in an identifier (".\\:popover-open", "\\31 a") resolved. */
122
+ function cssUnescape(text) {
123
+ return text.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/\\(.)/g, "$1");
124
+ }
125
+
126
+ /** Invalidation features a compound selector mentions (see selectorTraitsFor). */
127
+ function compoundFeatures(compound) {
128
+ const features = [];
129
+ const plain = compound.replace(/\[[^\]]*\]/g, (attribute) => {
130
+ const name = /^\[\s*([\w-]+)/.exec(attribute)?.[1];
131
+ if (name) features.push("[" + name.toLowerCase());
132
+ return "";
133
+ });
134
+ for (const match of plain.matchAll(/([.#])((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/g)) features.push(match[1] + cssUnescape(match[2]));
135
+ const tag = /^(?:[a-zA-Z][\w-]*)/.exec(plain)?.[0];
136
+ if (tag) features.push("t:" + tag.toLowerCase());
137
+ for (const match of plain.matchAll(STATE_PSEUDO)) features.push(match[0]);
138
+ // Nothing but a universal selector or pseudo-classes: it can match anything.
139
+ if (!features.length || /^\*/.test(plain)) features.push("*");
140
+ return features;
141
+ }
142
+
143
+ /** Positive outer-compound requirements that must hold regardless of pseudo state. */
144
+ function positiveCompoundFeatures(compound) {
145
+ const features = [];
146
+ const tag = /^[a-zA-Z][\w-]*/.exec(compound)?.[0];
147
+ if (tag) features.push("t:" + tag.toLowerCase());
148
+ let depth = 0;
149
+ for (let index = 0; index < compound.length; index++) {
150
+ const character = compound[index];
151
+ if (character === "\\") { index++; continue; }
152
+ if (character === "(") { depth++; continue; }
153
+ if (character === ")") { depth--; continue; }
154
+ if (depth) continue;
155
+ if (character === "[") {
156
+ const name = /^\s*([\w-]+)/.exec(compound.slice(index + 1))?.[1];
157
+ if (name) features.push("[" + name.toLowerCase());
158
+ let quote = "";
159
+ for (index++; index < compound.length; index++) {
160
+ const inner = compound[index];
161
+ if (inner === "\\") index++;
162
+ else if (quote) { if (inner === quote) quote = ""; }
163
+ else if (inner === '"' || inner === "'") quote = inner;
164
+ else if (inner === "]") break;
165
+ }
166
+ } else if (character === "." || character === "#") {
167
+ const match = /^((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/.exec(compound.slice(index + 1));
168
+ if (match) features.push(character + cssUnescape(match[1]));
169
+ }
170
+ }
171
+ return features;
172
+ }
173
+
174
+ /** The features an element has (for a sibling it inserts next to, for example). */
175
+ export function elementFeatures(element) {
176
+ const features = ["t:" + element.localName];
177
+ const id = element.getAttribute("id");
178
+ if (id) features.push("#" + id);
179
+ for (const name of element.classList) features.push("." + name);
180
+ for (const attribute of element.attributes) features.push("[" + attribute.name);
181
+ return features;
182
+ }
183
+
184
+ /** Whether a character code is JavaScript white space (what /\s/ matches and trim() removes). */
185
+ function isSpace(code) {
186
+ return code === 0x20 || (code >= 0x09 && code <= 0x0d) || code === 0xa0 || code === 0x1680
187
+ || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f
188
+ || code === 0x205f || code === 0x3000 || code === 0xfeff;
189
+ }
190
+
191
+ const GREATER = 0x3e, PLUS = 0x2b, TILDE = 0x7e;
192
+
193
+ /**
194
+ * Calls `separator(index, code)` at each top-level combinator character of
195
+ * a complex selector (white space, ">", "+", "~" outside (), [], quotes and
196
+ * escapes) and returns nothing; the text between separators is a compound.
197
+ */
198
+ function forEachSeparator(selector, separator) {
199
+ let depth = 0;
200
+ let quote = 0;
201
+ for (let index = 0; index < selector.length; index++) {
202
+ const code = selector.charCodeAt(index);
203
+ if (quote) {
204
+ if (code === 0x5c) index++;
205
+ else if (code === quote) quote = 0;
206
+ continue;
207
+ }
208
+ if (code === 0x22 || code === 0x27) { quote = code; continue; }
209
+ if (code === 0x5c) { index++; continue; }
210
+ if (code === 0x28 || code === 0x5b) depth++;
211
+ else if (code === 0x29 || code === 0x5d) depth--;
212
+ else if (depth === 0 && (code === GREATER || code === PLUS || code === TILDE || isSpace(code))) separator(index, code);
213
+ }
214
+ }
215
+
216
+ /** A complex selector's compounds with the combinator after each (" ", ">", "+", "~", or null for the subject). */
217
+ function complexParts(selector) {
218
+ const parts = [];
219
+ let start = 0;
220
+ forEachSeparator(selector, (index, code) => {
221
+ if (index > start) parts.push({ compound: selector.slice(start, index), combinator: null });
222
+ start = index + 1;
223
+ if (!isSpace(code) && parts.length) parts.at(-1).combinator = selector[index];
224
+ else if (parts.length && parts.at(-1).combinator === null) parts.at(-1).combinator = " ";
225
+ });
226
+ if (selector.length > start) parts.push({ compound: selector.slice(start), combinator: null });
227
+ return parts;
228
+ }
229
+
230
+ /** The compound selectors of a complex selector (split at top-level combinators). */
231
+ function compoundsOf(selector) {
232
+ const compounds = [];
233
+ let start = 0;
234
+ forEachSeparator(selector, (index) => {
235
+ if (index > start) compounds.push(selector.slice(start, index));
236
+ start = index + 1;
237
+ });
238
+ if (selector.length > start) compounds.push(selector.slice(start));
239
+ return compounds;
240
+ }
package/lib/user-agent.js CHANGED
@@ -1,5 +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.
1
+ import packageInfo from "../package.json" with { type: "json" };
2
+
3
+ export const BROWSER_PRODUCT = `Buninu/${packageInfo.version}`;
4
+ // Compatibility identities, not the version of this engine. Bun runs
5
+ // JavaScriptCore, so a WebKit profile can avoid a V8/JavaScriptCore mismatch.
6
+ // Keep the complete profile together: changing just the UA leaves conflicting
7
+ // vendor and User-Agent Client Hints exposed to scripts.
8
+ export const WEBKIT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15";
9
+ // Chrome 154 compatibility profile (2026-09-22).
3
10
  export const CHROMIUM_VERSION = "154.0.8037.57";
4
11
 
5
12
  export function desktopUserAgent(platform = process.platform, arch = process.arch) {
@@ -10,3 +17,8 @@ export function desktopUserAgent(platform = process.platform, arch = process.arc
10
17
  }
11
18
 
12
19
  export const DESKTOP_USER_AGENT = desktopUserAgent();
20
+
21
+ /** Desktop compatibility flags are last-wins; mobile presentation takes priority. */
22
+ export function webkitFromArgs(args) {
23
+ return !args.includes("--mobile") && args.lastIndexOf("--webkit") >= args.lastIndexOf("--chromium");
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -32,6 +32,9 @@
32
32
  "bugs": {
33
33
  "url": "https://github.com/jjtseng93/buninu-browser/issues"
34
34
  },
35
+ "scripts": {
36
+ "test": "bun test --parallel"
37
+ },
35
38
  "license": "MIT",
36
39
  "engines": {
37
40
  "bun": ">=1.4.2"