@miaskiewicz/turbo-dom 0.7.1 → 0.8.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.
@@ -145,8 +145,34 @@ const ATTR_OP = { '~': 'includes', '|': 'dash', '^': 'prefix', $: 'suffix', '*':
145
145
  // unterminated comment runs to the end. `closeAtEof` (the DOM selector APIs): an
146
146
  // unterminated string and every open `[` / `(` are closed, as CSS does at EOF.
147
147
  const nameLike = (c) => c !== undefined && (c >= '\x80' || c === '-' || c === '_' || c === '\\' || isLetter(c) || isDigit(c));
148
+ function needsNormalize(src, closeAtEof) {
149
+ return src.indexOf('/*') !== -1 || (closeAtEof && /[[("'\\]/.test(src));
150
+ }
151
+ // The index just past the comment opening at `i` (the input's end when unterminated).
152
+ function commentEnd(src, i) {
153
+ const end = src.indexOf('*/', i + 2);
154
+ return end === -1 ? src.length : end + 2;
155
+ }
156
+ // What a comment between `out` and `next` leaves behind: a separator between two name
157
+ // characters (a space inside `[…]`, the never-valid `!` elsewhere), else nothing.
158
+ function commentSeparator(out, next, open) {
159
+ const prev = out[out.length - 1];
160
+ if ((nameLike(prev) || prev === '#') && nameLike(next)) return open[open.length - 1] === ']' ? ' ' : '!';
161
+ return '';
162
+ }
163
+ function trackBracket(c, open) {
164
+ if (c === '[') open.push(']');
165
+ else if (c === '(') open.push(')');
166
+ else if (c === open[open.length - 1]) open.pop();
167
+ }
168
+ // Close an unterminated string and every open `[` / `(` at EOF.
169
+ function closeAtEnd(out, quote, open) {
170
+ if (quote) out += quote;
171
+ while (open.length) out += open.pop();
172
+ return out;
173
+ }
148
174
  function normalizeSource(src, closeAtEof) {
149
- if (src.indexOf('/*') === -1 && (!closeAtEof || !/[[("'\\]/.test(src))) return src;
175
+ if (!needsNormalize(src, closeAtEof)) return src;
150
176
  let out = '', quote = null;
151
177
  const open = [];
152
178
  for (let i = 0; i < src.length; i++) {
@@ -157,24 +183,17 @@ function normalizeSource(src, closeAtEof) {
157
183
  out += src.slice(i, i + 2); i++; continue;
158
184
  }
159
185
  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] === ']' ? ' ' : '!';
186
+ if (c === '/' && src[i + 1] === '*') {
187
+ const end = commentEnd(src, i);
188
+ out += commentSeparator(out, src[end], open);
166
189
  i = end - 1;
167
190
  continue;
168
- } else if (c === '[') open.push(']');
169
- else if (c === '(') open.push(')');
170
- else if (c === open[open.length - 1]) open.pop();
191
+ }
192
+ if (c === '"' || c === "'") quote = c;
193
+ else trackBracket(c, open);
171
194
  out += c;
172
195
  }
173
- if (closeAtEof) {
174
- if (quote) out += quote;
175
- while (open.length) out += open.pop();
176
- }
177
- return out;
196
+ return closeAtEof ? closeAtEnd(out, quote, open) : out;
178
197
  }
179
198
  // A quoted string starting at `i` (src[i] is the quote): { value, end } (end past the
180
199
  // closing quote; an unterminated string ends with the input, as in CSS — the attribute
@@ -201,11 +220,37 @@ function scanString(src, i) {
201
220
  // { op, value }; `ns: 'any'` for `[*|a]`; `ci: true` for the `i` modifier. Throws on what
202
221
  // Chrome 156 rejects, including the `s` modifier (so does the Rust engine since
203
222
  // turbo-dom#24).
223
+ function skipWsAt(s, i) {
224
+ while (i < s.length && isWs(s[i])) i++;
225
+ return i;
226
+ }
227
+ function skipDigits(s, i) {
228
+ while (i < s.length && isDigit(s[i])) i++;
229
+ return i;
230
+ }
231
+ // The attribute value at `i` (a string or an identifier): { value, end }.
232
+ function scanAttrValue(s, i, src) {
233
+ if (s[i] === '"' || s[i] === "'") return scanString(s, i);
234
+ if (startsIdent(s, i)) return scanIdent(s, i);
235
+ throw syntax(src, 'attribute value');
236
+ }
237
+ // The operator at `i`: { op, end }.
238
+ function scanAttrOp(s, i, src) {
239
+ if (ATTR_OP[s[i]] !== undefined && s[i + 1] === '=') return { op: ATTR_OP[s[i]], end: i + 2 };
240
+ if (s[i] === '=') return { op: 'equals', end: i + 1 };
241
+ throw syntax(src, 'attribute operator');
242
+ }
243
+ // The optional modifier at `i` (only `i`), then the end of the selector.
244
+ function parseAttrModifier(attr, s, i, src) {
245
+ if (i >= s.length) return;
246
+ const m = s[i];
247
+ if (m === 'i' || m === 'I') attr.ci = true;
248
+ else throw syntax(src, 'attribute modifier');
249
+ if (skipWsAt(s, i + 1) < s.length) throw syntax(src, 'attribute selector');
250
+ }
204
251
  function parseAttr(raw, src) {
205
252
  const s = raw;
206
- let i = 0;
207
- const skip = () => { while (i < s.length && isWs(s[i])) i++; };
208
- skip();
253
+ let i = skipWsAt(s, 0);
209
254
  let ns = null;
210
255
  if (s[i] === '*' && s[i + 1] === '|') { ns = 'any'; i += 2; }
211
256
  else if (s[i] === '|' && s[i + 1] !== '=') { i += 1; }
@@ -217,33 +262,12 @@ function parseAttr(raw, src) {
217
262
  if (ns) attr.ns = ns;
218
263
  // the HTML-element lookup name, folded once (non-enumerable: the AST stays `{ name, match }`)
219
264
  Object.defineProperty(attr, 'lname', { value: nameTok.value.toLowerCase() });
220
- skip();
265
+ i = skipWsAt(s, i);
221
266
  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
- }
267
+ const op = scanAttrOp(s, i, src);
268
+ const v = scanAttrValue(s, skipWsAt(s, op.end), src);
269
+ attr.match = { op: op.op, value: v.value };
270
+ parseAttrModifier(attr, s, skipWsAt(s, v.end), src);
247
271
  return attr;
248
272
  }
249
273
 
@@ -317,13 +341,15 @@ const PLAIN_AFTER = new Map([
317
341
  ]);
318
342
  // May pseudo-class `p` follow pseudo-element `pe` ({ name, arg })? (Chrome 156; mirrors
319
343
  // the Rust allowed_after_pseudo_element.)
344
+ // After an ELEMENT_LIKE pseudo-element.
345
+ function allowedAfterElementLike(p) {
346
+ if (p.arg !== null) return p.name === 'state' || p.name === 'lang' || p.name === 'dir';
347
+ return ELEMENT_LIKE_PLAIN.has(p.name) || (NEVER.has(p.name) && !NOT_AFTER_ELEMENT_LIKE.has(p.name));
348
+ }
320
349
  function allowedAfter(pe, p) {
321
350
  const name = pe.name;
322
351
  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
- }
352
+ if (ELEMENT_LIKE.has(name)) return allowedAfterElementLike(p);
327
353
  if (p.arg !== null) return false;
328
354
  if (SCROLLBAR.has(name)) return AFTER_SCROLLBAR.has(p.name);
329
355
  if (name.startsWith('-webkit-') || (name === 'cue' && pe.arg === null)) return USER_ACTION.has(p.name);
@@ -359,32 +385,33 @@ function parseAnB(arg) {
359
385
  let sign = 1;
360
386
  if (s[0] === '+' || s[0] === '-') { sign = s[0] === '-' ? -1 : 1; i = 1; }
361
387
  const ds = i;
362
- while (i < s.length && isDigit(s[i])) i++;
388
+ i = skipDigits(s, i);
363
389
  const digits = s.slice(ds, i);
364
390
  if (s[i] !== 'n') {
365
391
  if (!digits || i !== s.length) return null;
366
392
  return { a: 0, b: clamp32(clampInt(sign, digits)) };
367
393
  }
368
394
  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++;
395
+ return parseAnBTail(s, i + 1, a);
396
+ }
397
+ // `n-123` is one ident / dimension unit: its integer (after the `n` at i - 1) is converted
398
+ // exactly and must fit.
399
+ function gluedIntegerFits(s, i) {
400
+ if (s[i] !== '-' || i + 1 >= s.length || !isDigit(s[i + 1])) return true;
401
+ return clampInt(-1, s.slice(i + 1, skipDigits(s, i + 1))) >= INT_MIN;
402
+ }
403
+ // The part after An's `n` (at i - 1): nothing, or a signed B.
404
+ function parseAnBTail(s, i, a) {
405
+ if (!gluedIntegerFits(s, i)) return null;
406
+ i = skipWsAt(s, i);
377
407
  if (i === s.length) return { a, b: 0 };
378
408
  if (s[i] !== '+' && s[i] !== '-') return null;
379
409
  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++;
410
+ const bd = skipWsAt(s, i + 1);
411
+ i = skipDigits(s, bd);
384
412
  if (i === bd) return null;
385
413
  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;
414
+ return skipWsAt(s, i) === s.length ? { a, b } : null;
388
415
  }
389
416
  // The `of` keyword of `:nth-child(An+B of S)`: lowercase, whitespace before it, a non-empty
390
417
  // An+B part, and no name code point right after it. Index or -1.
@@ -407,6 +434,61 @@ function nthMatch(a, b, index) {
407
434
  // ── lexer ───────────────────────────────────────────────────────────────────
408
435
  // Token kinds: ws | comma | comb (v) | type (ns, name) | id (v) | class (v) | attr |
409
436
  // pseudo (name, arg) | pelem (name, arg).
437
+ // The type / universal selector at `i` (src[i] is `*`, `|` or an identifier start), with
438
+ // an optional `*|` / `|` namespace prefix: pushes its token, returns the next index.
439
+ function scanTypeToken(src, i, tokens) {
440
+ const c = src[i];
441
+ let ns = null, name = null;
442
+ if (c === '*') i++;
443
+ else if (c !== '|') { const t = scanIdent(src, i); name = t.value; i = t.end; }
444
+ if (src[i] === '|' && src[i + 1] !== '=') {
445
+ if (name !== null) throw syntax(src, 'undeclared namespace prefix');
446
+ ns = c === '*' ? 'any' : 'none';
447
+ i++;
448
+ if (src[i] === '*') i++;
449
+ else if (startsIdent(src, i)) { const t = scanIdent(src, i); name = t.value; i = t.end; }
450
+ else throw syntax(src, 'type selector after a namespace prefix');
451
+ } else if (c === '|') throw syntax(src, `unexpected '|'`);
452
+ tokens.push({ k: 'type', ns, name: name === null ? null : name.toLowerCase() });
453
+ return i;
454
+ }
455
+ // The pseudo-class / pseudo-element at `i` (src[i] is `:`): pushes its token, returns the
456
+ // next index.
457
+ function scanPseudoToken(src, i, tokens) {
458
+ const n = src.length;
459
+ i++;
460
+ const element = src[i] === ':';
461
+ if (element) i++;
462
+ if (!startsIdent(src, i)) throw syntax(src, `unexpected ':'`);
463
+ const t = scanIdent(src, i);
464
+ const name = t.value.toLowerCase();
465
+ i = t.end;
466
+ let arg = null;
467
+ if (src[i] === '(') {
468
+ const { inner, end } = scanBalanced(src, i + 1, '(', ')');
469
+ arg = inner;
470
+ i = end < n ? end + 1 : n; // an unterminated argument closes at the end (CSS)
471
+ }
472
+ const legacy = !element && LEGACY_PSEUDO_ELEMENTS.has(name);
473
+ tokens.push({ k: element || legacy ? 'pelem' : 'pseudo', name, arg, legacy });
474
+ return i;
475
+ }
476
+ // The attribute selector at `i` (src[i] is `[`): pushes its token, returns the next index.
477
+ function scanAttrToken(src, i, tokens) {
478
+ const { inner, end } = scanBalanced(src, i + 1, '[', ']');
479
+ if (end >= src.length) throw new SyntaxError(`unterminated attribute selector: ${src.slice(i)}`);
480
+ tokens.push({ k: 'attr', attr: parseAttr(inner, src) });
481
+ return end + 1;
482
+ }
483
+ // The id / class selector at `i` (src[i] is `#` / `.`): pushes its token, returns the next
484
+ // index.
485
+ function scanHashOrClassToken(src, i, tokens) {
486
+ const c = src[i];
487
+ if (!startsIdent(src, i + 1)) throw syntax(src, `unexpected '${c}'`);
488
+ const t = scanIdent(src, i + 1);
489
+ tokens.push({ k: c === '#' ? 'id' : 'class', v: t.value });
490
+ return t.end;
491
+ }
410
492
  function tokenize(src) {
411
493
  const tokens = [];
412
494
  const n = src.length;
@@ -414,53 +496,15 @@ function tokenize(src) {
414
496
  while (i < n) {
415
497
  const c = src[i];
416
498
  if (isWs(c)) {
417
- while (i < n && isWs(src[i])) i++;
499
+ i = skipWsAt(src, i);
418
500
  tokens.push({ k: 'ws' });
419
501
  } else if (c === ',') { tokens.push({ k: 'comma' }); i++; }
420
502
  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 === '[') {
441
- const { inner, end } = scanBalanced(src, i + 1, '[', ']');
442
- if (end >= n) throw new SyntaxError(`unterminated attribute selector: ${src.slice(i)}`);
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;
453
- let arg = null;
454
- if (src[i] === '(') {
455
- const { inner, end } = scanBalanced(src, i + 1, '(', ')');
456
- arg = inner;
457
- i = end < n ? end + 1 : n; // an unterminated argument closes at the end (CSS)
458
- }
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`);
463
- }
503
+ else if (c === '*' || c === '|' || startsIdent(src, i)) i = scanTypeToken(src, i, tokens);
504
+ else if (c === '#' || c === '.') i = scanHashOrClassToken(src, i, tokens);
505
+ else if (c === '[') i = scanAttrToken(src, i, tokens);
506
+ else if (c === ':') i = scanPseudoToken(src, i, tokens);
507
+ else throw new SyntaxError(`unexpected '${c}' in selector`);
464
508
  }
465
509
  return tokens;
466
510
  }
@@ -479,45 +523,46 @@ function parsePseudo(tok, src, opts) {
479
523
  const { inHas, inCompound = false } = opts;
480
524
  const { name, arg } = tok;
481
525
  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
- }
526
+ const parsed = arg === null ? parsePlainPseudo(name, src) : parseFunctionalPseudo(name, arg, src, inHas, inCompound);
518
527
  if (parsed) Object.defineProperty(p, 'parsed', { value: parsed });
519
528
  return p;
520
529
  }
530
+ // An argument-less pseudo-class: its `parsed` (null for an implemented one).
531
+ function parsePlainPseudo(name, src) {
532
+ if (PLAIN.has(name)) return null;
533
+ if (NEVER.has(name)) return { never: true };
534
+ throw syntax(src, `unknown pseudo-class :${name}`);
535
+ }
536
+ const NTH = new Set(['nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type']);
537
+ // A functional pseudo-class: its `parsed`.
538
+ function parseFunctionalPseudo(name, arg, src, inHas, inCompound) {
539
+ if (PLAIN.has(name)) throw syntax(src, `:${name}() takes no argument`);
540
+ if (NEVER_FUNCTIONAL.has(name)) return { never: true };
541
+ if (name === 'not') return { list: parseList(arg, { nested: true, forgiving: false, inHas, compound: inCompound, inCompound }) };
542
+ if (name === 'is' || name === 'where') return { list: parseList(arg, { nested: true, forgiving: true, inHas, compound: inCompound, inCompound }) };
543
+ // unforgiving compound selectors, no :has() (a nested :is() drops items with one)
544
+ if (name === '-webkit-any') return { list: parseList(arg, { nested: true, forgiving: false, inHas: true, compound: true, inCompound: true }) };
545
+ if (name === 'has') {
546
+ if (inHas) throw syntax(src, ':has() inside :has()');
547
+ return { rel: parseRelative(arg, src) };
548
+ }
549
+ if (NTH.has(name)) return parseNthPseudo(name, arg, src, inHas, inCompound);
550
+ if (name === 'lang' || name === 'dir') return { v: identArg(name, arg, src).toLowerCase() };
551
+ if (name === 'state') return { v: identArg(name, arg, src) };
552
+ throw syntax(src, `unknown pseudo-class :${name}()`);
553
+ }
554
+ // The one identifier argument of `:name()` (throws otherwise).
555
+ function identArg(name, arg, src) {
556
+ const v = identValue(trimWs(arg));
557
+ if (v === null) throw syntax(src, `:${name}() takes one identifier`);
558
+ return v;
559
+ }
560
+ function parseNthPseudo(name, arg, src, inHas, inCompound) {
561
+ const at = name.endsWith('child') ? nthOfPosition(arg) : -1;
562
+ const anb = parseAnB(at === -1 ? arg : arg.slice(0, at));
563
+ if (anb === null) throw syntax(src, `:${name}() argument`);
564
+ return { a: anb.a, b: anb.b, of: at === -1 ? null : parseList(arg.slice(at + 2), { nested: true, forgiving: false, inHas, inCompound }) };
565
+ }
521
566
 
522
567
  // A view-transition pseudo-element argument (<pt-name-and-class-selector>): `*` or a name,
523
568
  // then `.class` parts (no space after the dot), or only class parts; whitespace may
@@ -592,10 +637,75 @@ function parsePseudoElement(tok, src) {
592
637
  // Parse tokens[lo..hi) (one complex selector, commas already split out). Throws on an empty
593
638
  // item, a dangling combinator, a type selector after another simple selector, and a
594
639
  // pseudo-element anywhere but at the end of the last compound.
640
+ // The last pseudo-element of `pe`'s chain (`pe` itself without sub-pseudo-elements).
641
+ function lastPseudoElement(pe) {
642
+ return pe.chain ? pe.chain[pe.chain.length - 1] : pe;
643
+ }
644
+ // A pseudo-element token `p` of `compound`: the compound's pseudo-element, or a
645
+ // sub-pseudo-element (`::slotted(li)::marker`: valid, never matched here) of it.
646
+ function addPseudoElement(compound, p, src, opts) {
647
+ if (opts.nested) throw syntax(src, 'pseudo-element inside a pseudo-class argument');
648
+ const next = parsePseudoElement(p, src);
649
+ const pe = compound.pe;
650
+ if (pe === null) { compound.pe = next; return; }
651
+ const last = lastPseudoElement(pe);
652
+ if (!allowedSub(last.name, next.name)) throw syntax(src, `::${next.name} after ::${last.name}`);
653
+ if (!pe.chain) Object.defineProperty(pe, 'chain', { value: [] });
654
+ pe.chain.push(next);
655
+ }
656
+ // A simple-selector token `p` after the compound's pseudo-element: only the pseudo-classes
657
+ // Chrome allows there (the selector never matches an element either way) and further
658
+ // pseudo-elements.
659
+ function addAfterPseudoElement(compound, p, src, opts) {
660
+ if (p.k === 'pelem') { addPseudoElement(compound, p, src, opts); return; }
661
+ if (p.k !== 'pseudo') throw syntax(src, 'simple selector after a pseudo-element');
662
+ const last = lastPseudoElement(compound.pe);
663
+ if (!allowedAfter(last, p)) throw syntax(src, `:${p.name} after ::${last.name}`);
664
+ compound.pseudos.push({ name: p.name, arg: p.arg });
665
+ }
666
+ // Add simple-selector token `p` (the `parts`-th of its compound) to `compound`.
667
+ function addSimple(compound, p, parts, src, opts) {
668
+ if (compound.pe !== null) { addAfterPseudoElement(compound, p, src, opts); return; }
669
+ if (p.k === 'type') {
670
+ if (parts) throw syntax(src, 'type selector after another simple selector');
671
+ compound.tag = p.name === null ? '*' : p.name;
672
+ compound.ns = p.ns;
673
+ } else if (p.k === 'id') compound.id = p.v;
674
+ else if (p.k === 'class') compound.classes.push(p.v);
675
+ else if (p.k === 'attr') compound.attrs.push(p.attr);
676
+ else if (p.k === 'pelem') addPseudoElement(compound, p, src, opts);
677
+ else compound.pseudos.push(parsePseudo(p, src, opts));
678
+ }
679
+ // Fill `compound` from tokens[pos..end) up to the next whitespace / combinator; returns
680
+ // the index of that token (or `end`).
681
+ function parseCompound(tokens, pos, end, src, opts, compound) {
682
+ const start = pos;
683
+ for (; pos < end; pos++) {
684
+ const p = tokens[pos];
685
+ if (p.k === 'ws' || p.k === 'comb') break;
686
+ addSimple(compound, p, pos - start, src, opts);
687
+ }
688
+ return pos;
689
+ }
690
+ function firstNonWs(tokens, lo, hi) {
691
+ while (lo < hi && tokens[lo].k === 'ws') lo++;
692
+ return lo;
693
+ }
694
+ function endNonWs(tokens, lo, hi) {
695
+ while (hi > lo && tokens[hi - 1].k === 'ws') hi--;
696
+ return hi;
697
+ }
698
+ // Whitespace after a compound is the descendant combinator unless another follows.
699
+ function afterWhitespace(combinator, first) {
700
+ return combinator === null && first !== null ? ' ' : combinator;
701
+ }
702
+ // A pending combinator other than the descendant one has no compound after it.
703
+ function isDangling(combinator) {
704
+ return combinator !== null && combinator !== ' ';
705
+ }
595
706
  function parseComplexTokens(tokens, lo, hi, src, opts) {
596
- let start = lo, end = hi;
597
- while (start < end && tokens[start].k === 'ws') start++;
598
- while (end > start && tokens[end - 1].k === 'ws') end--;
707
+ const start = firstNonWs(tokens, lo, hi);
708
+ const end = endNonWs(tokens, start, hi);
599
709
  if (start >= end) throw syntax(src, 'empty selector');
600
710
  let first = null;
601
711
  const rest = [];
@@ -604,9 +714,9 @@ function parseComplexTokens(tokens, lo, hi, src, opts) {
604
714
  let sawPe = false;
605
715
  while (pos < end) {
606
716
  const t = tokens[pos];
607
- if (t.k === 'ws') { pos++; if (combinator === null && first !== null) combinator = ' '; continue; }
717
+ if (t.k === 'ws') { pos++; combinator = afterWhitespace(combinator, first); continue; }
608
718
  if (t.k === 'comb') {
609
- if (first === null || (combinator !== null && combinator !== ' ')) throw syntax(src, 'dangling combinator');
719
+ if (first === null || isDangling(combinator)) throw syntax(src, 'dangling combinator');
610
720
  combinator = t.v;
611
721
  pos++;
612
722
  continue;
@@ -616,47 +726,13 @@ function parseComplexTokens(tokens, lo, hi, src, opts) {
616
726
  // one object shape for every compound (monomorphic matching): `ns` is null / 'any' /
617
727
  // 'none', `pe` the pseudo-element or null
618
728
  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++;
653
- }
654
- if (pe !== null) { compound.pe = pe; sawPe = true; }
729
+ pos = parseCompound(tokens, pos, end, src, opts, compound);
730
+ if (compound.pe !== null) sawPe = true;
655
731
  if (first === null) first = compound;
656
732
  else rest.push({ combinator: combinator === null ? ' ' : combinator, compound });
657
733
  combinator = null;
658
734
  }
659
- if (combinator !== null && combinator !== ' ') throw syntax(src, 'dangling combinator');
735
+ if (isDangling(combinator)) throw syntax(src, 'dangling combinator');
660
736
  if (opts.compound && rest.length) throw syntax(src, 'combinator inside :-webkit-any()');
661
737
  return { first, rest };
662
738
  }
@@ -860,19 +936,25 @@ function disabledState(el) {
860
936
  return actuallyDisabled(el);
861
937
  case 'optgroup':
862
938
  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
- }
939
+ case 'option':
940
+ return optionDisabled(el);
872
941
  default:
873
942
  return undefined;
874
943
  }
875
944
  }
945
+ function isElementNamed(n, localName) {
946
+ return !!n && n.nodeType === 1 && n.localName === localName;
947
+ }
948
+ // An option is disabled by its own attribute, its optgroup's or a disabled select.
949
+ function optionDisabled(el) {
950
+ if (el.hasAttribute('disabled')) return true;
951
+ let p = el.parentNode;
952
+ if (isElementNamed(p, 'optgroup')) {
953
+ if (p.hasAttribute('disabled')) return true;
954
+ p = p.parentNode;
955
+ }
956
+ return isElementNamed(p, 'select') && actuallyDisabled(p);
957
+ }
876
958
  function isEditable(el) {
877
959
  for (let n = el; n && n.nodeType === 1; n = n.parentNode) {
878
960
  if (!isHtml(n)) continue;
@@ -1014,12 +1096,11 @@ function matchAttr(el, a) {
1014
1096
  // The element `:scope` matches during the current match (null = stylesheet: `:root`).
1015
1097
  let currentScope = null;
1016
1098
 
1099
+ // matchPseudo dispatches by family: tree-structural here, then the selector-argument and
1100
+ // language ones (matchLogicalPseudo), then element state (matchStatePseudo).
1017
1101
  function matchPseudo(el, p) {
1018
1102
  const parsed = p.parsed;
1019
1103
  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
1104
  // O(1) sibling steps (a parentless element is its own only sibling)
1024
1105
  case 'first-child': return previousElement(el) === null;
1025
1106
  case 'last-child': return nextElement(el) === null;
@@ -1027,41 +1108,64 @@ function matchPseudo(el, p) {
1027
1108
  case 'first-of-type': return position(el, true)[0] === 1;
1028
1109
  case 'last-of-type': { const [i, n] = position(el, true); return i === n; }
1029
1110
  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
- }
1111
+ case 'nth-child': return matchNthChild(el, parsed, false);
1112
+ case 'nth-last-child': return matchNthChild(el, parsed, true);
1039
1113
  case 'nth-of-type': case 'nth-last-of-type': {
1040
1114
  const [i, n] = position(el, true);
1041
1115
  return nthMatch(parsed.a, parsed.b, p.name === 'nth-last-of-type' ? n - i + 1 : i);
1042
1116
  }
1117
+ case 'scope': return currentScope === null ? isRootElement(el) : el === currentScope;
1118
+ default: return matchLogicalPseudo(el, p);
1119
+ }
1120
+ }
1121
+ function isRootElement(el) {
1122
+ return !!el.parentNode && el.parentNode.nodeType === 9;
1123
+ }
1124
+ function matchNthChild(el, parsed, last) {
1125
+ if (parsed.of) {
1126
+ if (!parsed.of.some((cx) => matchComplex(el, cx))) return false;
1127
+ return nthMatch(parsed.a, parsed.b, positionOf(el, parsed.of, last));
1128
+ }
1129
+ const [i, n] = position(el, false);
1130
+ return nthMatch(parsed.a, parsed.b, last ? n - i + 1 : i);
1131
+ }
1132
+ function matchLogicalPseudo(el, p) {
1133
+ const parsed = p.parsed;
1134
+ switch (p.name) {
1135
+ case 'not': return !parsed.list.some((cx) => matchComplex(el, cx));
1136
+ case 'is': case 'where': case '-webkit-any': return parsed.list.some((cx) => matchComplex(el, cx));
1137
+ case 'has': return parsed.rel.some((rel) => hasMatch(el, rel));
1138
+ case 'link': case 'any-link': return isLink(el);
1139
+ case 'defined': return isDefinedElement(el);
1140
+ case 'lang': return langMatches(el, parsed.v);
1141
+ case 'dir': return (parsed.v === 'rtl' || parsed.v === 'ltr') && isRtl(el) === (parsed.v === 'rtl');
1142
+ default: return matchStatePseudo(el, p);
1143
+ }
1144
+ }
1145
+ // element state; form state reads the live PROPERTY first (what React sets), then the attribute
1146
+ function matchStatePseudo(el, p) {
1147
+ switch (p.name) {
1043
1148
  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');
1149
+ case 'root': return isRootElement(el);
1150
+ case 'checked': return isChecked(el);
1050
1151
  case 'disabled': return disabledState(el) === true;
1051
1152
  case 'enabled': return disabledState(el) === false;
1052
1153
  case 'required': return boolAttr(el, 'required', 'required');
1053
- case 'optional': return (el.localName === 'input' || el.localName === 'select' || el.localName === 'textarea') && !boolAttr(el, 'required', 'required');
1154
+ case 'optional': return isOptional(el);
1054
1155
  case 'read-only': return isHtml(el) && !readWrite(el);
1055
1156
  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
1157
  // `:state()` parses; this runtime has no ElementInternals custom states, so it never
1061
1158
  // matches (falls through to the never-matching default)
1062
1159
  default: return false; // the valid never-matching pseudo-classes (:hover, :focus, …)
1063
1160
  }
1064
1161
  }
1162
+ function isChecked(el) {
1163
+ if (el.localName === 'option') return !!el.selected;
1164
+ return el.localName === 'input' && (inputType(el) === 'checkbox' || inputType(el) === 'radio') && boolAttr(el, 'checked', 'checked');
1165
+ }
1166
+ function isOptional(el) {
1167
+ return (el.localName === 'input' || el.localName === 'select' || el.localName === 'textarea') && !boolAttr(el, 'required', 'required');
1168
+ }
1065
1169
 
1066
1170
  // allocation-free "does the class attribute contain this whole-word class"
1067
1171
  function hasClass(cn, cls) {
@@ -1077,24 +1181,29 @@ function hasClass(cn, cls) {
1077
1181
  return false;
1078
1182
  }
1079
1183
 
1184
+ // The type selector `tag` (null = none, '*' = universal) against `el`.
1185
+ function tagMatches(el, tag) {
1186
+ if (tag === null || tag === '*') return true;
1187
+ const ln = el.localName;
1188
+ // type selectors compare ASCII case-insensitively (as the Rust engine); HTML local
1189
+ // names are already lowercase, so only foreign elements pay for the fold
1190
+ return ln === tag || (!!el.__ns && ln.toLowerCase() === tag);
1191
+ }
1192
+ // classes: read the class attribute ONCE and test membership without allocating
1193
+ function classesMatch(el, classes) {
1194
+ const cn = plainAttr(el, 'class');
1195
+ if (!cn) return false;
1196
+ for (let i = 0; i < classes.length; i++) if (!hasClass(cn, classes[i])) return false;
1197
+ return true;
1198
+ }
1199
+
1080
1200
  function matchCompound(el, compound) {
1081
1201
  if (!el || el.nodeType !== 1) return false;
1082
1202
  if (compound.pe) return false; // a pseudo-element is never an element
1083
1203
  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
- }
1204
+ if (!tagMatches(el, compound.tag)) return false;
1091
1205
  if (compound.id !== null && plainAttr(el, 'id') !== compound.id) return false;
1092
- // classes: read the class attribute ONCE and test membership without allocating
1093
- if (compound.classes.length) {
1094
- const cn = plainAttr(el, 'class');
1095
- if (!cn) return false;
1096
- for (const cls of compound.classes) if (!hasClass(cn, cls)) return false;
1097
- }
1206
+ if (compound.classes.length && !classesMatch(el, compound.classes)) return false;
1098
1207
  for (const a of compound.attrs) if (!matchAttr(el, a)) return false;
1099
1208
  for (const p of compound.pseudos) if (!matchPseudo(el, p)) return false;
1100
1209
  return true;
@@ -1113,7 +1222,8 @@ function matchComplex(el, cx) {
1113
1222
  // Match compounds 0..=k of `cx` ending at `el`, recursing leftward. Descendant (' ') and
1114
1223
  // general-sibling ('~') combinators try EVERY candidate ancestor/sibling and backtrack.
1115
1224
  // `scope` supports `:has()`: `{ comb, node }` anchors the leftmost compound to the subject.
1116
- function matchChain(el, cx, k, scope = null) {
1225
+ // (Every caller passes `scope`: null outside `:has()`.)
1226
+ function matchChain(el, cx, k, scope) {
1117
1227
  if (!matchCompound(el, compoundAt(cx, k))) return false;
1118
1228
  if (k === 0) return scope === null ? true : relatesToScope(el, scope.comb, scope.node);
1119
1229
  const comb = cx.rest[k - 1].combinator;
@@ -1125,14 +1235,20 @@ function matchChain(el, cx, k, scope = null) {
1125
1235
  const prev = previousElement(el);
1126
1236
  return !!prev && matchChain(prev, cx, k - 1, scope);
1127
1237
  }
1128
- if (comb === ' ') {
1129
- for (let anc = el.parentNode; anc && anc.nodeType === 1; anc = anc.parentNode) {
1130
- if (matchChain(anc, cx, k - 1, scope)) return true;
1131
- }
1132
- return false;
1238
+ if (comb === ' ') return someAncestorMatches(el, cx, k - 1, scope);
1239
+ return somePrecedingSiblingMatches(el, cx, k - 1, scope);
1240
+ }
1241
+ // The descendant combinator: compounds 0..=k end at SOME element ancestor of `el`.
1242
+ function someAncestorMatches(el, cx, k, scope) {
1243
+ for (let anc = el.parentNode; anc && anc.nodeType === 1; anc = anc.parentNode) {
1244
+ if (matchChain(anc, cx, k, scope)) return true;
1133
1245
  }
1246
+ return false;
1247
+ }
1248
+ // The general-sibling combinator: compounds 0..=k end at SOME preceding element sibling.
1249
+ function somePrecedingSiblingMatches(el, cx, k, scope) {
1134
1250
  for (let prev = previousElement(el); prev; prev = previousElement(prev)) {
1135
- if (matchChain(prev, cx, k - 1, scope)) return true;
1251
+ if (matchChain(prev, cx, k, scope)) return true;
1136
1252
  }
1137
1253
  return false;
1138
1254
  }