@designesy/score 0.9.1 → 0.9.3
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 +2527 -275
- 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);
|
|
@@ -2947,12 +5051,176 @@ function v46FluidFloor(value) {
|
|
|
2947
5051
|
const statics = args.filter((a) => a !== null);
|
|
2948
5052
|
return statics.length ? Math.max(...statics) : null;
|
|
2949
5053
|
}
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
5054
|
+
// Engine 1.5.2: the `font` shorthand sets a size too, and until 1.5.2 v46 read
|
|
5055
|
+
// only `font-size`, so `font: 500 0.66rem/1 var(--mono)` sat outside the check.
|
|
5056
|
+
// The size is the first token that is a length, a size keyword or a size
|
|
5057
|
+
// function, ahead of the line-height slash and the family; a weight (500) has
|
|
5058
|
+
// no unit and a family name is no length, so neither is taken. A system font
|
|
5059
|
+
// keyword (caption, menu…) or a CSS-wide keyword sets no measurable size.
|
|
5060
|
+
const V46_FONT_SYSTEM = /^(?:caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset|revert|revert-layer)$/i;
|
|
5061
|
+
const V46_SIZE_WORDS = new Set(['small', 'large', 'x-small', 'x-large', 'xx-small', 'xx-large', 'xxx-small', 'xxx-large', 'medium', 'smaller', 'larger']);
|
|
5062
|
+
/** `s` with A to Z lower-cased and every other code unit kept: the case folding of the /i flag without the u flag. */
|
|
5063
|
+
function foldAsciiCase(s) {
|
|
5064
|
+
return s.replace(/[A-Z]+/g, (run) => run.toLowerCase());
|
|
5065
|
+
}
|
|
5066
|
+
/**
|
|
5067
|
+
* Whether a shorthand token is a size, as
|
|
5068
|
+
* /^(?:\d*\.?\d+(?:[a-z]+|%)|(?:xx?-|xxx-)?(?:small|large)|medium|smaller|larger|(?:clamp|min|max|calc)\(.*\))$/i
|
|
5069
|
+
* answers: a number and then a unit or %, a size keyword, or a size function
|
|
5070
|
+
* whose parenthesis closes at the end of the token with no line break inside.
|
|
5071
|
+
* Read in one pass: the number in that pattern can split a run of digits
|
|
5072
|
+
* between \d* and \d+ in as many ways as the run is long.
|
|
5073
|
+
*/
|
|
5074
|
+
function isV46SizeToken(s) {
|
|
5075
|
+
const n = s.length;
|
|
5076
|
+
let k = 0;
|
|
5077
|
+
let dot = false;
|
|
5078
|
+
for (; k < n; k++) {
|
|
5079
|
+
const c = s.charCodeAt(k);
|
|
5080
|
+
if (c === 46) {
|
|
5081
|
+
if (dot)
|
|
5082
|
+
return false; // a second dot ends every reading
|
|
5083
|
+
dot = true;
|
|
5084
|
+
}
|
|
5085
|
+
else if (!isDigit(c))
|
|
5086
|
+
break;
|
|
5087
|
+
}
|
|
5088
|
+
if (k > 0) {
|
|
5089
|
+
if (k === n || s.charCodeAt(k - 1) === 46)
|
|
5090
|
+
return false; // the number ends in a digit, and a unit follows it
|
|
5091
|
+
if (k === n - 1 && s.charCodeAt(k) === 37)
|
|
5092
|
+
return true; // %
|
|
5093
|
+
for (let i = k; i < n; i++) {
|
|
5094
|
+
const c = s.charCodeAt(i) | 32;
|
|
5095
|
+
if (c < 97 || c > 122)
|
|
5096
|
+
return false;
|
|
5097
|
+
}
|
|
5098
|
+
return true;
|
|
5099
|
+
}
|
|
5100
|
+
if (n <= 9 && V46_SIZE_WORDS.has(foldAsciiCase(s)))
|
|
5101
|
+
return true;
|
|
5102
|
+
const open = s.indexOf('(');
|
|
5103
|
+
if (open < 3 || open > 5 || n < open + 2 || s.charCodeAt(n - 1) !== 41)
|
|
5104
|
+
return false;
|
|
5105
|
+
const fn = foldAsciiCase(s.slice(0, open));
|
|
5106
|
+
if (fn !== 'clamp' && fn !== 'min' && fn !== 'max' && fn !== 'calc')
|
|
5107
|
+
return false;
|
|
5108
|
+
for (let i = open + 1; i < n - 1; i++)
|
|
5109
|
+
if (isLineTerminator(s.charCodeAt(i)))
|
|
5110
|
+
return false;
|
|
5111
|
+
return true;
|
|
5112
|
+
}
|
|
5113
|
+
/** The size written inside a `font` shorthand value, or null when it carries none. */
|
|
5114
|
+
function v46ShorthandSize(value, tokens) {
|
|
5115
|
+
const whole = resolveVar(value, tokens); // a whole-shorthand token alias
|
|
5116
|
+
if (V46_FONT_SYSTEM.test(whole))
|
|
5117
|
+
return null;
|
|
5118
|
+
for (const part of splitCssTopLevel(whole.replace(/\//g, ' / ').replace(/\s+/g, ' '), ' ')) {
|
|
5119
|
+
if (part === '/')
|
|
5120
|
+
return null; // a slash before any size: not a shorthand v46 can read
|
|
5121
|
+
if (isV46SizeToken(resolveVar(part, tokens)))
|
|
5122
|
+
return part;
|
|
5123
|
+
}
|
|
5124
|
+
return null;
|
|
5125
|
+
}
|
|
5126
|
+
/**
|
|
5127
|
+
* The declarations v46 reads: matches of
|
|
5128
|
+
* /(font-size|(?<![\w-])font)\s*:\s*([^;}]+)/gi, groups [property, value].
|
|
5129
|
+
* A font-size match is read past its value, as the global pattern reads it.
|
|
5130
|
+
* After a `font` match the scan goes on from the end of the word, so a
|
|
5131
|
+
* shorthand never swallows a font-size written after it. A `font` match is
|
|
5132
|
+
* dropped when it sits inside the value of the shorthand before it (it is
|
|
5133
|
+
* that value's own text), or when its value holds a `{`: an @supports
|
|
5134
|
+
* condition such as `(font: -apple-system-body) and …{` declares nothing.
|
|
5135
|
+
*
|
|
5136
|
+
* Engine 1.5.2. Each start is tried once, and the starts that share a value's
|
|
5137
|
+
* end share one search for it, so the scan reads the sheet in linear time.
|
|
5138
|
+
*/
|
|
5139
|
+
function v46SizeDecls(css) {
|
|
5140
|
+
const start = /font/gi;
|
|
5141
|
+
const sizeWord = /-size/iy;
|
|
5142
|
+
const valueEnd = nextMatchFinder(css, /[;}]/g);
|
|
5143
|
+
const brace = nextMatchFinder(css, /\{/g);
|
|
5144
|
+
const out = [];
|
|
5145
|
+
let shorthandEnd = -1;
|
|
5146
|
+
for (let pos = 0;;) {
|
|
5147
|
+
start.lastIndex = pos;
|
|
5148
|
+
const m = start.exec(css);
|
|
5149
|
+
if (!m)
|
|
5150
|
+
break;
|
|
5151
|
+
const at = m.index;
|
|
5152
|
+
pos = at + 1;
|
|
5153
|
+
sizeWord.lastIndex = at + 4;
|
|
5154
|
+
const size = sizeWord.test(css);
|
|
5155
|
+
if (!size && (at < shorthandEnd || (at > 0 && isNameChar(css.charCodeAt(at - 1)))))
|
|
5156
|
+
continue;
|
|
5157
|
+
const word = size ? 9 : 4;
|
|
5158
|
+
const colon = wsEnd(css, at + word);
|
|
5159
|
+
if (css.charCodeAt(colon) !== 58)
|
|
5160
|
+
continue;
|
|
5161
|
+
const ws = wsEnd(css, colon + 1);
|
|
5162
|
+
let from = ws;
|
|
5163
|
+
let end = ws;
|
|
5164
|
+
if (ws < css.length && css.charCodeAt(ws) !== 59 && css.charCodeAt(ws) !== 125) {
|
|
5165
|
+
const r = valueEnd(ws);
|
|
5166
|
+
end = r === Infinity ? css.length : r;
|
|
5167
|
+
}
|
|
5168
|
+
else if (ws > colon + 1) {
|
|
5169
|
+
from = ws - 1; // only white space before the end: the value is its last character
|
|
5170
|
+
}
|
|
5171
|
+
else
|
|
5172
|
+
continue;
|
|
5173
|
+
if (size) {
|
|
5174
|
+
pos = end;
|
|
5175
|
+
}
|
|
5176
|
+
else {
|
|
5177
|
+
pos = at + 4;
|
|
5178
|
+
if (brace(from) < end)
|
|
5179
|
+
continue;
|
|
5180
|
+
shorthandEnd = end;
|
|
5181
|
+
}
|
|
5182
|
+
out.push({ index: at, text: css.slice(at, end), groups: [css.slice(at, at + word), css.slice(from, end)] });
|
|
5183
|
+
}
|
|
5184
|
+
return out;
|
|
5185
|
+
}
|
|
5186
|
+
/**
|
|
5187
|
+
* The selector of the rule a v46 finding sits in, by offset: the text from
|
|
5188
|
+
* the `{`, `}` or `;` before the last `{` at or before the offset up to that
|
|
5189
|
+
* brace, without comments, white space collapsed, cut to 80 characters (''
|
|
5190
|
+
* when no `{` comes before the offset).
|
|
5191
|
+
*
|
|
5192
|
+
* Engine 1.5.2: the sheet's braces and semicolons are listed once, each offset
|
|
5193
|
+
* is answered by binary search, and the findings of one rule share one
|
|
5194
|
+
* reading of its selector. Before, each finding ran four lastIndexOf scans,
|
|
5195
|
+
* and on a sheet with few braces or semicolons each one read back toward the
|
|
5196
|
+
* start of the sheet, so the check's cost grew with the square of its length.
|
|
5197
|
+
*/
|
|
5198
|
+
function v46SelectorFinder(css) {
|
|
5199
|
+
const opens = [];
|
|
5200
|
+
const marks = [];
|
|
5201
|
+
for (let i = 0; i < css.length; i++) {
|
|
5202
|
+
const c = css.charCodeAt(i);
|
|
5203
|
+
if (c === 123) {
|
|
5204
|
+
opens.push(i);
|
|
5205
|
+
marks.push(i);
|
|
5206
|
+
}
|
|
5207
|
+
else if (c === 125 || c === 59)
|
|
5208
|
+
marks.push(i);
|
|
5209
|
+
}
|
|
5210
|
+
const read = new Map();
|
|
5211
|
+
return (at) => {
|
|
5212
|
+
const k = lowerBound(opens, at + 1) - 1; // the last `{` at or before the offset
|
|
5213
|
+
if (k < 0)
|
|
5214
|
+
return '';
|
|
5215
|
+
const open = opens[k];
|
|
5216
|
+
let selector = read.get(open);
|
|
5217
|
+
if (selector === undefined) {
|
|
5218
|
+
const j = lowerBound(marks, open) - 1; // the `{`, `}` or `;` before it
|
|
5219
|
+
selector = withoutCssComments(css.slice(j < 0 ? 0 : marks[j] + 1, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
5220
|
+
read.set(open, selector);
|
|
5221
|
+
}
|
|
5222
|
+
return selector;
|
|
5223
|
+
};
|
|
2956
5224
|
}
|
|
2957
5225
|
function checkTypeScale(css, tokens) {
|
|
2958
5226
|
const scale = v46TypeScale(tokens);
|
|
@@ -2974,10 +5242,12 @@ function checkTypeScale(css, tokens) {
|
|
|
2974
5242
|
};
|
|
2975
5243
|
let judged = 0;
|
|
2976
5244
|
let fluid = 0;
|
|
2977
|
-
const
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
const written = m[
|
|
5245
|
+
const selectorAt = v46SelectorFinder(css);
|
|
5246
|
+
for (const m of v46SizeDecls(css)) { // engine 1.5.2: font-size and the font shorthand
|
|
5247
|
+
const declared = withoutCssComments(m.groups[1], '').replace(/!important/i, '').trim();
|
|
5248
|
+
const written = m.groups[0].length === 4 ? v46ShorthandSize(declared, tokens) : declared;
|
|
5249
|
+
if (written === null)
|
|
5250
|
+
continue;
|
|
2981
5251
|
const value = resolveVar(written, tokens);
|
|
2982
5252
|
if (/^var\(/i.test(value))
|
|
2983
5253
|
continue; // an alias the token table cannot resolve
|
|
@@ -2985,7 +5255,7 @@ function checkTypeScale(css, tokens) {
|
|
|
2985
5255
|
fluid++;
|
|
2986
5256
|
const low = v46FluidFloor(value);
|
|
2987
5257
|
if (low !== null && below(low)) {
|
|
2988
|
-
add({ selector:
|
|
5258
|
+
add({ selector: selectorAt(m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
|
|
2989
5259
|
}
|
|
2990
5260
|
continue;
|
|
2991
5261
|
}
|
|
@@ -2994,10 +5264,10 @@ function checkTypeScale(css, tokens) {
|
|
|
2994
5264
|
continue; // em, %, keywords, calc(): relative or not static
|
|
2995
5265
|
judged++;
|
|
2996
5266
|
if (below(px)) {
|
|
2997
|
-
add({ selector:
|
|
5267
|
+
add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
|
|
2998
5268
|
}
|
|
2999
5269
|
else if (!onScale(px)) {
|
|
3000
|
-
add({ selector:
|
|
5270
|
+
add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
|
|
3001
5271
|
}
|
|
3002
5272
|
}
|
|
3003
5273
|
const scaleText = `${steps.length}-step scale (ratio ${ratio.toFixed(3)}, ${scale.count} type tokens, floor ${floor}px)`;
|
|
@@ -3082,7 +5352,7 @@ function checkPausedEntrances(css) {
|
|
|
3082
5352
|
visibility = d.value.trim().toLowerCase();
|
|
3083
5353
|
}
|
|
3084
5354
|
}
|
|
3085
|
-
return (opacity !== null && /\d/.test(opacity) &&
|
|
5355
|
+
return (opacity !== null && /\d/.test(opacity) && isZeroOpacity(opacity)) || visibility === 'hidden';
|
|
3086
5356
|
};
|
|
3087
5357
|
if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
|
|
3088
5358
|
hiddenNames.add(name);
|
|
@@ -3302,55 +5572,45 @@ function checkAiDisclosure(html) {
|
|
|
3302
5572
|
const CATEGORY = 'identity';
|
|
3303
5573
|
// Strip scripts/styles for visible-text checks but keep them for script-src
|
|
3304
5574
|
// 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, '');
|
|
5575
|
+
const visibleHtml = withoutScriptsAndStyles(html);
|
|
3308
5576
|
// Text-only: strip all tags so attribute values like class="chatbot" don't
|
|
3309
5577
|
// false-positive as disclosure TEXT. Disclosure text must be human-readable
|
|
3310
5578
|
// content between tags, not class/id/aria attribute values.
|
|
3311
|
-
const textOnly = visibleHtml
|
|
5579
|
+
const textOnly = withoutTags(visibleHtml, ' ', true);
|
|
3312
5580
|
// ── 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
5581
|
// ── 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
5582
|
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
5583
|
const surfaceSignals = [];
|
|
3322
|
-
if (
|
|
5584
|
+
if (hasAiChatContainer(html))
|
|
3323
5585
|
surfaceSignals.push('chatbot container');
|
|
3324
|
-
if (
|
|
5586
|
+
if (hasAiWidgetScript(html))
|
|
3325
5587
|
surfaceSignals.push('AI widget script');
|
|
3326
|
-
if (
|
|
5588
|
+
if (hasAiInput(html))
|
|
3327
5589
|
surfaceSignals.push('AI input');
|
|
3328
5590
|
const possibleSignals = [];
|
|
3329
|
-
if (
|
|
5591
|
+
if (hasChatWidgetContainer(html))
|
|
3330
5592
|
possibleSignals.push('chat widget container');
|
|
3331
|
-
if (
|
|
5593
|
+
if (hasChatScript(html))
|
|
3332
5594
|
possibleSignals.push('chat or assistant script');
|
|
3333
5595
|
if (aiTextRe.test(textOnly))
|
|
3334
5596
|
possibleSignals.push('AI prompt text');
|
|
3335
|
-
if (
|
|
5597
|
+
if (hasAmbiguousWidget(html))
|
|
3336
5598
|
possibleSignals.push('ambiguous widget');
|
|
3337
5599
|
// ── Disclosure detection ────────────────────────────────────────────────
|
|
3338
5600
|
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
5601
|
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
5602
|
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
5603
|
const dataAiRe = /data-ai-disclosure\s*=/i;
|
|
3344
5604
|
const disclosureSignals = [];
|
|
3345
5605
|
if (disclosureTextRe.test(textOnly))
|
|
3346
5606
|
disclosureSignals.push('visible AI text');
|
|
3347
5607
|
if (ariaDisclosureRe.test(html))
|
|
3348
5608
|
disclosureSignals.push('aria-label');
|
|
3349
|
-
if (
|
|
5609
|
+
if (hasAiGeneratorMeta(html))
|
|
3350
5610
|
disclosureSignals.push('generator meta');
|
|
3351
5611
|
if (c2paRe.test(html))
|
|
3352
5612
|
disclosureSignals.push('C2PA manifest');
|
|
3353
|
-
if (
|
|
5613
|
+
if (hasAiJsonLd(html))
|
|
3354
5614
|
disclosureSignals.push('JSON-LD');
|
|
3355
5615
|
if (dataAiRe.test(html))
|
|
3356
5616
|
disclosureSignals.push('data-ai-disclosure');
|
|
@@ -3377,11 +5637,11 @@ function checkForcedColors(css) {
|
|
|
3377
5637
|
const ITEM = 'Forced-colors readiness: @media (forced-colors: active) block present';
|
|
3378
5638
|
const CATEGORY = 'accessibility';
|
|
3379
5639
|
// Primary signal: @media (forced-colors: active) block exists
|
|
3380
|
-
const hasForcedColorsMedia =
|
|
5640
|
+
const hasForcedColorsMedia = mediaQueryHasForcedColors(css);
|
|
3381
5641
|
// Secondary signal: forced-color-adjust property used anywhere
|
|
3382
5642
|
const hasForcedColorAdjust = /forced-color-adjust\s*:/i.test(css);
|
|
3383
5643
|
// Tertiary signal: -ms-high-contrast (legacy Edge/IE) — still relevant
|
|
3384
|
-
const hasHighContrast =
|
|
5644
|
+
const hasHighContrast = mediaQueryHasHighContrast(css);
|
|
3385
5645
|
if (hasForcedColorsMedia && hasForcedColorAdjust) {
|
|
3386
5646
|
return { id: 'v35', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'forced-colors media query + forced-color-adjust both present' };
|
|
3387
5647
|
}
|
|
@@ -3534,11 +5794,10 @@ function checkSecurityConfusables(css, tokens) {
|
|
|
3534
5794
|
}
|
|
3535
5795
|
// Scan 3: url() references — confusables in file paths can redirect to
|
|
3536
5796
|
// different assets (logo spoofing, CSS injection via @import)
|
|
3537
|
-
const urlRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
|
|
3538
5797
|
const urlConfusables = [];
|
|
3539
5798
|
const scannedUrls = new Set();
|
|
3540
|
-
|
|
3541
|
-
const urlPath =
|
|
5799
|
+
for (const u of cssUrlMatches(css)) {
|
|
5800
|
+
const urlPath = u.groups[0];
|
|
3542
5801
|
if (scannedUrls.has(urlPath))
|
|
3543
5802
|
continue;
|
|
3544
5803
|
scannedUrls.add(urlPath);
|
|
@@ -3661,8 +5920,6 @@ function checkButtonTextVerb(html) {
|
|
|
3661
5920
|
const ITEM = 'Button text is a verb phrase or recognized command (not a bare noun)';
|
|
3662
5921
|
const CATEGORY = 'copywriting';
|
|
3663
5922
|
// 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
5923
|
// Remove aria-hidden subtrees before extracting text.
|
|
3667
5924
|
//
|
|
3668
5925
|
// Content marked aria-hidden is by definition not part of the accessible name,
|
|
@@ -3679,7 +5936,7 @@ function checkButtonTextVerb(html) {
|
|
|
3679
5936
|
let out = inner;
|
|
3680
5937
|
for (let i = 0; i < 4; i++) {
|
|
3681
5938
|
// Repeat to handle an aria-hidden wrapper containing another.
|
|
3682
|
-
const next = out
|
|
5939
|
+
const next = withoutAriaHiddenElements(out);
|
|
3683
5940
|
if (next === out)
|
|
3684
5941
|
break;
|
|
3685
5942
|
out = next;
|
|
@@ -3701,12 +5958,10 @@ function checkButtonTextVerb(html) {
|
|
|
3701
5958
|
// Carry the opening tag alongside the label so the violation loop can tell a
|
|
3702
5959
|
// command button from a selection control (see isSelectionControl).
|
|
3703
5960
|
const buttonTexts = [];
|
|
3704
|
-
let m;
|
|
3705
5961
|
// Strip keyboard shortcut hints that are fused to or appended after the
|
|
3706
5962
|
// button label — e.g. "Find⌘K", "Search ⌘+K", "Save Ctrl+S". These are
|
|
3707
5963
|
// visual hints, not part of the verb. Ranges: ⌘ (U+2318), ⌃ (U+2303),
|
|
3708
5964
|
// ⌥ (U+2325), ⇧ (U+21E7), and common "Ctrl+", "Cmd+", "Shift+" prefixes.
|
|
3709
|
-
const SHORTCUT_RE = /[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i;
|
|
3710
5965
|
// The `+` is optional and the key may be fused directly to the modifier
|
|
3711
5966
|
// ("CtrlK"), because the badge is now platform-aware and renders "Ctrl" with
|
|
3712
5967
|
// no glyph to strip. Requiring a literal '+' meant "FindCtrlK" survived and
|
|
@@ -3715,14 +5970,13 @@ function checkButtonTextVerb(html) {
|
|
|
3715
5970
|
// The key must be ADJACENT to the modifier (plus form, or fused alphanumerics
|
|
3716
5971
|
// with no space between). A space separates a real word: "Alt text" is a
|
|
3717
5972
|
// 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
5973
|
// Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
|
|
3720
5974
|
// "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
|
|
3721
5975
|
// must stand as its own word (the start of the label or after a space), so
|
|
3722
5976
|
// "Halt+1" is untouched, and only the plus form is read: a modifier followed
|
|
3723
5977
|
// by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
|
|
3724
5978
|
const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
|
|
3725
|
-
const stripShortcut = (s) =>
|
|
5979
|
+
const stripShortcut = (s) => withoutTextShortcut(withoutGlyphShortcut(s)).replace(PLUS_SHORTCUT_RE, '').trim();
|
|
3726
5980
|
// Engine 1.3.1: keycaps are read from the markup, not guessed from text.
|
|
3727
5981
|
// A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
|
|
3728
5982
|
// a hint beside the label rather than the label, so its text is dropped
|
|
@@ -3737,23 +5991,23 @@ function checkButtonTextVerb(html) {
|
|
|
3737
5991
|
const visible = stripAriaHidden(inner);
|
|
3738
5992
|
let out = visible;
|
|
3739
5993
|
for (let i = 0; i < 4; i++) {
|
|
3740
|
-
const next = out
|
|
5994
|
+
const next = withoutKbdElements(out);
|
|
3741
5995
|
if (next === out)
|
|
3742
5996
|
break;
|
|
3743
5997
|
out = next;
|
|
3744
5998
|
}
|
|
3745
|
-
const text = out
|
|
3746
|
-
return out === visible ? text : text
|
|
5999
|
+
const text = withoutTags(out, '', false).trim();
|
|
6000
|
+
return out === visible ? text : withoutTrailingPluses(text).trim();
|
|
3747
6001
|
};
|
|
3748
|
-
|
|
3749
|
-
let text = labelText(m[
|
|
6002
|
+
for (const m of buttonElements(html)) {
|
|
6003
|
+
let text = labelText(m.groups[0]);
|
|
3750
6004
|
// Strip leading icon characters so "✕Close" → "Close"
|
|
3751
6005
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3752
6006
|
// Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
|
|
3753
6007
|
text = stripShortcut(text);
|
|
3754
6008
|
// Icon-only button with an accessible name: use the aria-label verb.
|
|
3755
6009
|
if (!text) {
|
|
3756
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
6010
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3757
6011
|
if (aria)
|
|
3758
6012
|
text = aria[1].trim();
|
|
3759
6013
|
}
|
|
@@ -3763,19 +6017,19 @@ function checkButtonTextVerb(html) {
|
|
|
3763
6017
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3764
6018
|
// on /maturity — an accurate observation of the wrong string.
|
|
3765
6019
|
if (text && text.length <= 2) {
|
|
3766
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
6020
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3767
6021
|
if (aria)
|
|
3768
6022
|
text = aria[1].trim();
|
|
3769
6023
|
}
|
|
3770
6024
|
if (text)
|
|
3771
|
-
buttonTexts.push({ text, tag: m
|
|
6025
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3772
6026
|
}
|
|
3773
|
-
|
|
3774
|
-
let text = labelText(m[
|
|
6027
|
+
for (const m of roleButtonElements(html)) {
|
|
6028
|
+
let text = labelText(m.groups[0]);
|
|
3775
6029
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3776
6030
|
text = stripShortcut(text);
|
|
3777
6031
|
if (!text) {
|
|
3778
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
6032
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3779
6033
|
if (aria)
|
|
3780
6034
|
text = aria[1].trim();
|
|
3781
6035
|
}
|
|
@@ -3785,12 +6039,12 @@ function checkButtonTextVerb(html) {
|
|
|
3785
6039
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3786
6040
|
// on /maturity — an accurate observation of the wrong string.
|
|
3787
6041
|
if (text && text.length <= 2) {
|
|
3788
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
6042
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3789
6043
|
if (aria)
|
|
3790
6044
|
text = aria[1].trim();
|
|
3791
6045
|
}
|
|
3792
6046
|
if (text)
|
|
3793
|
-
buttonTexts.push({ text, tag: m
|
|
6047
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3794
6048
|
}
|
|
3795
6049
|
if (buttonTexts.length === 0) {
|
|
3796
6050
|
return { id: 'v38', item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'no button elements found in HTML' };
|
|
@@ -3860,23 +6114,19 @@ function checkButtonTextVerb(html) {
|
|
|
3860
6114
|
function checkNoTrailingPeriod(html) {
|
|
3861
6115
|
const ITEM = 'No trailing period on button text, labels, or tab text';
|
|
3862
6116
|
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
6117
|
const texts = [];
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
6118
|
+
for (const m of buttonElements(html)) {
|
|
6119
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3870
6120
|
if (text)
|
|
3871
6121
|
texts.push({ type: 'button', text });
|
|
3872
6122
|
}
|
|
3873
|
-
|
|
3874
|
-
const text = m
|
|
6123
|
+
for (const m of labelElements(html)) {
|
|
6124
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3875
6125
|
if (text)
|
|
3876
6126
|
texts.push({ type: 'label', text });
|
|
3877
6127
|
}
|
|
3878
|
-
|
|
3879
|
-
const text = m
|
|
6128
|
+
for (const m of roleTabElements(html)) {
|
|
6129
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3880
6130
|
if (text)
|
|
3881
6131
|
texts.push({ type: 'tab', text });
|
|
3882
6132
|
}
|
|
@@ -3900,11 +6150,9 @@ const NON_DESCRIPTIVE_LINK_TEXT = /^(click here|here|learn more|read more|more|l
|
|
|
3900
6150
|
function checkLinkTextDescriptive(html) {
|
|
3901
6151
|
const ITEM = 'Link text is descriptive (not bare "click here", "learn more", "here")';
|
|
3902
6152
|
const CATEGORY = 'copywriting';
|
|
3903
|
-
const linkRe = /<a[^>]*>([\s\S]*?)<\/a>/gi;
|
|
3904
6153
|
const linkTexts = [];
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
6154
|
+
for (const m of anchorElements(html)) {
|
|
6155
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3908
6156
|
if (text)
|
|
3909
6157
|
linkTexts.push(text);
|
|
3910
6158
|
}
|
|
@@ -3934,20 +6182,18 @@ function checkNoAllCaps(html) {
|
|
|
3934
6182
|
const ITEM = 'No ALL CAPS UI text except eyebrow labels';
|
|
3935
6183
|
const CATEGORY = 'copywriting';
|
|
3936
6184
|
// 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
6185
|
const violations = [];
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
const
|
|
3942
|
-
const text = m[3].replace(/<[^>]*>/g, '').trim();
|
|
6186
|
+
for (const m of capsElementMatches(html)) {
|
|
6187
|
+
const attrs = m.groups[1] || '';
|
|
6188
|
+
const text = withoutTags(m.groups[2], '', false).trim();
|
|
3943
6189
|
if (!text || text.length < 4)
|
|
3944
6190
|
continue;
|
|
3945
6191
|
// Skip eyebrow labels (class contains "eyebrow" or "label" — per typography
|
|
3946
6192
|
// contract, eyebrows are intentionally uppercase)
|
|
3947
|
-
if (
|
|
6193
|
+
if (hasEyebrowClass(attrs))
|
|
3948
6194
|
continue;
|
|
3949
6195
|
// Skip elements with text-transform: uppercase in inline style
|
|
3950
|
-
if (
|
|
6196
|
+
if (hasUppercaseStyle(attrs))
|
|
3951
6197
|
continue;
|
|
3952
6198
|
// Check if text is ALL CAPS (only letters, all uppercase, >3 chars)
|
|
3953
6199
|
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
@@ -3979,19 +6225,19 @@ function checkPoiseInteractionRules(css) {
|
|
|
3979
6225
|
const rules = [
|
|
3980
6226
|
{
|
|
3981
6227
|
name: 'fine-pointer hover guard',
|
|
3982
|
-
|
|
6228
|
+
test: (s) => mediaQueryHasHoverGuard(s),
|
|
3983
6229
|
},
|
|
3984
6230
|
{
|
|
3985
6231
|
name: 'press settle scale ~0.97',
|
|
3986
|
-
|
|
6232
|
+
test: (s) => /scale\s*\(\s*0?\.9[5-9]\s*\)/i.test(s),
|
|
3987
6233
|
},
|
|
3988
6234
|
{
|
|
3989
6235
|
name: 'opacity-only mark breath',
|
|
3990
|
-
|
|
6236
|
+
test: (s) => hasBreathKeyframes(s),
|
|
3991
6237
|
},
|
|
3992
6238
|
];
|
|
3993
|
-
const found = rules.filter(r => r.
|
|
3994
|
-
const missing = rules.filter(r => !r.
|
|
6239
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6240
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
3995
6241
|
if (found.length >= 2) {
|
|
3996
6242
|
return {
|
|
3997
6243
|
id: 'v08',
|
|
@@ -4020,8 +6266,8 @@ function checkPoiseKeyboardPath(css, html) {
|
|
|
4020
6266
|
const hasFocusVisible = /:focus-visible/i.test(css);
|
|
4021
6267
|
const hasFocus = /:focus[^-]/i.test(css);
|
|
4022
6268
|
// Detect focus styles that strip outline without a replacement ring/box-shadow
|
|
4023
|
-
const stripsOutline =
|
|
4024
|
-
const hasFocusRing =
|
|
6269
|
+
const stripsOutline = focusStripsOutline(css);
|
|
6270
|
+
const hasFocusRing = focusHasRing(css);
|
|
4025
6271
|
const hasTabindex = /tabindex\s*=/i.test(html);
|
|
4026
6272
|
const hasAria = /aria-(label|labelledby|describedby|expanded|selected|pressed)/i.test(html);
|
|
4027
6273
|
const signals = [hasFocusVisible, hasFocus, hasFocusRing, hasTabindex, hasAria].filter(Boolean).length;
|
|
@@ -4063,19 +6309,19 @@ function checkTaktFeelRules(css) {
|
|
|
4063
6309
|
const rules = [
|
|
4064
6310
|
{
|
|
4065
6311
|
name: 'stagger enter animation-delay',
|
|
4066
|
-
|
|
6312
|
+
test: (s) => /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i.test(s),
|
|
4067
6313
|
},
|
|
4068
6314
|
{
|
|
4069
6315
|
name: 'soften exit transform ease-out',
|
|
4070
|
-
|
|
6316
|
+
test: (s) => hasTransformEaseTransition(s),
|
|
4071
6317
|
},
|
|
4072
6318
|
{
|
|
4073
6319
|
name: 'concentric border-radius set',
|
|
4074
|
-
|
|
6320
|
+
test: (s) => /border-radius\s*:\s*\d+/i.test(s),
|
|
4075
6321
|
},
|
|
4076
6322
|
];
|
|
4077
|
-
const found = rules.filter(r => r.
|
|
4078
|
-
const missing = rules.filter(r => !r.
|
|
6323
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6324
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
4079
6325
|
if (found.length >= 2) {
|
|
4080
6326
|
return {
|
|
4081
6327
|
id: 'v10',
|
|
@@ -4172,13 +6418,50 @@ async function checkDesignMdSpec(targetUrl) {
|
|
|
4172
6418
|
// (an account name often does); the last one may not, so the sentence after
|
|
4173
6419
|
// a path survives, and trailing punctuation is kept.
|
|
4174
6420
|
function sanitizeErrorText(text) {
|
|
4175
|
-
|
|
4176
|
-
|
|
6421
|
+
// The punctuation a replaced path ended with, read back from its end
|
|
6422
|
+
// (engine 1.5.1: /[.,;:!?)]+$/ was retried from every mark in a run).
|
|
6423
|
+
const tail = (m) => {
|
|
6424
|
+
let k = m.length;
|
|
6425
|
+
while (k > 0 && '.,;:!?)'.indexOf(m[k - 1]) >= 0)
|
|
6426
|
+
k--;
|
|
6427
|
+
return m.slice(k);
|
|
6428
|
+
};
|
|
6429
|
+
const keep = (label) => (m) => `${label}${tail(m)}`;
|
|
6430
|
+
const pathless = text
|
|
4177
6431
|
.replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
|
|
4178
6432
|
.replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
|
|
4179
6433
|
.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
|
-
|
|
6434
|
+
.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))}`);
|
|
6435
|
+
// npm cache segments: a run of characters other than space, quotes, angle
|
|
6436
|
+
// brackets, backtick and pipe that holds npm-cache, /_npx/ or /.npm/ is
|
|
6437
|
+
// replaced whole. Each run is read once (engine 1.5.1: the run pattern was
|
|
6438
|
+
// retried from every character in it).
|
|
6439
|
+
const marker = /npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/]/gi;
|
|
6440
|
+
const stop = /[\s'"<>`|]/;
|
|
6441
|
+
const parts = [];
|
|
6442
|
+
let last = 0;
|
|
6443
|
+
let markerAt = -1;
|
|
6444
|
+
for (let a = 0; a < pathless.length;) {
|
|
6445
|
+
if (stop.test(pathless[a])) {
|
|
6446
|
+
a++;
|
|
6447
|
+
continue;
|
|
6448
|
+
}
|
|
6449
|
+
let b = a + 1;
|
|
6450
|
+
while (b < pathless.length && !stop.test(pathless[b]))
|
|
6451
|
+
b++;
|
|
6452
|
+
if (markerAt < a) {
|
|
6453
|
+
marker.lastIndex = a;
|
|
6454
|
+
const found = marker.exec(pathless);
|
|
6455
|
+
markerAt = found ? found.index : Infinity;
|
|
6456
|
+
}
|
|
6457
|
+
if (markerAt < b) {
|
|
6458
|
+
parts.push(pathless.slice(last, a), keep('the npm cache')(pathless.slice(a, b)));
|
|
6459
|
+
last = b;
|
|
6460
|
+
}
|
|
6461
|
+
a = b;
|
|
6462
|
+
}
|
|
6463
|
+
parts.push(pathless.slice(last));
|
|
6464
|
+
return parts.join('');
|
|
4182
6465
|
}
|
|
4183
6466
|
/** The checks with every detail passed through sanitizeErrorText. */
|
|
4184
6467
|
function sanitizeCheckDetails(checks) {
|
|
@@ -4318,7 +6601,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4318
6601
|
// contrast or horizontal overflow cannot be A-grade no matter how good its
|
|
4319
6602
|
// tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
|
|
4320
6603
|
//
|
|
4321
|
-
// No A while any check FAILs (engine 1.3.0,
|
|
6604
|
+
// No A while any check FAILs (engine 1.3.0, see CHANGELOG). A
|
|
4322
6605
|
// FAIL is the engine's own finding that the page breaks a rule, so any FAIL
|
|
4323
6606
|
// caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
|
|
4324
6607
|
// A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
|
|
@@ -4346,7 +6629,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4346
6629
|
}
|
|
4347
6630
|
// v44 Status text contrast — the words that report a state fail WCAG
|
|
4348
6631
|
// 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
|
|
6632
|
+
// Same cap as v06 (engine 1.3.0): both are
|
|
4350
6633
|
// measured WCAG contrast failures on text.
|
|
4351
6634
|
if (c.id === 'v44') {
|
|
4352
6635
|
cap = 65;
|
|
@@ -4596,40 +6879,13 @@ export async function scoreFromParts(input) {
|
|
|
4596
6879
|
// reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
|
|
4597
6880
|
// graph-paper grid was reported at the maximum severity. Match a 1px stop
|
|
4598
6881
|
// 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
|
-
};
|
|
6882
|
+
const isGridPattern = (text) => hasColorThenHairline(text) || hasHairlineThenColor(text);
|
|
4627
6883
|
const pageGrad = [];
|
|
4628
6884
|
for (const rule of parseCssRules(css).rules) {
|
|
4629
6885
|
if (!/linear-gradient\s*\(/i.test(rule.decls))
|
|
4630
6886
|
continue;
|
|
4631
6887
|
const decls = parseCssDecls(rule.decls);
|
|
4632
|
-
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') &&
|
|
6888
|
+
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && hasMultiStopGradient(d.value)))
|
|
4633
6889
|
continue;
|
|
4634
6890
|
const text = `${rule.selector}{${rule.decls}}`;
|
|
4635
6891
|
if (isGridPattern(text))
|
|
@@ -4652,7 +6908,7 @@ export async function scoreFromParts(input) {
|
|
|
4652
6908
|
const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
|
|
4653
6909
|
|| (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
|
|
4654
6910
|
|| (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
|
|
4655
|
-
|| (d.prop === 'background' &&
|
|
6911
|
+
|| (d.prop === 'background' && hasSlashThenHairline(d.value)));
|
|
4656
6912
|
const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
|
|
4657
6913
|
if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
|
|
4658
6914
|
pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
|
|
@@ -4670,8 +6926,7 @@ export async function scoreFromParts(input) {
|
|
|
4670
6926
|
}
|
|
4671
6927
|
// S3. Purple/violet gradient
|
|
4672
6928
|
{
|
|
4673
|
-
const
|
|
4674
|
-
const matches = css.match(purplePattern);
|
|
6929
|
+
const matches = purpleGradientDecls(css);
|
|
4675
6930
|
if (matches)
|
|
4676
6931
|
slopFindings.push({ id: 'S3', label: 'Purple/violet AI gradient', severity: 4, instances: matches.length, evidence: matches.slice(0, 3) });
|
|
4677
6932
|
}
|
|
@@ -4683,7 +6938,7 @@ export async function scoreFromParts(input) {
|
|
|
4683
6938
|
// pattern anchored to `(?:^|})` takes 4 ms and matches the same rule.
|
|
4684
6939
|
// Measured 2026-09-19, Node 24. This was the real cause of the 55s cold
|
|
4685
6940
|
// score, not the network (0.9s) and not the sequential CSS fetch.
|
|
4686
|
-
const gradientClips = css
|
|
6941
|
+
const gradientClips = gradientClipRules(css);
|
|
4687
6942
|
const hueKey = (stop) => {
|
|
4688
6943
|
if (/var\(/.test(stop))
|
|
4689
6944
|
return null;
|
|
@@ -4705,7 +6960,7 @@ export async function scoreFromParts(input) {
|
|
|
4705
6960
|
h = Math.round(((h * 60) + 360) % 360);
|
|
4706
6961
|
return 'h' + Math.floor(h / 45);
|
|
4707
6962
|
}
|
|
4708
|
-
const rgb = stop
|
|
6963
|
+
const rgb = rgbArgs(stop);
|
|
4709
6964
|
if (rgb) {
|
|
4710
6965
|
const p = rgb[1].split(',').map(x => parseFloat(x));
|
|
4711
6966
|
if (p.length >= 3) {
|
|
@@ -4728,8 +6983,8 @@ export async function scoreFromParts(input) {
|
|
|
4728
6983
|
return null;
|
|
4729
6984
|
};
|
|
4730
6985
|
const multiColorGradient = gradientClips.filter(block => {
|
|
4731
|
-
const gradientMatch = block
|
|
4732
|
-
const gradText = (gradientMatch && (gradientMatch[
|
|
6986
|
+
const gradientMatch = linearGradientArgs(block);
|
|
6987
|
+
const gradText = (gradientMatch && (gradientMatch.groups[0] || gradientMatch.groups[1])) || '';
|
|
4733
6988
|
const stops = gradText.split(',').map(s => s.trim());
|
|
4734
6989
|
const hueKeys = new Set(stops.map(hueKey).filter((k) => k !== null && k !== 'neutral'));
|
|
4735
6990
|
return hueKeys.size >= 2;
|
|
@@ -4750,8 +7005,7 @@ export async function scoreFromParts(input) {
|
|
|
4750
7005
|
{
|
|
4751
7006
|
const cardPattern = /\.(card|panel|tile|feature|item|box|cell|block)\b/gi;
|
|
4752
7007
|
const cardClasses = css.match(cardPattern) || [];
|
|
4753
|
-
const
|
|
4754
|
-
const gridMatches = css.match(gridCardPattern) || [];
|
|
7008
|
+
const gridMatches = gridRepeatRules(css);
|
|
4755
7009
|
if (cardClasses.length >= 3 && gridMatches.length > 0)
|
|
4756
7010
|
slopFindings.push({ id: 'S6', label: 'Repeated identical card grid', severity: 5, instances: 1, evidence: [`${cardClasses.length} card-like classes with repeating grid layouts`] });
|
|
4757
7011
|
}
|
|
@@ -4765,7 +7019,7 @@ export async function scoreFromParts(input) {
|
|
|
4765
7019
|
// text-default ones promoted with U+FE0F. This also catches ⭐ (U+2B50), which
|
|
4766
7020
|
// the old range missed while it was busy flagging checkmarks.
|
|
4767
7021
|
{
|
|
4768
|
-
const emojiInButtons = html
|
|
7022
|
+
const emojiInButtons = emojiCtaMatches(html);
|
|
4769
7023
|
if (emojiInButtons && emojiInButtons.length >= 2)
|
|
4770
7024
|
slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
|
|
4771
7025
|
}
|
|
@@ -4783,11 +7037,7 @@ export async function scoreFromParts(input) {
|
|
|
4783
7037
|
// Skipped on a page that documents the rules (see isDocumentingSlopRules):
|
|
4784
7038
|
// the anti-slop rule table on /methodology lists these phrases.
|
|
4785
7039
|
{
|
|
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, ' ');
|
|
7040
|
+
const visible = withoutNonVisibleMarkup(html);
|
|
4791
7041
|
const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
|
|
4792
7042
|
const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
|
|
4793
7043
|
const tagRe = {};
|
|
@@ -4818,7 +7068,7 @@ export async function scoreFromParts(input) {
|
|
|
4818
7068
|
}
|
|
4819
7069
|
if (end < 0)
|
|
4820
7070
|
continue;
|
|
4821
|
-
const text = windowText.slice(0, end)
|
|
7071
|
+
const text = withoutTags(windowText.slice(0, end), ' ', false).replace(/\s+/g, ' ').trim();
|
|
4822
7072
|
if (text && text.length <= 40 && pillPhrase.test(text))
|
|
4823
7073
|
pills.push(text);
|
|
4824
7074
|
}
|
|
@@ -4854,7 +7104,7 @@ export async function scoreFromParts(input) {
|
|
|
4854
7104
|
// S11. Marketing buzzwords
|
|
4855
7105
|
{
|
|
4856
7106
|
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
|
|
7107
|
+
const bodyText = withoutTags(html, ' ', true).toLowerCase();
|
|
4858
7108
|
const found = [];
|
|
4859
7109
|
for (const word of buzzwords) {
|
|
4860
7110
|
if (bodyText.includes(word))
|
|
@@ -4880,21 +7130,20 @@ export async function scoreFromParts(input) {
|
|
|
4880
7130
|
const originalitySignals = [];
|
|
4881
7131
|
// O1. Bespoke easing
|
|
4882
7132
|
{
|
|
4883
|
-
const bezierRe = /cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)/gi;
|
|
4884
7133
|
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
7134
|
const distinct = new Set();
|
|
4886
7135
|
let hasOvershoot = false;
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
const key = `${
|
|
7136
|
+
for (const b of cubicBezierMatches(css)) {
|
|
7137
|
+
const [x1, y1s, x2, y2s] = b.groups;
|
|
7138
|
+
const key = withoutTrailingDecimalZeros(`${x1},${y1s},${x2},${y2s}`).replace(/\s+/g, '');
|
|
4890
7139
|
if (EASING_PRESETS.has(key))
|
|
4891
7140
|
continue;
|
|
4892
7141
|
distinct.add(key);
|
|
4893
|
-
const y1 = parseFloat(
|
|
7142
|
+
const y1 = parseFloat(y1s), y2 = parseFloat(y2s);
|
|
4894
7143
|
if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1)
|
|
4895
7144
|
hasOvershoot = true;
|
|
4896
7145
|
}
|
|
4897
|
-
const linearSprings = css
|
|
7146
|
+
const linearSprings = longLinearEasings(css);
|
|
4898
7147
|
if (linearSprings.length > 0)
|
|
4899
7148
|
hasOvershoot = true;
|
|
4900
7149
|
const easingCount = distinct.size;
|
|
@@ -4924,8 +7173,8 @@ export async function scoreFromParts(input) {
|
|
|
4924
7173
|
}
|
|
4925
7174
|
// O4. Tiered reduced-motion
|
|
4926
7175
|
{
|
|
4927
|
-
const reducedMotion =
|
|
4928
|
-
const blanketKill =
|
|
7176
|
+
const reducedMotion = mediaQueryHasReducedMotion(css);
|
|
7177
|
+
const blanketKill = hasBlanketMotionKill(css);
|
|
4929
7178
|
if (reducedMotion && !blanketKill)
|
|
4930
7179
|
originalitySignals.push({ id: 'O4', label: 'Tiered reduced-motion', points: 1, evidence: 'targeted (not blanket) motion reduction' });
|
|
4931
7180
|
}
|
|
@@ -4942,8 +7191,8 @@ export async function scoreFromParts(input) {
|
|
|
4942
7191
|
}
|
|
4943
7192
|
// O6. Bespoke iconography
|
|
4944
7193
|
{
|
|
4945
|
-
const inlineSvg = html
|
|
4946
|
-
const svgSymbols = html
|
|
7194
|
+
const inlineSvg = svgViewBoxTags(html);
|
|
7195
|
+
const svgSymbols = symbolTags(html);
|
|
4947
7196
|
if (inlineSvg.length >= 3 || svgSymbols.length >= 2)
|
|
4948
7197
|
originalitySignals.push({ id: 'O6', label: 'Bespoke iconography', points: 1, evidence: `${inlineSvg.length} inline SVGs${svgSymbols.length ? ` + ${svgSymbols.length} symbols` : ''}` });
|
|
4949
7198
|
}
|
|
@@ -4953,11 +7202,11 @@ export async function scoreFromParts(input) {
|
|
|
4953
7202
|
const shadcnHits = SHADCN_FINGERPRINT.filter((t) => css.includes(t + ':')).length;
|
|
4954
7203
|
const referencesTwPrimitives = /var\(--color-[a-z]+-\d{2,3}\)/i.test(css);
|
|
4955
7204
|
const isShadcnTemplate = shadcnHits >= 6 || (shadcnHits >= 4 && referencesTwPrimitives);
|
|
4956
|
-
const customProps = css
|
|
7205
|
+
const customProps = customPropertyDecls(css);
|
|
4957
7206
|
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
7207
|
const primitiveHue = /--(?:color|colour)-[a-z]+-\d{2,3}\s*:/i;
|
|
4959
7208
|
const semanticCount = isShadcnTemplate ? 0 : semanticTokens.filter((p) => !primitiveHue.test(p)).length;
|
|
4960
|
-
const hasLayering =
|
|
7209
|
+
const hasLayering = hasTokenLayering(css);
|
|
4961
7210
|
const hasTheming = /light-dark\(|prefers-color-scheme\s*:\s*dark|data-theme/i.test(css);
|
|
4962
7211
|
let tokenPoints = 0;
|
|
4963
7212
|
const tokenEvidence = [];
|
|
@@ -5110,9 +7359,12 @@ export function emitReview(url, result) {
|
|
|
5110
7359
|
continue;
|
|
5111
7360
|
num++;
|
|
5112
7361
|
const severity = c.status === 'FAIL' ? 'HIGH' : 'MEDIUM';
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
7362
|
+
// A cell escapes its backslashes before its pipes: a detail carries text
|
|
7363
|
+
// read from the scored page, where a backslash before a pipe would
|
|
7364
|
+
// otherwise escape the escape and leave a live cell separator.
|
|
7365
|
+
const before = c.detail.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7366
|
+
const after = (c.remediation || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7367
|
+
const why = `${c.item} (${c.category})`.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
5116
7368
|
lines.push(`| ${num} | ${severity} | ${c.category} | ${url} | ${before} | ${after} | ${why} |`);
|
|
5117
7369
|
}
|
|
5118
7370
|
if (num === 0)
|