buninu-browser 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { parseHTMLDocument } from "../lib/happy-dom/parser.js";
3
3
  import { RenderTreeBuilder, renderTreeText } from "../lib/render-tree/index.js";
4
- import { computeElementStyle, parseDeclarations, StyleEngine } from "../lib/style/computed-style.js";
4
+ import { computeElementStyle, matchSelectors, parseDeclarations, StyleEngine } from "../lib/style/computed-style.js";
5
5
 
6
6
  test("computes the initial UA and inherited style subset", () => {
7
7
  const { document, window } = parseHTMLDocument(`<body>
@@ -381,6 +381,50 @@ test("cascade layers: unlayered beats layered, later layers win, !important reve
381
381
  window.happyDOM.abort();
382
382
  });
383
383
 
384
+ test("sheets parsed once keep layer order and cascade when the sheet list changes or repeats", () => {
385
+ const { document, window } = parseHTMLDocument(`<p id="a" class="x">t</p><p id="b" class="y">u</p>`);
386
+ const base = `@layer one, two; @layer two { .x { color: blue } } @layer one { .x { color: red; display: flex } }`;
387
+ const anonymous = `@layer { .y { color: green !important } } .y { display: inline }`;
388
+ const later = `@layer three { .x { display: block } } .y { color: black }`;
389
+ const lists = [
390
+ [base, later],
391
+ [base, later, base], // a repeated sheet: its earlier copy still declares the layers first
392
+ [later, base],
393
+ [anonymous, later, anonymous], // anonymous layers: both copies count under !important
394
+ [later, `@layer two { .x { display: grid } }`, base],
395
+ ];
396
+ const engine = new StyleEngine();
397
+ for (const sheets of lists) {
398
+ engine.compute(document, sheets, {}, new Set([document.body]), null);
399
+ const fresh = new StyleEngine().compute(document, sheets.map((sheet) => `${sheet} `));
400
+ for (const id of ["a", "b"]) expect(engine.get(document.getElementById(id))).toEqual(fresh.get(document.getElementById(id)));
401
+ }
402
+ engine.compute(document, [base, later, base]);
403
+ expect(engine.get(document.getElementById("a")).color).toBe("rgba(0, 0, 255, 1)");
404
+ expect(engine.get(document.getElementById("a")).display).toBe("block");
405
+ window.happyDOM.abort();
406
+ });
407
+
408
+ test("matchSelectors finds what querySelectorAll finds, in one walk", () => {
409
+ const { document, window } = parseHTMLDocument(`<main><ul><li class="a">1</li><li id="i">2</li><li class="a b">3</li></ul>
410
+ <p>x</p><p class="a"><span>y</span></p><div><span hidden>z</span></div></main>`);
411
+ const selectors = ["li:first-child", ".a + li", "li ~ .b", "p:has(span)", ":is(ul, div) > *", "#i", "[hidden]", "*", ":root", "main span", "1x", "li:nth-child(2n+1)"];
412
+ // Elements compared as sets of document positions (deep equality of DOM
413
+ // nodes is slow, and querySelectorAll() may list an element twice).
414
+ const all = [...document.querySelectorAll("*")];
415
+ const positions = (elements) => [...new Set(elements)].map((element) => all.indexOf(element)).sort((x, y) => x - y);
416
+ const matched = matchSelectors(document, selectors);
417
+ for (const selector of selectors) {
418
+ let expected = null;
419
+ try { expected = positions(document.querySelectorAll(selector)); } catch {}
420
+ expect(matched.has(selector) ? positions(matched.get(selector)) : null).toEqual(expected);
421
+ }
422
+ // From an element: its descendants and the element itself.
423
+ const ul = document.querySelector("ul");
424
+ expect(positions(matchSelectors(ul, [":is(ul, li)"]).get(":is(ul, li)"))).toEqual(positions([ul, ...ul.children]));
425
+ window.happyDOM.abort();
426
+ });
427
+
384
428
  test("the font shorthand resets its parts and competes with font-size in the cascade", () => {
385
429
  const { document, window } = parseHTMLDocument(`<h2 id="a" style="font-size:32px;line-height:3">t</h2><h2 id="b">x</h2>
386
430
  <p id="c" style="font:italic small-caps bold 12px/1.5 Georgia, serif">u</p><p id="d" style="font-size:large">v</p>`);
@@ -455,6 +499,22 @@ test("the font shorthand allows spaces around the line-height slash", () => {
455
499
  }
456
500
  });
457
501
 
502
+ test("incremental restyling retains equal computed-style identities", () => {
503
+ const { document, window } = parseHTMLDocument(`<body><div><span>same</span></div></body>`);
504
+ const div = document.querySelector("div");
505
+ const span = document.querySelector("span");
506
+ const engine = new StyleEngine().compute(document, [".active { color: red }"]);
507
+ const before = [engine.get(div), engine.get(span)];
508
+ // Visiting this subtree is conservative: the class changes, but neither
509
+ // element's final values change.
510
+ div.className = "unmatched";
511
+ engine.compute(document, [".active { color: red }"], {}, new Set([div]));
512
+ expect([engine.get(div), engine.get(span)]).toEqual(before);
513
+ expect(engine.get(div)).toBe(before[0]);
514
+ expect(engine.get(span)).toBe(before[1]);
515
+ window.happyDOM.abort();
516
+ });
517
+
458
518
  test("incremental restyles of changed subtrees and added sheets equal a full restyle", () => {
459
519
  const { document, window } = parseHTMLDocument(`<body><div id="a" class="x"><p>one</p><p>two</p></div><div id="b"><span>s</span></div></body>`);
460
520
  const sheets = [".x p { color: red } .y p { color: blue; font-size: 20px } #b span { font-weight: 700 }"];
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
+ import { createBrowser } from "../lib/headless-shell.js";
2
3
 
3
4
  async function readDevToolsUrl(stream, timeout = 15_000) {
4
5
  const reader = stream.getReader();
@@ -343,3 +344,31 @@ test("top-level shell exposes the parsed document through Bun.WebView", async ()
343
344
  await browser.exited;
344
345
  }
345
346
  }, 30_000);
347
+
348
+ test("a client may set up and navigate before the first renderer is up", async () => {
349
+ let fetched = 0;
350
+ const site = Bun.serve({ port: 0, fetch() {
351
+ fetched++;
352
+ return new Response("<!doctype html><title>early</title><p>x</p>", { headers: { "content-type": "text/html" } });
353
+ } });
354
+ const browser = await createBrowser({ spareRenderer: false, waitForRenderer: false });
355
+ let ready = false;
356
+ browser.ready.then(() => { ready = true; });
357
+ try {
358
+ // Viewport and user agent are recorded at once, not after the renderer starts.
359
+ await browser.context.resize(321, 234, 1);
360
+ await browser.context.setUserAgent("EarlyAgent/1");
361
+ expect(ready).toBe(false);
362
+ // The document is fetched meanwhile; it commits once the renderer is up.
363
+ const navigation = browser.context.navigate(`http://127.0.0.1:${site.port}/`);
364
+ while (!fetched) await Bun.sleep(5);
365
+ expect(ready).toBe(false);
366
+ await navigation;
367
+ expect(fetched).toBe(1);
368
+ expect(await browser.context.title()).toBe("early");
369
+ expect(await browser.context.evaluate("innerWidth + 'x' + innerHeight + ' ' + navigator.userAgent")).toBe("321x234 EarlyAgent/1");
370
+ } finally {
371
+ browser.close();
372
+ site.stop(true);
373
+ }
374
+ }, 30_000);