@miaskiewicz/turbo-dom 0.5.8 → 0.7.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.
- package/Cargo.lock +3227 -389
- package/Cargo.toml +53 -3
- package/README.md +193 -224
- package/package.json +5 -3
- package/pkg/package.json +1 -1
- package/pkg/turbo_dom_parser_bg.wasm +0 -0
- package/pkg-web/package.json +1 -1
- package/pkg-web/turbo_dom_parser_bg.wasm +0 -0
- package/src/core.rs +387 -44
- package/src/lib.rs +43 -10
- package/src/runtime/buffer.mjs +0 -1
- package/src/runtime/cascade.mjs +18 -24
- package/src/runtime/color.mjs +1 -1
- package/src/runtime/css-shorthand-table.mjs +238 -0
- package/src/runtime/css-shorthand.mjs +2803 -0
- package/src/runtime/cssom.mjs +3 -4
- package/src/runtime/custom-elements.mjs +398 -0
- package/src/runtime/dom.mjs +924 -238
- package/src/runtime/html-serialize.mjs +15 -7
- package/src/runtime/index.d.ts +100 -0
- package/src/runtime/index.mjs +5 -0
- package/src/runtime/parser.mjs +2 -4
- package/src/runtime/selectors.mjs +1080 -402
- package/src/runtime/stubs.mjs +1 -19
- package/src/runtime/window.mjs +15 -7
- package/src/spike.rs +26 -6
- package/turbo-dom-parser.darwin-arm64.node +0 -0
- package/turbo-dom-parser.linux-arm64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-musl.node +0 -0
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
|
@@ -1,79 +1,117 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Testing Library usage.
|
|
7
|
-
|
|
8
|
-
// ── Tokenizer + recursive-descent parser ─────────────────────────────────────
|
|
9
|
-
// The selector string is first turned into a flat token stream by `tokenize`,
|
|
10
|
-
// which centrally handles the two things an ad-hoc character scanner keeps
|
|
11
|
-
// getting wrong: quoted strings and balanced `[]`/`()` runs. The grammar layer
|
|
12
|
-
// (`parseCompound`/`parseComplexTokens`) then only ever sees whole tokens, so it
|
|
13
|
-
// never has to re-discover where an attribute or pseudo argument ends. The AST it
|
|
14
|
-
// emits — a Complex `{ first, rest: [{ combinator, compound }] }` (mirroring the
|
|
15
|
-
// Rust port's "parse, don't validate" shape: no parallel head/tail arrays that can
|
|
16
|
-
// desync) with the same compound/attr/pseudo object shapes — is exactly what the
|
|
17
|
-
// matcher below consumes.
|
|
1
|
+
// CSS selector engine for the JS runtime. Correctness-first, right-to-left matching,
|
|
2
|
+
// kept in lockstep with the Rust engine (crates/turbo-dom/src/rtdom/query.rs +
|
|
3
|
+
// query_utf16.rs + query_cascade.rs): both answer like Chrome 156, checked against the
|
|
4
|
+
// same control (crates/turbo-dom/tests/fixtures/cascade_selectors_chrome156.json, asserted
|
|
5
|
+
// here by test/selectors-chrome.test.mjs).
|
|
18
6
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
7
|
+
// Grammar: comma lists; descendant (' '), child ('>'), adjacent ('+') and general-sibling
|
|
8
|
+
// ('~') combinators; type / '*' selectors with the '*|' / '|' namespace prefixes; '#id',
|
|
9
|
+
// '.class' and '[attr]' / '[attr op value i]' (ops = ~= |= ^= $= *=, '*|' / '|' attribute
|
|
10
|
+
// prefixes); CSS identifiers with escapes and non-ASCII code points; pseudo-classes
|
|
11
|
+
// (structural, :nth-*(An+B [of S]), :is/:where/:not/:has, form state, :link, :lang(),
|
|
12
|
+
// :dir(), :defined, :root, :scope, :empty, :state(), plus the static-resting-state ones
|
|
13
|
+
// that are valid and never match: :hover, :focus, …); pseudo-elements (valid, never match
|
|
14
|
+
// an element). Every selector Chrome rejects throws a SyntaxError (empty items, dangling
|
|
15
|
+
// combinators, a type selector after another simple selector, unknown pseudos, invalid
|
|
16
|
+
// An+B, nested :has(), a simple selector after a pseudo-element, …).
|
|
17
|
+
//
|
|
18
|
+
// Hot paths stay allocation-free: the parse is cached by selector string, compounds are
|
|
19
|
+
// matched with index loops over node.__children(), classes with a whole-word scan.
|
|
20
|
+
|
|
21
|
+
import { isDefinedElement } from './custom-elements.mjs';
|
|
22
|
+
|
|
23
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
24
|
+
|
|
25
|
+
// ── character classes ───────────────────────────────────────────────────────
|
|
34
26
|
function isWs(c) {
|
|
35
27
|
return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
36
28
|
}
|
|
29
|
+
function isLetter(c) {
|
|
30
|
+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
|
31
|
+
}
|
|
32
|
+
function isDigit(c) {
|
|
33
|
+
return c >= '0' && c <= '9';
|
|
34
|
+
}
|
|
35
|
+
function isHex(c) {
|
|
36
|
+
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
|
37
|
+
}
|
|
38
|
+
function isNameStart(c) {
|
|
39
|
+
return isLetter(c) || c === '_' || c.charCodeAt(0) >= 0x80;
|
|
40
|
+
}
|
|
41
|
+
function isNameChar(c) {
|
|
42
|
+
return isNameStart(c) || isDigit(c) || c === '-';
|
|
43
|
+
}
|
|
44
|
+
// A `\` at `i` is a valid escape unless a newline follows (a trailing `\` is U+FFFD).
|
|
45
|
+
function validEscape(src, i) {
|
|
46
|
+
const n = src[i + 1];
|
|
47
|
+
return src[i] === '\\' && n !== '\n' && n !== '\r' && n !== '\f';
|
|
48
|
+
}
|
|
49
|
+
// CSS Syntax "would start an identifier" at `i`.
|
|
50
|
+
function startsIdent(src, i) {
|
|
51
|
+
const c = src[i];
|
|
52
|
+
if (c === undefined) return false;
|
|
53
|
+
if (isNameStart(c) || (c === '\\' && validEscape(src, i))) return true;
|
|
54
|
+
if (c !== '-') return false;
|
|
55
|
+
const d = src[i + 1];
|
|
56
|
+
return d !== undefined && (d === '-' || isNameStart(d) || (d === '\\' && validEscape(src, i + 1)));
|
|
57
|
+
}
|
|
58
|
+
// Consume the escape at `src[i]` (a valid `\`): { ch, end }.
|
|
59
|
+
function consumeEscape(src, i) {
|
|
60
|
+
let j = i + 1;
|
|
61
|
+
if (j >= src.length) return { ch: '\uFFFD', end: j };
|
|
62
|
+
if (!isHex(src[j])) {
|
|
63
|
+
const cp = src.codePointAt(j);
|
|
64
|
+
return { ch: String.fromCodePoint(cp), end: j + (cp > 0xffff ? 2 : 1) };
|
|
65
|
+
}
|
|
66
|
+
let hex = '';
|
|
67
|
+
while (j < src.length && hex.length < 6 && isHex(src[j])) hex += src[j++];
|
|
68
|
+
if (src[j] === '\r' && src[j + 1] === '\n') j += 2;
|
|
69
|
+
else if (j < src.length && isWs(src[j])) j++;
|
|
70
|
+
const cp = parseInt(hex, 16);
|
|
71
|
+
const bad = cp === 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff);
|
|
72
|
+
return { ch: bad ? '\uFFFD' : String.fromCodePoint(cp), end: j };
|
|
73
|
+
}
|
|
74
|
+
// Scan an identifier starting at `i` (caller checked startsIdent): { value, end }.
|
|
75
|
+
function scanIdent(src, i) {
|
|
76
|
+
let value = '';
|
|
77
|
+
while (i < src.length) {
|
|
78
|
+
const c = src[i];
|
|
79
|
+
if (c === '\\') {
|
|
80
|
+
if (!validEscape(src, i)) break;
|
|
81
|
+
const e = consumeEscape(src, i);
|
|
82
|
+
value += e.ch;
|
|
83
|
+
i = e.end;
|
|
84
|
+
} else if (isNameChar(c)) {
|
|
85
|
+
value += c;
|
|
86
|
+
i++;
|
|
87
|
+
} else break;
|
|
88
|
+
}
|
|
89
|
+
return { value, end: i };
|
|
90
|
+
}
|
|
91
|
+
// Is `s` (trimmed by the caller) exactly one identifier? Returns its decoded value or null.
|
|
92
|
+
function identValue(s) {
|
|
93
|
+
if (!startsIdent(s, 0)) return null;
|
|
94
|
+
const r = scanIdent(s, 0);
|
|
95
|
+
return r.end === s.length ? r.value : null;
|
|
96
|
+
}
|
|
97
|
+
function trimWs(s) {
|
|
98
|
+
let a = 0, b = s.length;
|
|
99
|
+
while (a < b && isWs(s[a])) a++;
|
|
100
|
+
while (b > a && isWs(s[b - 1])) b--;
|
|
101
|
+
return s.slice(a, b);
|
|
102
|
+
}
|
|
103
|
+
function syntax(src, why) {
|
|
104
|
+
return new SyntaxError(`'${src}' is not a valid selector: ${why}`);
|
|
105
|
+
}
|
|
37
106
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// `{ op: 'present' }` (NO `value` key at all), and a valued test is
|
|
41
|
-
// `{ op, value }` with op ∈ equals|includes|dash|prefix|suffix|substr (mirroring
|
|
42
|
-
// the Rust `AttrMatch` variants). The first '=' (if any) splits name from value;
|
|
43
|
-
// an operator char (~ | ^ $ *) immediately before it selects the operator. A
|
|
44
|
-
// surrounding matching quote pair on the value is stripped. No '=' ⇒ presence.
|
|
45
|
-
const ATTR_OP = { '~': 'includes', '|': 'dash', '^': 'prefix', $: 'suffix', '*': 'substr' };
|
|
46
|
-
function parseAttr(inner) {
|
|
47
|
-
const eq = inner.indexOf('=');
|
|
48
|
-
if (eq === -1) return { name: inner.trim(), match: { op: 'present' } };
|
|
49
|
-
let name = inner.slice(0, eq);
|
|
50
|
-
let op = 'equals';
|
|
51
|
-
const last = name[name.length - 1];
|
|
52
|
-
if (ATTR_OP[last] !== undefined) {
|
|
53
|
-
op = ATTR_OP[last];
|
|
54
|
-
name = name.slice(0, -1);
|
|
55
|
-
}
|
|
56
|
-
let value = inner.slice(eq + 1).trim();
|
|
57
|
-
if (
|
|
58
|
-
value.length >= 2 &&
|
|
59
|
-
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
60
|
-
(value[0] === "'" && value[value.length - 1] === "'"))
|
|
61
|
-
) {
|
|
62
|
-
value = value.slice(1, -1);
|
|
63
|
-
}
|
|
64
|
-
return { name: name.trim(), match: { op, value } };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Scan from `start` (just past an opening `open`) to its matching `close`,
|
|
68
|
-
// honoring quotes and nesting. Returns { inner, end } where `end` is the index of
|
|
69
|
-
// the closing delimiter (or src.length if unterminated). One shared scanner for
|
|
70
|
-
// both `[...]` and `(...)`, mirroring the Rust `scan_balanced` — a `]`/`)` inside a
|
|
71
|
-
// quoted value never terminates early, and `:not(:nth-child(2))` nests correctly.
|
|
107
|
+
// Scan from `start` (just past an opening `open`) to its matching `close`, honoring quotes,
|
|
108
|
+
// escapes and nesting: { inner, end } with `end` the index of the closer (or src.length).
|
|
72
109
|
function scanBalanced(src, start, open, close) {
|
|
73
110
|
const n = src.length;
|
|
74
111
|
let depth = 1, i = start, quote = null;
|
|
75
112
|
for (; i < n; i++) {
|
|
76
113
|
const d = src[i];
|
|
114
|
+
if (d === '\\') { i++; continue; }
|
|
77
115
|
if (quote) { if (d === quote) quote = null; continue; }
|
|
78
116
|
if (d === '"' || d === "'") quote = d;
|
|
79
117
|
else if (d === open) depth++;
|
|
@@ -81,7 +119,294 @@ function scanBalanced(src, start, open, close) {
|
|
|
81
119
|
}
|
|
82
120
|
return { inner: src.slice(start, i), end: i };
|
|
83
121
|
}
|
|
122
|
+
// Split at top-level commas (outside quotes, escapes, () and []).
|
|
123
|
+
function splitTopLevel(src) {
|
|
124
|
+
const out = [];
|
|
125
|
+
let depth = 0, quote = null, last = 0;
|
|
126
|
+
for (let i = 0; i < src.length; i++) {
|
|
127
|
+
const c = src[i];
|
|
128
|
+
if (c === '\\') { i++; continue; }
|
|
129
|
+
if (quote) { if (c === quote) quote = null; continue; }
|
|
130
|
+
if (c === '"' || c === "'") quote = c;
|
|
131
|
+
else if (c === '(' || c === '[') depth++;
|
|
132
|
+
else if (c === ')' || c === ']') depth--;
|
|
133
|
+
else if (c === ',' && depth === 0) { out.push(src.slice(last, i)); last = i + 1; }
|
|
134
|
+
}
|
|
135
|
+
out.push(src.slice(last));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
84
138
|
|
|
139
|
+
// ── attribute selectors ─────────────────────────────────────────────────────
|
|
140
|
+
const ATTR_OP = { '~': 'includes', '|': 'dash', '^': 'prefix', $: 'suffix', '*': 'substr' };
|
|
141
|
+
// CSS Syntax preprocessing of selector source (miaskiewicz/turbo-dom#91; mirrors the Rust
|
|
142
|
+
// rtdom `selector_source`): comments (outside strings / escapes) are removed — between two
|
|
143
|
+
// name characters a comment still separates tokens, so it becomes a space inside `[…]` and
|
|
144
|
+
// the never-valid `!` elsewhere (`div/**/p` throws, `[title=a/**/i]` is `[title=a i]`); an
|
|
145
|
+
// unterminated comment runs to the end. `closeAtEof` (the DOM selector APIs): an
|
|
146
|
+
// unterminated string and every open `[` / `(` are closed, as CSS does at EOF.
|
|
147
|
+
const nameLike = (c) => c !== undefined && (c >= '\x80' || c === '-' || c === '_' || c === '\\' || isLetter(c) || isDigit(c));
|
|
148
|
+
function normalizeSource(src, closeAtEof) {
|
|
149
|
+
if (src.indexOf('/*') === -1 && (!closeAtEof || !/[[("'\\]/.test(src))) return src;
|
|
150
|
+
let out = '', quote = null;
|
|
151
|
+
const open = [];
|
|
152
|
+
for (let i = 0; i < src.length; i++) {
|
|
153
|
+
const c = src[i];
|
|
154
|
+
if (c === '\\') {
|
|
155
|
+
// an escape at the end: U+FFFD in a name, nothing in a string (closers stay unescaped)
|
|
156
|
+
if (i + 1 === src.length && closeAtEof) { if (!quote) out += '\uFFFD'; break; }
|
|
157
|
+
out += src.slice(i, i + 2); i++; continue;
|
|
158
|
+
}
|
|
159
|
+
if (quote) { if (c === quote) quote = null; out += c; continue; }
|
|
160
|
+
if (c === '"' || c === "'") quote = c;
|
|
161
|
+
else if (c === '/' && src[i + 1] === '*') {
|
|
162
|
+
let end = src.indexOf('*/', i + 2);
|
|
163
|
+
end = end === -1 ? src.length : end + 2;
|
|
164
|
+
const prev = out[out.length - 1];
|
|
165
|
+
if ((nameLike(prev) || prev === '#') && nameLike(src[end])) out += open[open.length - 1] === ']' ? ' ' : '!';
|
|
166
|
+
i = end - 1;
|
|
167
|
+
continue;
|
|
168
|
+
} else if (c === '[') open.push(']');
|
|
169
|
+
else if (c === '(') open.push(')');
|
|
170
|
+
else if (c === open[open.length - 1]) open.pop();
|
|
171
|
+
out += c;
|
|
172
|
+
}
|
|
173
|
+
if (closeAtEof) {
|
|
174
|
+
if (quote) out += quote;
|
|
175
|
+
while (open.length) out += open.pop();
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
// A quoted string starting at `i` (src[i] is the quote): { value, end } (end past the
|
|
180
|
+
// closing quote; an unterminated string ends with the input, as in CSS — the attribute
|
|
181
|
+
// scanner has already rejected unbalanced quotes).
|
|
182
|
+
function scanString(src, i) {
|
|
183
|
+
const q = src[i];
|
|
184
|
+
let value = '';
|
|
185
|
+
for (let j = i + 1; ; j++) {
|
|
186
|
+
const c = src[j];
|
|
187
|
+
if (c === q || j >= src.length) return { value, end: j + 1 };
|
|
188
|
+
if (c === '\\') {
|
|
189
|
+
const n = src[j + 1];
|
|
190
|
+
if (n === '\n' || n === '\f') { j++; continue; }
|
|
191
|
+
if (n === '\r') { j += src[j + 2] === '\n' ? 2 : 1; continue; }
|
|
192
|
+
const e = consumeEscape(src, j);
|
|
193
|
+
value += e.ch;
|
|
194
|
+
j = e.end - 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
value += c;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Parse a `[ … ]` interior: { name, match, ns?, ci? } — `match` = { op: 'present' } or
|
|
201
|
+
// { op, value }; `ns: 'any'` for `[*|a]`; `ci: true` for the `i` modifier. Throws on what
|
|
202
|
+
// Chrome 156 rejects, including the `s` modifier (so does the Rust engine since
|
|
203
|
+
// turbo-dom#24).
|
|
204
|
+
function parseAttr(raw, src) {
|
|
205
|
+
const s = raw;
|
|
206
|
+
let i = 0;
|
|
207
|
+
const skip = () => { while (i < s.length && isWs(s[i])) i++; };
|
|
208
|
+
skip();
|
|
209
|
+
let ns = null;
|
|
210
|
+
if (s[i] === '*' && s[i + 1] === '|') { ns = 'any'; i += 2; }
|
|
211
|
+
else if (s[i] === '|' && s[i + 1] !== '=') { i += 1; }
|
|
212
|
+
if (!startsIdent(s, i)) throw syntax(src, 'attribute name');
|
|
213
|
+
const nameTok = scanIdent(s, i);
|
|
214
|
+
i = nameTok.end;
|
|
215
|
+
if (s[i] === '|' && s[i + 1] !== '=') throw syntax(src, 'undeclared namespace prefix');
|
|
216
|
+
const attr = { name: nameTok.value, match: { op: 'present' } };
|
|
217
|
+
if (ns) attr.ns = ns;
|
|
218
|
+
// the HTML-element lookup name, folded once (non-enumerable: the AST stays `{ name, match }`)
|
|
219
|
+
Object.defineProperty(attr, 'lname', { value: nameTok.value.toLowerCase() });
|
|
220
|
+
skip();
|
|
221
|
+
if (i === s.length) return attr;
|
|
222
|
+
let op = 'equals';
|
|
223
|
+
if (ATTR_OP[s[i]] !== undefined && s[i + 1] === '=') { op = ATTR_OP[s[i]]; i += 2; }
|
|
224
|
+
else if (s[i] === '=') i += 1;
|
|
225
|
+
else throw syntax(src, 'attribute operator');
|
|
226
|
+
skip();
|
|
227
|
+
let value;
|
|
228
|
+
if (s[i] === '"' || s[i] === "'") {
|
|
229
|
+
const str = scanString(s, i);
|
|
230
|
+
value = str.value;
|
|
231
|
+
i = str.end;
|
|
232
|
+
} else if (startsIdent(s, i)) {
|
|
233
|
+
const v = scanIdent(s, i);
|
|
234
|
+
value = v.value;
|
|
235
|
+
i = v.end;
|
|
236
|
+
} else throw syntax(src, 'attribute value');
|
|
237
|
+
attr.match = { op, value };
|
|
238
|
+
skip();
|
|
239
|
+
if (i < s.length) {
|
|
240
|
+
const m = s[i];
|
|
241
|
+
if (m === 'i' || m === 'I') attr.ci = true;
|
|
242
|
+
else throw syntax(src, 'attribute modifier');
|
|
243
|
+
i++;
|
|
244
|
+
skip();
|
|
245
|
+
if (i < s.length) throw syntax(src, 'attribute selector');
|
|
246
|
+
}
|
|
247
|
+
return attr;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── pseudo-classes and pseudo-elements Chrome 156 accepts ───────────────────
|
|
251
|
+
// Implemented, argument-less.
|
|
252
|
+
const PLAIN = new Set([
|
|
253
|
+
'first-child', 'last-child', 'only-child', 'first-of-type', 'last-of-type', 'only-of-type',
|
|
254
|
+
'empty', 'root', 'scope', 'checked', 'disabled', 'enabled', 'required', 'optional',
|
|
255
|
+
'read-only', 'read-write', 'link', 'any-link', 'defined',
|
|
256
|
+
]);
|
|
257
|
+
// Valid and never matching (a static document's resting state; the npm runtime has no
|
|
258
|
+
// fullscreen API, so the fullscreen pseudo-classes never match — turbo-dom#58).
|
|
259
|
+
const NEVER = new Set([
|
|
260
|
+
'active', 'autofill', '-webkit-autofill', 'buffering', 'current', 'default', 'focus',
|
|
261
|
+
'focus-visible', 'focus-within', 'fullscreen', '-webkit-full-screen', '-webkit-full-screen-ancestor', 'future', 'host',
|
|
262
|
+
'hover', 'in-range', 'indeterminate', 'invalid', 'modal', 'muted', 'open', 'out-of-range',
|
|
263
|
+
'past', 'paused', 'picture-in-picture', 'placeholder-shown', 'playing', 'popover-open',
|
|
264
|
+
'seeking', 'stalled', 'target', 'user-invalid', 'user-valid', 'valid', 'visited',
|
|
265
|
+
'volume-locked', 'xr-overlay',
|
|
266
|
+
]);
|
|
267
|
+
const NEVER_FUNCTIONAL = new Set(['host', 'host-context']);
|
|
268
|
+
const PSEUDO_ELEMENTS = new Set([
|
|
269
|
+
'after', 'backdrop', 'before', 'checkmark', 'column', 'cue', 'details-content',
|
|
270
|
+
'file-selector-button', 'first-letter', 'first-line', 'grammar-error', 'marker',
|
|
271
|
+
'picker-icon', 'placeholder', 'scroll-marker', 'scroll-marker-group', 'search-text',
|
|
272
|
+
'selection', 'spelling-error', 'target-text', 'view-transition',
|
|
273
|
+
]);
|
|
274
|
+
const FUNCTIONAL_PSEUDO_ELEMENTS = new Set([
|
|
275
|
+
'cue', 'highlight', 'part', 'picker', 'scroll-button', 'slotted', 'view-transition-group',
|
|
276
|
+
'view-transition-image-pair', 'view-transition-new', 'view-transition-old',
|
|
277
|
+
]);
|
|
278
|
+
const LEGACY_PSEUDO_ELEMENTS = new Set(['after', 'before', 'first-letter', 'first-line']);
|
|
279
|
+
// The scrollbar pseudo-elements and the pseudo-classes that may follow them (scrollbar
|
|
280
|
+
// states and :hover / :active, no focus).
|
|
281
|
+
const SCROLLBAR = new Set([
|
|
282
|
+
'-webkit-resizer', '-webkit-scrollbar', '-webkit-scrollbar-button', '-webkit-scrollbar-corner',
|
|
283
|
+
'-webkit-scrollbar-thumb', '-webkit-scrollbar-track', '-webkit-scrollbar-track-piece',
|
|
284
|
+
]);
|
|
285
|
+
const AFTER_SCROLLBAR = new Set([
|
|
286
|
+
'active', 'corner-present', 'decrement', 'disabled', 'double-button', 'enabled', 'end',
|
|
287
|
+
'horizontal', 'hover', 'increment', 'no-button', 'single-button', 'start', 'vertical',
|
|
288
|
+
'window-inactive',
|
|
289
|
+
]);
|
|
290
|
+
const USER_ACTION = new Set(['active', 'focus', 'focus-visible', 'focus-within', 'hover']);
|
|
291
|
+
// Pseudo-elements that are real elements: any non-structural pseudo-class may follow,
|
|
292
|
+
// and so may another pseudo-element (but ::part() / ::slotted()).
|
|
293
|
+
const ELEMENT_LIKE = new Set(['details-content', 'part', 'picker']);
|
|
294
|
+
// The never-matching pseudo-classes Chrome 156 rejects after an ELEMENT_LIKE one.
|
|
295
|
+
const NOT_AFTER_ELEMENT_LIKE = new Set([
|
|
296
|
+
'buffering', 'current', 'host', 'muted', 'paused', 'playing', 'seeking', 'stalled', 'volume-locked',
|
|
297
|
+
]);
|
|
298
|
+
const ELEMENT_LIKE_PLAIN = new Set(['target-current', 'checked', 'disabled', 'enabled', 'required', 'optional', 'read-only', 'read-write', 'link', 'any-link', 'defined', 'window-inactive']);
|
|
299
|
+
// What each pseudo-element may be followed by (Chrome 156): the pseudo-elements that may
|
|
300
|
+
// follow it, and the argument-less pseudo-classes. `:is()` / `:where()` (forgiving) may
|
|
301
|
+
// follow any but ::column and ::slotted().
|
|
302
|
+
const SUB_AFTER_SLOTTED = new Set([
|
|
303
|
+
'after', 'backdrop', 'before', 'checkmark', 'details-content', 'file-selector-button',
|
|
304
|
+
'marker', 'picker', 'picker-icon', 'placeholder',
|
|
305
|
+
]);
|
|
306
|
+
const PLAIN_AFTER = new Map([
|
|
307
|
+
['file-selector-button', USER_ACTION],
|
|
308
|
+
['scroll-marker', new Set([...USER_ACTION, 'target-current'])],
|
|
309
|
+
['scroll-button', new Set([...USER_ACTION, 'disabled', 'enabled'])],
|
|
310
|
+
['scroll-marker-group', new Set(['hover', 'focus-within'])],
|
|
311
|
+
['selection', new Set(['window-inactive'])],
|
|
312
|
+
['search-text', new Set(['current'])],
|
|
313
|
+
['view-transition-group', new Set(['only-child'])],
|
|
314
|
+
['view-transition-image-pair', new Set(['only-child'])],
|
|
315
|
+
['view-transition-old', new Set(['only-child'])],
|
|
316
|
+
['view-transition-new', new Set(['only-child'])],
|
|
317
|
+
]);
|
|
318
|
+
// May pseudo-class `p` follow pseudo-element `pe` ({ name, arg })? (Chrome 156; mirrors
|
|
319
|
+
// the Rust allowed_after_pseudo_element.)
|
|
320
|
+
function allowedAfter(pe, p) {
|
|
321
|
+
const name = pe.name;
|
|
322
|
+
if ((p.name === 'is' || p.name === 'where') && p.arg !== null) return name !== 'column' && name !== 'slotted';
|
|
323
|
+
if (ELEMENT_LIKE.has(name)) {
|
|
324
|
+
if (p.arg !== null) return p.name === 'state' || p.name === 'lang' || p.name === 'dir';
|
|
325
|
+
return ELEMENT_LIKE_PLAIN.has(p.name) || (NEVER.has(p.name) && !NOT_AFTER_ELEMENT_LIKE.has(p.name));
|
|
326
|
+
}
|
|
327
|
+
if (p.arg !== null) return false;
|
|
328
|
+
if (SCROLLBAR.has(name)) return AFTER_SCROLLBAR.has(p.name);
|
|
329
|
+
if (name.startsWith('-webkit-') || (name === 'cue' && pe.arg === null)) return USER_ACTION.has(p.name);
|
|
330
|
+
const plain = PLAIN_AFTER.get(name);
|
|
331
|
+
return plain !== undefined && plain.has(p.name);
|
|
332
|
+
}
|
|
333
|
+
// May pseudo-element `next` follow pseudo-element `prev` (names)? (Chrome 156,
|
|
334
|
+
// miaskiewicz/turbo-dom#195; mirrors the Rust allowed_after_pseudo_element_chain.)
|
|
335
|
+
function allowedSub(prev, next) {
|
|
336
|
+
if (ELEMENT_LIKE.has(prev)) return next !== 'part' && next !== 'slotted';
|
|
337
|
+
if (prev === 'slotted') return SUB_AFTER_SLOTTED.has(next) || next.startsWith('view-transition');
|
|
338
|
+
if (prev === 'before' || prev === 'after') return next === 'marker';
|
|
339
|
+
return prev === 'column' && next === 'scroll-marker';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── An+B ────────────────────────────────────────────────────────────────────
|
|
343
|
+
// `<an+b>` (CSS Syntax 3 §6) → { a, b } or null. Mirrors the Rust valid_anb + parse_nth.
|
|
344
|
+
// Chrome 156's An+B integers (miaskiewicz/turbo-dom#98): a number token clamps to 32 bits;
|
|
345
|
+
// an integer glued into an ident / dimension unit (`n-123`) must fit exactly; an A or B
|
|
346
|
+
// outside 31 bits never matches (nthMatch).
|
|
347
|
+
const INT_MIN = -2147483648, INT_MAX = 2147483647;
|
|
348
|
+
const clampInt = (sign, digits) => {
|
|
349
|
+
let v = 0;
|
|
350
|
+
for (const d of digits) v = Math.min(v * 10 + (d.charCodeAt(0) - 48), INT_MAX + 2);
|
|
351
|
+
return sign < 0 ? Math.max(-v, INT_MIN - 1) : Math.min(v, INT_MAX + 1);
|
|
352
|
+
};
|
|
353
|
+
const clamp32 = (v) => Math.min(Math.max(v, INT_MIN), INT_MAX);
|
|
354
|
+
function parseAnB(arg) {
|
|
355
|
+
const s = trimWs(arg).toLowerCase();
|
|
356
|
+
if (s === 'odd') return { a: 2, b: 1 };
|
|
357
|
+
if (s === 'even') return { a: 2, b: 0 };
|
|
358
|
+
let i = 0;
|
|
359
|
+
let sign = 1;
|
|
360
|
+
if (s[0] === '+' || s[0] === '-') { sign = s[0] === '-' ? -1 : 1; i = 1; }
|
|
361
|
+
const ds = i;
|
|
362
|
+
while (i < s.length && isDigit(s[i])) i++;
|
|
363
|
+
const digits = s.slice(ds, i);
|
|
364
|
+
if (s[i] !== 'n') {
|
|
365
|
+
if (!digits || i !== s.length) return null;
|
|
366
|
+
return { a: 0, b: clamp32(clampInt(sign, digits)) };
|
|
367
|
+
}
|
|
368
|
+
const a = digits ? clamp32(clampInt(sign, digits)) : sign;
|
|
369
|
+
i++;
|
|
370
|
+
// `n-123` is one ident / dimension unit: its integer is converted exactly
|
|
371
|
+
if (s[i] === '-' && i + 1 < s.length && isDigit(s[i + 1])) {
|
|
372
|
+
let j = i + 1;
|
|
373
|
+
while (j < s.length && isDigit(s[j])) j++;
|
|
374
|
+
if (clampInt(-1, s.slice(i + 1, j)) < INT_MIN) return null;
|
|
375
|
+
}
|
|
376
|
+
while (i < s.length && isWs(s[i])) i++;
|
|
377
|
+
if (i === s.length) return { a, b: 0 };
|
|
378
|
+
if (s[i] !== '+' && s[i] !== '-') return null;
|
|
379
|
+
const bs = s[i] === '-' ? -1 : 1;
|
|
380
|
+
i++;
|
|
381
|
+
while (i < s.length && isWs(s[i])) i++;
|
|
382
|
+
const bd = i;
|
|
383
|
+
while (i < s.length && isDigit(s[i])) i++;
|
|
384
|
+
if (i === bd) return null;
|
|
385
|
+
const b = clamp32(clampInt(bs, s.slice(bd, i)));
|
|
386
|
+
while (i < s.length && isWs(s[i])) i++;
|
|
387
|
+
return i === s.length ? { a, b } : null;
|
|
388
|
+
}
|
|
389
|
+
// The `of` keyword of `:nth-child(An+B of S)`: lowercase, whitespace before it, a non-empty
|
|
390
|
+
// An+B part, and no name code point right after it. Index or -1.
|
|
391
|
+
function nthOfPosition(arg) {
|
|
392
|
+
for (let i = 1; i < arg.length - 1; i++) {
|
|
393
|
+
if (arg[i] !== 'o' || arg[i + 1] !== 'f' || !isWs(arg[i - 1])) continue;
|
|
394
|
+
if (!trimWs(arg.slice(0, i))) continue;
|
|
395
|
+
const after = arg[i + 2];
|
|
396
|
+
if (after === undefined || !(isNameChar(after) || after === '\\')) return i;
|
|
397
|
+
}
|
|
398
|
+
return -1;
|
|
399
|
+
}
|
|
400
|
+
function nthMatch(a, b, index) {
|
|
401
|
+
if (a < -1073741824 || a >= 1073741824 || b < -1073741824 || b >= 1073741824) return false;
|
|
402
|
+
if (a === 0) return index === b;
|
|
403
|
+
const d = index - b;
|
|
404
|
+
return d % a === 0 && d / a >= 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── lexer ───────────────────────────────────────────────────────────────────
|
|
408
|
+
// Token kinds: ws | comma | comb (v) | type (ns, name) | id (v) | class (v) | attr |
|
|
409
|
+
// pseudo (name, arg) | pelem (name, arg).
|
|
85
410
|
function tokenize(src) {
|
|
86
411
|
const tokens = [];
|
|
87
412
|
const n = src.length;
|
|
@@ -89,290 +414,381 @@ function tokenize(src) {
|
|
|
89
414
|
while (i < n) {
|
|
90
415
|
const c = src[i];
|
|
91
416
|
if (isWs(c)) {
|
|
92
|
-
i++;
|
|
93
417
|
while (i < n && isWs(src[i])) i++;
|
|
94
418
|
tokens.push({ k: 'ws' });
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
if (c === '
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
419
|
+
} else if (c === ',') { tokens.push({ k: 'comma' }); i++; }
|
|
420
|
+
else if (c === '>' || c === '+' || c === '~') { tokens.push({ k: 'comb', v: c }); i++; }
|
|
421
|
+
else if (c === '*' || c === '|' || startsIdent(src, i)) {
|
|
422
|
+
// type / universal selector with an optional `*|` / `|` namespace prefix
|
|
423
|
+
let ns = null, name = null;
|
|
424
|
+
if (c === '*') i++;
|
|
425
|
+
else if (c !== '|') { const t = scanIdent(src, i); name = t.value; i = t.end; }
|
|
426
|
+
if (src[i] === '|' && src[i + 1] !== '=') {
|
|
427
|
+
if (name !== null) throw syntax(src, 'undeclared namespace prefix');
|
|
428
|
+
ns = c === '*' ? 'any' : 'none';
|
|
429
|
+
i++;
|
|
430
|
+
if (src[i] === '*') i++;
|
|
431
|
+
else if (startsIdent(src, i)) { const t = scanIdent(src, i); name = t.value; i = t.end; }
|
|
432
|
+
else throw syntax(src, 'type selector after a namespace prefix');
|
|
433
|
+
} else if (c === '|') throw syntax(src, `unexpected '|'`);
|
|
434
|
+
tokens.push({ k: 'type', ns, name: name === null ? null : name.toLowerCase() });
|
|
435
|
+
} else if (c === '#' || c === '.') {
|
|
436
|
+
if (!startsIdent(src, i + 1)) throw syntax(src, `unexpected '${c}'`);
|
|
437
|
+
const t = scanIdent(src, i + 1);
|
|
438
|
+
tokens.push({ k: c === '#' ? 'id' : 'class', v: t.value });
|
|
439
|
+
i = t.end;
|
|
440
|
+
} else if (c === '[') {
|
|
113
441
|
const { inner, end } = scanBalanced(src, i + 1, '[', ']');
|
|
114
442
|
if (end >= n) throw new SyntaxError(`unterminated attribute selector: ${src.slice(i)}`);
|
|
115
|
-
tokens.push({ k: 'attr',
|
|
116
|
-
i = end + 1;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
443
|
+
tokens.push({ k: 'attr', attr: parseAttr(inner, src) });
|
|
444
|
+
i = end + 1;
|
|
445
|
+
} else if (c === ':') {
|
|
446
|
+
i++;
|
|
447
|
+
const element = src[i] === ':';
|
|
448
|
+
if (element) i++;
|
|
449
|
+
if (!startsIdent(src, i)) throw syntax(src, `unexpected ':'`);
|
|
450
|
+
const t = scanIdent(src, i);
|
|
451
|
+
const name = t.value.toLowerCase();
|
|
452
|
+
i = t.end;
|
|
122
453
|
let arg = null;
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
// or a quoted ')' survives intact for the recursive parse.
|
|
126
|
-
const { inner, end } = scanBalanced(src, j + 1, '(', ')');
|
|
454
|
+
if (src[i] === '(') {
|
|
455
|
+
const { inner, end } = scanBalanced(src, i + 1, '(', ')');
|
|
127
456
|
arg = inner;
|
|
128
|
-
i = end < n ? end + 1 : n;
|
|
129
|
-
} else {
|
|
130
|
-
i = j;
|
|
457
|
+
i = end < n ? end + 1 : n; // an unterminated argument closes at the end (CSS)
|
|
131
458
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
let j = i + 1;
|
|
137
|
-
while (j < n && NAME_CHAR.test(src[j])) j++;
|
|
138
|
-
tokens.push({ k: 'type', v: src.slice(i, j) });
|
|
139
|
-
i = j; continue;
|
|
459
|
+
const legacy = !element && LEGACY_PSEUDO_ELEMENTS.has(name);
|
|
460
|
+
tokens.push({ k: element || legacy ? 'pelem' : 'pseudo', name, arg, legacy });
|
|
461
|
+
} else {
|
|
462
|
+
throw new SyntaxError(`unexpected '${c}' in selector`);
|
|
140
463
|
}
|
|
141
|
-
throw new SyntaxError(`unexpected '${c}' in selector`);
|
|
142
464
|
}
|
|
143
465
|
return tokens;
|
|
144
466
|
}
|
|
145
467
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
468
|
+
// ── parser ──────────────────────────────────────────────────────────────────
|
|
469
|
+
// A Complex is `{ first, rest: [{ combinator, compound }] }`; a compound is
|
|
470
|
+
// `{ tag, ns, id, classes, attrs, pseudos, pe }`: `tag` null = no type selector, '*' =
|
|
471
|
+
// universal, else the lowercase name; `ns` the type's namespace prefix; `pe` the pseudo-element; pseudos are `{ name, arg }` with the
|
|
472
|
+
// parsed argument kept in a non-enumerable `parsed`.
|
|
473
|
+
|
|
474
|
+
// Parse one pseudo-class (throws on what Chrome rejects). `opts.inHas`: inside `:has()`
|
|
475
|
+
// (which cannot nest); `opts.inCompound`: inside `:-webkit-any()`, whose arguments are
|
|
476
|
+
// compound selectors — nested `:not()` takes compounds, nested `:is()` / `:where()` drop
|
|
477
|
+
// complex items, `of S` items may be complex (their own nested lists may not).
|
|
478
|
+
function parsePseudo(tok, src, opts) {
|
|
479
|
+
const { inHas, inCompound = false } = opts;
|
|
480
|
+
const { name, arg } = tok;
|
|
481
|
+
const p = { name, arg };
|
|
482
|
+
let parsed = null;
|
|
483
|
+
const functional = arg !== null;
|
|
484
|
+
if (PLAIN.has(name)) {
|
|
485
|
+
if (functional) throw syntax(src, `:${name}() takes no argument`);
|
|
486
|
+
} else if (NEVER.has(name) && !functional) {
|
|
487
|
+
parsed = { never: true };
|
|
488
|
+
} else if (NEVER_FUNCTIONAL.has(name) && functional) {
|
|
489
|
+
parsed = { never: true };
|
|
490
|
+
} else if (!functional) {
|
|
491
|
+
throw syntax(src, `unknown pseudo-class :${name}`);
|
|
492
|
+
} else if (name === 'not') {
|
|
493
|
+
parsed = { list: parseList(arg, { nested: true, forgiving: false, inHas, compound: inCompound, inCompound }) };
|
|
494
|
+
} else if (name === 'is' || name === 'where') {
|
|
495
|
+
parsed = { list: parseList(arg, { nested: true, forgiving: true, inHas, compound: inCompound, inCompound }) };
|
|
496
|
+
} else if (name === '-webkit-any') {
|
|
497
|
+
// unforgiving compound selectors, no :has() (a nested :is() drops items with one)
|
|
498
|
+
parsed = { list: parseList(arg, { nested: true, forgiving: false, inHas: true, compound: true, inCompound: true }) };
|
|
499
|
+
} else if (name === 'has') {
|
|
500
|
+
if (inHas) throw syntax(src, ':has() inside :has()');
|
|
501
|
+
parsed = { rel: parseRelative(arg, src) };
|
|
502
|
+
} else if (name === 'nth-child' || name === 'nth-last-child' || name === 'nth-of-type' || name === 'nth-last-of-type') {
|
|
503
|
+
const at = name.endsWith('child') ? nthOfPosition(arg) : -1;
|
|
504
|
+
const anb = parseAnB(at === -1 ? arg : arg.slice(0, at));
|
|
505
|
+
if (anb === null) throw syntax(src, `:${name}() argument`);
|
|
506
|
+
parsed = { a: anb.a, b: anb.b, of: at === -1 ? null : parseList(arg.slice(at + 2), { nested: true, forgiving: false, inHas, inCompound }) };
|
|
507
|
+
} else if (name === 'lang' || name === 'dir') {
|
|
508
|
+
const v = identValue(trimWs(arg));
|
|
509
|
+
if (v === null) throw syntax(src, `:${name}() takes one identifier`);
|
|
510
|
+
parsed = { v: v.toLowerCase() };
|
|
511
|
+
} else if (name === 'state') {
|
|
512
|
+
const v = identValue(trimWs(arg));
|
|
513
|
+
if (v === null) throw syntax(src, ':state() takes one identifier');
|
|
514
|
+
parsed = { v };
|
|
515
|
+
} else {
|
|
516
|
+
throw syntax(src, `unknown pseudo-class :${name}()`);
|
|
517
|
+
}
|
|
518
|
+
if (parsed) Object.defineProperty(p, 'parsed', { value: parsed });
|
|
519
|
+
return p;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A view-transition pseudo-element argument (<pt-name-and-class-selector>): `*` or a name,
|
|
523
|
+
// then `.class` parts (no space after the dot), or only class parts; whitespace may
|
|
524
|
+
// separate the parts (Chrome 156).
|
|
525
|
+
function isPtNameAndClass(arg) {
|
|
526
|
+
const s = trimWs(arg);
|
|
527
|
+
let i = 0;
|
|
528
|
+
if (s[0] === '*') i = 1;
|
|
529
|
+
else if (startsIdent(s, 0)) i = scanIdent(s, 0).end;
|
|
530
|
+
let parts = i;
|
|
531
|
+
for (;;) {
|
|
532
|
+
while (isWs(s[i])) i++;
|
|
533
|
+
if (s[i] !== '.') break;
|
|
534
|
+
if (!startsIdent(s, i + 1)) return false;
|
|
535
|
+
i = scanIdent(s, i + 1).end;
|
|
536
|
+
parts++;
|
|
537
|
+
}
|
|
538
|
+
return parts > 0 && i === s.length;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The (decoded) identifiers of a whitespace-separated list, or null when an item is not an
|
|
542
|
+
// identifier (or the list is empty).
|
|
543
|
+
function identList(arg) {
|
|
544
|
+
const s = trimWs(arg);
|
|
545
|
+
const out = [];
|
|
546
|
+
let i = 0;
|
|
547
|
+
while (i < s.length) {
|
|
548
|
+
if (!startsIdent(s, i)) return null;
|
|
549
|
+
const t = scanIdent(s, i);
|
|
550
|
+
out.push(t.value);
|
|
551
|
+
i = t.end;
|
|
552
|
+
if (i < s.length && !isWs(s[i])) return null;
|
|
553
|
+
while (isWs(s[i])) i++;
|
|
554
|
+
}
|
|
555
|
+
return out.length ? out : null;
|
|
556
|
+
}
|
|
557
|
+
const SCROLL_BUTTON_DIRECTIONS = new Set([
|
|
558
|
+
'block-end', 'block-start', 'down', 'inline-end', 'inline-start', 'left', 'right', 'up',
|
|
559
|
+
]);
|
|
560
|
+
// Is `arg` a valid argument of the functional pseudo-element `name` (Chrome 156)?
|
|
561
|
+
function validPseudoElementArg(name, arg) {
|
|
562
|
+
if (name.startsWith('view-transition-')) return isPtNameAndClass(arg);
|
|
563
|
+
const idents = identList(arg);
|
|
564
|
+
const one = idents !== null && idents.length === 1 ? idents[0].toLowerCase() : null;
|
|
565
|
+
if (name === 'part') return idents !== null;
|
|
566
|
+
if (name === 'highlight') return one !== null;
|
|
567
|
+
if (name === 'picker') return one === 'select';
|
|
568
|
+
if (name === 'scroll-button') return trimWs(arg) === '*' || SCROLL_BUTTON_DIRECTIONS.has(one);
|
|
569
|
+
return true; // ::cue() and ::slotted() take selectors (parsed below)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Parse one pseudo-element (throws on unknown ones): { name, arg, slotted? }.
|
|
573
|
+
function parsePseudoElement(tok, src) {
|
|
574
|
+
const { name, arg } = tok;
|
|
575
|
+
if (tok.legacy && arg !== null) throw syntax(src, `:${name}() takes no argument`);
|
|
576
|
+
const known = arg === null ? PSEUDO_ELEMENTS.has(name) : FUNCTIONAL_PSEUDO_ELEMENTS.has(name);
|
|
577
|
+
if (!known && !name.startsWith('-webkit-')) throw syntax(src, `unknown pseudo-element ::${name}`);
|
|
578
|
+
const pe = { name, arg };
|
|
579
|
+
if (arg !== null && !validPseudoElementArg(name, arg)) throw syntax(src, `::${name}() argument`);
|
|
580
|
+
// ::cue(): compound selectors, nested lists restricted as in :-webkit-any() (Chrome 156)
|
|
581
|
+
if (name === 'cue' && arg !== null) parseList(arg, { nested: true, forgiving: false, inHas: true, compound: true, inCompound: true });
|
|
582
|
+
if (name === 'slotted' && arg !== null) {
|
|
583
|
+
// one compound selector, nested lists restricted as in :-webkit-any() (Chrome 156)
|
|
584
|
+
const list = parseList(arg, { nested: true, forgiving: false, inHas: true, compound: true, inCompound: true });
|
|
585
|
+
if (list.length !== 1) throw syntax(src, '::slotted() takes one compound selector');
|
|
586
|
+
// kept for specificity (non-enumerable, like a pseudo-class's `parsed`)
|
|
587
|
+
Object.defineProperty(pe, 'parsed', { value: list });
|
|
588
|
+
}
|
|
589
|
+
return pe;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Parse tokens[lo..hi) (one complex selector, commas already split out). Throws on an empty
|
|
593
|
+
// item, a dangling combinator, a type selector after another simple selector, and a
|
|
594
|
+
// pseudo-element anywhere but at the end of the last compound.
|
|
595
|
+
function parseComplexTokens(tokens, lo, hi, src, opts) {
|
|
177
596
|
let start = lo, end = hi;
|
|
178
597
|
while (start < end && tokens[start].k === 'ws') start++;
|
|
179
598
|
while (end > start && tokens[end - 1].k === 'ws') end--;
|
|
180
|
-
if (start >= end)
|
|
181
|
-
let
|
|
182
|
-
const first = r.compound;
|
|
599
|
+
if (start >= end) throw syntax(src, 'empty selector');
|
|
600
|
+
let first = null;
|
|
183
601
|
const rest = [];
|
|
184
|
-
let
|
|
602
|
+
let combinator = null;
|
|
603
|
+
let pos = start;
|
|
604
|
+
let sawPe = false;
|
|
185
605
|
while (pos < end) {
|
|
186
|
-
while (pos < end && tokens[pos].k === 'ws') pos++; // a ws run here ⇒ candidate descendant
|
|
187
606
|
const t = tokens[pos];
|
|
188
|
-
|
|
607
|
+
if (t.k === 'ws') { pos++; if (combinator === null && first !== null) combinator = ' '; continue; }
|
|
189
608
|
if (t.k === 'comb') {
|
|
609
|
+
if (first === null || (combinator !== null && combinator !== ' ')) throw syntax(src, 'dangling combinator');
|
|
190
610
|
combinator = t.v;
|
|
191
611
|
pos++;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (sawPe) throw syntax(src, 'combinator after a pseudo-element');
|
|
615
|
+
// a compound
|
|
616
|
+
// one object shape for every compound (monomorphic matching): `ns` is null / 'any' /
|
|
617
|
+
// 'none', `pe` the pseudo-element or null
|
|
618
|
+
const compound = { tag: null, ns: null, id: null, classes: [], attrs: [], pseudos: [], pe: null };
|
|
619
|
+
let parts = 0;
|
|
620
|
+
let pe = null;
|
|
621
|
+
let last = null; // the last pseudo-element of the chain
|
|
622
|
+
for (; pos < end; pos++) {
|
|
623
|
+
const p = tokens[pos];
|
|
624
|
+
if (p.k === 'ws' || p.k === 'comb') break;
|
|
625
|
+
if (pe !== null && p.k !== 'pseudo' && p.k !== 'pelem') throw syntax(src, 'simple selector after a pseudo-element');
|
|
626
|
+
if (p.k === 'type') {
|
|
627
|
+
if (parts) throw syntax(src, 'type selector after another simple selector');
|
|
628
|
+
compound.tag = p.name === null ? '*' : p.name;
|
|
629
|
+
compound.ns = p.ns;
|
|
630
|
+
} else if (p.k === 'id') compound.id = p.v;
|
|
631
|
+
else if (p.k === 'class') compound.classes.push(p.v);
|
|
632
|
+
else if (p.k === 'attr') compound.attrs.push(p.attr);
|
|
633
|
+
else if (p.k === 'pelem') {
|
|
634
|
+
if (opts.nested) throw syntax(src, 'pseudo-element inside a pseudo-class argument');
|
|
635
|
+
const next = parsePseudoElement(p, src);
|
|
636
|
+
if (pe === null) pe = next;
|
|
637
|
+
else {
|
|
638
|
+
// a sub-pseudo-element (`::slotted(li)::marker`): valid, never matched here
|
|
639
|
+
if (!allowedSub(last.name, next.name)) throw syntax(src, `::${next.name} after ::${last.name}`);
|
|
640
|
+
if (!pe.chain) Object.defineProperty(pe, 'chain', { value: [] });
|
|
641
|
+
pe.chain.push(next);
|
|
642
|
+
}
|
|
643
|
+
last = next;
|
|
644
|
+
} else if (pe !== null) {
|
|
645
|
+
// after a pseudo-element only the pseudo-classes Chrome allows there (the selector
|
|
646
|
+
// never matches an element either way)
|
|
647
|
+
if (!allowedAfter(last, p)) throw syntax(src, `:${p.name} after ::${last.name}`);
|
|
648
|
+
compound.pseudos.push({ name: p.name, arg: p.arg });
|
|
649
|
+
} else {
|
|
650
|
+
compound.pseudos.push(parsePseudo(p, src, opts));
|
|
651
|
+
}
|
|
652
|
+
parts++;
|
|
198
653
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
654
|
+
if (pe !== null) { compound.pe = pe; sawPe = true; }
|
|
655
|
+
if (first === null) first = compound;
|
|
656
|
+
else rest.push({ combinator: combinator === null ? ' ' : combinator, compound });
|
|
657
|
+
combinator = null;
|
|
202
658
|
}
|
|
659
|
+
if (combinator !== null && combinator !== ' ') throw syntax(src, 'dangling combinator');
|
|
660
|
+
if (opts.compound && rest.length) throw syntax(src, 'combinator inside :-webkit-any()');
|
|
203
661
|
return { first, rest };
|
|
204
662
|
}
|
|
205
663
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
664
|
+
// A selector-list argument / list: `forgiving` (`:is()` / `:where()`) drops invalid items.
|
|
665
|
+
function parseList(src, opts) {
|
|
666
|
+
const out = [];
|
|
667
|
+
for (const item of splitTopLevel(src)) {
|
|
668
|
+
try {
|
|
669
|
+
const tokens = tokenize(item);
|
|
670
|
+
out.push(parseComplexTokens(tokens, 0, tokens.length, item, opts));
|
|
671
|
+
} catch (e) {
|
|
672
|
+
if (!opts.forgiving) throw e;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return out;
|
|
211
676
|
}
|
|
212
677
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
678
|
+
// `:has()` relative selector list (unforgiving): [{ combinator, complex }].
|
|
679
|
+
function parseRelative(arg, src) {
|
|
680
|
+
const out = [];
|
|
681
|
+
for (const item of splitTopLevel(arg)) {
|
|
682
|
+
const tokens = tokenize(item);
|
|
683
|
+
let lo = 0;
|
|
684
|
+
while (lo < tokens.length && tokens[lo].k === 'ws') lo++;
|
|
685
|
+
let combinator = ' ';
|
|
686
|
+
if (lo < tokens.length && tokens[lo].k === 'comb') { combinator = tokens[lo].v; lo++; }
|
|
687
|
+
out.push({ combinator, complex: parseComplexTokens(tokens, lo, tokens.length, src, { nested: true, forgiving: false, inHas: true }) });
|
|
222
688
|
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Parse one complex selector STRING (no top-level comma). Exposed via `_internal`.
|
|
693
|
+
function parseComplex(src) {
|
|
694
|
+
const tokens = tokenize(src);
|
|
695
|
+
return parseComplexTokens(tokens, 0, tokens.length, src, { nested: false, forgiving: false, inHas: false });
|
|
223
696
|
}
|
|
224
697
|
|
|
225
|
-
// parsed-selector cache: querySelector(All)/matches re-run the same selector
|
|
226
|
-
//
|
|
227
|
-
//
|
|
698
|
+
// parsed-selector cache: querySelector(All)/matches re-run the same selector strings
|
|
699
|
+
// constantly; parsing once and reusing is a large win on query-heavy suites. Bounded so a
|
|
700
|
+
// pathological generator can't grow it without limit. Invalid selectors are not cached
|
|
701
|
+
// (they throw every time, like the browser).
|
|
228
702
|
const __selectorCache = new Map();
|
|
229
703
|
export function parseSelectorList(selector) {
|
|
230
704
|
const hit = __selectorCache.get(selector);
|
|
231
705
|
if (hit !== undefined) return hit;
|
|
232
|
-
const
|
|
233
|
-
const parsed = [];
|
|
234
|
-
for (const [lo, hi] of splitTopLevelSegments(tokens)) {
|
|
235
|
-
const cx = parseComplexTokens(tokens, lo, hi);
|
|
236
|
-
if (cx !== null) parsed.push(cx); // a Complex is always non-empty (empties dropped)
|
|
237
|
-
}
|
|
706
|
+
const parsed = parseSource(selector, true);
|
|
238
707
|
if (__selectorCache.size > 10000) __selectorCache.clear();
|
|
239
708
|
__selectorCache.set(selector, parsed);
|
|
240
709
|
return parsed;
|
|
241
710
|
}
|
|
711
|
+
// `closeAtEof`: a DOM-API selector string (a stylesheet prelude never ends inside a block).
|
|
712
|
+
function parseSource(selector, closeAtEof) {
|
|
713
|
+
// CSS preprocessing: NUL becomes U+FFFD (a name code point); comments go
|
|
714
|
+
const src = normalizeSource(String(selector).replace(/\0/g, '\uFFFD'), closeAtEof);
|
|
715
|
+
return parseList(src, { nested: false, forgiving: false, inHas: false });
|
|
716
|
+
}
|
|
242
717
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
function parseRelativeSelectorList(arg) {
|
|
249
|
-
const src = arg || '';
|
|
250
|
-
const hit = __relativeCache.get(src);
|
|
251
|
-
if (hit !== undefined) return hit;
|
|
252
|
-
const tokens = tokenize(src);
|
|
253
|
-
const out = [];
|
|
254
|
-
for (let [lo, hi] of splitTopLevelSegments(tokens)) {
|
|
255
|
-
while (lo < hi && tokens[lo].k === 'ws') lo++; // skip leading ws of the segment
|
|
256
|
-
let combinator = ' ';
|
|
257
|
-
if (lo < hi && tokens[lo].k === 'comb') {
|
|
258
|
-
combinator = tokens[lo].v;
|
|
259
|
-
lo++;
|
|
260
|
-
}
|
|
261
|
-
const complex = parseComplexTokens(tokens, lo, hi);
|
|
262
|
-
if (complex !== null) out.push({ combinator, complex });
|
|
263
|
-
}
|
|
264
|
-
if (__relativeCache.size > 10000) __relativeCache.clear();
|
|
265
|
-
__relativeCache.set(src, out);
|
|
266
|
-
return out;
|
|
718
|
+
// The items of a style rule's selector list (top-level comma split), or [] when the list is
|
|
719
|
+
// invalid — a browser drops the whole rule (cascade.mjs; mirrors rtdom::cascade).
|
|
720
|
+
export function ruleSelectors(prelude) {
|
|
721
|
+
try { parseSource(prelude, false); } catch { return []; }
|
|
722
|
+
return splitTopLevel(prelude).map(trimWs);
|
|
267
723
|
}
|
|
268
724
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
725
|
+
// Selectors 4 specificity `[ids, classes + attributes + pseudo-classes, types +
|
|
726
|
+
// pseudo-elements]` of one complex selector of a (valid) rule, compared lexicographically by
|
|
727
|
+
// compareSpecificity — mirrors the Rust query_cascade specificity (miaskiewicz/turbo-dom#24):
|
|
728
|
+
// `:where()` is 0; `:is()`, `:not()` and `:has()` take their most specific argument;
|
|
729
|
+
// `:nth-child(An+B of S)` is a pseudo-class plus S; `*` counts nothing; a pseudo-element
|
|
730
|
+
// counts as a type (`::slotted(S)` plus S); everything else is one pseudo-class.
|
|
731
|
+
function addSpecificity(a, b) {
|
|
732
|
+
a[0] += b[0]; a[1] += b[1]; a[2] += b[2];
|
|
733
|
+
return a;
|
|
734
|
+
}
|
|
735
|
+
export function compareSpecificity(a, b) {
|
|
736
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
737
|
+
}
|
|
738
|
+
function maxSpecificity(list) {
|
|
739
|
+
let best = [0, 0, 0];
|
|
740
|
+
for (const cx of list) {
|
|
741
|
+
const s = complexSpecificity(cx);
|
|
742
|
+
if (compareSpecificity(s, best) > 0) best = s;
|
|
284
743
|
}
|
|
744
|
+
return best;
|
|
285
745
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// :has() — relational. SOME relative selector finds a matching element
|
|
298
|
-
// reachable from `el` per its leading combinator, scoped to `el`.
|
|
299
|
-
case 'has':
|
|
300
|
-
return parseRelativeSelectorList(p.arg).some((rel) => hasMatch(el, rel));
|
|
301
|
-
case 'first-child':
|
|
302
|
-
return previousElement(el) === null;
|
|
303
|
-
case 'last-child':
|
|
304
|
-
return nextElement(el) === null;
|
|
305
|
-
case 'only-child':
|
|
306
|
-
return previousElement(el) === null && nextElement(el) === null;
|
|
307
|
-
case 'empty':
|
|
308
|
-
return el.childNodes.length === 0;
|
|
309
|
-
case 'root':
|
|
310
|
-
return el.parentNode == null || el.parentNode.nodeType === 9;
|
|
311
|
-
// form-state pseudo-classes — read the live PROPERTY (what React sets),
|
|
312
|
-
// not just the HTML attribute.
|
|
313
|
-
case 'checked':
|
|
314
|
-
return el.localName === 'option' ? !!el.selected : !!el.checked;
|
|
315
|
-
case 'disabled':
|
|
316
|
-
return !!el.disabled || el.hasAttribute('disabled');
|
|
317
|
-
case 'enabled':
|
|
318
|
-
return /^(input|button|select|textarea|optgroup|option|fieldset)$/.test(el.localName) && !(el.disabled || el.hasAttribute('disabled'));
|
|
319
|
-
case 'required':
|
|
320
|
-
return !!el.required || el.hasAttribute('required');
|
|
321
|
-
case 'optional':
|
|
322
|
-
return /^(input|select|textarea)$/.test(el.localName) && !(el.required || el.hasAttribute('required'));
|
|
323
|
-
case 'read-only':
|
|
324
|
-
return el.hasAttribute('readonly') || !!el.readOnly;
|
|
325
|
-
case 'read-write':
|
|
326
|
-
return !(el.hasAttribute('readonly') || el.readOnly);
|
|
327
|
-
case 'selected':
|
|
328
|
-
return !!el.selected;
|
|
329
|
-
// positional pseudo-classes (1-based, support An+B / odd / even)
|
|
330
|
-
case 'nth-child':
|
|
331
|
-
return nthMatch(p.arg, siblingIndex(el) + 1);
|
|
332
|
-
case 'nth-last-child':
|
|
333
|
-
return nthMatch(p.arg, siblingCount(el) - siblingIndex(el));
|
|
334
|
-
case 'nth-of-type':
|
|
335
|
-
return nthMatch(p.arg, typeIndex(el) + 1);
|
|
336
|
-
case 'nth-last-of-type':
|
|
337
|
-
return nthMatch(p.arg, typeCount(el) - typeIndex(el));
|
|
338
|
-
case 'first-of-type':
|
|
339
|
-
return typeIndex(el) === 0;
|
|
340
|
-
case 'last-of-type':
|
|
341
|
-
return typeIndex(el) === typeCount(el) - 1;
|
|
342
|
-
case 'only-of-type':
|
|
343
|
-
return typeCount(el) === 1;
|
|
344
|
-
default:
|
|
345
|
-
return false; // unknown pseudo: never matches (honest, not a silent true)
|
|
746
|
+
function complexSpecificity(cx) {
|
|
747
|
+
const s = compoundSpecificity(cx.first);
|
|
748
|
+
for (const r of cx.rest) addSpecificity(s, compoundSpecificity(r.compound));
|
|
749
|
+
return s;
|
|
750
|
+
}
|
|
751
|
+
function compoundSpecificity(c) {
|
|
752
|
+
const s = [c.id !== null ? 1 : 0, c.classes.length + c.attrs.length, c.tag !== null && c.tag !== '*' ? 1 : 0];
|
|
753
|
+
for (const p of c.pseudos) addSpecificity(s, pseudoSpecificity(p));
|
|
754
|
+
if (c.pe !== null) {
|
|
755
|
+
s[2]++;
|
|
756
|
+
if (c.pe.parsed) addSpecificity(s, maxSpecificity(c.pe.parsed));
|
|
346
757
|
}
|
|
758
|
+
return s;
|
|
347
759
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
function
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
// match An+B / odd / even / integer against a 1-based index
|
|
361
|
-
function nthMatch(arg, index) {
|
|
362
|
-
const a0 = String(arg || '').trim().toLowerCase();
|
|
363
|
-
if (a0 === 'odd') return index % 2 === 1;
|
|
364
|
-
if (a0 === 'even') return index % 2 === 0;
|
|
365
|
-
const m = /^([+-]?\d*)n\s*([+-]\s*\d+)?$/.exec(a0.replace(/\s+/g, ''));
|
|
366
|
-
if (m) {
|
|
367
|
-
let a = m[1] === '' || m[1] === '+' ? 1 : m[1] === '-' ? -1 : parseInt(m[1], 10);
|
|
368
|
-
const b = m[2] ? parseInt(m[2].replace(/\s/g, ''), 10) : 0;
|
|
369
|
-
if (a === 0) return index === b;
|
|
370
|
-
return (index - b) % a === 0 && (index - b) / a >= 0;
|
|
371
|
-
}
|
|
372
|
-
const n = parseInt(a0, 10);
|
|
373
|
-
return !Number.isNaN(n) && index === n;
|
|
760
|
+
function pseudoSpecificity(p) {
|
|
761
|
+
const parsed = p.parsed;
|
|
762
|
+
if (p.name === 'where') return [0, 0, 0];
|
|
763
|
+
if (parsed !== undefined && parsed.list !== undefined && p.name !== '-webkit-any') return maxSpecificity(parsed.list);
|
|
764
|
+
if (parsed !== undefined && parsed.rel !== undefined) return maxSpecificity(parsed.rel.map((r) => r.complex));
|
|
765
|
+
if (parsed !== undefined && parsed.of) return addSpecificity([0, 1, 0], maxSpecificity(parsed.of));
|
|
766
|
+
return [0, 1, 0];
|
|
767
|
+
}
|
|
768
|
+
// The specificity of one item of ruleSelectors() (a valid complex selector).
|
|
769
|
+
export function specificity(item) {
|
|
770
|
+
return complexSpecificity(parseSelectorList(item)[0]);
|
|
374
771
|
}
|
|
375
772
|
|
|
773
|
+
// ── element state helpers ───────────────────────────────────────────────────
|
|
774
|
+
// HTML elements carry no short namespace (`__ns` is '' — 'svg' / 'math' otherwise).
|
|
775
|
+
function isHtml(el) {
|
|
776
|
+
return !el.__ns;
|
|
777
|
+
}
|
|
778
|
+
function attrList(el) {
|
|
779
|
+
return el.__attrs ?? (el.__buildAttrs ? (el.__attrs = el.__buildAttrs()) : []);
|
|
780
|
+
}
|
|
781
|
+
// The no-namespace attribute `name` (foreign elements: skip prefixed attributes such as
|
|
782
|
+
// xlink:href), or null.
|
|
783
|
+
function plainAttr(el, name) {
|
|
784
|
+
if (!el.__ns) return el.getAttribute(name);
|
|
785
|
+
const at = attrList(el);
|
|
786
|
+
for (let i = 0; i < at.length; i++) if (at[i].name === name && !at[i].prefix) return at[i].value;
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
function rawChildren(node) {
|
|
790
|
+
return typeof node.__children === 'function' ? node.__children() : Array.from(node.childNodes || []);
|
|
791
|
+
}
|
|
376
792
|
function previousElement(el) {
|
|
377
793
|
let n = el.previousSibling;
|
|
378
794
|
while (n && n.nodeType !== 1) n = n.previousSibling;
|
|
@@ -383,6 +799,269 @@ function nextElement(el) {
|
|
|
383
799
|
while (n && n.nodeType !== 1) n = n.nextSibling;
|
|
384
800
|
return n || null;
|
|
385
801
|
}
|
|
802
|
+
// (1-based index, count) among the element siblings (`ofType`: of the same local name). A
|
|
803
|
+
// parentless element is its own only sibling (as the Rust engine).
|
|
804
|
+
function position(el, ofType) {
|
|
805
|
+
const p = el.parentNode;
|
|
806
|
+
if (!p) return [1, 1];
|
|
807
|
+
const kids = rawChildren(p);
|
|
808
|
+
let index = 0, count = 0;
|
|
809
|
+
for (let i = 0; i < kids.length; i++) {
|
|
810
|
+
const c = kids[i];
|
|
811
|
+
if (c.nodeType !== 1 || (ofType && c.localName !== el.localName)) continue;
|
|
812
|
+
count++;
|
|
813
|
+
if (c === el) index = count;
|
|
814
|
+
}
|
|
815
|
+
return [index, count];
|
|
816
|
+
}
|
|
817
|
+
function positionOf(el, list, last) {
|
|
818
|
+
const p = el.parentNode;
|
|
819
|
+
const kids = p ? rawChildren(p) : [el];
|
|
820
|
+
let index = 0, count = 0;
|
|
821
|
+
for (let i = 0; i < kids.length; i++) {
|
|
822
|
+
const c = kids[i];
|
|
823
|
+
if (c.nodeType !== 1 || (c !== el && !list.some((cx) => matchComplex(c, cx)))) continue;
|
|
824
|
+
count++;
|
|
825
|
+
if (c === el) index = count;
|
|
826
|
+
}
|
|
827
|
+
return last ? count - index + 1 : index;
|
|
828
|
+
}
|
|
829
|
+
function boolAttr(el, prop, attr) {
|
|
830
|
+
const v = el[prop];
|
|
831
|
+
return typeof v === 'boolean' ? v : el.hasAttribute(attr);
|
|
832
|
+
}
|
|
833
|
+
const NAMES = (s) => new Set(s.split(' '));
|
|
834
|
+
const READONLY_INPUT_TYPES = NAMES('text search url tel email password date month week time datetime-local number');
|
|
835
|
+
const KNOWN_INPUT_TYPES = NAMES('hidden text search tel url email password date month week time datetime-local number range color checkbox radio file submit image reset button');
|
|
836
|
+
const AUTO_DIR_INPUT_TYPES = NAMES('hidden text search tel url email password submit reset button');
|
|
837
|
+
function inputType(el) {
|
|
838
|
+
const t = el.getAttribute('type');
|
|
839
|
+
return t === null ? null : t.toLowerCase();
|
|
840
|
+
}
|
|
841
|
+
// HTML "actually disabled" (button/input/select/textarea/fieldset): own state or a disabled
|
|
842
|
+
// fieldset ancestor outside its first legend child.
|
|
843
|
+
function actuallyDisabled(el) {
|
|
844
|
+
if (boolAttr(el, 'disabled', 'disabled')) return true;
|
|
845
|
+
let child = el;
|
|
846
|
+
for (let a = el.parentNode; a && a.nodeType === 1; child = a, a = a.parentNode) {
|
|
847
|
+
if (a.localName !== 'fieldset' || !isHtml(a) || !boolAttr(a, 'disabled', 'disabled')) continue;
|
|
848
|
+
const kids = rawChildren(a);
|
|
849
|
+
let legend = null;
|
|
850
|
+
for (let i = 0; i < kids.length && legend === null; i++) if (kids[i].nodeType === 1 && kids[i].localName === 'legend') legend = kids[i];
|
|
851
|
+
if (legend !== child) return true;
|
|
852
|
+
}
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
// :disabled / :enabled: undefined when neither applies, else whether `el` is disabled.
|
|
856
|
+
function disabledState(el) {
|
|
857
|
+
if (!isHtml(el)) return undefined;
|
|
858
|
+
switch (el.localName) {
|
|
859
|
+
case 'button': case 'input': case 'select': case 'textarea': case 'fieldset':
|
|
860
|
+
return actuallyDisabled(el);
|
|
861
|
+
case 'optgroup':
|
|
862
|
+
return boolAttr(el, 'disabled', 'disabled');
|
|
863
|
+
case 'option': {
|
|
864
|
+
if (el.hasAttribute('disabled')) return true;
|
|
865
|
+
let p = el.parentNode;
|
|
866
|
+
if (p && p.nodeType === 1 && p.localName === 'optgroup') {
|
|
867
|
+
if (p.hasAttribute('disabled')) return true;
|
|
868
|
+
p = p.parentNode;
|
|
869
|
+
}
|
|
870
|
+
return !!(p && p.nodeType === 1 && p.localName === 'select' && actuallyDisabled(p));
|
|
871
|
+
}
|
|
872
|
+
default:
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function isEditable(el) {
|
|
877
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentNode) {
|
|
878
|
+
if (!isHtml(n)) continue;
|
|
879
|
+
const v = n.getAttribute('contenteditable');
|
|
880
|
+
if (v === null) continue;
|
|
881
|
+
const l = v.toLowerCase();
|
|
882
|
+
if (l === '' || l === 'true' || l === 'plaintext-only') return true;
|
|
883
|
+
if (l === 'false') return false;
|
|
884
|
+
}
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
// The HTML :read-write rule (HTML elements; others match neither).
|
|
888
|
+
function readWrite(el) {
|
|
889
|
+
if (el.localName === 'input') {
|
|
890
|
+
const t = inputType(el);
|
|
891
|
+
const applies = t === null || READONLY_INPUT_TYPES.has(t) || !KNOWN_INPUT_TYPES.has(t);
|
|
892
|
+
return applies && !boolAttr(el, 'readOnly', 'readonly') && !actuallyDisabled(el);
|
|
893
|
+
}
|
|
894
|
+
if (el.localName === 'textarea') return !boolAttr(el, 'readOnly', 'readonly') && !actuallyDisabled(el);
|
|
895
|
+
return isEditable(el);
|
|
896
|
+
}
|
|
897
|
+
function isEmpty(el) {
|
|
898
|
+
const kids = rawChildren(el);
|
|
899
|
+
for (let i = 0; i < kids.length; i++) {
|
|
900
|
+
const c = kids[i];
|
|
901
|
+
if (c.nodeType === 1) return false;
|
|
902
|
+
if (c.nodeType === 3 && c.data !== '') return false;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
function isLink(el) {
|
|
907
|
+
if (isHtml(el)) return (el.localName === 'a' || el.localName === 'area') && el.hasAttribute('href');
|
|
908
|
+
return el.namespaceURI === SVG_NS && el.localName === 'a' && el.getAttribute('href') !== null;
|
|
909
|
+
}
|
|
910
|
+
// :defined — the element's custom element state (custom-elements.mjs: the creation-time
|
|
911
|
+
// state, upgrades and constructions), as the Rust engine's Tree::custom_element_state.
|
|
912
|
+
// :lang(range): the nearest `lang` / `xml:lang` (basic prefix match, case-insensitive).
|
|
913
|
+
function langMatches(el, range) {
|
|
914
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentNode) {
|
|
915
|
+
const v = n.getAttribute('lang');
|
|
916
|
+
if (v === null) continue;
|
|
917
|
+
const l = v.toLowerCase();
|
|
918
|
+
return l === range || (l.length > range.length && l.startsWith(range) && l[range.length] === '-');
|
|
919
|
+
}
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
function dirState(el) {
|
|
923
|
+
const v = el.getAttribute('dir');
|
|
924
|
+
if (v === null) return null;
|
|
925
|
+
const l = v.toLowerCase();
|
|
926
|
+
return l === 'ltr' || l === 'rtl' || l === 'auto' ? l : null;
|
|
927
|
+
}
|
|
928
|
+
// First strong character: true = RTL (bidi R / AL), false = LTR, null = none. The RTL
|
|
929
|
+
// ranges cover the Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic and RTL
|
|
930
|
+
// presentation-form blocks; every other letter is LTR.
|
|
931
|
+
const RTL_RE = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]|\uD802[\uDC00-\uDFFF]|\uD803[\uDC00-\uDFFF]|\uD83A[\uDC00-\uDFFF]|\uD83B[\uDC00-\uDEFF]/;
|
|
932
|
+
const LETTER_RE = /\p{L}/u;
|
|
933
|
+
function strongRtl(text) {
|
|
934
|
+
for (const ch of text) {
|
|
935
|
+
if (RTL_RE.test(ch)) return true;
|
|
936
|
+
if (LETTER_RE.test(ch)) return false;
|
|
937
|
+
}
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
// A textarea's value: its child text content (direct Text children only; turbo-dom#106).
|
|
941
|
+
function childText(el) {
|
|
942
|
+
let text = '';
|
|
943
|
+
const kids = rawChildren(el);
|
|
944
|
+
for (let i = 0; i < kids.length; i++) if (kids[i].nodeType === 3) text += kids[i].data;
|
|
945
|
+
return text;
|
|
946
|
+
}
|
|
947
|
+
function autoRtl(el) {
|
|
948
|
+
const ln = el.localName;
|
|
949
|
+
if (ln === 'textarea' || (ln === 'input' && (inputType(el) === null || AUTO_DIR_INPUT_TYPES.has(inputType(el)) || !KNOWN_INPUT_TYPES.has(inputType(el))))) {
|
|
950
|
+
const value = ln === 'textarea' ? childText(el) : (el.getAttribute('value') || '');
|
|
951
|
+
const r = strongRtl(value);
|
|
952
|
+
return r !== null ? r : value !== '' ? false : null;
|
|
953
|
+
}
|
|
954
|
+
const walk = (node) => {
|
|
955
|
+
const kids = rawChildren(node);
|
|
956
|
+
for (let i = 0; i < kids.length; i++) {
|
|
957
|
+
const c = kids[i];
|
|
958
|
+
if (c.nodeType === 3) { const r = strongRtl(c.data); if (r !== null) return r; continue; }
|
|
959
|
+
if (c.nodeType !== 1) continue;
|
|
960
|
+
if (isHtml(c) && (c.localName === 'bdi' || c.localName === 'script' || c.localName === 'style' || c.localName === 'textarea' || dirState(c) !== null)) continue;
|
|
961
|
+
const r = walk(c);
|
|
962
|
+
if (r !== null) return r;
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
};
|
|
966
|
+
return walk(el);
|
|
967
|
+
}
|
|
968
|
+
// HTML directionality: true = rtl. Mirrors the Rust query_cascade::is_rtl.
|
|
969
|
+
function isRtl(el) {
|
|
970
|
+
for (let n = el; n;) {
|
|
971
|
+
if (isHtml(n)) {
|
|
972
|
+
const d = dirState(n);
|
|
973
|
+
if (d === 'ltr') return false;
|
|
974
|
+
if (d === 'rtl') return true;
|
|
975
|
+
if (d === 'auto' || (d === null && n.localName === 'bdi')) return autoRtl(n) === true;
|
|
976
|
+
if (d === null && n.localName === 'input' && inputType(n) === 'tel') return false;
|
|
977
|
+
}
|
|
978
|
+
const p = n.parentNode;
|
|
979
|
+
n = p && p.__isShadowRoot ? p.host : p && p.nodeType === 1 ? p : null;
|
|
980
|
+
}
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ── matching ────────────────────────────────────────────────────────────────
|
|
985
|
+
function matchValue(m, got, ci) {
|
|
986
|
+
if (m.op === 'present') return true;
|
|
987
|
+
let v = got, t = m.value;
|
|
988
|
+
if (ci) { v = v.toLowerCase(); t = t.toLowerCase(); }
|
|
989
|
+
switch (m.op) {
|
|
990
|
+
case 'equals': return v === t;
|
|
991
|
+
case 'prefix': return t !== '' && v.startsWith(t);
|
|
992
|
+
case 'suffix': return t !== '' && v.endsWith(t);
|
|
993
|
+
case 'substr': return t !== '' && v.includes(t);
|
|
994
|
+
case 'includes': return t !== '' && !/\s/.test(t) && v.split(/[ \t\n\r\f]+/).includes(t);
|
|
995
|
+
default: return v === t || v.startsWith(t + '-'); // dash
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function matchAttr(el, a) {
|
|
999
|
+
const html = !el.__ns;
|
|
1000
|
+
const name = html ? a.lname : a.name;
|
|
1001
|
+
if (a.ns === 'any') {
|
|
1002
|
+
const at = attrList(el);
|
|
1003
|
+
for (let i = 0; i < at.length; i++) if (at[i].name === name && matchValue(a.match, at[i].value, a.ci === true)) return true;
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
const raw = html ? el.getAttribute(name) : plainAttr(el, name);
|
|
1007
|
+
if (raw === null) return false;
|
|
1008
|
+
if (a.match.op === 'present') return true;
|
|
1009
|
+
// HTML `type` on input/button/ol compares case-insensitively by default (as Rust)
|
|
1010
|
+
const implicit = html && name === 'type' && (el.localName === 'input' || el.localName === 'button' || el.localName === 'ol');
|
|
1011
|
+
return matchValue(a.match, raw, a.ci === true || implicit);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// The element `:scope` matches during the current match (null = stylesheet: `:root`).
|
|
1015
|
+
let currentScope = null;
|
|
1016
|
+
|
|
1017
|
+
function matchPseudo(el, p) {
|
|
1018
|
+
const parsed = p.parsed;
|
|
1019
|
+
switch (p.name) {
|
|
1020
|
+
case 'not': return !parsed.list.some((cx) => matchComplex(el, cx));
|
|
1021
|
+
case 'is': case 'where': case '-webkit-any': return parsed.list.some((cx) => matchComplex(el, cx));
|
|
1022
|
+
case 'has': return parsed.rel.some((rel) => hasMatch(el, rel));
|
|
1023
|
+
// O(1) sibling steps (a parentless element is its own only sibling)
|
|
1024
|
+
case 'first-child': return previousElement(el) === null;
|
|
1025
|
+
case 'last-child': return nextElement(el) === null;
|
|
1026
|
+
case 'only-child': return previousElement(el) === null && nextElement(el) === null;
|
|
1027
|
+
case 'first-of-type': return position(el, true)[0] === 1;
|
|
1028
|
+
case 'last-of-type': { const [i, n] = position(el, true); return i === n; }
|
|
1029
|
+
case 'only-of-type': return position(el, true)[1] === 1;
|
|
1030
|
+
case 'nth-child': case 'nth-last-child': {
|
|
1031
|
+
const last = p.name === 'nth-last-child';
|
|
1032
|
+
if (parsed.of) {
|
|
1033
|
+
if (!parsed.of.some((cx) => matchComplex(el, cx))) return false;
|
|
1034
|
+
return nthMatch(parsed.a, parsed.b, positionOf(el, parsed.of, last));
|
|
1035
|
+
}
|
|
1036
|
+
const [i, n] = position(el, false);
|
|
1037
|
+
return nthMatch(parsed.a, parsed.b, last ? n - i + 1 : i);
|
|
1038
|
+
}
|
|
1039
|
+
case 'nth-of-type': case 'nth-last-of-type': {
|
|
1040
|
+
const [i, n] = position(el, true);
|
|
1041
|
+
return nthMatch(parsed.a, parsed.b, p.name === 'nth-last-of-type' ? n - i + 1 : i);
|
|
1042
|
+
}
|
|
1043
|
+
case 'empty': return isEmpty(el);
|
|
1044
|
+
case 'root': return !!el.parentNode && el.parentNode.nodeType === 9;
|
|
1045
|
+
case 'scope': return currentScope === null ? !!el.parentNode && el.parentNode.nodeType === 9 : el === currentScope;
|
|
1046
|
+
// form state reads the live PROPERTY first (what React sets), then the attribute
|
|
1047
|
+
case 'checked':
|
|
1048
|
+
if (el.localName === 'option') return !!el.selected;
|
|
1049
|
+
return el.localName === 'input' && (inputType(el) === 'checkbox' || inputType(el) === 'radio') && boolAttr(el, 'checked', 'checked');
|
|
1050
|
+
case 'disabled': return disabledState(el) === true;
|
|
1051
|
+
case 'enabled': return disabledState(el) === false;
|
|
1052
|
+
case 'required': return boolAttr(el, 'required', 'required');
|
|
1053
|
+
case 'optional': return (el.localName === 'input' || el.localName === 'select' || el.localName === 'textarea') && !boolAttr(el, 'required', 'required');
|
|
1054
|
+
case 'read-only': return isHtml(el) && !readWrite(el);
|
|
1055
|
+
case 'read-write': return isHtml(el) && readWrite(el);
|
|
1056
|
+
case 'link': case 'any-link': return isLink(el);
|
|
1057
|
+
case 'defined': return isDefinedElement(el);
|
|
1058
|
+
case 'lang': return langMatches(el, parsed.v);
|
|
1059
|
+
case 'dir': return (parsed.v === 'rtl' || parsed.v === 'ltr') && isRtl(el) === (parsed.v === 'rtl');
|
|
1060
|
+
// `:state()` parses; this runtime has no ElementInternals custom states, so it never
|
|
1061
|
+
// matches (falls through to the never-matching default)
|
|
1062
|
+
default: return false; // the valid never-matching pseudo-classes (:hover, :focus, …)
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
386
1065
|
|
|
387
1066
|
// allocation-free "does the class attribute contain this whole-word class"
|
|
388
1067
|
function hasClass(cn, cls) {
|
|
@@ -390,8 +1069,8 @@ function hasClass(cn, cls) {
|
|
|
390
1069
|
const L = cls.length;
|
|
391
1070
|
let idx = cn.indexOf(cls);
|
|
392
1071
|
while (idx !== -1) {
|
|
393
|
-
const before = idx === 0 || cn
|
|
394
|
-
const after = idx + L === cn.length || cn
|
|
1072
|
+
const before = idx === 0 || isWs(cn[idx - 1]);
|
|
1073
|
+
const after = idx + L === cn.length || isWs(cn[idx + L]);
|
|
395
1074
|
if (before && after) return true;
|
|
396
1075
|
idx = cn.indexOf(cls, idx + 1);
|
|
397
1076
|
}
|
|
@@ -400,13 +1079,19 @@ function hasClass(cn, cls) {
|
|
|
400
1079
|
|
|
401
1080
|
function matchCompound(el, compound) {
|
|
402
1081
|
if (!el || el.nodeType !== 1) return false;
|
|
403
|
-
|
|
404
|
-
if (compound.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1082
|
+
if (compound.pe) return false; // a pseudo-element is never an element
|
|
1083
|
+
if (compound.ns === 'none' && el.namespaceURI !== null) return false;
|
|
1084
|
+
const tag = compound.tag;
|
|
1085
|
+
if (tag !== null && tag !== '*') {
|
|
1086
|
+
const ln = el.localName;
|
|
1087
|
+
// type selectors compare ASCII case-insensitively (as the Rust engine); HTML local
|
|
1088
|
+
// names are already lowercase, so only foreign elements pay for the fold
|
|
1089
|
+
if (ln !== tag && (!el.__ns || ln.toLowerCase() !== tag)) return false;
|
|
1090
|
+
}
|
|
1091
|
+
if (compound.id !== null && plainAttr(el, 'id') !== compound.id) return false;
|
|
1092
|
+
// classes: read the class attribute ONCE and test membership without allocating
|
|
408
1093
|
if (compound.classes.length) {
|
|
409
|
-
const cn = el
|
|
1094
|
+
const cn = plainAttr(el, 'class');
|
|
410
1095
|
if (!cn) return false;
|
|
411
1096
|
for (const cls of compound.classes) if (!hasClass(cn, cls)) return false;
|
|
412
1097
|
}
|
|
@@ -421,24 +1106,17 @@ function compoundAt(cx, k) {
|
|
|
421
1106
|
}
|
|
422
1107
|
|
|
423
1108
|
// Match a parsed complex selector against `el` (the rightmost compound applies to el).
|
|
424
|
-
// The rightmost compound sits at index `cx.rest.length`.
|
|
425
1109
|
function matchComplex(el, cx) {
|
|
426
1110
|
return matchChain(el, cx, cx.rest.length, null);
|
|
427
1111
|
}
|
|
428
1112
|
|
|
429
|
-
// Match compounds 0..=k of `cx` ending at `el`, recursing leftward. Descendant (' ')
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
// chains like `.a > .b .c` where a farther `.b` is the one that is a child of `.a`.
|
|
433
|
-
// `cx.rest[k-1].combinator` is the relation between compounds k-1 and k.
|
|
434
|
-
// `scope` supports `:has()`: when set to `{ comb, node }`, the LEFTMOST compound must,
|
|
435
|
-
// in addition to matching, relate to the scope element `node` by `comb` (so a relative
|
|
436
|
-
// selector's head is anchored to the `:has()` subject). `null` (what matchComplex and
|
|
437
|
-
// plain matching pass) means the leftmost compound stands alone — one sentinel.
|
|
1113
|
+
// Match compounds 0..=k of `cx` ending at `el`, recursing leftward. Descendant (' ') and
|
|
1114
|
+
// general-sibling ('~') combinators try EVERY candidate ancestor/sibling and backtrack.
|
|
1115
|
+
// `scope` supports `:has()`: `{ comb, node }` anchors the leftmost compound to the subject.
|
|
438
1116
|
function matchChain(el, cx, k, scope = null) {
|
|
439
1117
|
if (!matchCompound(el, compoundAt(cx, k))) return false;
|
|
440
1118
|
if (k === 0) return scope === null ? true : relatesToScope(el, scope.comb, scope.node);
|
|
441
|
-
const comb = cx.rest[k - 1].combinator;
|
|
1119
|
+
const comb = cx.rest[k - 1].combinator;
|
|
442
1120
|
if (comb === '>') {
|
|
443
1121
|
const p = el.parentNode;
|
|
444
1122
|
return !!p && p.nodeType === 1 && matchChain(p, cx, k - 1, scope);
|
|
@@ -448,43 +1126,27 @@ function matchChain(el, cx, k, scope = null) {
|
|
|
448
1126
|
return !!prev && matchChain(prev, cx, k - 1, scope);
|
|
449
1127
|
}
|
|
450
1128
|
if (comb === ' ') {
|
|
451
|
-
let anc = el.parentNode;
|
|
452
|
-
while (anc && anc.nodeType === 1) {
|
|
1129
|
+
for (let anc = el.parentNode; anc && anc.nodeType === 1; anc = anc.parentNode) {
|
|
453
1130
|
if (matchChain(anc, cx, k - 1, scope)) return true;
|
|
454
|
-
anc = anc.parentNode;
|
|
455
1131
|
}
|
|
456
1132
|
return false;
|
|
457
1133
|
}
|
|
458
|
-
|
|
459
|
-
let prev = previousElement(el);
|
|
460
|
-
while (prev) {
|
|
1134
|
+
for (let prev = previousElement(el); prev; prev = previousElement(prev)) {
|
|
461
1135
|
if (matchChain(prev, cx, k - 1, scope)) return true;
|
|
462
|
-
prev = previousElement(prev);
|
|
463
1136
|
}
|
|
464
1137
|
return false;
|
|
465
1138
|
}
|
|
466
1139
|
|
|
467
|
-
// Does `node` stand in relation `comb` to the `:has()`
|
|
468
|
-
// the leftmost compound of a relative selector: `> .a` means the `.a` head is a direct
|
|
469
|
-
// child of the subject, `.a .b` means the `.a` head is a descendant, etc. The leading
|
|
470
|
-
// combinator constrains the HEAD compound — NOT the whole complex (the bug in anchoring
|
|
471
|
-
// the entire relative complex at a child/sibling candidate).
|
|
1140
|
+
// Does `node` stand in relation `comb` to the `:has()` subject `el`?
|
|
472
1141
|
function relatesToScope(node, comb, el) {
|
|
473
1142
|
if (comb === '>') return node.parentNode === el;
|
|
474
1143
|
if (comb === '+') return previousElement(node) === el;
|
|
475
|
-
if (comb === '~') { let p = previousElement(node);
|
|
476
|
-
|
|
477
|
-
let a = node.parentNode;
|
|
478
|
-
while (a) { if (a === el) return true; a = a.parentNode; }
|
|
1144
|
+
if (comb === '~') { for (let p = previousElement(node); p; p = previousElement(p)) if (p === el) return true; return false; }
|
|
1145
|
+
for (let a = node.parentNode; a; a = a.parentNode) if (a === el) return true;
|
|
479
1146
|
return false;
|
|
480
1147
|
}
|
|
481
1148
|
|
|
482
|
-
// :has() existence search
|
|
483
|
-
// rightmost compound) reachable from the subject `el`, with its HEAD compound related
|
|
484
|
-
// to `el` by the leading combinator (enforced at k===0 via `relatesToScope`). Candidate
|
|
485
|
-
// targets: descendants of `el` for `>`/descendant heads (the head, and thus the whole
|
|
486
|
-
// match, lives in `el`'s subtree); a following-sibling subtree for `+`/`~` heads.
|
|
487
|
-
// Mirrors the Rust has_match.
|
|
1149
|
+
// :has() existence search (mirrors the Rust has_match).
|
|
488
1150
|
function hasMatch(el, rel) {
|
|
489
1151
|
const { combinator, complex } = rel;
|
|
490
1152
|
const scope = { comb: combinator, node: el };
|
|
@@ -494,20 +1156,14 @@ function hasMatch(el, rel) {
|
|
|
494
1156
|
return !!sib && someSelfOrDescendant(sib, test);
|
|
495
1157
|
}
|
|
496
1158
|
if (combinator === '~') {
|
|
497
|
-
let sib = nextElement(el);
|
|
498
|
-
while (sib) {
|
|
499
|
-
if (someSelfOrDescendant(sib, test)) return true;
|
|
500
|
-
sib = nextElement(sib);
|
|
501
|
-
}
|
|
1159
|
+
for (let sib = nextElement(el); sib; sib = nextElement(sib)) if (someSelfOrDescendant(sib, test)) return true;
|
|
502
1160
|
return false;
|
|
503
1161
|
}
|
|
504
|
-
// '>' and descendant leading combinator: the head (hence every match) is within
|
|
505
|
-
// `el`'s subtree, so any descendant can be the target endpoint.
|
|
506
1162
|
return someDescendant(el, test);
|
|
507
1163
|
}
|
|
508
1164
|
|
|
509
|
-
// Visit every descendant element of `node` (document order
|
|
510
|
-
// first that satisfies `pred`.
|
|
1165
|
+
// Visit every descendant element of `node` (document order; template content is not a
|
|
1166
|
+
// child); short-circuit on the first that satisfies `pred`.
|
|
511
1167
|
function someDescendant(node, pred) {
|
|
512
1168
|
const kids = rawChildren(node);
|
|
513
1169
|
for (let i = 0; i < kids.length; i++) {
|
|
@@ -518,85 +1174,107 @@ function someDescendant(node, pred) {
|
|
|
518
1174
|
}
|
|
519
1175
|
return false;
|
|
520
1176
|
}
|
|
521
|
-
|
|
522
|
-
// `node` itself (if an element) or any of its descendants satisfies `pred`.
|
|
523
1177
|
function someSelfOrDescendant(node, pred) {
|
|
524
1178
|
if (node.nodeType === 1 && pred(node)) return true;
|
|
525
1179
|
return someDescendant(node, pred);
|
|
526
1180
|
}
|
|
527
1181
|
|
|
528
|
-
|
|
1182
|
+
// `el.matches(selector)`; `scope` is the `:scope` element (`el` by default; null in a
|
|
1183
|
+
// stylesheet, where `:scope` is `:root`). Throws SyntaxError on an invalid selector.
|
|
1184
|
+
export function matchesSelector(el, selector, scope = el) {
|
|
529
1185
|
const list = parseSelectorList(selector);
|
|
530
|
-
|
|
1186
|
+
const saved = currentScope;
|
|
1187
|
+
currentScope = scope;
|
|
1188
|
+
try {
|
|
1189
|
+
return list.some((cx) => matchComplex(el, cx));
|
|
1190
|
+
} finally {
|
|
1191
|
+
currentScope = saved;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Does the `::slotted(S)` rule item `selector` (one complex selector of a shadow tree's
|
|
1196
|
+
// stylesheet) apply to `el`, which is assigned to `slot`? `el` matches S and `slot` the
|
|
1197
|
+
// rest of the selector (`slot[name=a]::slotted(S)`, `div ::slotted(S)`). A sub-pseudo-
|
|
1198
|
+
// element (`::slotted(S)::before`) targets generated content, never `el` itself.
|
|
1199
|
+
export function matchesSlotted(el, slot, selector) {
|
|
1200
|
+
const cx = parseSelectorList(selector)[0];
|
|
1201
|
+
const subject = cx.rest.length ? cx.rest[cx.rest.length - 1].compound : cx.first;
|
|
1202
|
+
const pe = subject.pe;
|
|
1203
|
+
if (pe === null || pe.name !== 'slotted' || pe.chain) return false;
|
|
1204
|
+
if (!pe.parsed.some((arg) => matchComplex(el, arg))) return false;
|
|
1205
|
+
subject.pe = null; // match the slot against the originating compound
|
|
1206
|
+
try {
|
|
1207
|
+
return matchComplex(slot, cx);
|
|
1208
|
+
} finally {
|
|
1209
|
+
subject.pe = pe;
|
|
1210
|
+
}
|
|
531
1211
|
}
|
|
532
1212
|
|
|
533
|
-
// Fast paths for the overwhelmingly common simple selectors, skipping the
|
|
534
|
-
//
|
|
535
|
-
const SIMPLE = /^\s*(#[\w-]
|
|
1213
|
+
// Fast paths for the overwhelmingly common simple selectors, skipping the parse +
|
|
1214
|
+
// per-element matchComplex machinery.
|
|
1215
|
+
const SIMPLE = /^\s*(#[A-Za-z_][\w-]*|\.[A-Za-z_][\w-]*|[a-zA-Z][\w-]*)\s*$/;
|
|
536
1216
|
function simpleMatcher(selector) {
|
|
537
1217
|
const m = SIMPLE.exec(selector);
|
|
538
1218
|
if (!m) return null;
|
|
539
1219
|
const s = m[1];
|
|
540
|
-
if (s[0] === '#') { const id = s.slice(1); return (el) => el.getAttribute('id') === id; }
|
|
541
|
-
if (s[0] === '.') { const cls = s.slice(1); return (el) => { const cn = el.getAttribute('class'); return cn ? hasClass(cn, cls) : false; }; }
|
|
542
|
-
const tag = s.toLowerCase();
|
|
1220
|
+
if (s[0] === '#') { const id = s.slice(1); return (el) => (el.__ns ? plainAttr(el, 'id') : el.getAttribute('id')) === id; }
|
|
1221
|
+
if (s[0] === '.') { const cls = s.slice(1); return (el) => { const cn = el.__ns ? plainAttr(el, 'class') : el.getAttribute('class'); return cn ? hasClass(cn, cls) : false; }; }
|
|
1222
|
+
const tag = s.toLowerCase();
|
|
1223
|
+
return (el) => el.localName === tag || (!!el.__ns && el.localName.toLowerCase() === tag);
|
|
543
1224
|
}
|
|
544
1225
|
|
|
545
|
-
//
|
|
546
|
-
function
|
|
547
|
-
|
|
1226
|
+
// The `:scope` element of a query rooted at `root` (the document element for a document).
|
|
1227
|
+
function scopeOf(root) {
|
|
1228
|
+
if (root.nodeType === 1) return root;
|
|
1229
|
+
return root.nodeType === 9 ? root.documentElement || null : undefined;
|
|
548
1230
|
}
|
|
549
1231
|
|
|
550
|
-
|
|
551
|
-
const simple = simpleMatcher(selector);
|
|
1232
|
+
function queryWalk(root, selector, first) {
|
|
552
1233
|
const out = [];
|
|
1234
|
+
const simple = simpleMatcher(selector);
|
|
553
1235
|
if (simple) {
|
|
1236
|
+
// no pseudo-classes in a simple selector, so no :scope to set
|
|
554
1237
|
const visit = (node) => {
|
|
555
1238
|
const kids = rawChildren(node);
|
|
556
|
-
for (let i = 0; i < kids.length; i++) {
|
|
1239
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1240
|
+
const c = kids[i];
|
|
1241
|
+
if (c.nodeType !== 1) continue;
|
|
1242
|
+
if (simple(c)) { out.push(c); if (first) return true; }
|
|
1243
|
+
if (visit(c)) return true;
|
|
1244
|
+
}
|
|
1245
|
+
return false;
|
|
557
1246
|
};
|
|
558
1247
|
visit(root);
|
|
559
1248
|
return out;
|
|
560
1249
|
}
|
|
561
1250
|
const list = parseSelectorList(selector);
|
|
562
1251
|
const single = list.length === 1 ? list[0] : null;
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const c = kids[i];
|
|
567
|
-
if (c.nodeType !== 1) continue;
|
|
568
|
-
if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) out.push(c);
|
|
569
|
-
visit(c);
|
|
570
|
-
}
|
|
571
|
-
};
|
|
572
|
-
visit(root);
|
|
573
|
-
return out;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export function querySelector(root, selector) {
|
|
577
|
-
const simple = simpleMatcher(selector);
|
|
578
|
-
if (simple) {
|
|
1252
|
+
const saved = currentScope;
|
|
1253
|
+
currentScope = scopeOf(root);
|
|
1254
|
+
try {
|
|
579
1255
|
const visit = (node) => {
|
|
580
1256
|
const kids = rawChildren(node);
|
|
581
|
-
for (let i = 0; i < kids.length; i++) {
|
|
582
|
-
|
|
1257
|
+
for (let i = 0; i < kids.length; i++) {
|
|
1258
|
+
const c = kids[i];
|
|
1259
|
+
if (c.nodeType !== 1) continue;
|
|
1260
|
+
if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) { out.push(c); if (first) return true; }
|
|
1261
|
+
if (visit(c)) return true;
|
|
1262
|
+
}
|
|
1263
|
+
return false;
|
|
583
1264
|
};
|
|
584
|
-
|
|
1265
|
+
visit(root);
|
|
1266
|
+
} finally {
|
|
1267
|
+
currentScope = saved;
|
|
585
1268
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (r) return r;
|
|
596
|
-
}
|
|
597
|
-
return null;
|
|
598
|
-
};
|
|
599
|
-
return visit(root);
|
|
1269
|
+
return out;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export function querySelectorAll(root, selector) {
|
|
1273
|
+
return queryWalk(root, selector, false);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export function querySelector(root, selector) {
|
|
1277
|
+
return queryWalk(root, selector, true)[0] || null;
|
|
600
1278
|
}
|
|
601
1279
|
|
|
602
|
-
export const _internal = { parseComplex, parseSelectorList, matchCompound, matchComplex };
|
|
1280
|
+
export const _internal = { parseComplex, parseSelectorList, matchCompound, matchComplex, parseAnB, ruleSelectors, specificity, compareSpecificity };
|