@designesy/score 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/engine.js +2350 -264
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -267,17 +267,2046 @@ function httpsFetch(url, headers, timeoutMs = 8000, hop = 0) {
|
|
|
267
267
|
}
|
|
268
268
|
});
|
|
269
269
|
}
|
|
270
|
+
// ── Linear-time scanning, shared by both engine copies (engine 1.5.1) ──────
|
|
271
|
+
// Each helper below returns exactly what the regular expression it replaced
|
|
272
|
+
// returned: the same matches at the same indexes with the same groups, or the
|
|
273
|
+
// same string after the same replace. The replaced patterns retried from every
|
|
274
|
+
// candidate start and rescanned the rest of the run each time, so their cost
|
|
275
|
+
// grew with the square (one url() pattern with the cube) of a long run of the
|
|
276
|
+
// right characters on a fetched page. These read each run once.
|
|
277
|
+
// test/linear-scan.test.mjs runs every helper against the pattern it replaced,
|
|
278
|
+
// over the fixture pages and generated input, and times each on 100 KB.
|
|
279
|
+
/** JavaScript's \s: WhiteSpace and LineTerminator code units. */
|
|
280
|
+
function isJsSpace(c) {
|
|
281
|
+
return c === 32 || (c >= 9 && c <= 13) || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a)
|
|
282
|
+
|| c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff;
|
|
283
|
+
}
|
|
284
|
+
/** The index after the run of \s that starts at `i`. */
|
|
285
|
+
function wsEnd(s, i) {
|
|
286
|
+
while (i < s.length && isJsSpace(s.charCodeAt(i)))
|
|
287
|
+
i++;
|
|
288
|
+
return i;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Spans of OPEN[\s\S]*?CLOSE, global and leftmost, as [start, end, ...] pairs.
|
|
292
|
+
* `open` matches a fixed-length opener, `close` the closer; both global. A
|
|
293
|
+
* closer is looked for once per opener, and when there is none after one
|
|
294
|
+
* opener there is none after any later one, so the scan stops there.
|
|
295
|
+
*/
|
|
296
|
+
function lazyBlockSpans(text, open, close) {
|
|
297
|
+
const spans = [];
|
|
298
|
+
let pos = 0;
|
|
299
|
+
for (;;) {
|
|
300
|
+
open.lastIndex = pos;
|
|
301
|
+
const o = open.exec(text);
|
|
302
|
+
if (!o)
|
|
303
|
+
break;
|
|
304
|
+
close.lastIndex = o.index + o[0].length;
|
|
305
|
+
const c = close.exec(text);
|
|
306
|
+
if (!c)
|
|
307
|
+
break;
|
|
308
|
+
spans.push(o.index, c.index + c[0].length);
|
|
309
|
+
pos = c.index + c[0].length;
|
|
310
|
+
}
|
|
311
|
+
return spans;
|
|
312
|
+
}
|
|
313
|
+
/** `text` with each [start, end) span replaced by `rep`. */
|
|
314
|
+
function replaceSpans(text, spans, rep) {
|
|
315
|
+
if (spans.length === 0)
|
|
316
|
+
return text;
|
|
317
|
+
const parts = [];
|
|
318
|
+
let last = 0;
|
|
319
|
+
for (let k = 0; k < spans.length; k += 2) {
|
|
320
|
+
parts.push(text.slice(last, spans[k]), rep);
|
|
321
|
+
last = spans[k + 1];
|
|
322
|
+
}
|
|
323
|
+
parts.push(text.slice(last));
|
|
324
|
+
return parts.join('');
|
|
325
|
+
}
|
|
326
|
+
/** html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '') */
|
|
327
|
+
function withoutScriptsAndStyles(html) {
|
|
328
|
+
const noScripts = replaceSpans(html, lazyBlockSpans(html, /<script/gi, /<\/script>/gi), '');
|
|
329
|
+
return replaceSpans(noScripts, lazyBlockSpans(noScripts, /<style/gi, /<\/style>/gi), '');
|
|
330
|
+
}
|
|
331
|
+
/** css.replace(/\/\*[\s\S]*?\*\//g, rep) */
|
|
332
|
+
function withoutCssComments(css, rep) {
|
|
333
|
+
return replaceSpans(css, lazyBlockSpans(css, /\/\*/g, /\*\//g), rep);
|
|
334
|
+
}
|
|
335
|
+
/** text.replace(/<[^>]*>/g, rep), or /<[^>]+>/g when `nonEmpty`. */
|
|
336
|
+
function withoutTags(text, rep, nonEmpty) {
|
|
337
|
+
const spans = [];
|
|
338
|
+
let pos = 0;
|
|
339
|
+
for (;;) {
|
|
340
|
+
const lt = text.indexOf('<', pos);
|
|
341
|
+
if (lt < 0)
|
|
342
|
+
break;
|
|
343
|
+
if (nonEmpty && text.charCodeAt(lt + 1) === 62) {
|
|
344
|
+
pos = lt + 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const gt = text.indexOf('>', lt + 1);
|
|
348
|
+
if (gt < 0)
|
|
349
|
+
break;
|
|
350
|
+
spans.push(lt, gt + 1);
|
|
351
|
+
pos = gt + 1;
|
|
352
|
+
}
|
|
353
|
+
return replaceSpans(text, spans, rep);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Matches of <TAG[^>]*>([\s\S]*?)<\/TAG> (global): `open` matches `<TAG`,
|
|
357
|
+
* `close` the closing tag, both global. groups[0] is the content.
|
|
358
|
+
*/
|
|
359
|
+
function elementMatches(html, open, close) {
|
|
360
|
+
const out = [];
|
|
361
|
+
let pos = 0;
|
|
362
|
+
for (;;) {
|
|
363
|
+
open.lastIndex = pos;
|
|
364
|
+
const o = open.exec(html);
|
|
365
|
+
if (!o)
|
|
366
|
+
break;
|
|
367
|
+
const gt = html.indexOf('>', o.index + o[0].length);
|
|
368
|
+
if (gt < 0)
|
|
369
|
+
break;
|
|
370
|
+
close.lastIndex = gt + 1;
|
|
371
|
+
const c = close.exec(html);
|
|
372
|
+
if (!c)
|
|
373
|
+
break;
|
|
374
|
+
const end = c.index + c[0].length;
|
|
375
|
+
out.push({ index: o.index, text: html.slice(o.index, end), groups: [html.slice(gt + 1, c.index)] });
|
|
376
|
+
pos = end;
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Matches of <NAME[^>]*ATTR[^>]*>([\s\S]*?)CLOSE (global). `open` matches `<`
|
|
382
|
+
* and as much of the name as must precede the attribute; `attr` and `close`
|
|
383
|
+
* are global. An opener whose tag holds no ATTR rules out every opener before
|
|
384
|
+
* the same `>`; one with no CLOSE after it rules out every later one.
|
|
385
|
+
*/
|
|
386
|
+
function roleElementMatches(html, open, attr, close) {
|
|
387
|
+
const out = [];
|
|
388
|
+
let pos = 0;
|
|
389
|
+
let attrFrom = -1;
|
|
390
|
+
let attrAt = -1;
|
|
391
|
+
for (;;) {
|
|
392
|
+
open.lastIndex = pos;
|
|
393
|
+
const o = open.exec(html);
|
|
394
|
+
if (!o)
|
|
395
|
+
break;
|
|
396
|
+
const gt = html.indexOf('>', o.index + 1);
|
|
397
|
+
if (gt < 0)
|
|
398
|
+
break;
|
|
399
|
+
const from = o.index + o[0].length;
|
|
400
|
+
if (!(attrFrom >= 0 && from >= attrFrom && from <= attrAt)) {
|
|
401
|
+
attr.lastIndex = from;
|
|
402
|
+
const a = attr.exec(html);
|
|
403
|
+
attrFrom = from;
|
|
404
|
+
attrAt = a ? a.index : Infinity;
|
|
405
|
+
}
|
|
406
|
+
if (attrAt >= gt) {
|
|
407
|
+
pos = gt + 1;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
close.lastIndex = gt + 1;
|
|
411
|
+
const c = close.exec(html);
|
|
412
|
+
if (!c)
|
|
413
|
+
break;
|
|
414
|
+
const end = c.index + c[0].length;
|
|
415
|
+
out.push({ index: o.index, text: html.slice(o.index, end), groups: [html.slice(gt + 1, c.index)] });
|
|
416
|
+
pos = end;
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
/** v41: matches of /<(button|a|label|td|th|p|li|h[1-6])\s([^>]*?)>([\s\S]*?)<\/\1>/gi, groups [tag, attrs, content]. */
|
|
421
|
+
function capsElementMatches(html) {
|
|
422
|
+
const out = [];
|
|
423
|
+
const open = /<(button|a|label|td|th|p|li|h[1-6])\s/gi;
|
|
424
|
+
const closers = {};
|
|
425
|
+
const noCloserFrom = {};
|
|
426
|
+
let gtFrom = -1;
|
|
427
|
+
let gtAt = -1;
|
|
428
|
+
let pos = 0;
|
|
429
|
+
for (;;) {
|
|
430
|
+
open.lastIndex = pos;
|
|
431
|
+
const o = open.exec(html);
|
|
432
|
+
if (!o)
|
|
433
|
+
break;
|
|
434
|
+
const from = o.index + o[0].length;
|
|
435
|
+
if (!(gtFrom >= 0 && from >= gtFrom && from <= gtAt)) {
|
|
436
|
+
gtFrom = from;
|
|
437
|
+
gtAt = html.indexOf('>', from);
|
|
438
|
+
}
|
|
439
|
+
if (gtAt < 0)
|
|
440
|
+
break;
|
|
441
|
+
const tag = o[1].toLowerCase();
|
|
442
|
+
if (noCloserFrom[tag] !== undefined && gtAt + 1 >= noCloserFrom[tag]) {
|
|
443
|
+
pos = o.index + 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const close = closers[tag] ?? (closers[tag] = new RegExp(`</${tag}>`, 'gi'));
|
|
447
|
+
close.lastIndex = gtAt + 1;
|
|
448
|
+
const c = close.exec(html);
|
|
449
|
+
if (!c) {
|
|
450
|
+
noCloserFrom[tag] = gtAt + 1;
|
|
451
|
+
pos = o.index + 1;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const end = c.index + c[0].length;
|
|
455
|
+
out.push({ index: o.index, text: html.slice(o.index, end), groups: [o[1], html.slice(from, gtAt), html.slice(gtAt + 1, c.index)] });
|
|
456
|
+
pos = end;
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* /NAME[^"']*NEEDLE[^"']*["']/i.test(attrs), where `name` (global) matches
|
|
462
|
+
* the attribute name, `=` and its opening quote, and `needle` (global) holds
|
|
463
|
+
* no quote: true when NEEDLE sits wholly inside one quoted value that closes.
|
|
464
|
+
*/
|
|
465
|
+
function quotedAttrHas(attrs, name, needle) {
|
|
466
|
+
let pos = 0;
|
|
467
|
+
let needleFrom = -1;
|
|
468
|
+
let needleAt = -1;
|
|
469
|
+
for (;;) {
|
|
470
|
+
name.lastIndex = pos;
|
|
471
|
+
const n = name.exec(attrs);
|
|
472
|
+
if (!n)
|
|
473
|
+
return false;
|
|
474
|
+
const from = n.index + n[0].length;
|
|
475
|
+
let q = from;
|
|
476
|
+
while (q < attrs.length && attrs[q] !== '"' && attrs[q] !== "'")
|
|
477
|
+
q++;
|
|
478
|
+
if (q >= attrs.length)
|
|
479
|
+
return false;
|
|
480
|
+
if (!(needleFrom >= 0 && from >= needleFrom && from <= needleAt)) {
|
|
481
|
+
needle.lastIndex = from;
|
|
482
|
+
const m = needle.exec(attrs);
|
|
483
|
+
needleFrom = from;
|
|
484
|
+
needleAt = m ? m.index : Infinity;
|
|
485
|
+
}
|
|
486
|
+
if (needleAt < q)
|
|
487
|
+
return true;
|
|
488
|
+
pos = n.index + 1;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/** The first match of a global regex at or after `from`, remembered while later queries fall before it. */
|
|
492
|
+
function nextMatchFinder(text, re) {
|
|
493
|
+
let memoFrom = -1;
|
|
494
|
+
let memoAt = -1;
|
|
495
|
+
return (from) => {
|
|
496
|
+
if (!(memoFrom >= 0 && from >= memoFrom && from <= memoAt)) {
|
|
497
|
+
re.lastIndex = from;
|
|
498
|
+
const m = re.exec(text);
|
|
499
|
+
memoFrom = from;
|
|
500
|
+
memoAt = m ? m.index : Infinity;
|
|
501
|
+
}
|
|
502
|
+
return memoAt;
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** Start positions of every match of a global regex, ascending. */
|
|
506
|
+
function matchStarts(text, re) {
|
|
507
|
+
const out = [];
|
|
508
|
+
re.lastIndex = 0;
|
|
509
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
510
|
+
out.push(m.index);
|
|
511
|
+
if (m[0].length === 0)
|
|
512
|
+
re.lastIndex++;
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Matches of START[^{]*\{[^}]*BODY (global): after each start, the rule body
|
|
518
|
+
* from the first `{` to the first `}` must hold BODY, and the match ends where
|
|
519
|
+
* BODY last matches in it (the greedy [^}]*). `next(from, lastEnd)` returns the
|
|
520
|
+
* first start at or after `from` whose prelude reaches a `{` (brace -1 when no
|
|
521
|
+
* later start can), or null; it is told where the previous match ended because
|
|
522
|
+
* some starts reach back before their first character. `lead` (global) finds
|
|
523
|
+
* where BODY can begin and `body` is BODY, sticky. Starts that share a body's
|
|
524
|
+
* `}` share its answer, so each body is read once. `first` stops at one match.
|
|
525
|
+
*/
|
|
526
|
+
function ruleBodyScan(css, next, lead, body, first) {
|
|
527
|
+
const out = [];
|
|
528
|
+
const leads = matchStarts(css, lead);
|
|
529
|
+
let ptr = 0;
|
|
530
|
+
let pos = 0;
|
|
531
|
+
let lastEnd = 0;
|
|
532
|
+
let deadUntil = -1;
|
|
533
|
+
for (;;) {
|
|
534
|
+
const st = next(pos, lastEnd);
|
|
535
|
+
if (!st || st.brace < 0)
|
|
536
|
+
break;
|
|
537
|
+
const b = st.brace;
|
|
538
|
+
if (b < deadUntil) {
|
|
539
|
+
pos = b + 1;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
let c = css.indexOf('}', b + 1);
|
|
543
|
+
if (c < 0)
|
|
544
|
+
c = css.length;
|
|
545
|
+
deadUntil = c;
|
|
546
|
+
while (ptr < leads.length && leads[ptr] <= b)
|
|
547
|
+
ptr++;
|
|
548
|
+
let end = ptr;
|
|
549
|
+
while (end < leads.length && leads[end] <= c)
|
|
550
|
+
end++;
|
|
551
|
+
let hit = null;
|
|
552
|
+
for (let k = end - 1; k >= ptr && !hit; k--) {
|
|
553
|
+
body.lastIndex = leads[k];
|
|
554
|
+
hit = body.exec(css);
|
|
555
|
+
}
|
|
556
|
+
ptr = end;
|
|
557
|
+
if (!hit) {
|
|
558
|
+
pos = b + 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const stop = hit.index + hit[0].length;
|
|
562
|
+
out.push({ index: st.index, text: css.slice(st.index, stop), groups: hit.slice(1) });
|
|
563
|
+
if (first)
|
|
564
|
+
break;
|
|
565
|
+
pos = stop;
|
|
566
|
+
lastEnd = stop;
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
/** next() for ruleBodyScan when the start is a plain pattern (global) followed by [^{]*\{. */
|
|
571
|
+
function bracedStarts(css, start) {
|
|
572
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
573
|
+
return (from) => {
|
|
574
|
+
start.lastIndex = from;
|
|
575
|
+
const m = start.exec(css);
|
|
576
|
+
if (!m)
|
|
577
|
+
return null;
|
|
578
|
+
const b = brace(m.index + m[0].length);
|
|
579
|
+
return { index: m.index, brace: b === Infinity ? -1 : b };
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** next() for v27's floor rule: input\s*(?:,[^{]*)?\{ */
|
|
583
|
+
function inputFloorStarts(css) {
|
|
584
|
+
const start = /input/gi;
|
|
585
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
586
|
+
return (from) => {
|
|
587
|
+
for (let at = from;;) {
|
|
588
|
+
start.lastIndex = at;
|
|
589
|
+
const m = start.exec(css);
|
|
590
|
+
if (!m)
|
|
591
|
+
return null;
|
|
592
|
+
const p = wsEnd(css, m.index + 5);
|
|
593
|
+
if (css.charCodeAt(p) === 123)
|
|
594
|
+
return { index: m.index, brace: p };
|
|
595
|
+
if (css.charCodeAt(p) === 44) {
|
|
596
|
+
const b = brace(p + 1);
|
|
597
|
+
return { index: m.index, brace: b === Infinity ? -1 : b };
|
|
598
|
+
}
|
|
599
|
+
at = m.index + 1;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** A LineTerminator code unit, where ^ matches under the m flag. */
|
|
604
|
+
function isLineTerminator(c) {
|
|
605
|
+
return c === 10 || c === 13 || c === 0x2028 || c === 0x2029;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* next() for v24's selector start (?:^|[,}\s])\s*(?:button|a\b|...) with the m
|
|
609
|
+
* flag: a keyword counts when the space before it begins on a line start, a
|
|
610
|
+
* space, a comma or a closing brace at or after the previous match's end.
|
|
611
|
+
*/
|
|
612
|
+
function touchTargetStarts(css) {
|
|
613
|
+
const keyword = /button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link/gi;
|
|
614
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
615
|
+
return (from, lastEnd) => {
|
|
616
|
+
for (let at = from;;) {
|
|
617
|
+
keyword.lastIndex = at;
|
|
618
|
+
const k = keyword.exec(css);
|
|
619
|
+
if (!k)
|
|
620
|
+
return null;
|
|
621
|
+
const q = k.index;
|
|
622
|
+
let w = q;
|
|
623
|
+
while (w > lastEnd && isJsSpace(css.charCodeAt(w - 1)))
|
|
624
|
+
w--;
|
|
625
|
+
let index = -1;
|
|
626
|
+
if (w - 1 >= lastEnd && (css.charCodeAt(w - 1) === 44 || css.charCodeAt(w - 1) === 125))
|
|
627
|
+
index = w - 1;
|
|
628
|
+
else if (w < q)
|
|
629
|
+
index = w;
|
|
630
|
+
else if (q >= lastEnd && (q === 0 || isLineTerminator(css.charCodeAt(q - 1))))
|
|
631
|
+
index = q;
|
|
632
|
+
if (index < 0) {
|
|
633
|
+
at = q + 1;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const b = brace(q + k[0].length);
|
|
637
|
+
return { index, brace: b === Infinity ? -1 : b };
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/** next() for O-motion's breath keyframes: @keyframes\s+[^{]*breath[^{]*\{ */
|
|
642
|
+
function breathKeyframeStarts(css) {
|
|
643
|
+
const start = /@keyframes/gi;
|
|
644
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
645
|
+
const breath = nextMatchFinder(css, /breath/gi);
|
|
646
|
+
return (from) => {
|
|
647
|
+
for (let at = from;;) {
|
|
648
|
+
start.lastIndex = at;
|
|
649
|
+
const m = start.exec(css);
|
|
650
|
+
if (!m)
|
|
651
|
+
return null;
|
|
652
|
+
const after = m.index + m[0].length;
|
|
653
|
+
if (!isJsSpace(css.charCodeAt(after))) {
|
|
654
|
+
at = m.index + 1;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const b = brace(after);
|
|
658
|
+
if (b === Infinity)
|
|
659
|
+
return { index: m.index, brace: -1 };
|
|
660
|
+
if (breath(after + 1) < b)
|
|
661
|
+
return { index: m.index, brace: b };
|
|
662
|
+
at = b + 1;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* /START[^{]*X/i.test(css): X (global, no `{`) begins in the prelude between
|
|
668
|
+
* a start and the first `{` after it. Only the first start of each prelude
|
|
669
|
+
* needs reading; later ones see less of it.
|
|
670
|
+
*/
|
|
671
|
+
function preludeHas(css, start, x) {
|
|
672
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
673
|
+
const found = nextMatchFinder(css, x);
|
|
674
|
+
for (let pos = 0;;) {
|
|
675
|
+
start.lastIndex = pos;
|
|
676
|
+
const m = start.exec(css);
|
|
677
|
+
if (!m)
|
|
678
|
+
return false;
|
|
679
|
+
const after = m.index + m[0].length;
|
|
680
|
+
const b = brace(after);
|
|
681
|
+
if (found(after) < b)
|
|
682
|
+
return true;
|
|
683
|
+
if (b === Infinity)
|
|
684
|
+
return false;
|
|
685
|
+
pos = b + 1;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Matches of START[^{]*X[^{]*\{ (global): X's last match in the prelude before
|
|
690
|
+
* the first `{` after the start; the match runs to that `{`. `lead` (global)
|
|
691
|
+
* finds where X can begin, `x` is X, sticky.
|
|
692
|
+
*/
|
|
693
|
+
function preludeMatches(css, start, lead, x) {
|
|
694
|
+
const out = [];
|
|
695
|
+
const leads = matchStarts(css, lead);
|
|
696
|
+
let ptr = 0;
|
|
697
|
+
for (let pos = 0;;) {
|
|
698
|
+
start.lastIndex = pos;
|
|
699
|
+
const m = start.exec(css);
|
|
700
|
+
if (!m)
|
|
701
|
+
break;
|
|
702
|
+
const after = m.index + m[0].length;
|
|
703
|
+
const b = css.indexOf('{', after);
|
|
704
|
+
if (b < 0)
|
|
705
|
+
break;
|
|
706
|
+
while (ptr < leads.length && leads[ptr] < after)
|
|
707
|
+
ptr++;
|
|
708
|
+
let end = ptr;
|
|
709
|
+
while (end < leads.length && leads[end] < b)
|
|
710
|
+
end++;
|
|
711
|
+
let hit = null;
|
|
712
|
+
for (let k = end - 1; k >= ptr && !hit; k--) {
|
|
713
|
+
x.lastIndex = leads[k];
|
|
714
|
+
hit = x.exec(css);
|
|
715
|
+
}
|
|
716
|
+
ptr = end;
|
|
717
|
+
if (hit)
|
|
718
|
+
out.push({ index: m.index, text: css.slice(m.index, b + 1), groups: hit.slice(1) });
|
|
719
|
+
pos = b + 1;
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
/** css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '') */
|
|
724
|
+
function withoutMediaBlocks(css) {
|
|
725
|
+
const start = /@media/gi;
|
|
726
|
+
const spans = [];
|
|
727
|
+
for (let pos = 0;;) {
|
|
728
|
+
start.lastIndex = pos;
|
|
729
|
+
const m = start.exec(css);
|
|
730
|
+
if (!m)
|
|
731
|
+
break;
|
|
732
|
+
const b = css.indexOf('{', m.index + m[0].length);
|
|
733
|
+
if (b < 0)
|
|
734
|
+
break;
|
|
735
|
+
let end = -1;
|
|
736
|
+
for (let k = b + 1; k < css.length; k++) {
|
|
737
|
+
const ch = css.charCodeAt(k);
|
|
738
|
+
if (ch === 64)
|
|
739
|
+
break;
|
|
740
|
+
if (ch === 125) {
|
|
741
|
+
const t = wsEnd(css, k + 1);
|
|
742
|
+
if (css.charCodeAt(t) === 125) {
|
|
743
|
+
end = t + 1;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (end < 0) {
|
|
749
|
+
pos = b + 1;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
spans.push(m.index, end);
|
|
753
|
+
pos = end;
|
|
754
|
+
}
|
|
755
|
+
return replaceSpans(css, spans, '');
|
|
756
|
+
}
|
|
757
|
+
/** css.replace(/@keyframes\s+[^{]+\{[^@]*?\}/gi, '') */
|
|
758
|
+
function withoutKeyframesBlocks(css) {
|
|
759
|
+
const start = /@keyframes/gi;
|
|
760
|
+
const spans = [];
|
|
761
|
+
for (let pos = 0;;) {
|
|
762
|
+
start.lastIndex = pos;
|
|
763
|
+
const m = start.exec(css);
|
|
764
|
+
if (!m)
|
|
765
|
+
break;
|
|
766
|
+
const after = m.index + m[0].length;
|
|
767
|
+
if (!isJsSpace(css.charCodeAt(after))) {
|
|
768
|
+
pos = m.index + 1;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const b = css.indexOf('{', after);
|
|
772
|
+
if (b < 0)
|
|
773
|
+
break;
|
|
774
|
+
if (b < after + 2) {
|
|
775
|
+
pos = m.index + 1;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
let end = -1;
|
|
779
|
+
for (let k = b + 1; k < css.length; k++) {
|
|
780
|
+
const ch = css.charCodeAt(k);
|
|
781
|
+
if (ch === 64)
|
|
782
|
+
break;
|
|
783
|
+
if (ch === 125) {
|
|
784
|
+
end = k + 1;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (end < 0) {
|
|
789
|
+
pos = b + 1;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
spans.push(m.index, end);
|
|
793
|
+
pos = end;
|
|
794
|
+
}
|
|
795
|
+
return replaceSpans(css, spans, '');
|
|
796
|
+
}
|
|
797
|
+
/** css.match(/::selection\s*\{[^}]*\}/gi) */
|
|
798
|
+
function selectionRules(css) {
|
|
799
|
+
const start = /::selection/gi;
|
|
800
|
+
const out = [];
|
|
801
|
+
for (let pos = 0;;) {
|
|
802
|
+
start.lastIndex = pos;
|
|
803
|
+
const m = start.exec(css);
|
|
804
|
+
if (!m)
|
|
805
|
+
break;
|
|
806
|
+
const b = wsEnd(css, m.index + m[0].length);
|
|
807
|
+
if (css.charCodeAt(b) !== 123) {
|
|
808
|
+
pos = m.index + 1;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const c = css.indexOf('}', b + 1);
|
|
812
|
+
if (c < 0)
|
|
813
|
+
break;
|
|
814
|
+
out.push(css.slice(m.index, c + 1));
|
|
815
|
+
pos = c + 1;
|
|
816
|
+
}
|
|
817
|
+
return out.length ? out : null;
|
|
818
|
+
}
|
|
819
|
+
/** The matches css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, fn) hands fn: groups [lead, value]. */
|
|
820
|
+
function fontSizeVarMatches(css) {
|
|
821
|
+
const start = /font-size/gi;
|
|
822
|
+
const varAt = /var\(/iy;
|
|
823
|
+
const out = [];
|
|
824
|
+
for (let pos = 0;;) {
|
|
825
|
+
start.lastIndex = pos;
|
|
826
|
+
const m = start.exec(css);
|
|
827
|
+
if (!m)
|
|
828
|
+
break;
|
|
829
|
+
const colon = wsEnd(css, m.index + m[0].length);
|
|
830
|
+
if (css.charCodeAt(colon) !== 58) {
|
|
831
|
+
pos = m.index + 1;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const v = wsEnd(css, colon + 1);
|
|
835
|
+
varAt.lastIndex = v;
|
|
836
|
+
if (!varAt.test(css)) {
|
|
837
|
+
pos = m.index + 1;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
let r = v + 4;
|
|
841
|
+
while (r < css.length) {
|
|
842
|
+
const ch = css.charCodeAt(r);
|
|
843
|
+
if (ch === 59 || ch === 123 || ch === 125)
|
|
844
|
+
break;
|
|
845
|
+
r++;
|
|
846
|
+
}
|
|
847
|
+
let close = r - 1;
|
|
848
|
+
while (close >= v + 4 && css.charCodeAt(close) !== 41)
|
|
849
|
+
close--;
|
|
850
|
+
if (close >= v + 4) {
|
|
851
|
+
out.push({ index: m.index, text: css.slice(m.index, close + 1), groups: [css.slice(m.index, v), css.slice(v, close + 1)] });
|
|
852
|
+
}
|
|
853
|
+
// Every later start before r ends at the same r and finds no ')' after this one.
|
|
854
|
+
pos = r;
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
/** Matches of /([^{}]+)\{([^{}]*)\}/g: groups [prelude, body]. */
|
|
859
|
+
function cssRuleBlocks(css) {
|
|
860
|
+
const out = [];
|
|
861
|
+
const n = css.length;
|
|
862
|
+
const braceAt = (k) => {
|
|
863
|
+
const ch = css.charCodeAt(k);
|
|
864
|
+
return ch === 123 || ch === 125;
|
|
865
|
+
};
|
|
866
|
+
for (let pos = 0; pos < n;) {
|
|
867
|
+
let b = pos;
|
|
868
|
+
while (b < n && !braceAt(b))
|
|
869
|
+
b++;
|
|
870
|
+
if (b === n)
|
|
871
|
+
break;
|
|
872
|
+
if (b === pos || css.charCodeAt(b) === 125) {
|
|
873
|
+
pos = b + 1;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
let c = b + 1;
|
|
877
|
+
while (c < n && !braceAt(c))
|
|
878
|
+
c++;
|
|
879
|
+
if (c === n)
|
|
880
|
+
break;
|
|
881
|
+
if (css.charCodeAt(c) === 123) {
|
|
882
|
+
pos = b + 1;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
out.push({ index: pos, text: css.slice(pos, c + 1), groups: [css.slice(pos, b), css.slice(b + 1, c)] });
|
|
886
|
+
pos = c + 1;
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
/** Matches of /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi: groups [path]. */
|
|
891
|
+
function cssUrlMatches(css) {
|
|
892
|
+
const start = /url\(/gi;
|
|
893
|
+
const urlAt = /url\(/iy;
|
|
894
|
+
const out = [];
|
|
895
|
+
const n = css.length;
|
|
896
|
+
const isQuote = (c) => c === 34 || c === 39;
|
|
897
|
+
// First quote or ')' at or after `from`.
|
|
898
|
+
const stopAt = (from) => {
|
|
899
|
+
let k = from;
|
|
900
|
+
while (k < n) {
|
|
901
|
+
const ch = css.charCodeAt(k);
|
|
902
|
+
if (ch === 34 || ch === 39 || ch === 41)
|
|
903
|
+
break;
|
|
904
|
+
k++;
|
|
905
|
+
}
|
|
906
|
+
return k;
|
|
907
|
+
};
|
|
908
|
+
// ['"]?\s*\) at a quote or ')': the index after it, or -1.
|
|
909
|
+
const tailEnd = (r) => {
|
|
910
|
+
if (r >= n)
|
|
911
|
+
return -1;
|
|
912
|
+
if (css.charCodeAt(r) === 41)
|
|
913
|
+
return r + 1;
|
|
914
|
+
const t = wsEnd(css, r + 1);
|
|
915
|
+
return css.charCodeAt(t) === 41 ? t + 1 : -1;
|
|
916
|
+
};
|
|
917
|
+
for (let pos = 0;;) {
|
|
918
|
+
start.lastIndex = pos;
|
|
919
|
+
const m = start.exec(css);
|
|
920
|
+
if (!m)
|
|
921
|
+
break;
|
|
922
|
+
const p0 = m.index + m[0].length;
|
|
923
|
+
const p1 = wsEnd(css, p0);
|
|
924
|
+
let hit = -1;
|
|
925
|
+
let from = -1;
|
|
926
|
+
let to = -1;
|
|
927
|
+
// All the leading space, then the quote after it taken, then not taken.
|
|
928
|
+
if (p1 < n && isQuote(css.charCodeAt(p1))) {
|
|
929
|
+
const r = stopAt(p1 + 1);
|
|
930
|
+
const e = r > p1 + 1 ? tailEnd(r) : -1;
|
|
931
|
+
if (e >= 0) {
|
|
932
|
+
hit = e;
|
|
933
|
+
from = p1 + 1;
|
|
934
|
+
to = r;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (hit < 0) {
|
|
938
|
+
const r = stopAt(p1);
|
|
939
|
+
const e = r > p1 ? tailEnd(r) : -1;
|
|
940
|
+
if (e >= 0) {
|
|
941
|
+
hit = e;
|
|
942
|
+
from = p1;
|
|
943
|
+
to = r;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Less leading space: the path starts on a space and runs to the same stop.
|
|
947
|
+
if (hit < 0 && p1 > p0) {
|
|
948
|
+
const r = stopAt(p1 - 1);
|
|
949
|
+
const e = tailEnd(r);
|
|
950
|
+
if (e >= 0) {
|
|
951
|
+
hit = e;
|
|
952
|
+
from = p1 - 1;
|
|
953
|
+
to = r;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (hit >= 0) {
|
|
957
|
+
out.push({ index: m.index, text: css.slice(m.index, hit), groups: [css.slice(from, to)] });
|
|
958
|
+
pos = hit;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
// A later url( before the first quote or ')' after this one reads the same
|
|
962
|
+
// stop and fails the same way, unless only space separates it from a
|
|
963
|
+
// quote at that stop: that one can take the quote and must be tried.
|
|
964
|
+
const r1 = stopAt(p0);
|
|
965
|
+
if (r1 >= n)
|
|
966
|
+
break;
|
|
967
|
+
let w = r1;
|
|
968
|
+
while (w > p0 && isJsSpace(css.charCodeAt(w - 1)))
|
|
969
|
+
w--;
|
|
970
|
+
urlAt.lastIndex = w - 4;
|
|
971
|
+
pos = w - 4 > m.index && urlAt.test(css) ? w - 4 : r1;
|
|
972
|
+
}
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
975
|
+
/** css.match(/(?:^|\})[^{}]*\{[^}]*background-clip\s*:\s*text[^}]*\}/gi) || [] */
|
|
976
|
+
function gradientClipRules(css) {
|
|
977
|
+
const found = nextMatchFinder(css, /background-clip\s*:\s*text/gi);
|
|
978
|
+
const out = [];
|
|
979
|
+
const n = css.length;
|
|
980
|
+
let pos = 0;
|
|
981
|
+
for (let first = true;;) {
|
|
982
|
+
let i;
|
|
983
|
+
let from;
|
|
984
|
+
if (first) {
|
|
985
|
+
first = false;
|
|
986
|
+
i = 0;
|
|
987
|
+
from = css.charCodeAt(0) === 125 ? 1 : 0;
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
i = css.indexOf('}', pos);
|
|
991
|
+
if (i < 0)
|
|
992
|
+
break;
|
|
993
|
+
from = i + 1;
|
|
994
|
+
}
|
|
995
|
+
let b = from;
|
|
996
|
+
while (b < n && css.charCodeAt(b) !== 123 && css.charCodeAt(b) !== 125)
|
|
997
|
+
b++;
|
|
998
|
+
if (b >= n)
|
|
999
|
+
break;
|
|
1000
|
+
if (css.charCodeAt(b) === 125) {
|
|
1001
|
+
pos = b;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const c = css.indexOf('}', b + 1);
|
|
1005
|
+
if (c < 0)
|
|
1006
|
+
break;
|
|
1007
|
+
if (found(b + 1) < c) {
|
|
1008
|
+
out.push(css.slice(i, c + 1));
|
|
1009
|
+
pos = c + 1;
|
|
1010
|
+
}
|
|
1011
|
+
else
|
|
1012
|
+
pos = c;
|
|
1013
|
+
}
|
|
1014
|
+
return out;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* block.match(/linear-gradient\(([\s\S]+)\)\s*;?\s*background-size|linear-gradient\(([^;]+)\)/i):
|
|
1018
|
+
* groups [args before background-size, args before the first ';'].
|
|
1019
|
+
*/
|
|
1020
|
+
function linearGradientArgs(block) {
|
|
1021
|
+
const start = /linear-gradient\(/gi;
|
|
1022
|
+
const size = /background-size/iy;
|
|
1023
|
+
const n = block.length;
|
|
1024
|
+
// The last ')' followed by \s*;?\s*background-size anywhere in the block.
|
|
1025
|
+
let tailParen = -1;
|
|
1026
|
+
let tailStop = -1;
|
|
1027
|
+
for (let p = block.lastIndexOf(')'); p >= 0; p = p > 0 ? block.lastIndexOf(')', p - 1) : -1) {
|
|
1028
|
+
let t = wsEnd(block, p + 1);
|
|
1029
|
+
if (block.charCodeAt(t) === 59)
|
|
1030
|
+
t = wsEnd(block, t + 1);
|
|
1031
|
+
size.lastIndex = t;
|
|
1032
|
+
if (size.test(block)) {
|
|
1033
|
+
tailParen = p;
|
|
1034
|
+
tailStop = t + 15;
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
let semiFor = -1;
|
|
1039
|
+
let semi = -1;
|
|
1040
|
+
let parenFor = -1;
|
|
1041
|
+
let paren = -1;
|
|
1042
|
+
for (let m = start.exec(block); m; m = start.exec(block)) {
|
|
1043
|
+
const a = m.index + m[0].length;
|
|
1044
|
+
if (tailParen >= a + 1) {
|
|
1045
|
+
return { index: m.index, text: block.slice(m.index, tailStop), groups: [block.slice(a, tailParen), undefined] };
|
|
1046
|
+
}
|
|
1047
|
+
if (!(semiFor >= 0 && a >= semiFor && a <= semi)) {
|
|
1048
|
+
semiFor = a;
|
|
1049
|
+
semi = block.indexOf(';', a);
|
|
1050
|
+
if (semi < 0)
|
|
1051
|
+
semi = n;
|
|
1052
|
+
}
|
|
1053
|
+
// The last ')' before that ';' and after this opener: the same for every
|
|
1054
|
+
// opener before the ';', so it is looked for once and only as far back as
|
|
1055
|
+
// the first of them.
|
|
1056
|
+
if (parenFor !== semi) {
|
|
1057
|
+
parenFor = semi;
|
|
1058
|
+
paren = semi - 1;
|
|
1059
|
+
while (paren >= a + 1 && block.charCodeAt(paren) !== 41)
|
|
1060
|
+
paren--;
|
|
1061
|
+
}
|
|
1062
|
+
if (paren >= a + 1) {
|
|
1063
|
+
return { index: m.index, text: block.slice(m.index, paren + 1), groups: [undefined, block.slice(a, paren)] };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
/** css.match(/grid-template-columns[^}]*repeat\s*\(\s*(?:auto-fit|auto-fill|\d+)/gi) || [] */
|
|
1069
|
+
function gridRepeatRules(css) {
|
|
1070
|
+
const start = /grid-template-columns/gi;
|
|
1071
|
+
const x = /repeat\s*\(\s*(?:auto-fit|auto-fill|\d+)/iy;
|
|
1072
|
+
const leads = matchStarts(css, /repeat/gi);
|
|
1073
|
+
const out = [];
|
|
1074
|
+
let ptr = 0;
|
|
1075
|
+
for (let pos = 0;;) {
|
|
1076
|
+
start.lastIndex = pos;
|
|
1077
|
+
const m = start.exec(css);
|
|
1078
|
+
if (!m)
|
|
1079
|
+
break;
|
|
1080
|
+
const after = m.index + m[0].length;
|
|
1081
|
+
let c = css.indexOf('}', after);
|
|
1082
|
+
if (c < 0)
|
|
1083
|
+
c = css.length;
|
|
1084
|
+
while (ptr < leads.length && leads[ptr] < after)
|
|
1085
|
+
ptr++;
|
|
1086
|
+
let end = ptr;
|
|
1087
|
+
while (end < leads.length && leads[end] < c)
|
|
1088
|
+
end++;
|
|
1089
|
+
let hit = null;
|
|
1090
|
+
for (let k = end - 1; k >= ptr && !hit; k--) {
|
|
1091
|
+
x.lastIndex = leads[k];
|
|
1092
|
+
hit = x.exec(css);
|
|
1093
|
+
}
|
|
1094
|
+
ptr = end;
|
|
1095
|
+
if (hit)
|
|
1096
|
+
out.push(css.slice(m.index, hit.index + hit[0].length));
|
|
1097
|
+
if (c >= css.length)
|
|
1098
|
+
break;
|
|
1099
|
+
pos = c + 1;
|
|
1100
|
+
}
|
|
1101
|
+
return out;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* html.match(/(?:<button|<a[^>]*class[^>]*(?:btn|cta|primary|action))[^>]*>[\s\S]{0,200}(?:[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F)/giu)
|
|
1105
|
+
*/
|
|
1106
|
+
function emojiCtaMatches(html) {
|
|
1107
|
+
const n = html.length;
|
|
1108
|
+
const start = /<button|<a/giu;
|
|
1109
|
+
const cls = nextMatchFinder(html, /class/giu);
|
|
1110
|
+
const word = nextMatchFinder(html, /btn|cta|primary|action/giu);
|
|
1111
|
+
const gt = nextMatchFinder(html, />/g);
|
|
1112
|
+
const emoji = /[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F/gu;
|
|
1113
|
+
const emojiAt = [];
|
|
1114
|
+
const emojiEnd = [];
|
|
1115
|
+
for (let e = emoji.exec(html); e; e = emoji.exec(html)) {
|
|
1116
|
+
emojiAt.push(e.index);
|
|
1117
|
+
emojiEnd.push(e.index + e[0].length);
|
|
1118
|
+
}
|
|
1119
|
+
// End of the match for the content after `>` at g: the last emoji that
|
|
1120
|
+
// starts within 200 code points of it, or -1.
|
|
1121
|
+
const tail = (g) => {
|
|
1122
|
+
let w = g + 1;
|
|
1123
|
+
for (let k = 0; k < 200 && w < n; k++) {
|
|
1124
|
+
const c = html.charCodeAt(w);
|
|
1125
|
+
w += c >= 0xd800 && c <= 0xdbff && w + 1 < n && html.charCodeAt(w + 1) >= 0xdc00 && html.charCodeAt(w + 1) <= 0xdfff ? 2 : 1;
|
|
1126
|
+
}
|
|
1127
|
+
let lo = 0;
|
|
1128
|
+
let hi = emojiAt.length - 1;
|
|
1129
|
+
let best = -1;
|
|
1130
|
+
while (lo <= hi) {
|
|
1131
|
+
const mid = (lo + hi) >> 1;
|
|
1132
|
+
if (emojiAt[mid] <= w) {
|
|
1133
|
+
best = mid;
|
|
1134
|
+
lo = mid + 1;
|
|
1135
|
+
}
|
|
1136
|
+
else
|
|
1137
|
+
hi = mid - 1;
|
|
1138
|
+
}
|
|
1139
|
+
return best >= 0 && emojiAt[best] >= g + 1 ? emojiEnd[best] : -1;
|
|
1140
|
+
};
|
|
1141
|
+
const out = [];
|
|
1142
|
+
let tailFor = -1;
|
|
1143
|
+
let tailStop = -1;
|
|
1144
|
+
for (let pos = 0;;) {
|
|
1145
|
+
start.lastIndex = pos;
|
|
1146
|
+
const m = start.exec(html);
|
|
1147
|
+
if (!m)
|
|
1148
|
+
break;
|
|
1149
|
+
const g = gt(m.index + 1);
|
|
1150
|
+
if (g === Infinity)
|
|
1151
|
+
break;
|
|
1152
|
+
if (tailFor !== g) {
|
|
1153
|
+
tailFor = g;
|
|
1154
|
+
tailStop = tail(g);
|
|
1155
|
+
}
|
|
1156
|
+
if (tailStop < 0) {
|
|
1157
|
+
pos = g + 1;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
let ok = m[0].length === 7;
|
|
1161
|
+
if (!ok) {
|
|
1162
|
+
const c = cls(m.index + 2);
|
|
1163
|
+
ok = c < g && word(c + 5) < g;
|
|
1164
|
+
}
|
|
1165
|
+
if (!ok) {
|
|
1166
|
+
pos = m.index + 1;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
out.push(html.slice(m.index, tailStop));
|
|
1170
|
+
pos = tailStop;
|
|
1171
|
+
}
|
|
1172
|
+
return out.length ? out : null;
|
|
1173
|
+
}
|
|
1174
|
+
/** key.replace(/(\.\d*?)0+(?=\D|$)/g, '$1') */
|
|
1175
|
+
function withoutTrailingDecimalZeros(key) {
|
|
1176
|
+
const parts = [];
|
|
1177
|
+
let last = 0;
|
|
1178
|
+
for (let i = key.indexOf('.'); i >= 0;) {
|
|
1179
|
+
let d = i + 1;
|
|
1180
|
+
while (d < key.length && key.charCodeAt(d) >= 48 && key.charCodeAt(d) <= 57)
|
|
1181
|
+
d++;
|
|
1182
|
+
let z = d;
|
|
1183
|
+
while (z > i + 1 && key.charCodeAt(z - 1) === 48)
|
|
1184
|
+
z--;
|
|
1185
|
+
if (z < d) {
|
|
1186
|
+
parts.push(key.slice(last, z));
|
|
1187
|
+
last = d;
|
|
1188
|
+
}
|
|
1189
|
+
i = key.indexOf('.', d);
|
|
1190
|
+
}
|
|
1191
|
+
parts.push(key.slice(last));
|
|
1192
|
+
return parts.join('');
|
|
1193
|
+
}
|
|
1194
|
+
/** /prefers-reduced-motion[\s\S]{0,200}\*\s*\{[^}]*animation\s*:\s*none/i.test(css) */
|
|
1195
|
+
function hasBlanketMotionKill(css) {
|
|
1196
|
+
const start = /prefers-reduced-motion/gi;
|
|
1197
|
+
const kill = nextMatchFinder(css, /animation\s*:\s*none/gi);
|
|
1198
|
+
const close = nextMatchFinder(css, /\}/g);
|
|
1199
|
+
let checked = -1;
|
|
1200
|
+
for (let m = start.exec(css); m; m = start.exec(css)) {
|
|
1201
|
+
const from = m.index + m[0].length;
|
|
1202
|
+
const to = Math.min(css.length - 1, from + 200);
|
|
1203
|
+
// A `*` reads the same whichever start reaches it, so each is read once.
|
|
1204
|
+
for (let q = Math.max(from, checked + 1); q <= to; q++) {
|
|
1205
|
+
if (css.charCodeAt(q) !== 42)
|
|
1206
|
+
continue;
|
|
1207
|
+
const b = wsEnd(css, q + 1);
|
|
1208
|
+
if (css.charCodeAt(b) !== 123)
|
|
1209
|
+
continue;
|
|
1210
|
+
if (kill(b + 1) < close(b + 1))
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
if (to > checked)
|
|
1214
|
+
checked = to;
|
|
1215
|
+
}
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
/** html.match(/<svg[^>]*viewBox=/gi) || [] */
|
|
1219
|
+
function svgViewBoxTags(html) {
|
|
1220
|
+
const start = /<svg/gi;
|
|
1221
|
+
const leads = matchStarts(html, /viewBox=/gi);
|
|
1222
|
+
const gt = nextMatchFinder(html, />/g);
|
|
1223
|
+
const out = [];
|
|
1224
|
+
let ptr = 0;
|
|
1225
|
+
for (let pos = 0;;) {
|
|
1226
|
+
start.lastIndex = pos;
|
|
1227
|
+
const m = start.exec(html);
|
|
1228
|
+
if (!m)
|
|
1229
|
+
break;
|
|
1230
|
+
const after = m.index + m[0].length;
|
|
1231
|
+
const g = Math.min(gt(after), html.length);
|
|
1232
|
+
while (ptr < leads.length && leads[ptr] < after)
|
|
1233
|
+
ptr++;
|
|
1234
|
+
let end = ptr;
|
|
1235
|
+
while (end < leads.length && leads[end] < g)
|
|
1236
|
+
end++;
|
|
1237
|
+
if (end > ptr) {
|
|
1238
|
+
const k = leads[end - 1];
|
|
1239
|
+
out.push(html.slice(m.index, k + 8));
|
|
1240
|
+
pos = k + 8;
|
|
1241
|
+
ptr = end;
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
if (g >= html.length)
|
|
1245
|
+
break;
|
|
1246
|
+
pos = g + 1;
|
|
1247
|
+
}
|
|
1248
|
+
return out;
|
|
1249
|
+
}
|
|
1250
|
+
/** html.match(/<symbol[^>]*>/gi) || [] */
|
|
1251
|
+
function symbolTags(html) {
|
|
1252
|
+
const start = /<symbol/gi;
|
|
1253
|
+
const out = [];
|
|
1254
|
+
for (let pos = 0;;) {
|
|
1255
|
+
start.lastIndex = pos;
|
|
1256
|
+
const m = start.exec(html);
|
|
1257
|
+
if (!m)
|
|
1258
|
+
break;
|
|
1259
|
+
const g = html.indexOf('>', m.index + m[0].length);
|
|
1260
|
+
if (g < 0)
|
|
1261
|
+
break;
|
|
1262
|
+
out.push(html.slice(m.index, g + 1));
|
|
1263
|
+
pos = g + 1;
|
|
1264
|
+
}
|
|
1265
|
+
return out;
|
|
1266
|
+
}
|
|
1267
|
+
/** A [\w-] code unit (\w without the u flag). */
|
|
1268
|
+
function isNameChar(c) {
|
|
1269
|
+
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 45;
|
|
1270
|
+
}
|
|
1271
|
+
/** The index after the run of [\w-] that starts at `i`. */
|
|
1272
|
+
function nameEnd(s, i) {
|
|
1273
|
+
while (i < s.length && isNameChar(s.charCodeAt(i)))
|
|
1274
|
+
i++;
|
|
1275
|
+
return i;
|
|
1276
|
+
}
|
|
1277
|
+
/** css.match(/--[\w-]+\s*:/g) || [] */
|
|
1278
|
+
function customPropertyDecls(css) {
|
|
1279
|
+
const out = [];
|
|
1280
|
+
for (let pos = 0;;) {
|
|
1281
|
+
const i = css.indexOf('--', pos);
|
|
1282
|
+
if (i < 0)
|
|
1283
|
+
break;
|
|
1284
|
+
const r = nameEnd(css, i + 2);
|
|
1285
|
+
if (r === i + 2) {
|
|
1286
|
+
pos = i + 1;
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const t = wsEnd(css, r);
|
|
1290
|
+
if (css.charCodeAt(t) === 58) {
|
|
1291
|
+
out.push(css.slice(i, t + 1));
|
|
1292
|
+
pos = t + 1;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
// Every later `--` inside the same name ends at the same r and fails too.
|
|
1296
|
+
pos = r;
|
|
1297
|
+
}
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|
|
1300
|
+
/** Matches of /--([\w-]+)\s*:\s*([^;]+?)(?:;|$)/g: groups [name, value]. */
|
|
1301
|
+
function customPropertyValues(block) {
|
|
1302
|
+
const out = [];
|
|
1303
|
+
const n = block.length;
|
|
1304
|
+
for (let pos = 0;;) {
|
|
1305
|
+
const i = block.indexOf('--', pos);
|
|
1306
|
+
if (i < 0)
|
|
1307
|
+
break;
|
|
1308
|
+
const r = nameEnd(block, i + 2);
|
|
1309
|
+
if (r === i + 2) {
|
|
1310
|
+
pos = i + 1;
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
const colon = wsEnd(block, r);
|
|
1314
|
+
if (block.charCodeAt(colon) !== 58) {
|
|
1315
|
+
pos = r;
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
let v = wsEnd(block, colon + 1);
|
|
1319
|
+
// ([^;]+?) needs one character: when the space runs up to a ';' or the
|
|
1320
|
+
// end, the last space is that character.
|
|
1321
|
+
if (v >= n || block.charCodeAt(v) === 59) {
|
|
1322
|
+
if (v === colon + 1) {
|
|
1323
|
+
pos = r;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
v--;
|
|
1327
|
+
}
|
|
1328
|
+
let e = block.indexOf(';', v + 1);
|
|
1329
|
+
if (e < 0)
|
|
1330
|
+
e = n;
|
|
1331
|
+
const stop = e < n ? e + 1 : n;
|
|
1332
|
+
out.push({ index: i, text: block.slice(i, stop), groups: [block.slice(i + 2, r), block.slice(v, e)] });
|
|
1333
|
+
pos = stop;
|
|
1334
|
+
}
|
|
1335
|
+
return out;
|
|
1336
|
+
}
|
|
1337
|
+
/** /--(?:color|surface|accent|action|feedback)[\w-]*\s*:\s*var\(--/i.test(css) */
|
|
1338
|
+
function hasTokenLayering(css) {
|
|
1339
|
+
const start = /--(?:color|surface|accent|action|feedback)/gi;
|
|
1340
|
+
const varAt = /var\(--/iy;
|
|
1341
|
+
for (let pos = 0;;) {
|
|
1342
|
+
start.lastIndex = pos;
|
|
1343
|
+
const m = start.exec(css);
|
|
1344
|
+
if (!m)
|
|
1345
|
+
return false;
|
|
1346
|
+
const r = nameEnd(css, m.index + m[0].length);
|
|
1347
|
+
const t = wsEnd(css, r);
|
|
1348
|
+
if (css.charCodeAt(t) === 58) {
|
|
1349
|
+
varAt.lastIndex = wsEnd(css, t + 1);
|
|
1350
|
+
if (varAt.test(css))
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
// Every later start inside the same name reaches the same r.
|
|
1354
|
+
pos = Math.max(r, m.index + 1);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
/** `text` with each [start, end) span replaced by fn(the spanned text). */
|
|
1358
|
+
function replaceSpansWith(text, spans, fn) {
|
|
1359
|
+
if (spans.length === 0)
|
|
1360
|
+
return text;
|
|
1361
|
+
const parts = [];
|
|
1362
|
+
let last = 0;
|
|
1363
|
+
for (let k = 0; k < spans.length; k += 2) {
|
|
1364
|
+
parts.push(text.slice(last, spans[k]), fn(text.slice(spans[k], spans[k + 1])));
|
|
1365
|
+
last = spans[k + 1];
|
|
1366
|
+
}
|
|
1367
|
+
parts.push(text.slice(last));
|
|
1368
|
+
return parts.join('');
|
|
1369
|
+
}
|
|
1370
|
+
/** Spans of /\[[^\]]*\]/g. */
|
|
1371
|
+
function bracketSpans(text) {
|
|
1372
|
+
const spans = [];
|
|
1373
|
+
for (let pos = 0;;) {
|
|
1374
|
+
const i = text.indexOf('[', pos);
|
|
1375
|
+
if (i < 0)
|
|
1376
|
+
break;
|
|
1377
|
+
const j = text.indexOf(']', i + 1);
|
|
1378
|
+
if (j < 0)
|
|
1379
|
+
break;
|
|
1380
|
+
spans.push(i, j + 1);
|
|
1381
|
+
pos = j + 1;
|
|
1382
|
+
}
|
|
1383
|
+
return spans;
|
|
1384
|
+
}
|
|
1385
|
+
/** text.match(/\[[^\]]*\]|\.[\w-]+/g) */
|
|
1386
|
+
function attrAndClassParts(text) {
|
|
1387
|
+
const out = [];
|
|
1388
|
+
const lastClose = text.lastIndexOf(']');
|
|
1389
|
+
for (let pos = 0; pos < text.length;) {
|
|
1390
|
+
const ch = text.charCodeAt(pos);
|
|
1391
|
+
if (ch === 91 && lastClose > pos) {
|
|
1392
|
+
const j = text.indexOf(']', pos + 1);
|
|
1393
|
+
out.push(text.slice(pos, j + 1));
|
|
1394
|
+
pos = j + 1;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (ch === 46) {
|
|
1398
|
+
const r = nameEnd(text, pos + 1);
|
|
1399
|
+
if (r > pos + 1) {
|
|
1400
|
+
out.push(text.slice(pos, r));
|
|
1401
|
+
pos = r;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
pos++;
|
|
1406
|
+
}
|
|
1407
|
+
return out.length ? out : null;
|
|
1408
|
+
}
|
|
1409
|
+
/** v45: /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i.test(s) */
|
|
1410
|
+
function isMotionToggleAttr(s) {
|
|
1411
|
+
const n = s.length;
|
|
1412
|
+
if (n < 2 || s.charCodeAt(0) !== 91 || s.charCodeAt(n - 1) !== 93)
|
|
1413
|
+
return false;
|
|
1414
|
+
const r = nameEnd(s, 1);
|
|
1415
|
+
if (!/motion|anim/i.test(s.slice(1, r)))
|
|
1416
|
+
return false;
|
|
1417
|
+
if (r === n - 1)
|
|
1418
|
+
return true;
|
|
1419
|
+
let k = r;
|
|
1420
|
+
if ('~|^$*'.indexOf(s[k]) >= 0)
|
|
1421
|
+
k++;
|
|
1422
|
+
if (s.charCodeAt(k) !== 61)
|
|
1423
|
+
return false;
|
|
1424
|
+
return s.indexOf(']', k + 1) === n - 1;
|
|
1425
|
+
}
|
|
1426
|
+
/** Where `\s*!\s*important\s*$` (case-insensitive) begins in `value`, or -1. */
|
|
1427
|
+
function importantSuffixStart(value) {
|
|
1428
|
+
let e = value.length;
|
|
1429
|
+
while (e > 0 && isJsSpace(value.charCodeAt(e - 1)))
|
|
1430
|
+
e--;
|
|
1431
|
+
if (e < 9 || !/^important$/i.test(value.slice(e - 9, e)))
|
|
1432
|
+
return -1;
|
|
1433
|
+
let k = e - 9;
|
|
1434
|
+
while (k > 0 && isJsSpace(value.charCodeAt(k - 1)))
|
|
1435
|
+
k--;
|
|
1436
|
+
if (k === 0 || value.charCodeAt(k - 1) !== 33)
|
|
1437
|
+
return -1;
|
|
1438
|
+
k--;
|
|
1439
|
+
while (k > 0 && isJsSpace(value.charCodeAt(k - 1)))
|
|
1440
|
+
k--;
|
|
1441
|
+
return k;
|
|
1442
|
+
}
|
|
1443
|
+
/** A [0-9] code unit. */
|
|
1444
|
+
function isDigit(c) {
|
|
1445
|
+
return c >= 48 && c <= 57;
|
|
1446
|
+
}
|
|
1447
|
+
/** color-mix: /^([\s\S]*?)\s+(\d*\.?\d+)%$/.exec(p) */
|
|
1448
|
+
function percentAfter(p) {
|
|
1449
|
+
const n = p.length;
|
|
1450
|
+
if (n === 0 || p.charCodeAt(n - 1) !== 37)
|
|
1451
|
+
return null;
|
|
1452
|
+
let q = n - 1;
|
|
1453
|
+
while (q > 0 && (isDigit(p.charCodeAt(q - 1)) || p.charCodeAt(q - 1) === 46))
|
|
1454
|
+
q--;
|
|
1455
|
+
if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(p.slice(q, n - 1)))
|
|
1456
|
+
return null;
|
|
1457
|
+
if (q === 0 || !isJsSpace(p.charCodeAt(q - 1)))
|
|
1458
|
+
return null;
|
|
1459
|
+
let w = q - 1;
|
|
1460
|
+
while (w > 0 && isJsSpace(p.charCodeAt(w - 1)))
|
|
1461
|
+
w--;
|
|
1462
|
+
return [p, p.slice(0, w), p.slice(q, n - 1)];
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Relative colour syntax:
|
|
1466
|
+
* /^from\s+([\s\S]+?)\s+(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/.exec(args)
|
|
1467
|
+
*/
|
|
1468
|
+
function relativeColorMatch(args) {
|
|
1469
|
+
if (!args.startsWith('from') || !isJsSpace(args.charCodeAt(4)))
|
|
1470
|
+
return null;
|
|
1471
|
+
const channels = /(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/y;
|
|
1472
|
+
const tail = (k) => {
|
|
1473
|
+
channels.lastIndex = k;
|
|
1474
|
+
return channels.exec(args);
|
|
1475
|
+
};
|
|
1476
|
+
const start = wsEnd(args, 4);
|
|
1477
|
+
// The base is lazy: it ends at the first run of space after its first
|
|
1478
|
+
// character that the channel keywords and the rest of the value follow.
|
|
1479
|
+
for (let k = start + 1; k < args.length; k++) {
|
|
1480
|
+
if (!isJsSpace(args.charCodeAt(k)))
|
|
1481
|
+
continue;
|
|
1482
|
+
const w = wsEnd(args, k);
|
|
1483
|
+
const t = tail(w);
|
|
1484
|
+
if (t)
|
|
1485
|
+
return [args, args.slice(start, k), t[1], t[2]];
|
|
1486
|
+
k = w;
|
|
1487
|
+
}
|
|
1488
|
+
// With less of the leading space taken, the base can be one space whose
|
|
1489
|
+
// following space leads straight to the keywords.
|
|
1490
|
+
if (start - 4 >= 3) {
|
|
1491
|
+
const t = tail(start);
|
|
1492
|
+
if (t)
|
|
1493
|
+
return [args, args.slice(start - 2, start - 1), t[1], t[2]];
|
|
1494
|
+
}
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
/** html.match(/<input\b[^>]*>/gi) || [] */
|
|
1498
|
+
function inputTags(html) {
|
|
1499
|
+
const start = /<input\b/gi;
|
|
1500
|
+
const out = [];
|
|
1501
|
+
for (let pos = 0;;) {
|
|
1502
|
+
start.lastIndex = pos;
|
|
1503
|
+
const m = start.exec(html);
|
|
1504
|
+
if (!m)
|
|
1505
|
+
break;
|
|
1506
|
+
const g = html.indexOf('>', m.index + m[0].length);
|
|
1507
|
+
if (g < 0)
|
|
1508
|
+
break;
|
|
1509
|
+
out.push(html.slice(m.index, g + 1));
|
|
1510
|
+
pos = g + 1;
|
|
1511
|
+
}
|
|
1512
|
+
return out;
|
|
1513
|
+
}
|
|
1514
|
+
/** /<title\b[^>]*>[^<]+<\/title>/i.test(html) */
|
|
1515
|
+
function hasTitleText(html) {
|
|
1516
|
+
const start = /<title\b/gi;
|
|
1517
|
+
const close = /<\/title>/iy;
|
|
1518
|
+
for (let pos = 0;;) {
|
|
1519
|
+
start.lastIndex = pos;
|
|
1520
|
+
const m = start.exec(html);
|
|
1521
|
+
if (!m)
|
|
1522
|
+
return false;
|
|
1523
|
+
const g = html.indexOf('>', m.index + m[0].length);
|
|
1524
|
+
if (g < 0)
|
|
1525
|
+
return false;
|
|
1526
|
+
const lt = html.indexOf('<', g + 1);
|
|
1527
|
+
if (lt < 0)
|
|
1528
|
+
return false;
|
|
1529
|
+
if (lt > g + 1) {
|
|
1530
|
+
close.lastIndex = lt;
|
|
1531
|
+
if (close.test(html))
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
// Every start before g reaches the same `>` and the same `<` after it.
|
|
1535
|
+
pos = g + 1;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* /TAG[^>]*ATTR[^"']*WORD/i.test(html): `open` (global) matches the tag's
|
|
1540
|
+
* start, `attr` (global) an attribute name with its `=` and opening quote, and
|
|
1541
|
+
* `word` (global, holding no quote) what the value must contain before its
|
|
1542
|
+
* closing quote.
|
|
1543
|
+
*/
|
|
1544
|
+
function tagAttrValueHas(html, open, attr, word) {
|
|
1545
|
+
const attrs = [];
|
|
1546
|
+
const values = [];
|
|
1547
|
+
attr.lastIndex = 0;
|
|
1548
|
+
for (let a = attr.exec(html); a; a = attr.exec(html)) {
|
|
1549
|
+
attrs.push(a.index);
|
|
1550
|
+
values.push(a.index + a[0].length);
|
|
1551
|
+
}
|
|
1552
|
+
const gt = nextMatchFinder(html, />/g);
|
|
1553
|
+
const found = nextMatchFinder(html, word);
|
|
1554
|
+
const n = html.length;
|
|
1555
|
+
let ptr = 0;
|
|
1556
|
+
for (let pos = 0;;) {
|
|
1557
|
+
open.lastIndex = pos;
|
|
1558
|
+
const m = open.exec(html);
|
|
1559
|
+
if (!m)
|
|
1560
|
+
return false;
|
|
1561
|
+
const after = m.index + m[0].length;
|
|
1562
|
+
const g = Math.min(gt(after), n);
|
|
1563
|
+
// Each attribute in the tag is read once: what follows it does not
|
|
1564
|
+
// depend on which start reached it.
|
|
1565
|
+
while (ptr < attrs.length && attrs[ptr] < after)
|
|
1566
|
+
ptr++;
|
|
1567
|
+
for (; ptr < attrs.length && attrs[ptr] < g; ptr++) {
|
|
1568
|
+
const v = values[ptr];
|
|
1569
|
+
let q = v;
|
|
1570
|
+
while (q < n && html.charCodeAt(q) !== 34 && html.charCodeAt(q) !== 39)
|
|
1571
|
+
q++;
|
|
1572
|
+
if (found(v) < q)
|
|
1573
|
+
return true;
|
|
1574
|
+
}
|
|
1575
|
+
if (g >= n)
|
|
1576
|
+
return false;
|
|
1577
|
+
pos = g + 1;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
/** The index of the first entry of an ascending list at or after `x` (the length when none). */
|
|
1581
|
+
function lowerBound(list, x) {
|
|
1582
|
+
let lo = 0;
|
|
1583
|
+
let hi = list.length;
|
|
1584
|
+
while (lo < hi) {
|
|
1585
|
+
const mid = (lo + hi) >> 1;
|
|
1586
|
+
if (list[mid] < x)
|
|
1587
|
+
lo = mid + 1;
|
|
1588
|
+
else
|
|
1589
|
+
hi = mid;
|
|
1590
|
+
}
|
|
1591
|
+
return lo;
|
|
1592
|
+
}
|
|
1593
|
+
/** Matches of /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g: groups [body]. */
|
|
1594
|
+
function rootTokenBlocks(css) {
|
|
1595
|
+
const start = /:root/g;
|
|
1596
|
+
const out = [];
|
|
1597
|
+
for (let pos = 0;;) {
|
|
1598
|
+
start.lastIndex = pos;
|
|
1599
|
+
const m = start.exec(css);
|
|
1600
|
+
if (!m)
|
|
1601
|
+
break;
|
|
1602
|
+
const p = m.index + m[0].length;
|
|
1603
|
+
let b = -1;
|
|
1604
|
+
// The optional ", :host": whitespace, a comma, whitespace, ":host".
|
|
1605
|
+
const comma = wsEnd(css, p);
|
|
1606
|
+
if (css.charCodeAt(comma) === 44) {
|
|
1607
|
+
const h = wsEnd(css, comma + 1);
|
|
1608
|
+
if (css.startsWith(':host', h)) {
|
|
1609
|
+
const q = wsEnd(css, h + 5);
|
|
1610
|
+
if (css.charCodeAt(q) === 123)
|
|
1611
|
+
b = q;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (b < 0) {
|
|
1615
|
+
const q = wsEnd(css, p);
|
|
1616
|
+
if (css.charCodeAt(q) === 123)
|
|
1617
|
+
b = q;
|
|
1618
|
+
}
|
|
1619
|
+
if (b < 0) {
|
|
1620
|
+
pos = m.index + 1;
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const c = css.indexOf('}', b + 1);
|
|
1624
|
+
if (c < 0)
|
|
1625
|
+
break;
|
|
1626
|
+
out.push({ index: m.index, text: css.slice(m.index, c + 1), groups: [css.slice(b + 1, c)] });
|
|
1627
|
+
pos = c + 1;
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
}
|
|
1631
|
+
/** v05: /@view-transition\s*\{[^}]*navigation\s*:\s*auto/i.test(body) */
|
|
1632
|
+
function hasViewTransitionNavigation(body) {
|
|
1633
|
+
const start = /@view-transition/gi;
|
|
1634
|
+
const found = nextMatchFinder(body, /navigation\s*:\s*auto/gi);
|
|
1635
|
+
const close = nextMatchFinder(body, /\}/g);
|
|
1636
|
+
for (let pos = 0;;) {
|
|
1637
|
+
start.lastIndex = pos;
|
|
1638
|
+
const m = start.exec(body);
|
|
1639
|
+
if (!m)
|
|
1640
|
+
return false;
|
|
1641
|
+
const b = wsEnd(body, m.index + m[0].length);
|
|
1642
|
+
if (body.charCodeAt(b) !== 123) {
|
|
1643
|
+
pos = m.index + 1;
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
const c = close(b + 1);
|
|
1647
|
+
if (found(b + 1) < c)
|
|
1648
|
+
return true;
|
|
1649
|
+
if (c === Infinity)
|
|
1650
|
+
return false;
|
|
1651
|
+
// Every later start before c reads the same body, or less of it.
|
|
1652
|
+
pos = c + 1;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* v34: /<script\s+type\s*=\s*["']application\/ld\+json["'][\s\S]*?"@type"\s*:\s*"[^"]*\b(?:ai|softwareapplication)\b[^"]*"[\s\S]*?(?:"applicationCategory"\s*:\s*"[^"]*ai[^"]*"|"[^"]*ai[^"]*")/i.test(html)
|
|
1657
|
+
* Only the first opener and the first matching @type matter: later ones see
|
|
1658
|
+
* less of the page. The applicationCategory form is one of the quoted
|
|
1659
|
+
* strings holding "ai" that the second form accepts.
|
|
1660
|
+
*/
|
|
1661
|
+
function hasAiJsonLd(html) {
|
|
1662
|
+
const open = /<script\s+type\s*=\s*["']application\/ld\+json["']/i.exec(html);
|
|
1663
|
+
if (!open)
|
|
1664
|
+
return false;
|
|
1665
|
+
const type = /"@type"\s*:\s*"/gi;
|
|
1666
|
+
const word = nextMatchFinder(html, /\b(?:ai|softwareapplication)\b/gi);
|
|
1667
|
+
type.lastIndex = open.index + open[0].length;
|
|
1668
|
+
for (let t = type.exec(html); t; t = type.exec(html)) {
|
|
1669
|
+
const v = t.index + t[0].length;
|
|
1670
|
+
const q = html.indexOf('"', v);
|
|
1671
|
+
if (q < 0)
|
|
1672
|
+
return false;
|
|
1673
|
+
if (word(v) < q) {
|
|
1674
|
+
for (let p = html.indexOf('"', q + 1); p >= 0;) {
|
|
1675
|
+
const e = html.indexOf('"', p + 1);
|
|
1676
|
+
if (e < 0)
|
|
1677
|
+
return false;
|
|
1678
|
+
if (/ai/i.test(html.slice(p + 1, e)))
|
|
1679
|
+
return true;
|
|
1680
|
+
p = e;
|
|
1681
|
+
}
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
type.lastIndex = q;
|
|
1685
|
+
}
|
|
1686
|
+
return false;
|
|
1687
|
+
}
|
|
1688
|
+
/** v10: /transition\s*:[^;]*transform[^;]*(ease-out|cubic-bezier\([^)]*0[, ])/i.test(css) */
|
|
1689
|
+
function hasTransformEaseTransition(css) {
|
|
1690
|
+
const start = /transition\s*:/gi;
|
|
1691
|
+
const transform = nextMatchFinder(css, /transform/gi);
|
|
1692
|
+
const ease = nextMatchFinder(css, /ease-out/gi);
|
|
1693
|
+
const paren = nextMatchFinder(css, /\)/g);
|
|
1694
|
+
const beziers = matchStarts(css, /cubic-bezier\(/gi);
|
|
1695
|
+
const zeros = matchStarts(css, /0[, ]/g);
|
|
1696
|
+
for (let pos = 0;;) {
|
|
1697
|
+
start.lastIndex = pos;
|
|
1698
|
+
const m = start.exec(css);
|
|
1699
|
+
if (!m)
|
|
1700
|
+
return false;
|
|
1701
|
+
const p = m.index + m[0].length;
|
|
1702
|
+
let r = css.indexOf(';', p);
|
|
1703
|
+
if (r < 0)
|
|
1704
|
+
r = css.length;
|
|
1705
|
+
const t = transform(p);
|
|
1706
|
+
if (t < r) {
|
|
1707
|
+
const from = t + 9;
|
|
1708
|
+
if (ease(from) < r)
|
|
1709
|
+
return true;
|
|
1710
|
+
// A cubic-bezier( that starts before r and holds a 0 followed by a
|
|
1711
|
+
// comma or a space before its first ')'.
|
|
1712
|
+
for (let k = lowerBound(beziers, from); k < beziers.length && beziers[k] < r; k++) {
|
|
1713
|
+
const inner = beziers[k] + 13;
|
|
1714
|
+
const z = lowerBound(zeros, inner);
|
|
1715
|
+
if (z < zeros.length && zeros[z] + 1 < paren(inner))
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
// Every later start before r reads the same declaration, or less of it.
|
|
1720
|
+
if (r >= css.length)
|
|
1721
|
+
return false;
|
|
1722
|
+
pos = r + 1;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
/** S3: css.match(/(?:background(?:-image)?\s*:[^;]*linear-gradient[^;]*(?:#?(?:615fff|8e51ff|4f39f6|7f22fe|a855f7|9333ea|7c3aed|6d28d9|5b21b6|4c1d95)))/gi) */
|
|
1726
|
+
function purpleGradientDecls(css) {
|
|
1727
|
+
const start = /background(?:-image)?\s*:/gi;
|
|
1728
|
+
const gradient = nextMatchFinder(css, /linear-gradient/gi);
|
|
1729
|
+
const hueRe = /615fff|8e51ff|4f39f6|7f22fe|a855f7|9333ea|7c3aed|6d28d9|5b21b6|4c1d95/gi;
|
|
1730
|
+
// Every place a hue starts, overlapping ones included (a855f7f22fe holds two).
|
|
1731
|
+
const hues = [];
|
|
1732
|
+
for (let h = hueRe.exec(css); h; h = hueRe.exec(css)) {
|
|
1733
|
+
hues.push(h.index);
|
|
1734
|
+
hueRe.lastIndex = h.index + 1;
|
|
1735
|
+
}
|
|
1736
|
+
const out = [];
|
|
1737
|
+
for (let pos = 0;;) {
|
|
1738
|
+
start.lastIndex = pos;
|
|
1739
|
+
const m = start.exec(css);
|
|
1740
|
+
if (!m)
|
|
1741
|
+
break;
|
|
1742
|
+
const p = m.index + m[0].length;
|
|
1743
|
+
let r = css.indexOf(';', p);
|
|
1744
|
+
if (r < 0)
|
|
1745
|
+
r = css.length;
|
|
1746
|
+
const g = gradient(p);
|
|
1747
|
+
if (g < r) {
|
|
1748
|
+
// The last hue before r, if it starts after the gradient's name.
|
|
1749
|
+
const k = lowerBound(hues, r) - 1;
|
|
1750
|
+
if (k >= 0 && hues[k] >= g + 15)
|
|
1751
|
+
out.push(css.slice(m.index, hues[k] + 6));
|
|
1752
|
+
}
|
|
1753
|
+
// Every later start before r finds no hue after this one.
|
|
1754
|
+
if (r >= css.length)
|
|
1755
|
+
break;
|
|
1756
|
+
pos = r + 1;
|
|
1757
|
+
}
|
|
1758
|
+
return out.length ? out : null;
|
|
1759
|
+
}
|
|
1760
|
+
/** S4: stop.match(/rgba?\(([^)]+)\)/i) */
|
|
1761
|
+
function rgbArgs(stop) {
|
|
1762
|
+
const start = /rgba?\(/gi;
|
|
1763
|
+
for (let m = start.exec(stop); m; m = start.exec(stop)) {
|
|
1764
|
+
const p = m.index + m[0].length;
|
|
1765
|
+
const c = stop.indexOf(')', p);
|
|
1766
|
+
if (c < 0)
|
|
1767
|
+
return null;
|
|
1768
|
+
if (c > p)
|
|
1769
|
+
return [stop.slice(m.index, c + 1), stop.slice(p, c)];
|
|
1770
|
+
}
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
/** A tag-name code unit for [a-z0-9-] under the i flag. */
|
|
1774
|
+
function isTagNameChar(c) {
|
|
1775
|
+
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 45;
|
|
1776
|
+
}
|
|
1777
|
+
/** A \w code unit (no u flag). */
|
|
1778
|
+
function isWordChar(c) {
|
|
1779
|
+
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95;
|
|
1780
|
+
}
|
|
1781
|
+
/** The first entry of an ascending list at or after `x`, or -1. */
|
|
1782
|
+
function firstAtOrAfter(list, x) {
|
|
1783
|
+
let lo = 0;
|
|
1784
|
+
let hi = list.length;
|
|
1785
|
+
while (lo < hi) {
|
|
1786
|
+
const mid = (lo + hi) >> 1;
|
|
1787
|
+
if (list[mid] < x)
|
|
1788
|
+
lo = mid + 1;
|
|
1789
|
+
else
|
|
1790
|
+
hi = mid;
|
|
1791
|
+
}
|
|
1792
|
+
return lo < list.length ? list[lo] : -1;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* v38: inner.replace(/<([a-z][a-z0-9-]*)\b[^>]*\baria-hidden\s*=\s*["']?true["']?[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
1796
|
+
* The closing tag must repeat the name, so the names of the closing tags are
|
|
1797
|
+
* kept in a trie and each opener's name is walked through it once.
|
|
1798
|
+
*/
|
|
1799
|
+
function withoutAriaHiddenElements(inner) {
|
|
1800
|
+
const n = inner.length;
|
|
1801
|
+
const root = { kids: new Map(), at: [] };
|
|
1802
|
+
const closeRe = /<\/([a-z0-9-]+)>/gi;
|
|
1803
|
+
for (let c = closeRe.exec(inner); c; c = closeRe.exec(inner)) {
|
|
1804
|
+
let node = root;
|
|
1805
|
+
const name = c[1].toLowerCase();
|
|
1806
|
+
for (let k = 0; k < name.length; k++) {
|
|
1807
|
+
const code = name.charCodeAt(k);
|
|
1808
|
+
let next = node.kids.get(code);
|
|
1809
|
+
if (!next) {
|
|
1810
|
+
next = { kids: new Map(), at: [] };
|
|
1811
|
+
node.kids.set(code, next);
|
|
1812
|
+
}
|
|
1813
|
+
node = next;
|
|
1814
|
+
}
|
|
1815
|
+
node.at.push(c.index);
|
|
1816
|
+
}
|
|
1817
|
+
const hidden = matchStarts(inner, /\baria-hidden\s*=\s*["']?true/gi);
|
|
1818
|
+
const gt = nextMatchFinder(inner, />/g);
|
|
1819
|
+
const start = /<[a-z]/gi;
|
|
1820
|
+
const spans = [];
|
|
1821
|
+
for (let pos = 0;;) {
|
|
1822
|
+
start.lastIndex = pos;
|
|
1823
|
+
const m = start.exec(inner);
|
|
1824
|
+
if (!m)
|
|
1825
|
+
break;
|
|
1826
|
+
const i = m.index;
|
|
1827
|
+
const g = gt(i + 1);
|
|
1828
|
+
if (g === Infinity)
|
|
1829
|
+
break;
|
|
1830
|
+
// Name lengths whose name is also a closing tag's name, longest last.
|
|
1831
|
+
const lens = [];
|
|
1832
|
+
const nodes = [];
|
|
1833
|
+
let node = root;
|
|
1834
|
+
for (let e = i + 1; e < n && isTagNameChar(inner.charCodeAt(e)); e++) {
|
|
1835
|
+
const code = inner.charCodeAt(e);
|
|
1836
|
+
node = node.kids.get(code >= 65 && code <= 90 ? code + 32 : code);
|
|
1837
|
+
if (!node)
|
|
1838
|
+
break;
|
|
1839
|
+
if (node.at.length) {
|
|
1840
|
+
lens.push(e + 1 - (i + 1));
|
|
1841
|
+
nodes.push(node);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
let stop = -1;
|
|
1845
|
+
for (let k = lens.length - 1; k >= 0 && stop < 0; k--) {
|
|
1846
|
+
const ne = i + 1 + lens[k];
|
|
1847
|
+
const before = isWordChar(inner.charCodeAt(ne - 1));
|
|
1848
|
+
const after = ne < n && isWordChar(inner.charCodeAt(ne));
|
|
1849
|
+
if (before === after)
|
|
1850
|
+
continue;
|
|
1851
|
+
const a = firstAtOrAfter(hidden, ne);
|
|
1852
|
+
if (a < 0 || a >= g)
|
|
1853
|
+
continue;
|
|
1854
|
+
const c = firstAtOrAfter(nodes[k].at, g + 1);
|
|
1855
|
+
if (c < 0)
|
|
1856
|
+
continue;
|
|
1857
|
+
stop = c + 3 + lens[k];
|
|
1858
|
+
}
|
|
1859
|
+
if (stop >= 0) {
|
|
1860
|
+
spans.push(i, stop);
|
|
1861
|
+
pos = stop;
|
|
1862
|
+
}
|
|
1863
|
+
else
|
|
1864
|
+
pos = i + 1;
|
|
1865
|
+
}
|
|
1866
|
+
return replaceSpans(inner, spans, '');
|
|
1867
|
+
}
|
|
1868
|
+
/** text.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ') */
|
|
1869
|
+
function withoutKbdElements(text) {
|
|
1870
|
+
const start = /<kbd\b/gi;
|
|
1871
|
+
const gt = nextMatchFinder(text, />/g);
|
|
1872
|
+
const opener = nextMatchFinder(text, /<kbd\b/gi);
|
|
1873
|
+
const closer = nextMatchFinder(text, /<\/kbd>/gi);
|
|
1874
|
+
const spans = [];
|
|
1875
|
+
for (let pos = 0;;) {
|
|
1876
|
+
start.lastIndex = pos;
|
|
1877
|
+
const m = start.exec(text);
|
|
1878
|
+
if (!m)
|
|
1879
|
+
break;
|
|
1880
|
+
const g = gt(m.index + m[0].length);
|
|
1881
|
+
if (g === Infinity)
|
|
1882
|
+
break;
|
|
1883
|
+
const c = closer(g + 1);
|
|
1884
|
+
if (c === Infinity)
|
|
1885
|
+
break;
|
|
1886
|
+
// A nested <kbd> opens first: no start before this `>` matches; the
|
|
1887
|
+
// nested one is tried in turn.
|
|
1888
|
+
if (opener(g + 1) < c) {
|
|
1889
|
+
pos = g + 1;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
spans.push(m.index, c + 6);
|
|
1893
|
+
pos = c + 6;
|
|
1894
|
+
}
|
|
1895
|
+
return replaceSpans(text, spans, ' ');
|
|
1896
|
+
}
|
|
1897
|
+
/** s.replace(/[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i, '') */
|
|
1898
|
+
function withoutGlyphShortcut(s) {
|
|
1899
|
+
let lt = -1;
|
|
1900
|
+
for (let k = s.length - 1; k >= 0; k--) {
|
|
1901
|
+
if (isLineTerminator(s.charCodeAt(k))) {
|
|
1902
|
+
lt = k;
|
|
1903
|
+
break;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
for (let k = lt + 1; k < s.length; k++) {
|
|
1907
|
+
const c = s.charCodeAt(k);
|
|
1908
|
+
if (c === 0x2303 || c === 0x2318 || c === 0x2325 || c === 0x21e7 || c === 0x2387) {
|
|
1909
|
+
let w = k;
|
|
1910
|
+
while (w > 0 && isJsSpace(s.charCodeAt(w - 1)))
|
|
1911
|
+
w--;
|
|
1912
|
+
return s.slice(0, w);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return s;
|
|
1916
|
+
}
|
|
1917
|
+
/** s.replace(/[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/, '') */
|
|
1918
|
+
function withoutTextShortcut(s) {
|
|
1919
|
+
const n = s.length;
|
|
1920
|
+
let plusRun = n;
|
|
1921
|
+
while (plusRun > 0) {
|
|
1922
|
+
const c = s.charCodeAt(plusRun - 1);
|
|
1923
|
+
if (!(isDigit(c) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 45))
|
|
1924
|
+
break;
|
|
1925
|
+
plusRun--;
|
|
1926
|
+
}
|
|
1927
|
+
let upperRun = n;
|
|
1928
|
+
while (upperRun > 0) {
|
|
1929
|
+
const c = s.charCodeAt(upperRun - 1);
|
|
1930
|
+
if (!(isDigit(c) || (c >= 65 && c <= 90)))
|
|
1931
|
+
break;
|
|
1932
|
+
upperRun--;
|
|
1933
|
+
}
|
|
1934
|
+
const mod = /Ctrl|Cmd|Shift|Alt|Option|Command/g;
|
|
1935
|
+
for (let m = mod.exec(s); m; m = mod.exec(s)) {
|
|
1936
|
+
const t = m.index + m[0].length;
|
|
1937
|
+
if (t === n || (s.charCodeAt(t) === 43 && t + 1 >= plusRun) || t >= upperRun) {
|
|
1938
|
+
let w = m.index;
|
|
1939
|
+
while (w > 0 && isJsSpace(s.charCodeAt(w - 1)))
|
|
1940
|
+
w--;
|
|
1941
|
+
return s.slice(0, w);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return s;
|
|
1945
|
+
}
|
|
1946
|
+
/** s.replace(/(?:\s*\+)+$/, '') */
|
|
1947
|
+
function withoutTrailingPluses(s) {
|
|
1948
|
+
if (s.charCodeAt(s.length - 1) !== 43)
|
|
1949
|
+
return s;
|
|
1950
|
+
let k = s.length;
|
|
1951
|
+
while (k > 0 && (s.charCodeAt(k - 1) === 43 || isJsSpace(s.charCodeAt(k - 1))))
|
|
1952
|
+
k--;
|
|
1953
|
+
return s.slice(0, k);
|
|
1954
|
+
}
|
|
1955
|
+
/** S2: /(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\)|#[0-9a-fA-F]{3,8})\s+1px(?:\s*,)/.test(text) */
|
|
1956
|
+
function hasColorThenHairline(text) {
|
|
1957
|
+
const close = nextMatchFinder(text, /\)/g);
|
|
1958
|
+
let tailAt = -1;
|
|
1959
|
+
let tailOk = false;
|
|
1960
|
+
const tail = (e) => {
|
|
1961
|
+
if (e !== tailAt) {
|
|
1962
|
+
tailAt = e;
|
|
1963
|
+
const t = wsEnd(text, e);
|
|
1964
|
+
tailOk = t > e && text.startsWith('1px', t) && text.charCodeAt(wsEnd(text, t + 3)) === 44;
|
|
1965
|
+
}
|
|
1966
|
+
return tailOk;
|
|
1967
|
+
};
|
|
1968
|
+
const head = /transparent|rgba\(|color-mix\(|var\(|#/g;
|
|
1969
|
+
for (let m = head.exec(text); m; m = head.exec(text)) {
|
|
1970
|
+
const i = m.index;
|
|
1971
|
+
const after = i + m[0].length;
|
|
1972
|
+
// `transparent` can overlap itself: try the very next position too.
|
|
1973
|
+
head.lastIndex = i + 1;
|
|
1974
|
+
if (m[0] === 'transparent') {
|
|
1975
|
+
if (tail(after))
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
else if (m[0] === '#') {
|
|
1979
|
+
let h = after;
|
|
1980
|
+
while (h < text.length && /[0-9a-fA-F]/.test(text[h]))
|
|
1981
|
+
h++;
|
|
1982
|
+
if (h - after >= 3 && h - after <= 8 && tail(h))
|
|
1983
|
+
return true;
|
|
1984
|
+
}
|
|
1985
|
+
else {
|
|
1986
|
+
const p = close(after);
|
|
1987
|
+
if (p === Infinity)
|
|
1988
|
+
continue;
|
|
1989
|
+
if (m[0] === 'rgba(' && p === after)
|
|
1990
|
+
continue;
|
|
1991
|
+
if (tail(p + 1))
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return false;
|
|
1996
|
+
}
|
|
1997
|
+
/** S2: /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text) */
|
|
1998
|
+
function hasHairlineThenColor(text) {
|
|
1999
|
+
const lastClose = text.lastIndexOf(')');
|
|
2000
|
+
for (let at = text.indexOf('1px'); at >= 0; at = text.indexOf('1px', at + 1)) {
|
|
2001
|
+
if (at === 0 || !isJsSpace(text.charCodeAt(at - 1)))
|
|
2002
|
+
continue;
|
|
2003
|
+
let t = wsEnd(text, at + 3);
|
|
2004
|
+
if (text.charCodeAt(t) !== 44)
|
|
2005
|
+
continue;
|
|
2006
|
+
t = wsEnd(text, t + 1);
|
|
2007
|
+
if (text.startsWith('transparent', t))
|
|
2008
|
+
return true;
|
|
2009
|
+
if (text.startsWith('rgba(', t) && text.charCodeAt(t + 5) !== 41 && lastClose > t + 5)
|
|
2010
|
+
return true;
|
|
2011
|
+
if (text.startsWith('color-mix(', t) && lastClose >= t + 10)
|
|
2012
|
+
return true;
|
|
2013
|
+
if (text.startsWith('var(', t) && lastClose >= t + 4)
|
|
2014
|
+
return true;
|
|
2015
|
+
}
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
/** S2: /\/\s*[^,/]*(?:^|\s)1px(?:$|[\s,])/.test(value) */
|
|
2019
|
+
function hasSlashThenHairline(value) {
|
|
2020
|
+
let lastSep = -1;
|
|
2021
|
+
for (let k = 0; k < value.length; k++) {
|
|
2022
|
+
const c = value.charCodeAt(k);
|
|
2023
|
+
if (c === 44 || c === 47) {
|
|
2024
|
+
lastSep = k;
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (c !== 49 || k === 0 || !value.startsWith('1px', k) || !isJsSpace(value.charCodeAt(k - 1)))
|
|
2028
|
+
continue;
|
|
2029
|
+
const next = value.charCodeAt(k + 3);
|
|
2030
|
+
if ((k + 3 === value.length || isJsSpace(next) || next === 44) && lastSep >= 0 && value.charCodeAt(lastSep) === 47)
|
|
2031
|
+
return true;
|
|
2032
|
+
}
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
/** css.match(/linear\(\s*[^)]{20,}\)/gi) || [] */
|
|
2036
|
+
function longLinearEasings(css) {
|
|
2037
|
+
const start = /linear\(/gi;
|
|
2038
|
+
const out = [];
|
|
2039
|
+
for (let pos = 0;;) {
|
|
2040
|
+
start.lastIndex = pos;
|
|
2041
|
+
const m = start.exec(css);
|
|
2042
|
+
if (!m)
|
|
2043
|
+
break;
|
|
2044
|
+
const after = m.index + m[0].length;
|
|
2045
|
+
const c = css.indexOf(')', after);
|
|
2046
|
+
if (c < 0)
|
|
2047
|
+
break;
|
|
2048
|
+
if (c - after >= 20)
|
|
2049
|
+
out.push(css.slice(m.index, c + 1));
|
|
2050
|
+
// Every start before c reads the same ')' with less between.
|
|
2051
|
+
pos = c + 1;
|
|
2052
|
+
}
|
|
2053
|
+
return out;
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Group 1 of each match of
|
|
2057
|
+
* /<link[^>]*rel=["']?stylesheet["']?[^>]*href=["']([^"']+)["']/gi:
|
|
2058
|
+
* the last quoted href in the tag after its first rel=stylesheet.
|
|
2059
|
+
*/
|
|
2060
|
+
function stylesheetLinkMatches(html) {
|
|
2061
|
+
const start = /<link/gi;
|
|
2062
|
+
const rel = nextMatchFinder(html, /rel=["']?stylesheet/gi);
|
|
2063
|
+
const hrefs = matchStarts(html, /href=["']/gi);
|
|
2064
|
+
const gt = nextMatchFinder(html, />/g);
|
|
2065
|
+
const n = html.length;
|
|
2066
|
+
const isQuote = (c) => c === 34 || c === 39;
|
|
2067
|
+
let lastQuote = n - 1;
|
|
2068
|
+
while (lastQuote >= 0 && !isQuote(html.charCodeAt(lastQuote)))
|
|
2069
|
+
lastQuote--;
|
|
2070
|
+
const out = [];
|
|
2071
|
+
let ptr = 0;
|
|
2072
|
+
for (let pos = 0;;) {
|
|
2073
|
+
start.lastIndex = pos;
|
|
2074
|
+
const m = start.exec(html);
|
|
2075
|
+
if (!m)
|
|
2076
|
+
break;
|
|
2077
|
+
const after = m.index + m[0].length;
|
|
2078
|
+
const g = Math.min(gt(after), n);
|
|
2079
|
+
while (ptr < hrefs.length && hrefs[ptr] < after)
|
|
2080
|
+
ptr++;
|
|
2081
|
+
let end = ptr;
|
|
2082
|
+
while (end < hrefs.length && hrefs[end] < g)
|
|
2083
|
+
end++;
|
|
2084
|
+
// The last href=" in the tag whose value is non-empty and closes.
|
|
2085
|
+
let h = -1;
|
|
2086
|
+
for (let k = end - 1; k >= ptr && h < 0; k--) {
|
|
2087
|
+
const v = hrefs[k] + 6;
|
|
2088
|
+
if (v < n && !isQuote(html.charCodeAt(v)) && lastQuote > v)
|
|
2089
|
+
h = hrefs[k];
|
|
2090
|
+
}
|
|
2091
|
+
const r = rel(after);
|
|
2092
|
+
if (h >= 0 && r < g && r + (isQuote(html.charCodeAt(r + 4)) ? 15 : 14) <= h) {
|
|
2093
|
+
let q = h + 7;
|
|
2094
|
+
while (!isQuote(html.charCodeAt(q)))
|
|
2095
|
+
q++;
|
|
2096
|
+
out.push({ index: m.index, text: html.slice(m.index, q + 1), groups: [html.slice(h + 6, q)] });
|
|
2097
|
+
pos = q + 1;
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
// Every start before g sees less of the same tag.
|
|
2101
|
+
if (g >= n)
|
|
2102
|
+
break;
|
|
2103
|
+
pos = g + 1;
|
|
2104
|
+
}
|
|
2105
|
+
return out;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* For each `{` in `opens`, the index of the `}` that brings the depth back to
|
|
2109
|
+
* zero from it, or -1: what a depth-counting walk from that brace finds, for
|
|
2110
|
+
* every brace in one pass.
|
|
2111
|
+
*/
|
|
2112
|
+
function closingBraces(code, opens) {
|
|
2113
|
+
const out = new Map();
|
|
2114
|
+
const stack = [];
|
|
2115
|
+
for (let k = 0; k < code.length; k++) {
|
|
2116
|
+
const ch = code.charCodeAt(k);
|
|
2117
|
+
if (ch === 123)
|
|
2118
|
+
stack.push(k);
|
|
2119
|
+
else if (ch === 125 && stack.length) {
|
|
2120
|
+
const open = stack.pop();
|
|
2121
|
+
if (opens.has(open))
|
|
2122
|
+
out.set(open, k);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
return out;
|
|
2126
|
+
}
|
|
2127
|
+
// ── The call sites, one helper each, so the test runs exactly what they run ──
|
|
2128
|
+
/** /<button[^>]*>([\s\S]*?)<\/button>/gi (v38, v39). */
|
|
2129
|
+
function buttonElements(html) {
|
|
2130
|
+
return elementMatches(html, /<button/gi, /<\/button>/gi);
|
|
2131
|
+
}
|
|
2132
|
+
/** /<label[^>]*>([\s\S]*?)<\/label>/gi (v39). */
|
|
2133
|
+
function labelElements(html) {
|
|
2134
|
+
return elementMatches(html, /<label/gi, /<\/label>/gi);
|
|
2135
|
+
}
|
|
2136
|
+
/** /<a[^>]*>([\s\S]*?)<\/a>/gi (v40). */
|
|
2137
|
+
function anchorElements(html) {
|
|
2138
|
+
return elementMatches(html, /<a/gi, /<\/a>/gi);
|
|
2139
|
+
}
|
|
2140
|
+
/** /<style[^>]*>([\s\S]*?)<\/style>/gi (inline stylesheets). */
|
|
2141
|
+
function styleElements(html) {
|
|
2142
|
+
return elementMatches(html, /<style/gi, /<\/style>/gi);
|
|
2143
|
+
}
|
|
2144
|
+
/** /<(?:a|div|span)[^>]*role=["']button["'][^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi (v38). */
|
|
2145
|
+
function roleButtonElements(html) {
|
|
2146
|
+
return roleElementMatches(html, /<(?:a|div|span)/gi, /role=["']button["']/gi, /<\/(?:a|div|span)>/gi);
|
|
2147
|
+
}
|
|
2148
|
+
/** /<[a-z]+[^>]*role=["']tab["'][^>]*>([\s\S]*?)<\/[a-z]+>/gi (v39). */
|
|
2149
|
+
function roleTabElements(html) {
|
|
2150
|
+
return roleElementMatches(html, /<[a-z]/gi, /role=["']tab["']/gi, /<\/[a-z]+>/gi);
|
|
2151
|
+
}
|
|
2152
|
+
/** v41: /class=["'][^"']*(eyebrow|eyebro|meta-label)[^"']*["']/i.test(attrs) */
|
|
2153
|
+
function hasEyebrowClass(attrs) {
|
|
2154
|
+
return quotedAttrHas(attrs, /class=["']/gi, /eyebro|meta-label/gi);
|
|
2155
|
+
}
|
|
2156
|
+
/** v41: /style=["'][^"']*text-transform:\s*uppercase[^"']*["']/i.test(attrs) */
|
|
2157
|
+
function hasUppercaseStyle(attrs) {
|
|
2158
|
+
return quotedAttrHas(attrs, /style=["']/gi, /text-transform:\s*uppercase/gi);
|
|
2159
|
+
}
|
|
2160
|
+
/** v17: /(?:h1|h2|h3|h4|h5|h6|\.h\d|heading|title)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi */
|
|
2161
|
+
function headingLineHeights(css) {
|
|
2162
|
+
return ruleBodyScan(css, bracedStarts(css, /h1|h2|h3|h4|h5|h6|\.h\d|heading|title/gi), /line-height/gi, /line-height\s*:\s*([0-9.]+)/iy, false);
|
|
2163
|
+
}
|
|
2164
|
+
/** v17: /(?:body|p|article|\.body|\.prose|\.copy)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi */
|
|
2165
|
+
function bodyLineHeights(css) {
|
|
2166
|
+
return ruleBodyScan(css, bracedStarts(css, /body|p|article|\.body|\.prose|\.copy/gi), /line-height/gi, /line-height\s*:\s*([0-9.]+)/iy, false);
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* v24: /(?:^|[,}\s])\s*(?:button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link)\s*[^{]*\{[^}]*(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gim
|
|
2170
|
+
*/
|
|
2171
|
+
function touchTargetSizes(css) {
|
|
2172
|
+
return ruleBodyScan(css, touchTargetStarts(css), /min-(?:height|width)/gi, /(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/iy, false);
|
|
2173
|
+
}
|
|
2174
|
+
/** v27: /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi */
|
|
2175
|
+
function fieldFontSizes(sheet) {
|
|
2176
|
+
return ruleBodyScan(sheet, bracedStarts(sheet, /input|textarea|select|\.input|\.field/gi), /font-size/gi, /font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/iy, false);
|
|
2177
|
+
}
|
|
2178
|
+
/** v27: /input\s*(?:,[^{]*)?\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/gi */
|
|
2179
|
+
function fieldFontFloors(sheet) {
|
|
2180
|
+
return ruleBodyScan(sheet, inputFloorStarts(sheet), /font-size/gi, /font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/iy, false);
|
|
2181
|
+
}
|
|
2182
|
+
/** v09: /:focus[^{]*\{[^}]*outline\s*:\s*(none|0)\s*[;}]/i.test(css) */
|
|
2183
|
+
function focusStripsOutline(css) {
|
|
2184
|
+
return ruleBodyScan(css, bracedStarts(css, /:focus/gi), /outline/gi, /outline\s*:\s*(none|0)\s*[;}]/iy, true).length > 0;
|
|
2185
|
+
}
|
|
2186
|
+
/** v09: /:focus[^{]*\{[^}]*(box-shadow|outline\s*:\s*[^n0])/i.test(css) */
|
|
2187
|
+
function focusHasRing(css) {
|
|
2188
|
+
return ruleBodyScan(css, bracedStarts(css, /:focus/gi), /box-shadow|outline/gi, /(box-shadow|outline\s*:\s*[^n0])/iy, true).length > 0;
|
|
2189
|
+
}
|
|
2190
|
+
/** v08: /@keyframes\s+[^{]*breath[^{]*\{[^}]*opacity\s*:/i.test(css) */
|
|
2191
|
+
function hasBreathKeyframes(css) {
|
|
2192
|
+
return ruleBodyScan(css, breathKeyframeStarts(css), /opacity/gi, /opacity\s*:/iy, true).length > 0;
|
|
2193
|
+
}
|
|
2194
|
+
/** v08: /@media[^{]*(?:hover\s*:\s*hover|pointer\s*:\s*fine)/i.test(css) */
|
|
2195
|
+
function mediaQueryHasHoverGuard(css) {
|
|
2196
|
+
return preludeHas(css, /@media/gi, /hover\s*:\s*hover|pointer\s*:\s*fine/gi);
|
|
2197
|
+
}
|
|
2198
|
+
/** v35: /@media[^{]*forced-colors\s*:\s*active/i.test(css) */
|
|
2199
|
+
function mediaQueryHasForcedColors(css) {
|
|
2200
|
+
return preludeHas(css, /@media/gi, /forced-colors\s*:\s*active/gi);
|
|
2201
|
+
}
|
|
2202
|
+
/** v35: /@media[^{]*-ms-high-contrast/i.test(css) */
|
|
2203
|
+
function mediaQueryHasHighContrast(css) {
|
|
2204
|
+
return preludeHas(css, /@media/gi, /-ms-high-contrast/gi);
|
|
2205
|
+
}
|
|
2206
|
+
/** O4: /@media[^{]*prefers-reduced-motion/i.test(css) */
|
|
2207
|
+
function mediaQueryHasReducedMotion(css) {
|
|
2208
|
+
return preludeHas(css, /@media/gi, /prefers-reduced-motion/gi);
|
|
2209
|
+
}
|
|
2210
|
+
/** v05: /@media[^{]*prefers-reduced-motion\s*:\s*(reduce|no-preference)\b[^{]*\{/gi */
|
|
2211
|
+
function reducedMotionQueries(code) {
|
|
2212
|
+
return preludeMatches(code, /@media/gi, /prefers-reduced-motion/gi, /prefers-reduced-motion\s*:\s*(reduce|no-preference)\b/iy);
|
|
2213
|
+
}
|
|
2214
|
+
/** color-mix: /^(\d*\.?\d+)%\s+([\s\S]*)$/.exec(p), the number without the ambiguous \d*\.?\d+ */
|
|
2215
|
+
function percentBefore(p) {
|
|
2216
|
+
return /^(\d+(?:\.\d+)?|\.\d+)%\s+([\s\S]*)$/.exec(p);
|
|
2217
|
+
}
|
|
2218
|
+
/** v45: /^(?:0*\.?0*|0*(?:\.0+)?%)$/.test(v), without the ambiguous 0*\.?0* */
|
|
2219
|
+
function isZeroOpacity(v) {
|
|
2220
|
+
return /^(?:0*(?:\.0*)?|0*(?:\.0+)?%)$/.test(v);
|
|
2221
|
+
}
|
|
2222
|
+
/** O1: the cubic-bezier() matches, each number written without the ambiguous \d*\.?\d+ */
|
|
2223
|
+
function cubicBezierMatches(css) {
|
|
2224
|
+
const re = /cubic-bezier\(\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*\)/gi;
|
|
2225
|
+
const out = [];
|
|
2226
|
+
for (let m = re.exec(css); m; m = re.exec(css))
|
|
2227
|
+
out.push({ index: m.index, text: m[0], groups: m.slice(1) });
|
|
2228
|
+
return out;
|
|
2229
|
+
}
|
|
2230
|
+
/** v34: /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i.test(html) */
|
|
2231
|
+
function hasAiWidgetScript(html) {
|
|
2232
|
+
return tagAttrValueHas(html, /<script\b/gi, /src\s*=\s*["']/gi, /\b(?:chatbot|ai-widget|dialogflow)\b/gi);
|
|
2233
|
+
}
|
|
2234
|
+
/** v34: /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i.test(html) */
|
|
2235
|
+
function hasChatScript(html) {
|
|
2236
|
+
return tagAttrValueHas(html, /<script\b/gi, /src\s*=\s*["']/gi, /\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/gi);
|
|
2237
|
+
}
|
|
2238
|
+
/** v34: /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i.test(html) */
|
|
2239
|
+
function hasAiChatContainer(html) {
|
|
2240
|
+
return tagAttrValueHas(html, /<(?:iframe|div|section|aside)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/gi);
|
|
2241
|
+
}
|
|
2242
|
+
/** v34: /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i.test(html) */
|
|
2243
|
+
function hasAiInput(html) {
|
|
2244
|
+
return tagAttrValueHas(html, /<(?:input|textarea|form)\b/gi, /(?:placeholder|aria-label)\s*=\s*["']/gi, /\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/gi);
|
|
2245
|
+
}
|
|
2246
|
+
/** v34: /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i.test(html) */
|
|
2247
|
+
function hasChatWidgetContainer(html) {
|
|
2248
|
+
return tagAttrValueHas(html, /<(?:iframe|div|section|aside)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/gi);
|
|
2249
|
+
}
|
|
2250
|
+
/** v34: /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i.test(html) */
|
|
2251
|
+
function hasAmbiguousWidget(html) {
|
|
2252
|
+
return tagAttrValueHas(html, /<(?:iframe|div|section)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/gi);
|
|
2253
|
+
}
|
|
2254
|
+
/** v34: /<meta\s+name\s*=\s*["']generator["'][^>]*content\s*=\s*["'][^"']*\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/i.test(html) */
|
|
2255
|
+
function hasAiGeneratorMeta(html) {
|
|
2256
|
+
return tagAttrValueHas(html, /<meta\s+name\s*=\s*["']generator["']/gi, /content\s*=\s*["']/gi, /\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/gi);
|
|
2257
|
+
}
|
|
2258
|
+
/** text.replace(/\[[^\]]*\]/g, fn) */
|
|
2259
|
+
function replaceBrackets(text, fn) {
|
|
2260
|
+
return replaceSpansWith(text, bracketSpans(text), fn);
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* S8: html with <script>, <style> and <template> elements and comments each
|
|
2264
|
+
* replaced by a space (/<script\b[\s\S]*?<\/script\s*>/gi and the like).
|
|
2265
|
+
*/
|
|
2266
|
+
function withoutNonVisibleMarkup(html) {
|
|
2267
|
+
const a = replaceSpans(html, lazyBlockSpans(html, /<script\b/gi, /<\/script\s*>/gi), ' ');
|
|
2268
|
+
const b = replaceSpans(a, lazyBlockSpans(a, /<style\b/gi, /<\/style\s*>/gi), ' ');
|
|
2269
|
+
const c = replaceSpans(b, lazyBlockSpans(b, /<template\b/gi, /<\/template\s*>/gi), ' ');
|
|
2270
|
+
return replaceSpans(c, lazyBlockSpans(c, /<!--/g, /-->/g), ' ');
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* S2: does some linear-gradient() in the value carry two or more colour stops?
|
|
2274
|
+
* The lower-cased copy is made once; it was remade for every gradient read.
|
|
2275
|
+
*/
|
|
2276
|
+
function hasMultiStopGradient(value) {
|
|
2277
|
+
const lower = value.toLowerCase();
|
|
2278
|
+
let from = 0;
|
|
2279
|
+
for (;;) {
|
|
2280
|
+
const at = lower.indexOf('linear-gradient(', from);
|
|
2281
|
+
if (at < 0)
|
|
2282
|
+
return false;
|
|
2283
|
+
let depth = 0;
|
|
2284
|
+
let end = -1;
|
|
2285
|
+
for (let i = at + 15; i < value.length; i++) {
|
|
2286
|
+
if (value[i] === '(')
|
|
2287
|
+
depth++;
|
|
2288
|
+
else if (value[i] === ')' && --depth === 0) {
|
|
2289
|
+
end = i;
|
|
2290
|
+
break;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (end < 0)
|
|
2294
|
+
return false;
|
|
2295
|
+
const args = splitCssTopLevel(value.slice(at + 16, end), ',');
|
|
2296
|
+
const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
|
|
2297
|
+
if (stops.length >= 2)
|
|
2298
|
+
return true;
|
|
2299
|
+
from = end;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
270
2302
|
// ── CSS extraction + token parsing ──────────────────────────────────────────
|
|
271
2303
|
function extractCssLinks(html, baseUrl) {
|
|
272
2304
|
const links = [];
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
links.push(m[1]);
|
|
277
|
-
const linkRe = /<link[^>]*rel=["']?stylesheet["']?[^>]*href=["']([^"']+)["']/gi;
|
|
278
|
-
while ((m = linkRe.exec(html)) !== null) {
|
|
2305
|
+
for (const m of styleElements(html))
|
|
2306
|
+
links.push(m.groups[0]);
|
|
2307
|
+
for (const m of stylesheetLinkMatches(html)) {
|
|
279
2308
|
try {
|
|
280
|
-
links.push(new URL(m[
|
|
2309
|
+
links.push(new URL(m.groups[0], baseUrl).href);
|
|
281
2310
|
}
|
|
282
2311
|
catch { /* skip */ }
|
|
283
2312
|
}
|
|
@@ -325,18 +2354,13 @@ async function fetchPageResilient(targetUrl) {
|
|
|
325
2354
|
return { ok: true, html, css: cssParts.join('\n') };
|
|
326
2355
|
}
|
|
327
2356
|
function extractRootTokens(css) {
|
|
328
|
-
const stripped = css
|
|
2357
|
+
const stripped = withoutMediaBlocks(css);
|
|
329
2358
|
const tokens = {};
|
|
330
2359
|
// `:root, :host { ... }` is the block Tailwind v4 writes its theme into;
|
|
331
2360
|
// until engine 1.3.0 only `:root {` was read, so those tokens were missed.
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const block = m[1];
|
|
336
|
-
const propRe = /--([\w-]+)\s*:\s*([^;]+?)(?:;|$)/g;
|
|
337
|
-
let p;
|
|
338
|
-
while ((p = propRe.exec(block)) !== null) {
|
|
339
|
-
tokens[`--${p[1]}`] = p[2].trim();
|
|
2361
|
+
for (const m of rootTokenBlocks(stripped)) {
|
|
2362
|
+
for (const p of customPropertyValues(m.groups[0])) {
|
|
2363
|
+
tokens[`--${p.groups[0]}`] = p.groups[1].trim();
|
|
340
2364
|
}
|
|
341
2365
|
}
|
|
342
2366
|
return tokens;
|
|
@@ -699,7 +2723,7 @@ function checkTabularNums(css) {
|
|
|
699
2723
|
* fixture broken-reduced-motion-inverted pins that case.
|
|
700
2724
|
*/
|
|
701
2725
|
function optsIntoMotion(body) {
|
|
702
|
-
if (
|
|
2726
|
+
if (hasViewTransitionNavigation(body))
|
|
703
2727
|
return true;
|
|
704
2728
|
const declRe = /(?:^|[{;\s])(animation|animation-name|animation-duration|transition|transition-property|transition-duration|scroll-behavior|view-transition-name)\s*:\s*([^;{}]+)/gi;
|
|
705
2729
|
let m;
|
|
@@ -713,7 +2737,7 @@ function optsIntoMotion(body) {
|
|
|
713
2737
|
}
|
|
714
2738
|
if (/^(?:none|initial|unset|revert|revert-layer|0|0s|0ms)$/.test(value))
|
|
715
2739
|
continue;
|
|
716
|
-
const times = [...value.matchAll(/(?:^|[\s,(])(\d
|
|
2740
|
+
const times = [...value.matchAll(/(?:^|[\s,(])(\d+(?:\.\d+)?|\.\d+)(ms|s)\b/g)].map((t) => parseFloat(t[1]) * (t[2] === 's' ? 1000 : 1));
|
|
717
2741
|
if (times.length > 0 && times.every((t) => t <= 10))
|
|
718
2742
|
continue;
|
|
719
2743
|
return true;
|
|
@@ -727,7 +2751,7 @@ function checkReducedMotion(css) {
|
|
|
727
2751
|
// prose mentioning prefers-reduced-motion — satisfied the old check. The
|
|
728
2752
|
// calibration corpus caught exactly that: the previous regex matched the
|
|
729
2753
|
// string anywhere, including inside /* */.
|
|
730
|
-
const code = css
|
|
2754
|
+
const code = withoutCssComments(css, '');
|
|
731
2755
|
// Match the media query and read its VALUE. Two shapes honour reduced motion:
|
|
732
2756
|
// * `reduce` with a non-empty body: motion is switched off for those users.
|
|
733
2757
|
// * `no-preference` with a body that opts INTO motion (optsIntoMotion): the
|
|
@@ -737,33 +2761,29 @@ function checkReducedMotion(css) {
|
|
|
737
2761
|
// pattern. A bare substring match on the feature name stays wrong in both
|
|
738
2762
|
// directions: an empty block, or a `no-preference` block that only switches
|
|
739
2763
|
// motion off, honours nothing and still WARNs.
|
|
740
|
-
const
|
|
741
|
-
let match;
|
|
2764
|
+
const queries = reducedMotionQueries(code);
|
|
742
2765
|
let sawReduce = false;
|
|
743
2766
|
let sawMotionOptIn = false;
|
|
744
2767
|
let sawNoPreference = false;
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (depth === 0) {
|
|
757
|
-
close = i;
|
|
758
|
-
break;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
2768
|
+
// Each block body ends at the brace that brings the depth back to zero,
|
|
2769
|
+
// found for every query in one pass (engine 1.5.1; a walk per query read
|
|
2770
|
+
// nested queries' bodies over and over).
|
|
2771
|
+
const closes = closingBraces(code, new Set(queries.map((q) => q.index + q.text.length - 1)));
|
|
2772
|
+
// A no-preference block inside one already read opts into motion only if
|
|
2773
|
+
// that one does, so it is not read again.
|
|
2774
|
+
let readUntil = -1;
|
|
2775
|
+
for (const match of queries) {
|
|
2776
|
+
const value = match.groups[0].toLowerCase();
|
|
2777
|
+
const open = match.index + match.text.length - 1;
|
|
2778
|
+
const close = closes.get(open) ?? -1;
|
|
762
2779
|
const body = close > open ? code.slice(open + 1, close).trim() : '';
|
|
763
2780
|
if (value === 'no-preference') {
|
|
764
2781
|
sawNoPreference = true;
|
|
765
|
-
if (
|
|
766
|
-
|
|
2782
|
+
if (!sawMotionOptIn && close > readUntil) {
|
|
2783
|
+
if (optsIntoMotion(body))
|
|
2784
|
+
sawMotionOptIn = true;
|
|
2785
|
+
readUntil = close;
|
|
2786
|
+
}
|
|
767
2787
|
continue;
|
|
768
2788
|
}
|
|
769
2789
|
// A `reduce` query that declares no rules reduces nothing.
|
|
@@ -792,9 +2812,9 @@ function checkSemanticHtmlFoundation(html) {
|
|
|
792
2812
|
// <meta name="description">, and a <main>/<header>/<nav> landmark. These are
|
|
793
2813
|
// Lighthouse a11y basics (document-title weight 4.1, heading-order 0.8) and
|
|
794
2814
|
// apply to every site. The check name/id stays v07 for continuity.
|
|
795
|
-
const visibleHtml = html
|
|
2815
|
+
const visibleHtml = withoutScriptsAndStyles(html);
|
|
796
2816
|
const h1Count = (visibleHtml.match(/<h1\b/gi) || []).length;
|
|
797
|
-
const hasTitle =
|
|
2817
|
+
const hasTitle = hasTitleText(html);
|
|
798
2818
|
const hasMetaDesc = /<meta\s+name=["']description["']/i.test(html);
|
|
799
2819
|
const hasLandmark = /<(main|header|nav)\b/i.test(visibleHtml);
|
|
800
2820
|
const signals = [];
|
|
@@ -830,7 +2850,7 @@ function checkSemanticHtmlFoundation(html) {
|
|
|
830
2850
|
function checkPressScale(css) {
|
|
831
2851
|
// Strip @keyframes blocks — scale(0) inside a ripple/particle keyframe is a
|
|
832
2852
|
// legitimate animation starting point, not a press scale.
|
|
833
|
-
const stripped = css
|
|
2853
|
+
const stripped = withoutKeyframesBlocks(css);
|
|
834
2854
|
// Collect all press-scale candidates: scale() < 1 found in CSS rule blocks
|
|
835
2855
|
// (not keyframes). We split by '}' to get individual rule blocks so we can
|
|
836
2856
|
// inspect the selector context for each scale() value.
|
|
@@ -888,15 +2908,8 @@ function checkPressScale(css) {
|
|
|
888
2908
|
// v17 — REAL line-height by role check. Scans for heading-tight (1.0-1.15)
|
|
889
2909
|
// and body-relaxed (1.4-1.7) line-heights. Contract: headings 1.08, body 1.55.
|
|
890
2910
|
function checkLineHeightByRole(css) {
|
|
891
|
-
const
|
|
892
|
-
const
|
|
893
|
-
const headingLhs = [];
|
|
894
|
-
const bodyLhs = [];
|
|
895
|
-
let m;
|
|
896
|
-
while ((m = headingRe.exec(css)) !== null)
|
|
897
|
-
headingLhs.push(parseFloat(m[1]));
|
|
898
|
-
while ((m = bodyRe.exec(css)) !== null)
|
|
899
|
-
bodyLhs.push(parseFloat(m[1]));
|
|
2911
|
+
const headingLhs = headingLineHeights(css).map((m) => parseFloat(m.groups[0]));
|
|
2912
|
+
const bodyLhs = bodyLineHeights(css).map((m) => parseFloat(m.groups[0]));
|
|
900
2913
|
const headingOk = headingLhs.some(lh => lh >= 1.0 && lh <= 1.15);
|
|
901
2914
|
const bodyOk = bodyLhs.some(lh => lh >= 1.4 && lh <= 1.7);
|
|
902
2915
|
if (headingOk && bodyOk)
|
|
@@ -914,7 +2927,7 @@ function checkLineHeightByRole(css) {
|
|
|
914
2927
|
// var(--signal) reference (or any non-default color). Browser default is
|
|
915
2928
|
// #000/#fff text on #0000ff blue background — any custom rule beats that.
|
|
916
2929
|
function checkSelectionStyled(css) {
|
|
917
|
-
const selMatch = css
|
|
2930
|
+
const selMatch = selectionRules(css);
|
|
918
2931
|
if (!selMatch || selMatch.length === 0)
|
|
919
2932
|
return { id: 'v20', item: '::selection styled with var(--signal) instead of the browser default', category: 'cadence', status: 'WARN', detail: 'no ::selection rule found: browser default will show' };
|
|
920
2933
|
const usesSignal = selMatch.some(r => /var\(\s*--signal/i.test(r));
|
|
@@ -971,12 +2984,10 @@ function checkSkipInk(css) {
|
|
|
971
2984
|
// Full verification needs a browser (getBoundingClientRect on rendered elements).
|
|
972
2985
|
function checkTouchTargets(css) {
|
|
973
2986
|
// Look for min-height/min-width ≥ 44px on button, a, input, [role=button] selectors.
|
|
974
|
-
const interactiveRe = /(?:^|[,}\s])\s*(?:button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link)\s*[^{]*\{[^}]*(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gim;
|
|
975
2987
|
const targets = [];
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
const unit = m[2].toLowerCase();
|
|
2988
|
+
for (const m of touchTargetSizes(css)) {
|
|
2989
|
+
const val = parseFloat(m.groups[0]);
|
|
2990
|
+
const unit = m.groups[1].toLowerCase();
|
|
980
2991
|
const px = unit === 'rem' ? val * 16 : val;
|
|
981
2992
|
targets.push(px);
|
|
982
2993
|
}
|
|
@@ -990,7 +3001,7 @@ function checkTouchTargets(css) {
|
|
|
990
3001
|
// v25 — Heading hierarchy: single h1, no skipped levels (h1→h3 jump = fail).
|
|
991
3002
|
// Scans HTML for heading order. design-auditor + Lighthouse heading-order check.
|
|
992
3003
|
function checkHeadingHierarchy(html) {
|
|
993
|
-
const visibleHtml = html
|
|
3004
|
+
const visibleHtml = withoutScriptsAndStyles(html);
|
|
994
3005
|
const headings = [...visibleHtml.matchAll(/<h([1-6])\b/gi)];
|
|
995
3006
|
if (headings.length === 0)
|
|
996
3007
|
return { id: 'v25', item: 'Heading hierarchy: single h1, no skipped levels', category: 'accessibility', status: 'WARN', detail: 'no heading elements found: page may lack structure' };
|
|
@@ -1129,7 +3140,7 @@ function checkFontFamilyCount(css, tokens) {
|
|
|
1129
3140
|
* ".field label" and "user-select" are not fields.
|
|
1130
3141
|
*/
|
|
1131
3142
|
function isFieldSelector(selector) {
|
|
1132
|
-
const clean = selector
|
|
3143
|
+
const clean = withoutCssComments(selector, ' ');
|
|
1133
3144
|
return splitCssTopLevel(clean, ',').some((part) => {
|
|
1134
3145
|
const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
|
|
1135
3146
|
const subject = compounds[compounds.length - 1] || '';
|
|
@@ -1140,22 +3151,60 @@ function isFieldSelector(selector) {
|
|
|
1140
3151
|
});
|
|
1141
3152
|
}
|
|
1142
3153
|
/**
|
|
1143
|
-
* v27 helper (engine 1.4.1): the
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
3154
|
+
* v27 helper (engine 1.4.1): the rule whose body holds the declaration at an
|
|
3155
|
+
* offset, its selector read backwards from that body's opening brace to the
|
|
3156
|
+
* previous closing brace, opening brace or semicolon. The finder returns
|
|
3157
|
+
* [start, open], the selector being css.slice(start, open), or null when the
|
|
3158
|
+
* offset is not inside a rule body (a stray declaration, or a brace closed in
|
|
3159
|
+
* between).
|
|
3160
|
+
*
|
|
3161
|
+
* Engine 1.5.1: the finder lists the sheet's braces and semicolons once and
|
|
3162
|
+
* answers each offset by binary search. Before, each declaration ran three
|
|
3163
|
+
* lastIndexOf scans, each reading back to the last character of its kind, so
|
|
3164
|
+
* a sheet with no semicolons was read back to its start once per declaration
|
|
3165
|
+
* and cost the square of its length. Each answer is the one those scans gave.
|
|
1148
3166
|
*/
|
|
1149
|
-
function
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
3167
|
+
function ruleSelectorFinder(css) {
|
|
3168
|
+
const opens = [];
|
|
3169
|
+
const closes = [];
|
|
3170
|
+
const marks = [];
|
|
3171
|
+
for (let i = 0; i < css.length; i++) {
|
|
3172
|
+
const c = css.charCodeAt(i);
|
|
3173
|
+
if (c === 123) {
|
|
3174
|
+
opens.push(i);
|
|
3175
|
+
marks.push(i);
|
|
3176
|
+
}
|
|
3177
|
+
else if (c === 125) {
|
|
3178
|
+
closes.push(i);
|
|
3179
|
+
marks.push(i);
|
|
3180
|
+
}
|
|
3181
|
+
else if (c === 59)
|
|
3182
|
+
marks.push(i);
|
|
3183
|
+
}
|
|
3184
|
+
// The last entry at or before x in an ascending list, or -1.
|
|
3185
|
+
const atOrBefore = (list, x) => {
|
|
3186
|
+
let lo = 0;
|
|
3187
|
+
let hi = list.length;
|
|
3188
|
+
while (lo < hi) {
|
|
3189
|
+
const mid = (lo + hi) >> 1;
|
|
3190
|
+
if (list[mid] <= x)
|
|
3191
|
+
lo = mid + 1;
|
|
3192
|
+
else
|
|
3193
|
+
hi = mid;
|
|
3194
|
+
}
|
|
3195
|
+
return lo > 0 ? list[lo - 1] : -1;
|
|
3196
|
+
};
|
|
3197
|
+
return (offset) => {
|
|
3198
|
+
const at = Math.max(0, offset);
|
|
3199
|
+
const open = atOrBefore(opens, at);
|
|
3200
|
+
if (open < 0 || atOrBefore(closes, at) > open)
|
|
3201
|
+
return null;
|
|
3202
|
+
return [atOrBefore(marks, open - 1) + 1, open];
|
|
3203
|
+
};
|
|
1155
3204
|
}
|
|
1156
3205
|
/**
|
|
1157
|
-
* v27 helper (engine 1.4.1): does the font-size declaration
|
|
1158
|
-
* a rule whose own selector names a field keyword (input, textarea, select,
|
|
3206
|
+
* v27 helper (engine 1.4.1): for an offset, does the font-size declaration
|
|
3207
|
+
* there sit in a rule whose own selector names a field keyword (input, textarea, select,
|
|
1159
3208
|
* .input, .field), the same words the literal scan looks for? v27's literal
|
|
1160
3209
|
* regexes find a keyword and then the next rule body, and their `[^{]*` runs
|
|
1161
3210
|
* across a closing brace. So the word "select" in `.chip { user-select: none }`
|
|
@@ -1168,9 +3217,22 @@ function ruleSelectorAt(css, offset) {
|
|
|
1168
3217
|
* isFieldSelector test dropped real fields (a 14px textarea, a 16px search
|
|
1169
3218
|
* input's floor).
|
|
1170
3219
|
*/
|
|
1171
|
-
function
|
|
1172
|
-
const
|
|
1173
|
-
|
|
3220
|
+
function fieldRuleFinder(css) {
|
|
3221
|
+
const ruleAt = ruleSelectorFinder(css);
|
|
3222
|
+
let seen = -1;
|
|
3223
|
+
let verdict = false;
|
|
3224
|
+
return (offset) => {
|
|
3225
|
+
const rule = ruleAt(offset);
|
|
3226
|
+
if (rule === null)
|
|
3227
|
+
return false;
|
|
3228
|
+
// The declarations of one rule share its verdict (engine 1.5.1: each one
|
|
3229
|
+
// read the selector again).
|
|
3230
|
+
if (rule[1] !== seen) {
|
|
3231
|
+
seen = rule[1];
|
|
3232
|
+
verdict = /input|textarea|select|\.field/i.test(withoutCssComments(css.slice(rule[0], rule[1]), ' '));
|
|
3233
|
+
}
|
|
3234
|
+
return verdict;
|
|
3235
|
+
};
|
|
1174
3236
|
}
|
|
1175
3237
|
/**
|
|
1176
3238
|
* v27 helper (engine 1.3.1): in each rule whose selector styles a field
|
|
@@ -1184,22 +3246,45 @@ function inFieldRule(css, offset) {
|
|
|
1184
3246
|
* a card caption) as an input below 16px. Only values that start with var(
|
|
1185
3247
|
* are touched; calc(), clamp() and min()/max() stay as written, as before.
|
|
1186
3248
|
*
|
|
1187
|
-
* The scan is anchored on each `font-size: var(` and
|
|
1188
|
-
*
|
|
3249
|
+
* The scan is anchored on each `font-size: var(` and finds its rule's selector
|
|
3250
|
+
* through ruleSelectorFinder, so its cost is linear in the sheet. A
|
|
1189
3251
|
* whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
|
|
1190
3252
|
* @media prelude: measured at +4.5 s on five 43 KB preludes.
|
|
1191
3253
|
*/
|
|
1192
3254
|
function resolveFontSizeVars(css, tokens) {
|
|
1193
3255
|
if (css.indexOf('var(') < 0)
|
|
1194
3256
|
return css;
|
|
1195
|
-
|
|
3257
|
+
const parts = [];
|
|
3258
|
+
const ruleAt = ruleSelectorFinder(css);
|
|
3259
|
+
let seen = -1;
|
|
3260
|
+
let styled = false;
|
|
3261
|
+
let last = 0;
|
|
3262
|
+
for (const m of fontSizeVarMatches(css)) {
|
|
3263
|
+
const decl = m.text;
|
|
3264
|
+
const lead = m.groups[0];
|
|
3265
|
+
const value = m.groups[1];
|
|
3266
|
+
const offset = m.index;
|
|
3267
|
+
parts.push(css.slice(last, offset));
|
|
3268
|
+
last = offset + decl.length;
|
|
1196
3269
|
// Not inside a rule body (a stray declaration, or a brace closed between).
|
|
1197
|
-
const
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
3270
|
+
const rule = ruleAt(offset);
|
|
3271
|
+
if (rule === null) {
|
|
3272
|
+
parts.push(decl);
|
|
3273
|
+
continue;
|
|
3274
|
+
}
|
|
3275
|
+
if (rule[1] !== seen) {
|
|
3276
|
+
seen = rule[1];
|
|
3277
|
+
styled = isFieldSelector(css.slice(rule[0], rule[1]));
|
|
3278
|
+
}
|
|
3279
|
+
if (!styled) {
|
|
3280
|
+
parts.push(decl);
|
|
3281
|
+
continue;
|
|
3282
|
+
}
|
|
1200
3283
|
const sub = substituteCssVars(value, [tokens]);
|
|
1201
|
-
|
|
1202
|
-
}
|
|
3284
|
+
parts.push(sub === null ? decl : lead + sub.trim());
|
|
3285
|
+
}
|
|
3286
|
+
parts.push(css.slice(last));
|
|
3287
|
+
return parts.join('');
|
|
1203
3288
|
}
|
|
1204
3289
|
/**
|
|
1205
3290
|
* v27 helper: does the delivered markup carry a field iOS Safari zooms into on
|
|
@@ -1208,10 +3293,10 @@ function resolveFontSizeVars(css, tokens) {
|
|
|
1208
3293
|
* image, file, range and color. An <input> with no type is a text field.
|
|
1209
3294
|
*/
|
|
1210
3295
|
function hasZoomableField(html) {
|
|
1211
|
-
const visible = html
|
|
3296
|
+
const visible = withoutScriptsAndStyles(html);
|
|
1212
3297
|
if (/<(?:textarea|select)\b/i.test(visible))
|
|
1213
3298
|
return true;
|
|
1214
|
-
const inputs = visible
|
|
3299
|
+
const inputs = inputTags(visible);
|
|
1215
3300
|
return inputs.some((tag) => {
|
|
1216
3301
|
const type = /\stype\s*=\s*["']?([a-z-]+)/i.exec(tag);
|
|
1217
3302
|
return !type || !/^(?:hidden|checkbox|radio|submit|button|reset|image|file|range|color)$/i.test(type[1]);
|
|
@@ -1236,16 +3321,15 @@ function hasZoomableField(html) {
|
|
|
1236
3321
|
// cannot resolve is left as written, so it reads as it did before.
|
|
1237
3322
|
function checkInputFontFloor(css, html, tokens = {}) {
|
|
1238
3323
|
const sheet = resolveFontSizeVars(css, tokens);
|
|
3324
|
+
const inFieldRule = fieldRuleFinder(sheet);
|
|
1239
3325
|
// Find input/textarea/select rules with font-size < 16px or < 1rem.
|
|
1240
|
-
const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
|
|
1241
3326
|
const below = [];
|
|
1242
|
-
|
|
1243
|
-
while ((m = inputRe.exec(sheet)) !== null) {
|
|
3327
|
+
for (const m of fieldFontSizes(sheet)) {
|
|
1244
3328
|
// Engine 1.4.1: only a declaration whose own rule styles a field counts.
|
|
1245
|
-
if (!inFieldRule(
|
|
3329
|
+
if (!inFieldRule(m.index + m.text.lastIndexOf('font-size')))
|
|
1246
3330
|
continue;
|
|
1247
|
-
const val = parseFloat(m[
|
|
1248
|
-
const unit = m[
|
|
3331
|
+
const val = parseFloat(m.groups[0]);
|
|
3332
|
+
const unit = m.groups[1].toLowerCase();
|
|
1249
3333
|
const px = unit === 'rem' ? val * 16 : val;
|
|
1250
3334
|
if (px < 16)
|
|
1251
3335
|
below.push(`${val}${unit} (${px}px)`);
|
|
@@ -1255,11 +3339,11 @@ function checkInputFontFloor(css, html, tokens = {}) {
|
|
|
1255
3339
|
// The strict form `input\s*\{` matches standalone rules; the grouped form
|
|
1256
3340
|
// `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
|
|
1257
3341
|
// Engine 1.4.1: the floor rule must itself style a field, as above.
|
|
1258
|
-
const floorRe = /input\s*(?:,[^{]*)?\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/gi;
|
|
1259
3342
|
let hasGlobalFloor = false;
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
3343
|
+
for (const f of fieldFontFloors(sheet)) {
|
|
3344
|
+
if (hasGlobalFloor)
|
|
3345
|
+
break;
|
|
3346
|
+
hasGlobalFloor = inFieldRule(f.index + f.text.lastIndexOf('font-size'));
|
|
1263
3347
|
}
|
|
1264
3348
|
if (hasGlobalFloor)
|
|
1265
3349
|
return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
|
|
@@ -1281,14 +3365,12 @@ function checkReadingWidth(css) {
|
|
|
1281
3365
|
// contains a prose-ish word (e.g. `.prose-grid`).
|
|
1282
3366
|
const STRUCTURAL_SELECTOR = /grid|table|row|col\b|flex|swatch|chip|badge|tab\b|nav\b|toolbar|chart|canvas|pre\b|code\b|kbd/i;
|
|
1283
3367
|
// Walk every rule block, capturing selector + declarations together.
|
|
1284
|
-
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
1285
3368
|
const proseInRange = [];
|
|
1286
3369
|
const proseOutOfRange = [];
|
|
1287
3370
|
const nonProse = [];
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
const
|
|
1291
|
-
const body = m[2];
|
|
3371
|
+
for (const m of cssRuleBlocks(css)) {
|
|
3372
|
+
const selector = withoutCssComments(m.groups[0], '').trim();
|
|
3373
|
+
const body = m.groups[1];
|
|
1292
3374
|
// Skip at-rule preludes (`@media (...)`, `@supports (...)`) — the block
|
|
1293
3375
|
// body is walked on the next iteration when the inner rule is seen. A
|
|
1294
3376
|
// media query wrapping prose rules should not itself be classified.
|
|
@@ -1607,6 +3689,16 @@ function parseCssRules(css) {
|
|
|
1607
3689
|
lastCssParse = { css, result };
|
|
1608
3690
|
return result;
|
|
1609
3691
|
}
|
|
3692
|
+
// Engine 1.5.1: limits that only a malformed or hostile sheet reaches. The
|
|
3693
|
+
// leaderboard cohort, measured 2026-10-15, nests at most 6 levels deep and
|
|
3694
|
+
// leaves at most one block open at the end of a page's CSS. A brace that
|
|
3695
|
+
// would open a level deeper than CSS_MAX_NESTING is read as text of the block
|
|
3696
|
+
// it sits in. At the end of the sheet at most CSS_MAX_OPEN_AT_END blocks left
|
|
3697
|
+
// open are closed and read, innermost first: each one reads to the end of the
|
|
3698
|
+
// sheet, so every further one was another full copy of the rest of it, and a
|
|
3699
|
+
// short sheet that left many blocks open cost seconds.
|
|
3700
|
+
const CSS_MAX_NESTING = 16;
|
|
3701
|
+
const CSS_MAX_OPEN_AT_END = 4;
|
|
1610
3702
|
function parseCssRulesUncached(css) {
|
|
1611
3703
|
const src = stripCssComments(css);
|
|
1612
3704
|
const n = src.length;
|
|
@@ -1616,6 +3708,7 @@ function parseCssRulesUncached(css) {
|
|
|
1616
3708
|
let order = 0;
|
|
1617
3709
|
let start = 0;
|
|
1618
3710
|
let paren = 0;
|
|
3711
|
+
let overflow = 0;
|
|
1619
3712
|
const close = (f, end) => {
|
|
1620
3713
|
f.parts.push(src.slice(f.segStart, end));
|
|
1621
3714
|
const decls = f.parts.join(' ').trim();
|
|
@@ -1674,6 +3767,12 @@ function parseCssRulesUncached(css) {
|
|
|
1674
3767
|
}
|
|
1675
3768
|
if (c === 123) {
|
|
1676
3769
|
paren = 0;
|
|
3770
|
+
if (stack.length >= CSS_MAX_NESTING) {
|
|
3771
|
+
overflow++;
|
|
3772
|
+
start = i + 1;
|
|
3773
|
+
i++;
|
|
3774
|
+
continue;
|
|
3775
|
+
}
|
|
1677
3776
|
const prelude = src.slice(start, i).trim();
|
|
1678
3777
|
const parent = stack.length ? stack[stack.length - 1] : null;
|
|
1679
3778
|
if (parent)
|
|
@@ -1714,6 +3813,12 @@ function parseCssRulesUncached(css) {
|
|
|
1714
3813
|
}
|
|
1715
3814
|
if (c === 125) {
|
|
1716
3815
|
paren = 0;
|
|
3816
|
+
if (overflow > 0) {
|
|
3817
|
+
overflow--;
|
|
3818
|
+
start = i + 1;
|
|
3819
|
+
i++;
|
|
3820
|
+
continue;
|
|
3821
|
+
}
|
|
1717
3822
|
const f = stack.pop();
|
|
1718
3823
|
if (f) {
|
|
1719
3824
|
close(f, i);
|
|
@@ -1727,9 +3832,9 @@ function parseCssRulesUncached(css) {
|
|
|
1727
3832
|
}
|
|
1728
3833
|
i++;
|
|
1729
3834
|
}
|
|
1730
|
-
|
|
3835
|
+
for (let closed = 0; stack.length; closed++) {
|
|
1731
3836
|
const f = stack.pop();
|
|
1732
|
-
if (f)
|
|
3837
|
+
if (f && closed < CSS_MAX_OPEN_AT_END)
|
|
1733
3838
|
close(f, n);
|
|
1734
3839
|
}
|
|
1735
3840
|
return { rules, keyframes };
|
|
@@ -1744,9 +3849,10 @@ function parseCssDecls(decls) {
|
|
|
1744
3849
|
if (!/^-{0,2}[a-zA-Z_][\w-]*$/.test(name))
|
|
1745
3850
|
continue;
|
|
1746
3851
|
let value = raw.slice(colon + 1).trim();
|
|
1747
|
-
const
|
|
3852
|
+
const cut = importantSuffixStart(value);
|
|
3853
|
+
const important = cut >= 0;
|
|
1748
3854
|
if (important)
|
|
1749
|
-
value = value.
|
|
3855
|
+
value = value.slice(0, cut).trim();
|
|
1750
3856
|
out.push({ prop: name.startsWith('--') ? name : name.toLowerCase(), value, important });
|
|
1751
3857
|
}
|
|
1752
3858
|
return out;
|
|
@@ -1837,7 +3943,7 @@ function cssNumber(token, percentScale) {
|
|
|
1837
3943
|
const t = token.trim().toLowerCase();
|
|
1838
3944
|
if (t === 'none')
|
|
1839
3945
|
return 0;
|
|
1840
|
-
const m = /^([+-]?(?:\d
|
|
3946
|
+
const m = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(%|deg|turn|rad|grad)?$/.exec(t);
|
|
1841
3947
|
if (!m)
|
|
1842
3948
|
return null;
|
|
1843
3949
|
const x = parseFloat(m[1]);
|
|
@@ -1919,8 +4025,8 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1919
4025
|
if (!['srgb', 'oklab', 'oklch'].includes(space))
|
|
1920
4026
|
return null;
|
|
1921
4027
|
const side = (p) => {
|
|
1922
|
-
const after =
|
|
1923
|
-
const before =
|
|
4028
|
+
const after = percentAfter(p);
|
|
4029
|
+
const before = percentBefore(p);
|
|
1924
4030
|
if (after)
|
|
1925
4031
|
return { color: after[1], pct: parseFloat(after[2]) / 100 };
|
|
1926
4032
|
if (before)
|
|
@@ -1986,7 +4092,7 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1986
4092
|
const mult = Math.min(1, sum);
|
|
1987
4093
|
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), Math.round(alpha * mult * 255) / 255];
|
|
1988
4094
|
}
|
|
1989
|
-
const rel =
|
|
4095
|
+
const rel = relativeColorMatch(args);
|
|
1990
4096
|
if (rel) {
|
|
1991
4097
|
const base = parseCssColor(rel[1], dark, depth + 1);
|
|
1992
4098
|
if (!base)
|
|
@@ -2132,8 +4238,7 @@ function themeSelectorKeys(selector) {
|
|
|
2132
4238
|
}
|
|
2133
4239
|
/** A selector part with its theme attributes and classes removed. */
|
|
2134
4240
|
function withoutThemeKeys(part) {
|
|
2135
|
-
return part
|
|
2136
|
-
.replace(/\[[^\]]*\]/g, (a) => (themeSelectorKeys(a).length ? '' : a))
|
|
4241
|
+
return replaceBrackets(part, (a) => (themeSelectorKeys(a).length ? '' : a))
|
|
2137
4242
|
.replace(V44_THEME_CLASS, '')
|
|
2138
4243
|
.trim();
|
|
2139
4244
|
}
|
|
@@ -2267,7 +4372,7 @@ function checkStatusTextContrast(css) {
|
|
|
2267
4372
|
const mediaKey = `@media (prefers-color-scheme: ${scheme})`;
|
|
2268
4373
|
const theme = t === 'root'
|
|
2269
4374
|
? (scheme ? ensure(mediaKey, [], mediaKey) : root)
|
|
2270
|
-
: ensure(t, t
|
|
4375
|
+
: ensure(t, attrAndClassParts(t) ?? [], null);
|
|
2271
4376
|
if (t === 'root' && scheme)
|
|
2272
4377
|
theme.dark = scheme === 'dark';
|
|
2273
4378
|
for (const d of decls) {
|
|
@@ -2799,7 +4904,7 @@ function compareSpecificity(a, b) {
|
|
|
2799
4904
|
}
|
|
2800
4905
|
/** One time value in ms, null when it is not a literal time. */
|
|
2801
4906
|
function cssTimeMs(token) {
|
|
2802
|
-
const m = /^(-?(?:\d
|
|
4907
|
+
const m = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(ms|s)$/i.exec(token.trim());
|
|
2803
4908
|
return m ? parseFloat(m[1]) * (m[2].toLowerCase() === 's' ? 1000 : 1) : null;
|
|
2804
4909
|
}
|
|
2805
4910
|
/** :is() and :where() in a selector, expanded into their alternatives (bounded). */
|
|
@@ -2861,7 +4966,6 @@ const V45_USER_ACTION = /^:(?:hover|focus|focus-within|focus-visible|active|targ
|
|
|
2861
4966
|
// ([data-motion=paused], [data-reduced-motion]), or a class that is one
|
|
2862
4967
|
// (.reduce-motion, .no-motion, .motion-paused, .animations-off). Component
|
|
2863
4968
|
// classes that only end in "paused" (a marquee's .logobar--paused) are not.
|
|
2864
|
-
const V45_TOGGLE_ATTR = /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i;
|
|
2865
4969
|
const V45_TOGGLE_CLASS = /^\.(?:is-|has-|prefers-)?(?:reduced?-motion|no-motion|motion-(?:off|paused|reduced?|none)|paused-motion|(?:no-)?animations?(?:-(?:off|paused|disabled|none))?|(?:pause|stop)-animations?|still)$/i;
|
|
2866
4970
|
/**
|
|
2867
4971
|
* Whether a compound before `*` reaches the whole document: html, :root or
|
|
@@ -2879,7 +4983,7 @@ function isDocumentScope(compound) {
|
|
|
2879
4983
|
if (type)
|
|
2880
4984
|
return /^(?:html|body|:root)$/i.test(type);
|
|
2881
4985
|
return simple.every((s) => s.startsWith('.') || s.startsWith('['))
|
|
2882
|
-
&& simple.some((s) =>
|
|
4986
|
+
&& simple.some((s) => isMotionToggleAttr(s) || V45_TOGGLE_CLASS.test(s));
|
|
2883
4987
|
}
|
|
2884
4988
|
// v46 — Type sizes come from the scale (engine 1.4.0).
|
|
2885
4989
|
//
|
|
@@ -2905,7 +5009,7 @@ const V46_ITEM = 'Type sizes come from the scale';
|
|
|
2905
5009
|
const V46_TOLERANCE = 0.025;
|
|
2906
5010
|
const V46_FLUID = /(?:^|[^a-z-])(?:[sld]?v[wh]|v[ib]|vmin|vmax|cq[iwhb]|cqmin|cqmax)\b/i;
|
|
2907
5011
|
function v46StaticPx(value) {
|
|
2908
|
-
const m = value.trim().match(/^(\d
|
|
5012
|
+
const m = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(rem|px)$/i);
|
|
2909
5013
|
if (!m)
|
|
2910
5014
|
return null;
|
|
2911
5015
|
return parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1);
|
|
@@ -2952,7 +5056,7 @@ function v46Selector(css, at) {
|
|
|
2952
5056
|
if (open < 0)
|
|
2953
5057
|
return '';
|
|
2954
5058
|
const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
|
|
2955
|
-
return css.slice(start, open)
|
|
5059
|
+
return withoutCssComments(css.slice(start, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
2956
5060
|
}
|
|
2957
5061
|
function checkTypeScale(css, tokens) {
|
|
2958
5062
|
const scale = v46TypeScale(tokens);
|
|
@@ -2977,7 +5081,7 @@ function checkTypeScale(css, tokens) {
|
|
|
2977
5081
|
const re = /font-size\s*:\s*([^;}]+)/gi;
|
|
2978
5082
|
let m;
|
|
2979
5083
|
while ((m = re.exec(css)) !== null) {
|
|
2980
|
-
const written = m[1]
|
|
5084
|
+
const written = withoutCssComments(m[1], '').replace(/!important/i, '').trim();
|
|
2981
5085
|
const value = resolveVar(written, tokens);
|
|
2982
5086
|
if (/^var\(/i.test(value))
|
|
2983
5087
|
continue; // an alias the token table cannot resolve
|
|
@@ -3082,7 +5186,7 @@ function checkPausedEntrances(css) {
|
|
|
3082
5186
|
visibility = d.value.trim().toLowerCase();
|
|
3083
5187
|
}
|
|
3084
5188
|
}
|
|
3085
|
-
return (opacity !== null && /\d/.test(opacity) &&
|
|
5189
|
+
return (opacity !== null && /\d/.test(opacity) && isZeroOpacity(opacity)) || visibility === 'hidden';
|
|
3086
5190
|
};
|
|
3087
5191
|
if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
|
|
3088
5192
|
hiddenNames.add(name);
|
|
@@ -3302,55 +5406,45 @@ function checkAiDisclosure(html) {
|
|
|
3302
5406
|
const CATEGORY = 'identity';
|
|
3303
5407
|
// Strip scripts/styles for visible-text checks but keep them for script-src
|
|
3304
5408
|
// and JSON-LD detection. We use both raw html and a visible-only variant.
|
|
3305
|
-
const visibleHtml = html
|
|
3306
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
3307
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
5409
|
+
const visibleHtml = withoutScriptsAndStyles(html);
|
|
3308
5410
|
// Text-only: strip all tags so attribute values like class="chatbot" don't
|
|
3309
5411
|
// false-positive as disclosure TEXT. Disclosure text must be human-readable
|
|
3310
5412
|
// content between tags, not class/id/aria attribute values.
|
|
3311
|
-
const textOnly = visibleHtml
|
|
5413
|
+
const textOnly = withoutTags(visibleHtml, ' ', true);
|
|
3312
5414
|
// ── AI chat surfaces, positively identified ─────────────────────────────
|
|
3313
|
-
const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
|
|
3314
|
-
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
|
|
3315
|
-
const aiInputRe = /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i;
|
|
3316
5415
|
// ── Possible AI surfaces: a review item, never a FAIL ───────────────────
|
|
3317
|
-
const chatContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i;
|
|
3318
|
-
const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
|
|
3319
5416
|
const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
|
|
3320
|
-
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
3321
5417
|
const surfaceSignals = [];
|
|
3322
|
-
if (
|
|
5418
|
+
if (hasAiChatContainer(html))
|
|
3323
5419
|
surfaceSignals.push('chatbot container');
|
|
3324
|
-
if (
|
|
5420
|
+
if (hasAiWidgetScript(html))
|
|
3325
5421
|
surfaceSignals.push('AI widget script');
|
|
3326
|
-
if (
|
|
5422
|
+
if (hasAiInput(html))
|
|
3327
5423
|
surfaceSignals.push('AI input');
|
|
3328
5424
|
const possibleSignals = [];
|
|
3329
|
-
if (
|
|
5425
|
+
if (hasChatWidgetContainer(html))
|
|
3330
5426
|
possibleSignals.push('chat widget container');
|
|
3331
|
-
if (
|
|
5427
|
+
if (hasChatScript(html))
|
|
3332
5428
|
possibleSignals.push('chat or assistant script');
|
|
3333
5429
|
if (aiTextRe.test(textOnly))
|
|
3334
5430
|
possibleSignals.push('AI prompt text');
|
|
3335
|
-
if (
|
|
5431
|
+
if (hasAmbiguousWidget(html))
|
|
3336
5432
|
possibleSignals.push('ambiguous widget');
|
|
3337
5433
|
// ── Disclosure detection ────────────────────────────────────────────────
|
|
3338
5434
|
const disclosureTextRe = /\b(?:ai\s+assistant|ai\s+chatbot|ai-powered|powered\s+by\s+ai|automated\s+assistant|virtual\s+assistant|chatbot)\b/i;
|
|
3339
5435
|
const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
|
|
3340
|
-
const generatorMetaRe = /<meta\s+name\s*=\s*["']generator["'][^>]*content\s*=\s*["'][^"']*\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/i;
|
|
3341
5436
|
const c2paRe = /(?:<link\s+rel\s*=\s*["']c2pa-manifest["']|<meta\s+name\s*=\s*["']c2pa["'])/i;
|
|
3342
|
-
const jsonldAiRe = /<script\s+type\s*=\s*["']application\/ld\+json["'][\s\S]*?"@type"\s*:\s*"[^"]*\b(?:ai|softwareapplication)\b[^"]*"[\s\S]*?(?:"applicationCategory"\s*:\s*"[^"]*ai[^"]*"|"[^"]*ai[^"]*")/i;
|
|
3343
5437
|
const dataAiRe = /data-ai-disclosure\s*=/i;
|
|
3344
5438
|
const disclosureSignals = [];
|
|
3345
5439
|
if (disclosureTextRe.test(textOnly))
|
|
3346
5440
|
disclosureSignals.push('visible AI text');
|
|
3347
5441
|
if (ariaDisclosureRe.test(html))
|
|
3348
5442
|
disclosureSignals.push('aria-label');
|
|
3349
|
-
if (
|
|
5443
|
+
if (hasAiGeneratorMeta(html))
|
|
3350
5444
|
disclosureSignals.push('generator meta');
|
|
3351
5445
|
if (c2paRe.test(html))
|
|
3352
5446
|
disclosureSignals.push('C2PA manifest');
|
|
3353
|
-
if (
|
|
5447
|
+
if (hasAiJsonLd(html))
|
|
3354
5448
|
disclosureSignals.push('JSON-LD');
|
|
3355
5449
|
if (dataAiRe.test(html))
|
|
3356
5450
|
disclosureSignals.push('data-ai-disclosure');
|
|
@@ -3377,11 +5471,11 @@ function checkForcedColors(css) {
|
|
|
3377
5471
|
const ITEM = 'Forced-colors readiness: @media (forced-colors: active) block present';
|
|
3378
5472
|
const CATEGORY = 'accessibility';
|
|
3379
5473
|
// Primary signal: @media (forced-colors: active) block exists
|
|
3380
|
-
const hasForcedColorsMedia =
|
|
5474
|
+
const hasForcedColorsMedia = mediaQueryHasForcedColors(css);
|
|
3381
5475
|
// Secondary signal: forced-color-adjust property used anywhere
|
|
3382
5476
|
const hasForcedColorAdjust = /forced-color-adjust\s*:/i.test(css);
|
|
3383
5477
|
// Tertiary signal: -ms-high-contrast (legacy Edge/IE) — still relevant
|
|
3384
|
-
const hasHighContrast =
|
|
5478
|
+
const hasHighContrast = mediaQueryHasHighContrast(css);
|
|
3385
5479
|
if (hasForcedColorsMedia && hasForcedColorAdjust) {
|
|
3386
5480
|
return { id: 'v35', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'forced-colors media query + forced-color-adjust both present' };
|
|
3387
5481
|
}
|
|
@@ -3534,11 +5628,10 @@ function checkSecurityConfusables(css, tokens) {
|
|
|
3534
5628
|
}
|
|
3535
5629
|
// Scan 3: url() references — confusables in file paths can redirect to
|
|
3536
5630
|
// different assets (logo spoofing, CSS injection via @import)
|
|
3537
|
-
const urlRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
|
|
3538
5631
|
const urlConfusables = [];
|
|
3539
5632
|
const scannedUrls = new Set();
|
|
3540
|
-
|
|
3541
|
-
const urlPath =
|
|
5633
|
+
for (const u of cssUrlMatches(css)) {
|
|
5634
|
+
const urlPath = u.groups[0];
|
|
3542
5635
|
if (scannedUrls.has(urlPath))
|
|
3543
5636
|
continue;
|
|
3544
5637
|
scannedUrls.add(urlPath);
|
|
@@ -3661,8 +5754,6 @@ function checkButtonTextVerb(html) {
|
|
|
3661
5754
|
const ITEM = 'Button text is a verb phrase or recognized command (not a bare noun)';
|
|
3662
5755
|
const CATEGORY = 'copywriting';
|
|
3663
5756
|
// Extract <button> and [role="button"] text content
|
|
3664
|
-
const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
|
|
3665
|
-
const roleButtonRe = /<(?:a|div|span)[^>]*role=["']button["'][^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi;
|
|
3666
5757
|
// Remove aria-hidden subtrees before extracting text.
|
|
3667
5758
|
//
|
|
3668
5759
|
// Content marked aria-hidden is by definition not part of the accessible name,
|
|
@@ -3679,7 +5770,7 @@ function checkButtonTextVerb(html) {
|
|
|
3679
5770
|
let out = inner;
|
|
3680
5771
|
for (let i = 0; i < 4; i++) {
|
|
3681
5772
|
// Repeat to handle an aria-hidden wrapper containing another.
|
|
3682
|
-
const next = out
|
|
5773
|
+
const next = withoutAriaHiddenElements(out);
|
|
3683
5774
|
if (next === out)
|
|
3684
5775
|
break;
|
|
3685
5776
|
out = next;
|
|
@@ -3701,12 +5792,10 @@ function checkButtonTextVerb(html) {
|
|
|
3701
5792
|
// Carry the opening tag alongside the label so the violation loop can tell a
|
|
3702
5793
|
// command button from a selection control (see isSelectionControl).
|
|
3703
5794
|
const buttonTexts = [];
|
|
3704
|
-
let m;
|
|
3705
5795
|
// Strip keyboard shortcut hints that are fused to or appended after the
|
|
3706
5796
|
// button label — e.g. "Find⌘K", "Search ⌘+K", "Save Ctrl+S". These are
|
|
3707
5797
|
// visual hints, not part of the verb. Ranges: ⌘ (U+2318), ⌃ (U+2303),
|
|
3708
5798
|
// ⌥ (U+2325), ⇧ (U+21E7), and common "Ctrl+", "Cmd+", "Shift+" prefixes.
|
|
3709
|
-
const SHORTCUT_RE = /[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i;
|
|
3710
5799
|
// The `+` is optional and the key may be fused directly to the modifier
|
|
3711
5800
|
// ("CtrlK"), because the badge is now platform-aware and renders "Ctrl" with
|
|
3712
5801
|
// no glyph to strip. Requiring a literal '+' meant "FindCtrlK" survived and
|
|
@@ -3715,14 +5804,13 @@ function checkButtonTextVerb(html) {
|
|
|
3715
5804
|
// The key must be ADJACENT to the modifier (plus form, or fused alphanumerics
|
|
3716
5805
|
// with no space between). A space separates a real word: "Alt text" is a
|
|
3717
5806
|
// label, not a shortcut, and must not be eaten.
|
|
3718
|
-
const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
|
|
3719
5807
|
// Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
|
|
3720
5808
|
// "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
|
|
3721
5809
|
// must stand as its own word (the start of the label or after a space), so
|
|
3722
5810
|
// "Halt+1" is untouched, and only the plus form is read: a modifier followed
|
|
3723
5811
|
// by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
|
|
3724
5812
|
const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
|
|
3725
|
-
const stripShortcut = (s) =>
|
|
5813
|
+
const stripShortcut = (s) => withoutTextShortcut(withoutGlyphShortcut(s)).replace(PLUS_SHORTCUT_RE, '').trim();
|
|
3726
5814
|
// Engine 1.3.1: keycaps are read from the markup, not guessed from text.
|
|
3727
5815
|
// A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
|
|
3728
5816
|
// a hint beside the label rather than the label, so its text is dropped
|
|
@@ -3737,23 +5825,23 @@ function checkButtonTextVerb(html) {
|
|
|
3737
5825
|
const visible = stripAriaHidden(inner);
|
|
3738
5826
|
let out = visible;
|
|
3739
5827
|
for (let i = 0; i < 4; i++) {
|
|
3740
|
-
const next = out
|
|
5828
|
+
const next = withoutKbdElements(out);
|
|
3741
5829
|
if (next === out)
|
|
3742
5830
|
break;
|
|
3743
5831
|
out = next;
|
|
3744
5832
|
}
|
|
3745
|
-
const text = out
|
|
3746
|
-
return out === visible ? text : text
|
|
5833
|
+
const text = withoutTags(out, '', false).trim();
|
|
5834
|
+
return out === visible ? text : withoutTrailingPluses(text).trim();
|
|
3747
5835
|
};
|
|
3748
|
-
|
|
3749
|
-
let text = labelText(m[
|
|
5836
|
+
for (const m of buttonElements(html)) {
|
|
5837
|
+
let text = labelText(m.groups[0]);
|
|
3750
5838
|
// Strip leading icon characters so "✕Close" → "Close"
|
|
3751
5839
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3752
5840
|
// Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
|
|
3753
5841
|
text = stripShortcut(text);
|
|
3754
5842
|
// Icon-only button with an accessible name: use the aria-label verb.
|
|
3755
5843
|
if (!text) {
|
|
3756
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5844
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3757
5845
|
if (aria)
|
|
3758
5846
|
text = aria[1].trim();
|
|
3759
5847
|
}
|
|
@@ -3763,19 +5851,19 @@ function checkButtonTextVerb(html) {
|
|
|
3763
5851
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3764
5852
|
// on /maturity — an accurate observation of the wrong string.
|
|
3765
5853
|
if (text && text.length <= 2) {
|
|
3766
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5854
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3767
5855
|
if (aria)
|
|
3768
5856
|
text = aria[1].trim();
|
|
3769
5857
|
}
|
|
3770
5858
|
if (text)
|
|
3771
|
-
buttonTexts.push({ text, tag: m
|
|
5859
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3772
5860
|
}
|
|
3773
|
-
|
|
3774
|
-
let text = labelText(m[
|
|
5861
|
+
for (const m of roleButtonElements(html)) {
|
|
5862
|
+
let text = labelText(m.groups[0]);
|
|
3775
5863
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3776
5864
|
text = stripShortcut(text);
|
|
3777
5865
|
if (!text) {
|
|
3778
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5866
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3779
5867
|
if (aria)
|
|
3780
5868
|
text = aria[1].trim();
|
|
3781
5869
|
}
|
|
@@ -3785,12 +5873,12 @@ function checkButtonTextVerb(html) {
|
|
|
3785
5873
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3786
5874
|
// on /maturity — an accurate observation of the wrong string.
|
|
3787
5875
|
if (text && text.length <= 2) {
|
|
3788
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5876
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3789
5877
|
if (aria)
|
|
3790
5878
|
text = aria[1].trim();
|
|
3791
5879
|
}
|
|
3792
5880
|
if (text)
|
|
3793
|
-
buttonTexts.push({ text, tag: m
|
|
5881
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3794
5882
|
}
|
|
3795
5883
|
if (buttonTexts.length === 0) {
|
|
3796
5884
|
return { id: 'v38', item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'no button elements found in HTML' };
|
|
@@ -3860,23 +5948,19 @@ function checkButtonTextVerb(html) {
|
|
|
3860
5948
|
function checkNoTrailingPeriod(html) {
|
|
3861
5949
|
const ITEM = 'No trailing period on button text, labels, or tab text';
|
|
3862
5950
|
const CATEGORY = 'copywriting';
|
|
3863
|
-
const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
|
|
3864
|
-
const labelRe = /<label[^>]*>([\s\S]*?)<\/label>/gi;
|
|
3865
|
-
const tabRe = /<[a-z]+[^>]*role=["']tab["'][^>]*>([\s\S]*?)<\/[a-z]+>/gi;
|
|
3866
5951
|
const texts = [];
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
5952
|
+
for (const m of buttonElements(html)) {
|
|
5953
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3870
5954
|
if (text)
|
|
3871
5955
|
texts.push({ type: 'button', text });
|
|
3872
5956
|
}
|
|
3873
|
-
|
|
3874
|
-
const text = m
|
|
5957
|
+
for (const m of labelElements(html)) {
|
|
5958
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3875
5959
|
if (text)
|
|
3876
5960
|
texts.push({ type: 'label', text });
|
|
3877
5961
|
}
|
|
3878
|
-
|
|
3879
|
-
const text = m
|
|
5962
|
+
for (const m of roleTabElements(html)) {
|
|
5963
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3880
5964
|
if (text)
|
|
3881
5965
|
texts.push({ type: 'tab', text });
|
|
3882
5966
|
}
|
|
@@ -3900,11 +5984,9 @@ const NON_DESCRIPTIVE_LINK_TEXT = /^(click here|here|learn more|read more|more|l
|
|
|
3900
5984
|
function checkLinkTextDescriptive(html) {
|
|
3901
5985
|
const ITEM = 'Link text is descriptive (not bare "click here", "learn more", "here")';
|
|
3902
5986
|
const CATEGORY = 'copywriting';
|
|
3903
|
-
const linkRe = /<a[^>]*>([\s\S]*?)<\/a>/gi;
|
|
3904
5987
|
const linkTexts = [];
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
5988
|
+
for (const m of anchorElements(html)) {
|
|
5989
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3908
5990
|
if (text)
|
|
3909
5991
|
linkTexts.push(text);
|
|
3910
5992
|
}
|
|
@@ -3934,20 +6016,18 @@ function checkNoAllCaps(html) {
|
|
|
3934
6016
|
const ITEM = 'No ALL CAPS UI text except eyebrow labels';
|
|
3935
6017
|
const CATEGORY = 'copywriting';
|
|
3936
6018
|
// Match elements with their class attributes so we can exclude eyebrow labels
|
|
3937
|
-
const elementRe = /<(button|a|label|td|th|p|li|h[1-6])\s([^>]*?)>([\s\S]*?)<\/\1>/gi;
|
|
3938
6019
|
const violations = [];
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
const
|
|
3942
|
-
const text = m[3].replace(/<[^>]*>/g, '').trim();
|
|
6020
|
+
for (const m of capsElementMatches(html)) {
|
|
6021
|
+
const attrs = m.groups[1] || '';
|
|
6022
|
+
const text = withoutTags(m.groups[2], '', false).trim();
|
|
3943
6023
|
if (!text || text.length < 4)
|
|
3944
6024
|
continue;
|
|
3945
6025
|
// Skip eyebrow labels (class contains "eyebrow" or "label" — per typography
|
|
3946
6026
|
// contract, eyebrows are intentionally uppercase)
|
|
3947
|
-
if (
|
|
6027
|
+
if (hasEyebrowClass(attrs))
|
|
3948
6028
|
continue;
|
|
3949
6029
|
// Skip elements with text-transform: uppercase in inline style
|
|
3950
|
-
if (
|
|
6030
|
+
if (hasUppercaseStyle(attrs))
|
|
3951
6031
|
continue;
|
|
3952
6032
|
// Check if text is ALL CAPS (only letters, all uppercase, >3 chars)
|
|
3953
6033
|
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
@@ -3979,19 +6059,19 @@ function checkPoiseInteractionRules(css) {
|
|
|
3979
6059
|
const rules = [
|
|
3980
6060
|
{
|
|
3981
6061
|
name: 'fine-pointer hover guard',
|
|
3982
|
-
|
|
6062
|
+
test: (s) => mediaQueryHasHoverGuard(s),
|
|
3983
6063
|
},
|
|
3984
6064
|
{
|
|
3985
6065
|
name: 'press settle scale ~0.97',
|
|
3986
|
-
|
|
6066
|
+
test: (s) => /scale\s*\(\s*0?\.9[5-9]\s*\)/i.test(s),
|
|
3987
6067
|
},
|
|
3988
6068
|
{
|
|
3989
6069
|
name: 'opacity-only mark breath',
|
|
3990
|
-
|
|
6070
|
+
test: (s) => hasBreathKeyframes(s),
|
|
3991
6071
|
},
|
|
3992
6072
|
];
|
|
3993
|
-
const found = rules.filter(r => r.
|
|
3994
|
-
const missing = rules.filter(r => !r.
|
|
6073
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6074
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
3995
6075
|
if (found.length >= 2) {
|
|
3996
6076
|
return {
|
|
3997
6077
|
id: 'v08',
|
|
@@ -4020,8 +6100,8 @@ function checkPoiseKeyboardPath(css, html) {
|
|
|
4020
6100
|
const hasFocusVisible = /:focus-visible/i.test(css);
|
|
4021
6101
|
const hasFocus = /:focus[^-]/i.test(css);
|
|
4022
6102
|
// Detect focus styles that strip outline without a replacement ring/box-shadow
|
|
4023
|
-
const stripsOutline =
|
|
4024
|
-
const hasFocusRing =
|
|
6103
|
+
const stripsOutline = focusStripsOutline(css);
|
|
6104
|
+
const hasFocusRing = focusHasRing(css);
|
|
4025
6105
|
const hasTabindex = /tabindex\s*=/i.test(html);
|
|
4026
6106
|
const hasAria = /aria-(label|labelledby|describedby|expanded|selected|pressed)/i.test(html);
|
|
4027
6107
|
const signals = [hasFocusVisible, hasFocus, hasFocusRing, hasTabindex, hasAria].filter(Boolean).length;
|
|
@@ -4063,19 +6143,19 @@ function checkTaktFeelRules(css) {
|
|
|
4063
6143
|
const rules = [
|
|
4064
6144
|
{
|
|
4065
6145
|
name: 'stagger enter animation-delay',
|
|
4066
|
-
|
|
6146
|
+
test: (s) => /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i.test(s),
|
|
4067
6147
|
},
|
|
4068
6148
|
{
|
|
4069
6149
|
name: 'soften exit transform ease-out',
|
|
4070
|
-
|
|
6150
|
+
test: (s) => hasTransformEaseTransition(s),
|
|
4071
6151
|
},
|
|
4072
6152
|
{
|
|
4073
6153
|
name: 'concentric border-radius set',
|
|
4074
|
-
|
|
6154
|
+
test: (s) => /border-radius\s*:\s*\d+/i.test(s),
|
|
4075
6155
|
},
|
|
4076
6156
|
];
|
|
4077
|
-
const found = rules.filter(r => r.
|
|
4078
|
-
const missing = rules.filter(r => !r.
|
|
6157
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6158
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
4079
6159
|
if (found.length >= 2) {
|
|
4080
6160
|
return {
|
|
4081
6161
|
id: 'v10',
|
|
@@ -4172,13 +6252,50 @@ async function checkDesignMdSpec(targetUrl) {
|
|
|
4172
6252
|
// (an account name often does); the last one may not, so the sentence after
|
|
4173
6253
|
// a path survives, and trailing punctuation is kept.
|
|
4174
6254
|
function sanitizeErrorText(text) {
|
|
4175
|
-
|
|
4176
|
-
|
|
6255
|
+
// The punctuation a replaced path ended with, read back from its end
|
|
6256
|
+
// (engine 1.5.1: /[.,;:!?)]+$/ was retried from every mark in a run).
|
|
6257
|
+
const tail = (m) => {
|
|
6258
|
+
let k = m.length;
|
|
6259
|
+
while (k > 0 && '.,;:!?)'.indexOf(m[k - 1]) >= 0)
|
|
6260
|
+
k--;
|
|
6261
|
+
return m.slice(k);
|
|
6262
|
+
};
|
|
6263
|
+
const keep = (label) => (m) => `${label}${tail(m)}`;
|
|
6264
|
+
const pathless = text
|
|
4177
6265
|
.replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
|
|
4178
6266
|
.replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
|
|
4179
6267
|
.replace(/(?<![\w\\/])[A-Za-z]:([\\/])(?:[^\\/\r\n'"<>`|*?]+\1)*[^\\/\s'"<>`|*?]*/g, keep('a local path'))
|
|
4180
|
-
.replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`)
|
|
4181
|
-
|
|
6268
|
+
.replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`);
|
|
6269
|
+
// npm cache segments: a run of characters other than space, quotes, angle
|
|
6270
|
+
// brackets, backtick and pipe that holds npm-cache, /_npx/ or /.npm/ is
|
|
6271
|
+
// replaced whole. Each run is read once (engine 1.5.1: the run pattern was
|
|
6272
|
+
// retried from every character in it).
|
|
6273
|
+
const marker = /npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/]/gi;
|
|
6274
|
+
const stop = /[\s'"<>`|]/;
|
|
6275
|
+
const parts = [];
|
|
6276
|
+
let last = 0;
|
|
6277
|
+
let markerAt = -1;
|
|
6278
|
+
for (let a = 0; a < pathless.length;) {
|
|
6279
|
+
if (stop.test(pathless[a])) {
|
|
6280
|
+
a++;
|
|
6281
|
+
continue;
|
|
6282
|
+
}
|
|
6283
|
+
let b = a + 1;
|
|
6284
|
+
while (b < pathless.length && !stop.test(pathless[b]))
|
|
6285
|
+
b++;
|
|
6286
|
+
if (markerAt < a) {
|
|
6287
|
+
marker.lastIndex = a;
|
|
6288
|
+
const found = marker.exec(pathless);
|
|
6289
|
+
markerAt = found ? found.index : Infinity;
|
|
6290
|
+
}
|
|
6291
|
+
if (markerAt < b) {
|
|
6292
|
+
parts.push(pathless.slice(last, a), keep('the npm cache')(pathless.slice(a, b)));
|
|
6293
|
+
last = b;
|
|
6294
|
+
}
|
|
6295
|
+
a = b;
|
|
6296
|
+
}
|
|
6297
|
+
parts.push(pathless.slice(last));
|
|
6298
|
+
return parts.join('');
|
|
4182
6299
|
}
|
|
4183
6300
|
/** The checks with every detail passed through sanitizeErrorText. */
|
|
4184
6301
|
function sanitizeCheckDetails(checks) {
|
|
@@ -4318,7 +6435,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4318
6435
|
// contrast or horizontal overflow cannot be A-grade no matter how good its
|
|
4319
6436
|
// tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
|
|
4320
6437
|
//
|
|
4321
|
-
// No A while any check FAILs (engine 1.3.0,
|
|
6438
|
+
// No A while any check FAILs (engine 1.3.0, see CHANGELOG). A
|
|
4322
6439
|
// FAIL is the engine's own finding that the page breaks a rule, so any FAIL
|
|
4323
6440
|
// caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
|
|
4324
6441
|
// A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
|
|
@@ -4346,7 +6463,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4346
6463
|
}
|
|
4347
6464
|
// v44 Status text contrast — the words that report a state fail WCAG
|
|
4348
6465
|
// 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
|
|
4349
|
-
// Same cap as v06 (engine 1.3.0
|
|
6466
|
+
// Same cap as v06 (engine 1.3.0): both are
|
|
4350
6467
|
// measured WCAG contrast failures on text.
|
|
4351
6468
|
if (c.id === 'v44') {
|
|
4352
6469
|
cap = 65;
|
|
@@ -4596,40 +6713,13 @@ export async function scoreFromParts(input) {
|
|
|
4596
6713
|
// reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
|
|
4597
6714
|
// graph-paper grid was reported at the maximum severity. Match a 1px stop
|
|
4598
6715
|
// in EITHER order, which is what "hairline" means however it is written.
|
|
4599
|
-
const isGridPattern = (text) =>
|
|
4600
|
-
|| /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text);
|
|
4601
|
-
// Two or more colour stops in some linear-gradient() of the value.
|
|
4602
|
-
const multiStop = (value) => {
|
|
4603
|
-
let from = 0;
|
|
4604
|
-
for (;;) {
|
|
4605
|
-
const at = value.toLowerCase().indexOf('linear-gradient(', from);
|
|
4606
|
-
if (at < 0)
|
|
4607
|
-
return false;
|
|
4608
|
-
let depth = 0;
|
|
4609
|
-
let end = -1;
|
|
4610
|
-
for (let i = at + 15; i < value.length; i++) {
|
|
4611
|
-
if (value[i] === '(')
|
|
4612
|
-
depth++;
|
|
4613
|
-
else if (value[i] === ')' && --depth === 0) {
|
|
4614
|
-
end = i;
|
|
4615
|
-
break;
|
|
4616
|
-
}
|
|
4617
|
-
}
|
|
4618
|
-
if (end < 0)
|
|
4619
|
-
return false;
|
|
4620
|
-
const args = splitCssTopLevel(value.slice(at + 16, end), ',');
|
|
4621
|
-
const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
|
|
4622
|
-
if (stops.length >= 2)
|
|
4623
|
-
return true;
|
|
4624
|
-
from = end;
|
|
4625
|
-
}
|
|
4626
|
-
};
|
|
6716
|
+
const isGridPattern = (text) => hasColorThenHairline(text) || hasHairlineThenColor(text);
|
|
4627
6717
|
const pageGrad = [];
|
|
4628
6718
|
for (const rule of parseCssRules(css).rules) {
|
|
4629
6719
|
if (!/linear-gradient\s*\(/i.test(rule.decls))
|
|
4630
6720
|
continue;
|
|
4631
6721
|
const decls = parseCssDecls(rule.decls);
|
|
4632
|
-
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') &&
|
|
6722
|
+
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && hasMultiStopGradient(d.value)))
|
|
4633
6723
|
continue;
|
|
4634
6724
|
const text = `${rule.selector}{${rule.decls}}`;
|
|
4635
6725
|
if (isGridPattern(text))
|
|
@@ -4652,7 +6742,7 @@ export async function scoreFromParts(input) {
|
|
|
4652
6742
|
const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
|
|
4653
6743
|
|| (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
|
|
4654
6744
|
|| (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
|
|
4655
|
-
|| (d.prop === 'background' &&
|
|
6745
|
+
|| (d.prop === 'background' && hasSlashThenHairline(d.value)));
|
|
4656
6746
|
const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
|
|
4657
6747
|
if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
|
|
4658
6748
|
pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
|
|
@@ -4670,8 +6760,7 @@ export async function scoreFromParts(input) {
|
|
|
4670
6760
|
}
|
|
4671
6761
|
// S3. Purple/violet gradient
|
|
4672
6762
|
{
|
|
4673
|
-
const
|
|
4674
|
-
const matches = css.match(purplePattern);
|
|
6763
|
+
const matches = purpleGradientDecls(css);
|
|
4675
6764
|
if (matches)
|
|
4676
6765
|
slopFindings.push({ id: 'S3', label: 'Purple/violet AI gradient', severity: 4, instances: matches.length, evidence: matches.slice(0, 3) });
|
|
4677
6766
|
}
|
|
@@ -4683,7 +6772,7 @@ export async function scoreFromParts(input) {
|
|
|
4683
6772
|
// pattern anchored to `(?:^|})` takes 4 ms and matches the same rule.
|
|
4684
6773
|
// Measured 2026-09-19, Node 24. This was the real cause of the 55s cold
|
|
4685
6774
|
// score, not the network (0.9s) and not the sequential CSS fetch.
|
|
4686
|
-
const gradientClips = css
|
|
6775
|
+
const gradientClips = gradientClipRules(css);
|
|
4687
6776
|
const hueKey = (stop) => {
|
|
4688
6777
|
if (/var\(/.test(stop))
|
|
4689
6778
|
return null;
|
|
@@ -4705,7 +6794,7 @@ export async function scoreFromParts(input) {
|
|
|
4705
6794
|
h = Math.round(((h * 60) + 360) % 360);
|
|
4706
6795
|
return 'h' + Math.floor(h / 45);
|
|
4707
6796
|
}
|
|
4708
|
-
const rgb = stop
|
|
6797
|
+
const rgb = rgbArgs(stop);
|
|
4709
6798
|
if (rgb) {
|
|
4710
6799
|
const p = rgb[1].split(',').map(x => parseFloat(x));
|
|
4711
6800
|
if (p.length >= 3) {
|
|
@@ -4728,8 +6817,8 @@ export async function scoreFromParts(input) {
|
|
|
4728
6817
|
return null;
|
|
4729
6818
|
};
|
|
4730
6819
|
const multiColorGradient = gradientClips.filter(block => {
|
|
4731
|
-
const gradientMatch = block
|
|
4732
|
-
const gradText = (gradientMatch && (gradientMatch[
|
|
6820
|
+
const gradientMatch = linearGradientArgs(block);
|
|
6821
|
+
const gradText = (gradientMatch && (gradientMatch.groups[0] || gradientMatch.groups[1])) || '';
|
|
4733
6822
|
const stops = gradText.split(',').map(s => s.trim());
|
|
4734
6823
|
const hueKeys = new Set(stops.map(hueKey).filter((k) => k !== null && k !== 'neutral'));
|
|
4735
6824
|
return hueKeys.size >= 2;
|
|
@@ -4750,8 +6839,7 @@ export async function scoreFromParts(input) {
|
|
|
4750
6839
|
{
|
|
4751
6840
|
const cardPattern = /\.(card|panel|tile|feature|item|box|cell|block)\b/gi;
|
|
4752
6841
|
const cardClasses = css.match(cardPattern) || [];
|
|
4753
|
-
const
|
|
4754
|
-
const gridMatches = css.match(gridCardPattern) || [];
|
|
6842
|
+
const gridMatches = gridRepeatRules(css);
|
|
4755
6843
|
if (cardClasses.length >= 3 && gridMatches.length > 0)
|
|
4756
6844
|
slopFindings.push({ id: 'S6', label: 'Repeated identical card grid', severity: 5, instances: 1, evidence: [`${cardClasses.length} card-like classes with repeating grid layouts`] });
|
|
4757
6845
|
}
|
|
@@ -4765,7 +6853,7 @@ export async function scoreFromParts(input) {
|
|
|
4765
6853
|
// text-default ones promoted with U+FE0F. This also catches ⭐ (U+2B50), which
|
|
4766
6854
|
// the old range missed while it was busy flagging checkmarks.
|
|
4767
6855
|
{
|
|
4768
|
-
const emojiInButtons = html
|
|
6856
|
+
const emojiInButtons = emojiCtaMatches(html);
|
|
4769
6857
|
if (emojiInButtons && emojiInButtons.length >= 2)
|
|
4770
6858
|
slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
|
|
4771
6859
|
}
|
|
@@ -4783,11 +6871,7 @@ export async function scoreFromParts(input) {
|
|
|
4783
6871
|
// Skipped on a page that documents the rules (see isDocumentingSlopRules):
|
|
4784
6872
|
// the anti-slop rule table on /methodology lists these phrases.
|
|
4785
6873
|
{
|
|
4786
|
-
const visible = html
|
|
4787
|
-
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, ' ')
|
|
4788
|
-
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, ' ')
|
|
4789
|
-
.replace(/<template\b[\s\S]*?<\/template\s*>/gi, ' ')
|
|
4790
|
-
.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
6874
|
+
const visible = withoutNonVisibleMarkup(html);
|
|
4791
6875
|
const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
|
|
4792
6876
|
const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
|
|
4793
6877
|
const tagRe = {};
|
|
@@ -4818,7 +6902,7 @@ export async function scoreFromParts(input) {
|
|
|
4818
6902
|
}
|
|
4819
6903
|
if (end < 0)
|
|
4820
6904
|
continue;
|
|
4821
|
-
const text = windowText.slice(0, end)
|
|
6905
|
+
const text = withoutTags(windowText.slice(0, end), ' ', false).replace(/\s+/g, ' ').trim();
|
|
4822
6906
|
if (text && text.length <= 40 && pillPhrase.test(text))
|
|
4823
6907
|
pills.push(text);
|
|
4824
6908
|
}
|
|
@@ -4854,7 +6938,7 @@ export async function scoreFromParts(input) {
|
|
|
4854
6938
|
// S11. Marketing buzzwords
|
|
4855
6939
|
{
|
|
4856
6940
|
const buzzwords = ['streamline', 'empower', 'supercharge', 'world-class', 'enterprise-grade', 'next-generation', 'unlock', 'leverage', 'seamless', 'cutting-edge', 'revolutionize', 'game-chang', 'disrupt', 'synerg'];
|
|
4857
|
-
const bodyText = html
|
|
6941
|
+
const bodyText = withoutTags(html, ' ', true).toLowerCase();
|
|
4858
6942
|
const found = [];
|
|
4859
6943
|
for (const word of buzzwords) {
|
|
4860
6944
|
if (bodyText.includes(word))
|
|
@@ -4880,21 +6964,20 @@ export async function scoreFromParts(input) {
|
|
|
4880
6964
|
const originalitySignals = [];
|
|
4881
6965
|
// O1. Bespoke easing
|
|
4882
6966
|
{
|
|
4883
|
-
const bezierRe = /cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)/gi;
|
|
4884
6967
|
const EASING_PRESETS = new Set(['0.25,0.1,0.25,1', '0.42,0,1,1', '0,0,0.58,1', '0.42,0,0.58,1', '0.4,0,0.2,1', '0.4,0,0.6,1', '0,0,0.2,1', '0.4,0,1,1', '0.68,-0.55,0.265,1.55']);
|
|
4885
6968
|
const distinct = new Set();
|
|
4886
6969
|
let hasOvershoot = false;
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
const key = `${
|
|
6970
|
+
for (const b of cubicBezierMatches(css)) {
|
|
6971
|
+
const [x1, y1s, x2, y2s] = b.groups;
|
|
6972
|
+
const key = withoutTrailingDecimalZeros(`${x1},${y1s},${x2},${y2s}`).replace(/\s+/g, '');
|
|
4890
6973
|
if (EASING_PRESETS.has(key))
|
|
4891
6974
|
continue;
|
|
4892
6975
|
distinct.add(key);
|
|
4893
|
-
const y1 = parseFloat(
|
|
6976
|
+
const y1 = parseFloat(y1s), y2 = parseFloat(y2s);
|
|
4894
6977
|
if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1)
|
|
4895
6978
|
hasOvershoot = true;
|
|
4896
6979
|
}
|
|
4897
|
-
const linearSprings = css
|
|
6980
|
+
const linearSprings = longLinearEasings(css);
|
|
4898
6981
|
if (linearSprings.length > 0)
|
|
4899
6982
|
hasOvershoot = true;
|
|
4900
6983
|
const easingCount = distinct.size;
|
|
@@ -4924,8 +7007,8 @@ export async function scoreFromParts(input) {
|
|
|
4924
7007
|
}
|
|
4925
7008
|
// O4. Tiered reduced-motion
|
|
4926
7009
|
{
|
|
4927
|
-
const reducedMotion =
|
|
4928
|
-
const blanketKill =
|
|
7010
|
+
const reducedMotion = mediaQueryHasReducedMotion(css);
|
|
7011
|
+
const blanketKill = hasBlanketMotionKill(css);
|
|
4929
7012
|
if (reducedMotion && !blanketKill)
|
|
4930
7013
|
originalitySignals.push({ id: 'O4', label: 'Tiered reduced-motion', points: 1, evidence: 'targeted (not blanket) motion reduction' });
|
|
4931
7014
|
}
|
|
@@ -4942,8 +7025,8 @@ export async function scoreFromParts(input) {
|
|
|
4942
7025
|
}
|
|
4943
7026
|
// O6. Bespoke iconography
|
|
4944
7027
|
{
|
|
4945
|
-
const inlineSvg = html
|
|
4946
|
-
const svgSymbols = html
|
|
7028
|
+
const inlineSvg = svgViewBoxTags(html);
|
|
7029
|
+
const svgSymbols = symbolTags(html);
|
|
4947
7030
|
if (inlineSvg.length >= 3 || svgSymbols.length >= 2)
|
|
4948
7031
|
originalitySignals.push({ id: 'O6', label: 'Bespoke iconography', points: 1, evidence: `${inlineSvg.length} inline SVGs${svgSymbols.length ? ` + ${svgSymbols.length} symbols` : ''}` });
|
|
4949
7032
|
}
|
|
@@ -4953,11 +7036,11 @@ export async function scoreFromParts(input) {
|
|
|
4953
7036
|
const shadcnHits = SHADCN_FINGERPRINT.filter((t) => css.includes(t + ':')).length;
|
|
4954
7037
|
const referencesTwPrimitives = /var\(--color-[a-z]+-\d{2,3}\)/i.test(css);
|
|
4955
7038
|
const isShadcnTemplate = shadcnHits >= 6 || (shadcnHits >= 4 && referencesTwPrimitives);
|
|
4956
|
-
const customProps = css
|
|
7039
|
+
const customProps = customPropertyDecls(css);
|
|
4957
7040
|
const semanticTokens = customProps.filter((p) => /--(surface|ink|paper|signal|line|muted|elevation|radius|duration|ease|accent|foreground|space|gap|shadow|canvas|action|feedback|success|warning|danger|error|info|subtle|on-[a-z]+)/i.test(p));
|
|
4958
7041
|
const primitiveHue = /--(?:color|colour)-[a-z]+-\d{2,3}\s*:/i;
|
|
4959
7042
|
const semanticCount = isShadcnTemplate ? 0 : semanticTokens.filter((p) => !primitiveHue.test(p)).length;
|
|
4960
|
-
const hasLayering =
|
|
7043
|
+
const hasLayering = hasTokenLayering(css);
|
|
4961
7044
|
const hasTheming = /light-dark\(|prefers-color-scheme\s*:\s*dark|data-theme/i.test(css);
|
|
4962
7045
|
let tokenPoints = 0;
|
|
4963
7046
|
const tokenEvidence = [];
|
|
@@ -5110,9 +7193,12 @@ export function emitReview(url, result) {
|
|
|
5110
7193
|
continue;
|
|
5111
7194
|
num++;
|
|
5112
7195
|
const severity = c.status === 'FAIL' ? 'HIGH' : 'MEDIUM';
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
7196
|
+
// A cell escapes its backslashes before its pipes: a detail carries text
|
|
7197
|
+
// read from the scored page, where a backslash before a pipe would
|
|
7198
|
+
// otherwise escape the escape and leave a live cell separator.
|
|
7199
|
+
const before = c.detail.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7200
|
+
const after = (c.remediation || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7201
|
+
const why = `${c.item} (${c.category})`.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
5116
7202
|
lines.push(`| ${num} | ${severity} | ${c.category} | ${url} | ${before} | ${after} | ${why} |`);
|
|
5117
7203
|
}
|
|
5118
7204
|
if (num === 0)
|