@designesy/score 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/engine.js +2375 -251
- 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;
|
|
@@ -485,7 +2509,7 @@ const REMEDIATION = {
|
|
|
485
2509
|
v34: 'If the page hosts an AI chatbot or agent, tell people they are talking to an AI no later than their first interaction, in a form assistive technology announces. EU AI Act Article 50(1), which applies from 2026-08-02, places this duty on the provider of the AI system (the organisation that develops it and places it on the market or puts it into service under its own name); it does not apply where the AI nature is obvious from the context, and Article 50(5) asks for the information to be clear and accessible. The check reads any one of: visible "AI assistant" or "Chatbot" text, aria-label="AI assistant" on the chat container, or a data-ai-disclosure attribute on the widget. A live chat answered by people needs no AI disclosure: if the check flagged one for review, confirm who answers. US laws differ in scope: California\'s bot-disclosure law (SB 1001, Bus. & Prof. Code §§17940-17943) covers bots that mislead people about being bots to drive a sale or influence a vote; Colorado SB 24-205 was repealed and re-enacted by SB 26-189 (signed 2026-05-14, obligations from 2027-01-01), which governs automated decision-making in consequential decisions. This is not legal advice.',
|
|
486
2510
|
v35: 'Add a forced-colors readiness block: @media (forced-colors: active) { ... } with forced-color-adjust: none on elements that must preserve brand identity (logos, charts, semantic-color indicators). Windows High Contrast Mode and Chrome forced-colors recolor the page: without this media query, critical UI becomes illegible. Also ensure borders/outlines use currentColor or system colors so they adapt. Test with Windows HCM (Settings > Accessibility > Contrast themes).',
|
|
487
2511
|
v36: 'Remove UTS #39 confusable characters from CSS identifiers and token names. Confusables are Unicode characters from different scripts (Cyrillic, Greek, fullwidth) that look identical to ASCII letters; Cyrillic а (U+0430), for example, looks like Latin a (U+0061). In token names they enable shadowing attacks (--соlor-bg with Cyrillic с vs --color-bg). Audit all custom property names, class names, and url() paths for non-ASCII characters using a Unicode confusable detector. Provenance: Unicode Technical Standard #39, Unicode 16.0.0. Designesy is the only design verification engine that checks this surface.',
|
|
488
|
-
v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly.
|
|
2512
|
+
v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly. The DESIGN.md format\'s linter (v0.4.0, Apache-2.0) validates the file: 11 lint rules covering broken token refs, missing primary colors, WCAG contrast, orphaned tokens, section order, and more. Designesy integrates Google\'s linter as the spec layer and runs its own 45-check contract verification as the layer above. Install the CLI: npm install -g @google/design.md. Lint locally: npx @google/design.md lint DESIGN.md. Export to W3C DTCG: npx @google/design.md export --format dtcg DESIGN.md. Note: DESIGN.md uses sRGB hex only; for OKLCH/Display P3 color spaces, use the W3C DTCG JSON format directly.',
|
|
489
2513
|
v38: 'Rewrite button labels to start with a verb or recognized command. NN/g: "Lead with verbs or verb phrases that clearly outline what will happen after the command is selected." Use "Save changes" not "Changes", "Delete file" not "File". Recognized commands: Save, Cancel, Delete, Edit, Share, Close, Back, Next, etc. This is a WARN (heuristic): review flagged buttons manually.',
|
|
490
2514
|
v39: 'Remove trailing periods from button text, labels, and tab text. Microsoft Fluent: "Don\'t end text for buttons, radio buttons, labels, or checkboxes with a period." Periods are for full sentences in tooltips, error messages, and dialog bodies only.',
|
|
491
2515
|
v40: 'Replace non-descriptive link text with destination-revealing text. WCAG 2.4.4 Link Purpose: link text should describe the destination. Use "Read the typography guide" not "Click here". Use "View the leaderboard" not "Learn more". NN/g: non-descriptive links force users to read surrounding context to understand the destination.',
|
|
@@ -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] || '';
|
|
@@ -1139,6 +3150,90 @@ function isFieldSelector(selector) {
|
|
|
1139
3150
|
|| /\.(?:input|field)(?![\w-])/i.test(subject);
|
|
1140
3151
|
});
|
|
1141
3152
|
}
|
|
3153
|
+
/**
|
|
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.
|
|
3166
|
+
*/
|
|
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
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
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,
|
|
3208
|
+
* .input, .field), the same words the literal scan looks for? v27's literal
|
|
3209
|
+
* regexes find a keyword and then the next rule body, and their `[^{]*` runs
|
|
3210
|
+
* across a closing brace. So the word "select" in `.chip { user-select: none }`
|
|
3211
|
+
* carried into `.chip-label { font-size: 12px }` and FAILed a page whose
|
|
3212
|
+
* fields were all 16px. The regexes still find the candidates; this reads the
|
|
3213
|
+
* selector of the rule each matched declaration actually sits in, and drops a
|
|
3214
|
+
* candidate whose own selector carries no keyword. Class names stay matched
|
|
3215
|
+
* as before (`.search-input`, `.form-select`): many sites style their fields
|
|
3216
|
+
* through such classes, and measured on the leaderboard cohort the stricter
|
|
3217
|
+
* isFieldSelector test dropped real fields (a 14px textarea, a 16px search
|
|
3218
|
+
* input's floor).
|
|
3219
|
+
*/
|
|
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
|
+
};
|
|
3236
|
+
}
|
|
1142
3237
|
/**
|
|
1143
3238
|
* v27 helper (engine 1.3.1): in each rule whose selector styles a field
|
|
1144
3239
|
* (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
|
|
@@ -1151,25 +3246,45 @@ function isFieldSelector(selector) {
|
|
|
1151
3246
|
* a card caption) as an input below 16px. Only values that start with var(
|
|
1152
3247
|
* are touched; calc(), clamp() and min()/max() stay as written, as before.
|
|
1153
3248
|
*
|
|
1154
|
-
* The scan is anchored on each `font-size: var(` and
|
|
1155
|
-
*
|
|
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
|
|
1156
3251
|
* whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
|
|
1157
3252
|
* @media prelude: measured at +4.5 s on five 43 KB preludes.
|
|
1158
3253
|
*/
|
|
1159
3254
|
function resolveFontSizeVars(css, tokens) {
|
|
1160
3255
|
if (css.indexOf('var(') < 0)
|
|
1161
3256
|
return css;
|
|
1162
|
-
|
|
1163
|
-
|
|
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;
|
|
1164
3269
|
// Not inside a rule body (a stray declaration, or a brace closed between).
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
+
}
|
|
1170
3283
|
const sub = substituteCssVars(value, [tokens]);
|
|
1171
|
-
|
|
1172
|
-
}
|
|
3284
|
+
parts.push(sub === null ? decl : lead + sub.trim());
|
|
3285
|
+
}
|
|
3286
|
+
parts.push(css.slice(last));
|
|
3287
|
+
return parts.join('');
|
|
1173
3288
|
}
|
|
1174
3289
|
/**
|
|
1175
3290
|
* v27 helper: does the delivered markup carry a field iOS Safari zooms into on
|
|
@@ -1178,10 +3293,10 @@ function resolveFontSizeVars(css, tokens) {
|
|
|
1178
3293
|
* image, file, range and color. An <input> with no type is a text field.
|
|
1179
3294
|
*/
|
|
1180
3295
|
function hasZoomableField(html) {
|
|
1181
|
-
const visible = html
|
|
3296
|
+
const visible = withoutScriptsAndStyles(html);
|
|
1182
3297
|
if (/<(?:textarea|select)\b/i.test(visible))
|
|
1183
3298
|
return true;
|
|
1184
|
-
const inputs = visible
|
|
3299
|
+
const inputs = inputTags(visible);
|
|
1185
3300
|
return inputs.some((tag) => {
|
|
1186
3301
|
const type = /\stype\s*=\s*["']?([a-z-]+)/i.exec(tag);
|
|
1187
3302
|
return !type || !/^(?:hidden|checkbox|radio|submit|button|reset|image|file|range|color)$/i.test(type[1]);
|
|
@@ -1206,13 +3321,15 @@ function hasZoomableField(html) {
|
|
|
1206
3321
|
// cannot resolve is left as written, so it reads as it did before.
|
|
1207
3322
|
function checkInputFontFloor(css, html, tokens = {}) {
|
|
1208
3323
|
const sheet = resolveFontSizeVars(css, tokens);
|
|
3324
|
+
const inFieldRule = fieldRuleFinder(sheet);
|
|
1209
3325
|
// Find input/textarea/select rules with font-size < 16px or < 1rem.
|
|
1210
|
-
const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
|
|
1211
3326
|
const below = [];
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
3327
|
+
for (const m of fieldFontSizes(sheet)) {
|
|
3328
|
+
// Engine 1.4.1: only a declaration whose own rule styles a field counts.
|
|
3329
|
+
if (!inFieldRule(m.index + m.text.lastIndexOf('font-size')))
|
|
3330
|
+
continue;
|
|
3331
|
+
const val = parseFloat(m.groups[0]);
|
|
3332
|
+
const unit = m.groups[1].toLowerCase();
|
|
1216
3333
|
const px = unit === 'rem' ? val * 16 : val;
|
|
1217
3334
|
if (px < 16)
|
|
1218
3335
|
below.push(`${val}${unit} (${px}px)`);
|
|
@@ -1221,8 +3338,13 @@ function checkInputFontFloor(css, html, tokens = {}) {
|
|
|
1221
3338
|
// Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
|
|
1222
3339
|
// The strict form `input\s*\{` matches standalone rules; the grouped form
|
|
1223
3340
|
// `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
|
|
1224
|
-
|
|
1225
|
-
|
|
3341
|
+
// Engine 1.4.1: the floor rule must itself style a field, as above.
|
|
3342
|
+
let hasGlobalFloor = false;
|
|
3343
|
+
for (const f of fieldFontFloors(sheet)) {
|
|
3344
|
+
if (hasGlobalFloor)
|
|
3345
|
+
break;
|
|
3346
|
+
hasGlobalFloor = inFieldRule(f.index + f.text.lastIndexOf('font-size'));
|
|
3347
|
+
}
|
|
1226
3348
|
if (hasGlobalFloor)
|
|
1227
3349
|
return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
|
|
1228
3350
|
if (!hasZoomableField(html))
|
|
@@ -1243,14 +3365,12 @@ function checkReadingWidth(css) {
|
|
|
1243
3365
|
// contains a prose-ish word (e.g. `.prose-grid`).
|
|
1244
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;
|
|
1245
3367
|
// Walk every rule block, capturing selector + declarations together.
|
|
1246
|
-
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
1247
3368
|
const proseInRange = [];
|
|
1248
3369
|
const proseOutOfRange = [];
|
|
1249
3370
|
const nonProse = [];
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
const
|
|
1253
|
-
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];
|
|
1254
3374
|
// Skip at-rule preludes (`@media (...)`, `@supports (...)`) — the block
|
|
1255
3375
|
// body is walked on the next iteration when the inner rule is seen. A
|
|
1256
3376
|
// media query wrapping prose rules should not itself be classified.
|
|
@@ -1569,6 +3689,16 @@ function parseCssRules(css) {
|
|
|
1569
3689
|
lastCssParse = { css, result };
|
|
1570
3690
|
return result;
|
|
1571
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;
|
|
1572
3702
|
function parseCssRulesUncached(css) {
|
|
1573
3703
|
const src = stripCssComments(css);
|
|
1574
3704
|
const n = src.length;
|
|
@@ -1578,6 +3708,7 @@ function parseCssRulesUncached(css) {
|
|
|
1578
3708
|
let order = 0;
|
|
1579
3709
|
let start = 0;
|
|
1580
3710
|
let paren = 0;
|
|
3711
|
+
let overflow = 0;
|
|
1581
3712
|
const close = (f, end) => {
|
|
1582
3713
|
f.parts.push(src.slice(f.segStart, end));
|
|
1583
3714
|
const decls = f.parts.join(' ').trim();
|
|
@@ -1636,6 +3767,12 @@ function parseCssRulesUncached(css) {
|
|
|
1636
3767
|
}
|
|
1637
3768
|
if (c === 123) {
|
|
1638
3769
|
paren = 0;
|
|
3770
|
+
if (stack.length >= CSS_MAX_NESTING) {
|
|
3771
|
+
overflow++;
|
|
3772
|
+
start = i + 1;
|
|
3773
|
+
i++;
|
|
3774
|
+
continue;
|
|
3775
|
+
}
|
|
1639
3776
|
const prelude = src.slice(start, i).trim();
|
|
1640
3777
|
const parent = stack.length ? stack[stack.length - 1] : null;
|
|
1641
3778
|
if (parent)
|
|
@@ -1676,6 +3813,12 @@ function parseCssRulesUncached(css) {
|
|
|
1676
3813
|
}
|
|
1677
3814
|
if (c === 125) {
|
|
1678
3815
|
paren = 0;
|
|
3816
|
+
if (overflow > 0) {
|
|
3817
|
+
overflow--;
|
|
3818
|
+
start = i + 1;
|
|
3819
|
+
i++;
|
|
3820
|
+
continue;
|
|
3821
|
+
}
|
|
1679
3822
|
const f = stack.pop();
|
|
1680
3823
|
if (f) {
|
|
1681
3824
|
close(f, i);
|
|
@@ -1689,9 +3832,9 @@ function parseCssRulesUncached(css) {
|
|
|
1689
3832
|
}
|
|
1690
3833
|
i++;
|
|
1691
3834
|
}
|
|
1692
|
-
|
|
3835
|
+
for (let closed = 0; stack.length; closed++) {
|
|
1693
3836
|
const f = stack.pop();
|
|
1694
|
-
if (f)
|
|
3837
|
+
if (f && closed < CSS_MAX_OPEN_AT_END)
|
|
1695
3838
|
close(f, n);
|
|
1696
3839
|
}
|
|
1697
3840
|
return { rules, keyframes };
|
|
@@ -1706,9 +3849,10 @@ function parseCssDecls(decls) {
|
|
|
1706
3849
|
if (!/^-{0,2}[a-zA-Z_][\w-]*$/.test(name))
|
|
1707
3850
|
continue;
|
|
1708
3851
|
let value = raw.slice(colon + 1).trim();
|
|
1709
|
-
const
|
|
3852
|
+
const cut = importantSuffixStart(value);
|
|
3853
|
+
const important = cut >= 0;
|
|
1710
3854
|
if (important)
|
|
1711
|
-
value = value.
|
|
3855
|
+
value = value.slice(0, cut).trim();
|
|
1712
3856
|
out.push({ prop: name.startsWith('--') ? name : name.toLowerCase(), value, important });
|
|
1713
3857
|
}
|
|
1714
3858
|
return out;
|
|
@@ -1799,7 +3943,7 @@ function cssNumber(token, percentScale) {
|
|
|
1799
3943
|
const t = token.trim().toLowerCase();
|
|
1800
3944
|
if (t === 'none')
|
|
1801
3945
|
return 0;
|
|
1802
|
-
const m = /^([+-]?(?:\d
|
|
3946
|
+
const m = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(%|deg|turn|rad|grad)?$/.exec(t);
|
|
1803
3947
|
if (!m)
|
|
1804
3948
|
return null;
|
|
1805
3949
|
const x = parseFloat(m[1]);
|
|
@@ -1881,8 +4025,8 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1881
4025
|
if (!['srgb', 'oklab', 'oklch'].includes(space))
|
|
1882
4026
|
return null;
|
|
1883
4027
|
const side = (p) => {
|
|
1884
|
-
const after =
|
|
1885
|
-
const before =
|
|
4028
|
+
const after = percentAfter(p);
|
|
4029
|
+
const before = percentBefore(p);
|
|
1886
4030
|
if (after)
|
|
1887
4031
|
return { color: after[1], pct: parseFloat(after[2]) / 100 };
|
|
1888
4032
|
if (before)
|
|
@@ -1948,7 +4092,7 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1948
4092
|
const mult = Math.min(1, sum);
|
|
1949
4093
|
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), Math.round(alpha * mult * 255) / 255];
|
|
1950
4094
|
}
|
|
1951
|
-
const rel =
|
|
4095
|
+
const rel = relativeColorMatch(args);
|
|
1952
4096
|
if (rel) {
|
|
1953
4097
|
const base = parseCssColor(rel[1], dark, depth + 1);
|
|
1954
4098
|
if (!base)
|
|
@@ -2094,8 +4238,7 @@ function themeSelectorKeys(selector) {
|
|
|
2094
4238
|
}
|
|
2095
4239
|
/** A selector part with its theme attributes and classes removed. */
|
|
2096
4240
|
function withoutThemeKeys(part) {
|
|
2097
|
-
return part
|
|
2098
|
-
.replace(/\[[^\]]*\]/g, (a) => (themeSelectorKeys(a).length ? '' : a))
|
|
4241
|
+
return replaceBrackets(part, (a) => (themeSelectorKeys(a).length ? '' : a))
|
|
2099
4242
|
.replace(V44_THEME_CLASS, '')
|
|
2100
4243
|
.trim();
|
|
2101
4244
|
}
|
|
@@ -2229,7 +4372,7 @@ function checkStatusTextContrast(css) {
|
|
|
2229
4372
|
const mediaKey = `@media (prefers-color-scheme: ${scheme})`;
|
|
2230
4373
|
const theme = t === 'root'
|
|
2231
4374
|
? (scheme ? ensure(mediaKey, [], mediaKey) : root)
|
|
2232
|
-
: ensure(t, t
|
|
4375
|
+
: ensure(t, attrAndClassParts(t) ?? [], null);
|
|
2233
4376
|
if (t === 'root' && scheme)
|
|
2234
4377
|
theme.dark = scheme === 'dark';
|
|
2235
4378
|
for (const d of decls) {
|
|
@@ -2761,7 +4904,7 @@ function compareSpecificity(a, b) {
|
|
|
2761
4904
|
}
|
|
2762
4905
|
/** One time value in ms, null when it is not a literal time. */
|
|
2763
4906
|
function cssTimeMs(token) {
|
|
2764
|
-
const m = /^(-?(?:\d
|
|
4907
|
+
const m = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(ms|s)$/i.exec(token.trim());
|
|
2765
4908
|
return m ? parseFloat(m[1]) * (m[2].toLowerCase() === 's' ? 1000 : 1) : null;
|
|
2766
4909
|
}
|
|
2767
4910
|
/** :is() and :where() in a selector, expanded into their alternatives (bounded). */
|
|
@@ -2823,7 +4966,6 @@ const V45_USER_ACTION = /^:(?:hover|focus|focus-within|focus-visible|active|targ
|
|
|
2823
4966
|
// ([data-motion=paused], [data-reduced-motion]), or a class that is one
|
|
2824
4967
|
// (.reduce-motion, .no-motion, .motion-paused, .animations-off). Component
|
|
2825
4968
|
// classes that only end in "paused" (a marquee's .logobar--paused) are not.
|
|
2826
|
-
const V45_TOGGLE_ATTR = /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i;
|
|
2827
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;
|
|
2828
4970
|
/**
|
|
2829
4971
|
* Whether a compound before `*` reaches the whole document: html, :root or
|
|
@@ -2841,7 +4983,7 @@ function isDocumentScope(compound) {
|
|
|
2841
4983
|
if (type)
|
|
2842
4984
|
return /^(?:html|body|:root)$/i.test(type);
|
|
2843
4985
|
return simple.every((s) => s.startsWith('.') || s.startsWith('['))
|
|
2844
|
-
&& simple.some((s) =>
|
|
4986
|
+
&& simple.some((s) => isMotionToggleAttr(s) || V45_TOGGLE_CLASS.test(s));
|
|
2845
4987
|
}
|
|
2846
4988
|
// v46 — Type sizes come from the scale (engine 1.4.0).
|
|
2847
4989
|
//
|
|
@@ -2867,7 +5009,7 @@ const V46_ITEM = 'Type sizes come from the scale';
|
|
|
2867
5009
|
const V46_TOLERANCE = 0.025;
|
|
2868
5010
|
const V46_FLUID = /(?:^|[^a-z-])(?:[sld]?v[wh]|v[ib]|vmin|vmax|cq[iwhb]|cqmin|cqmax)\b/i;
|
|
2869
5011
|
function v46StaticPx(value) {
|
|
2870
|
-
const m = value.trim().match(/^(\d
|
|
5012
|
+
const m = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(rem|px)$/i);
|
|
2871
5013
|
if (!m)
|
|
2872
5014
|
return null;
|
|
2873
5015
|
return parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1);
|
|
@@ -2914,7 +5056,7 @@ function v46Selector(css, at) {
|
|
|
2914
5056
|
if (open < 0)
|
|
2915
5057
|
return '';
|
|
2916
5058
|
const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
|
|
2917
|
-
return css.slice(start, open)
|
|
5059
|
+
return withoutCssComments(css.slice(start, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
2918
5060
|
}
|
|
2919
5061
|
function checkTypeScale(css, tokens) {
|
|
2920
5062
|
const scale = v46TypeScale(tokens);
|
|
@@ -2939,7 +5081,7 @@ function checkTypeScale(css, tokens) {
|
|
|
2939
5081
|
const re = /font-size\s*:\s*([^;}]+)/gi;
|
|
2940
5082
|
let m;
|
|
2941
5083
|
while ((m = re.exec(css)) !== null) {
|
|
2942
|
-
const written = m[1]
|
|
5084
|
+
const written = withoutCssComments(m[1], '').replace(/!important/i, '').trim();
|
|
2943
5085
|
const value = resolveVar(written, tokens);
|
|
2944
5086
|
if (/^var\(/i.test(value))
|
|
2945
5087
|
continue; // an alias the token table cannot resolve
|
|
@@ -3044,7 +5186,7 @@ function checkPausedEntrances(css) {
|
|
|
3044
5186
|
visibility = d.value.trim().toLowerCase();
|
|
3045
5187
|
}
|
|
3046
5188
|
}
|
|
3047
|
-
return (opacity !== null && /\d/.test(opacity) &&
|
|
5189
|
+
return (opacity !== null && /\d/.test(opacity) && isZeroOpacity(opacity)) || visibility === 'hidden';
|
|
3048
5190
|
};
|
|
3049
5191
|
if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
|
|
3050
5192
|
hiddenNames.add(name);
|
|
@@ -3264,55 +5406,45 @@ function checkAiDisclosure(html) {
|
|
|
3264
5406
|
const CATEGORY = 'identity';
|
|
3265
5407
|
// Strip scripts/styles for visible-text checks but keep them for script-src
|
|
3266
5408
|
// and JSON-LD detection. We use both raw html and a visible-only variant.
|
|
3267
|
-
const visibleHtml = html
|
|
3268
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
3269
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
5409
|
+
const visibleHtml = withoutScriptsAndStyles(html);
|
|
3270
5410
|
// Text-only: strip all tags so attribute values like class="chatbot" don't
|
|
3271
5411
|
// false-positive as disclosure TEXT. Disclosure text must be human-readable
|
|
3272
5412
|
// content between tags, not class/id/aria attribute values.
|
|
3273
|
-
const textOnly = visibleHtml
|
|
5413
|
+
const textOnly = withoutTags(visibleHtml, ' ', true);
|
|
3274
5414
|
// ── AI chat surfaces, positively identified ─────────────────────────────
|
|
3275
|
-
const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
|
|
3276
|
-
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
|
|
3277
|
-
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;
|
|
3278
5415
|
// ── Possible AI surfaces: a review item, never a FAIL ───────────────────
|
|
3279
|
-
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;
|
|
3280
|
-
const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
|
|
3281
5416
|
const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
|
|
3282
|
-
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
3283
5417
|
const surfaceSignals = [];
|
|
3284
|
-
if (
|
|
5418
|
+
if (hasAiChatContainer(html))
|
|
3285
5419
|
surfaceSignals.push('chatbot container');
|
|
3286
|
-
if (
|
|
5420
|
+
if (hasAiWidgetScript(html))
|
|
3287
5421
|
surfaceSignals.push('AI widget script');
|
|
3288
|
-
if (
|
|
5422
|
+
if (hasAiInput(html))
|
|
3289
5423
|
surfaceSignals.push('AI input');
|
|
3290
5424
|
const possibleSignals = [];
|
|
3291
|
-
if (
|
|
5425
|
+
if (hasChatWidgetContainer(html))
|
|
3292
5426
|
possibleSignals.push('chat widget container');
|
|
3293
|
-
if (
|
|
5427
|
+
if (hasChatScript(html))
|
|
3294
5428
|
possibleSignals.push('chat or assistant script');
|
|
3295
5429
|
if (aiTextRe.test(textOnly))
|
|
3296
5430
|
possibleSignals.push('AI prompt text');
|
|
3297
|
-
if (
|
|
5431
|
+
if (hasAmbiguousWidget(html))
|
|
3298
5432
|
possibleSignals.push('ambiguous widget');
|
|
3299
5433
|
// ── Disclosure detection ────────────────────────────────────────────────
|
|
3300
5434
|
const disclosureTextRe = /\b(?:ai\s+assistant|ai\s+chatbot|ai-powered|powered\s+by\s+ai|automated\s+assistant|virtual\s+assistant|chatbot)\b/i;
|
|
3301
5435
|
const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
|
|
3302
|
-
const generatorMetaRe = /<meta\s+name\s*=\s*["']generator["'][^>]*content\s*=\s*["'][^"']*\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/i;
|
|
3303
5436
|
const c2paRe = /(?:<link\s+rel\s*=\s*["']c2pa-manifest["']|<meta\s+name\s*=\s*["']c2pa["'])/i;
|
|
3304
|
-
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;
|
|
3305
5437
|
const dataAiRe = /data-ai-disclosure\s*=/i;
|
|
3306
5438
|
const disclosureSignals = [];
|
|
3307
5439
|
if (disclosureTextRe.test(textOnly))
|
|
3308
5440
|
disclosureSignals.push('visible AI text');
|
|
3309
5441
|
if (ariaDisclosureRe.test(html))
|
|
3310
5442
|
disclosureSignals.push('aria-label');
|
|
3311
|
-
if (
|
|
5443
|
+
if (hasAiGeneratorMeta(html))
|
|
3312
5444
|
disclosureSignals.push('generator meta');
|
|
3313
5445
|
if (c2paRe.test(html))
|
|
3314
5446
|
disclosureSignals.push('C2PA manifest');
|
|
3315
|
-
if (
|
|
5447
|
+
if (hasAiJsonLd(html))
|
|
3316
5448
|
disclosureSignals.push('JSON-LD');
|
|
3317
5449
|
if (dataAiRe.test(html))
|
|
3318
5450
|
disclosureSignals.push('data-ai-disclosure');
|
|
@@ -3339,11 +5471,11 @@ function checkForcedColors(css) {
|
|
|
3339
5471
|
const ITEM = 'Forced-colors readiness: @media (forced-colors: active) block present';
|
|
3340
5472
|
const CATEGORY = 'accessibility';
|
|
3341
5473
|
// Primary signal: @media (forced-colors: active) block exists
|
|
3342
|
-
const hasForcedColorsMedia =
|
|
5474
|
+
const hasForcedColorsMedia = mediaQueryHasForcedColors(css);
|
|
3343
5475
|
// Secondary signal: forced-color-adjust property used anywhere
|
|
3344
5476
|
const hasForcedColorAdjust = /forced-color-adjust\s*:/i.test(css);
|
|
3345
5477
|
// Tertiary signal: -ms-high-contrast (legacy Edge/IE) — still relevant
|
|
3346
|
-
const hasHighContrast =
|
|
5478
|
+
const hasHighContrast = mediaQueryHasHighContrast(css);
|
|
3347
5479
|
if (hasForcedColorsMedia && hasForcedColorAdjust) {
|
|
3348
5480
|
return { id: 'v35', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'forced-colors media query + forced-color-adjust both present' };
|
|
3349
5481
|
}
|
|
@@ -3496,11 +5628,10 @@ function checkSecurityConfusables(css, tokens) {
|
|
|
3496
5628
|
}
|
|
3497
5629
|
// Scan 3: url() references — confusables in file paths can redirect to
|
|
3498
5630
|
// different assets (logo spoofing, CSS injection via @import)
|
|
3499
|
-
const urlRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
|
|
3500
5631
|
const urlConfusables = [];
|
|
3501
5632
|
const scannedUrls = new Set();
|
|
3502
|
-
|
|
3503
|
-
const urlPath =
|
|
5633
|
+
for (const u of cssUrlMatches(css)) {
|
|
5634
|
+
const urlPath = u.groups[0];
|
|
3504
5635
|
if (scannedUrls.has(urlPath))
|
|
3505
5636
|
continue;
|
|
3506
5637
|
scannedUrls.add(urlPath);
|
|
@@ -3623,8 +5754,6 @@ function checkButtonTextVerb(html) {
|
|
|
3623
5754
|
const ITEM = 'Button text is a verb phrase or recognized command (not a bare noun)';
|
|
3624
5755
|
const CATEGORY = 'copywriting';
|
|
3625
5756
|
// Extract <button> and [role="button"] text content
|
|
3626
|
-
const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
|
|
3627
|
-
const roleButtonRe = /<(?:a|div|span)[^>]*role=["']button["'][^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi;
|
|
3628
5757
|
// Remove aria-hidden subtrees before extracting text.
|
|
3629
5758
|
//
|
|
3630
5759
|
// Content marked aria-hidden is by definition not part of the accessible name,
|
|
@@ -3641,7 +5770,7 @@ function checkButtonTextVerb(html) {
|
|
|
3641
5770
|
let out = inner;
|
|
3642
5771
|
for (let i = 0; i < 4; i++) {
|
|
3643
5772
|
// Repeat to handle an aria-hidden wrapper containing another.
|
|
3644
|
-
const next = out
|
|
5773
|
+
const next = withoutAriaHiddenElements(out);
|
|
3645
5774
|
if (next === out)
|
|
3646
5775
|
break;
|
|
3647
5776
|
out = next;
|
|
@@ -3663,12 +5792,10 @@ function checkButtonTextVerb(html) {
|
|
|
3663
5792
|
// Carry the opening tag alongside the label so the violation loop can tell a
|
|
3664
5793
|
// command button from a selection control (see isSelectionControl).
|
|
3665
5794
|
const buttonTexts = [];
|
|
3666
|
-
let m;
|
|
3667
5795
|
// Strip keyboard shortcut hints that are fused to or appended after the
|
|
3668
5796
|
// button label — e.g. "Find⌘K", "Search ⌘+K", "Save Ctrl+S". These are
|
|
3669
5797
|
// visual hints, not part of the verb. Ranges: ⌘ (U+2318), ⌃ (U+2303),
|
|
3670
5798
|
// ⌥ (U+2325), ⇧ (U+21E7), and common "Ctrl+", "Cmd+", "Shift+" prefixes.
|
|
3671
|
-
const SHORTCUT_RE = /[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i;
|
|
3672
5799
|
// The `+` is optional and the key may be fused directly to the modifier
|
|
3673
5800
|
// ("CtrlK"), because the badge is now platform-aware and renders "Ctrl" with
|
|
3674
5801
|
// no glyph to strip. Requiring a literal '+' meant "FindCtrlK" survived and
|
|
@@ -3677,14 +5804,13 @@ function checkButtonTextVerb(html) {
|
|
|
3677
5804
|
// The key must be ADJACENT to the modifier (plus form, or fused alphanumerics
|
|
3678
5805
|
// with no space between). A space separates a real word: "Alt text" is a
|
|
3679
5806
|
// label, not a shortcut, and must not be eaten.
|
|
3680
|
-
const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
|
|
3681
5807
|
// Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
|
|
3682
5808
|
// "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
|
|
3683
5809
|
// must stand as its own word (the start of the label or after a space), so
|
|
3684
5810
|
// "Halt+1" is untouched, and only the plus form is read: a modifier followed
|
|
3685
5811
|
// by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
|
|
3686
5812
|
const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
|
|
3687
|
-
const stripShortcut = (s) =>
|
|
5813
|
+
const stripShortcut = (s) => withoutTextShortcut(withoutGlyphShortcut(s)).replace(PLUS_SHORTCUT_RE, '').trim();
|
|
3688
5814
|
// Engine 1.3.1: keycaps are read from the markup, not guessed from text.
|
|
3689
5815
|
// A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
|
|
3690
5816
|
// a hint beside the label rather than the label, so its text is dropped
|
|
@@ -3699,23 +5825,23 @@ function checkButtonTextVerb(html) {
|
|
|
3699
5825
|
const visible = stripAriaHidden(inner);
|
|
3700
5826
|
let out = visible;
|
|
3701
5827
|
for (let i = 0; i < 4; i++) {
|
|
3702
|
-
const next = out
|
|
5828
|
+
const next = withoutKbdElements(out);
|
|
3703
5829
|
if (next === out)
|
|
3704
5830
|
break;
|
|
3705
5831
|
out = next;
|
|
3706
5832
|
}
|
|
3707
|
-
const text = out
|
|
3708
|
-
return out === visible ? text : text
|
|
5833
|
+
const text = withoutTags(out, '', false).trim();
|
|
5834
|
+
return out === visible ? text : withoutTrailingPluses(text).trim();
|
|
3709
5835
|
};
|
|
3710
|
-
|
|
3711
|
-
let text = labelText(m[
|
|
5836
|
+
for (const m of buttonElements(html)) {
|
|
5837
|
+
let text = labelText(m.groups[0]);
|
|
3712
5838
|
// Strip leading icon characters so "✕Close" → "Close"
|
|
3713
5839
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3714
5840
|
// Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
|
|
3715
5841
|
text = stripShortcut(text);
|
|
3716
5842
|
// Icon-only button with an accessible name: use the aria-label verb.
|
|
3717
5843
|
if (!text) {
|
|
3718
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5844
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3719
5845
|
if (aria)
|
|
3720
5846
|
text = aria[1].trim();
|
|
3721
5847
|
}
|
|
@@ -3725,19 +5851,19 @@ function checkButtonTextVerb(html) {
|
|
|
3725
5851
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3726
5852
|
// on /maturity — an accurate observation of the wrong string.
|
|
3727
5853
|
if (text && text.length <= 2) {
|
|
3728
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5854
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3729
5855
|
if (aria)
|
|
3730
5856
|
text = aria[1].trim();
|
|
3731
5857
|
}
|
|
3732
5858
|
if (text)
|
|
3733
|
-
buttonTexts.push({ text, tag: m
|
|
5859
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3734
5860
|
}
|
|
3735
|
-
|
|
3736
|
-
let text = labelText(m[
|
|
5861
|
+
for (const m of roleButtonElements(html)) {
|
|
5862
|
+
let text = labelText(m.groups[0]);
|
|
3737
5863
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3738
5864
|
text = stripShortcut(text);
|
|
3739
5865
|
if (!text) {
|
|
3740
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5866
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3741
5867
|
if (aria)
|
|
3742
5868
|
text = aria[1].trim();
|
|
3743
5869
|
}
|
|
@@ -3747,12 +5873,12 @@ function checkButtonTextVerb(html) {
|
|
|
3747
5873
|
// Without this, v38 reported six single-letter "buttons" as non-verb labels
|
|
3748
5874
|
// on /maturity — an accurate observation of the wrong string.
|
|
3749
5875
|
if (text && text.length <= 2) {
|
|
3750
|
-
const aria = /aria-label=["']([^"']+)["']/i.exec(m
|
|
5876
|
+
const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
|
|
3751
5877
|
if (aria)
|
|
3752
5878
|
text = aria[1].trim();
|
|
3753
5879
|
}
|
|
3754
5880
|
if (text)
|
|
3755
|
-
buttonTexts.push({ text, tag: m
|
|
5881
|
+
buttonTexts.push({ text, tag: m.text });
|
|
3756
5882
|
}
|
|
3757
5883
|
if (buttonTexts.length === 0) {
|
|
3758
5884
|
return { id: 'v38', item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'no button elements found in HTML' };
|
|
@@ -3822,23 +5948,19 @@ function checkButtonTextVerb(html) {
|
|
|
3822
5948
|
function checkNoTrailingPeriod(html) {
|
|
3823
5949
|
const ITEM = 'No trailing period on button text, labels, or tab text';
|
|
3824
5950
|
const CATEGORY = 'copywriting';
|
|
3825
|
-
const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
|
|
3826
|
-
const labelRe = /<label[^>]*>([\s\S]*?)<\/label>/gi;
|
|
3827
|
-
const tabRe = /<[a-z]+[^>]*role=["']tab["'][^>]*>([\s\S]*?)<\/[a-z]+>/gi;
|
|
3828
5951
|
const texts = [];
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
5952
|
+
for (const m of buttonElements(html)) {
|
|
5953
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3832
5954
|
if (text)
|
|
3833
5955
|
texts.push({ type: 'button', text });
|
|
3834
5956
|
}
|
|
3835
|
-
|
|
3836
|
-
const text = m
|
|
5957
|
+
for (const m of labelElements(html)) {
|
|
5958
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3837
5959
|
if (text)
|
|
3838
5960
|
texts.push({ type: 'label', text });
|
|
3839
5961
|
}
|
|
3840
|
-
|
|
3841
|
-
const text = m
|
|
5962
|
+
for (const m of roleTabElements(html)) {
|
|
5963
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3842
5964
|
if (text)
|
|
3843
5965
|
texts.push({ type: 'tab', text });
|
|
3844
5966
|
}
|
|
@@ -3862,11 +5984,9 @@ const NON_DESCRIPTIVE_LINK_TEXT = /^(click here|here|learn more|read more|more|l
|
|
|
3862
5984
|
function checkLinkTextDescriptive(html) {
|
|
3863
5985
|
const ITEM = 'Link text is descriptive (not bare "click here", "learn more", "here")';
|
|
3864
5986
|
const CATEGORY = 'copywriting';
|
|
3865
|
-
const linkRe = /<a[^>]*>([\s\S]*?)<\/a>/gi;
|
|
3866
5987
|
const linkTexts = [];
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
const text = m[1].replace(/<[^>]*>/g, '').trim();
|
|
5988
|
+
for (const m of anchorElements(html)) {
|
|
5989
|
+
const text = withoutTags(m.groups[0], '', false).trim();
|
|
3870
5990
|
if (text)
|
|
3871
5991
|
linkTexts.push(text);
|
|
3872
5992
|
}
|
|
@@ -3896,20 +6016,18 @@ function checkNoAllCaps(html) {
|
|
|
3896
6016
|
const ITEM = 'No ALL CAPS UI text except eyebrow labels';
|
|
3897
6017
|
const CATEGORY = 'copywriting';
|
|
3898
6018
|
// Match elements with their class attributes so we can exclude eyebrow labels
|
|
3899
|
-
const elementRe = /<(button|a|label|td|th|p|li|h[1-6])\s([^>]*?)>([\s\S]*?)<\/\1>/gi;
|
|
3900
6019
|
const violations = [];
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
const
|
|
3904
|
-
const text = m[3].replace(/<[^>]*>/g, '').trim();
|
|
6020
|
+
for (const m of capsElementMatches(html)) {
|
|
6021
|
+
const attrs = m.groups[1] || '';
|
|
6022
|
+
const text = withoutTags(m.groups[2], '', false).trim();
|
|
3905
6023
|
if (!text || text.length < 4)
|
|
3906
6024
|
continue;
|
|
3907
6025
|
// Skip eyebrow labels (class contains "eyebrow" or "label" — per typography
|
|
3908
6026
|
// contract, eyebrows are intentionally uppercase)
|
|
3909
|
-
if (
|
|
6027
|
+
if (hasEyebrowClass(attrs))
|
|
3910
6028
|
continue;
|
|
3911
6029
|
// Skip elements with text-transform: uppercase in inline style
|
|
3912
|
-
if (
|
|
6030
|
+
if (hasUppercaseStyle(attrs))
|
|
3913
6031
|
continue;
|
|
3914
6032
|
// Check if text is ALL CAPS (only letters, all uppercase, >3 chars)
|
|
3915
6033
|
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
@@ -3941,19 +6059,19 @@ function checkPoiseInteractionRules(css) {
|
|
|
3941
6059
|
const rules = [
|
|
3942
6060
|
{
|
|
3943
6061
|
name: 'fine-pointer hover guard',
|
|
3944
|
-
|
|
6062
|
+
test: (s) => mediaQueryHasHoverGuard(s),
|
|
3945
6063
|
},
|
|
3946
6064
|
{
|
|
3947
6065
|
name: 'press settle scale ~0.97',
|
|
3948
|
-
|
|
6066
|
+
test: (s) => /scale\s*\(\s*0?\.9[5-9]\s*\)/i.test(s),
|
|
3949
6067
|
},
|
|
3950
6068
|
{
|
|
3951
6069
|
name: 'opacity-only mark breath',
|
|
3952
|
-
|
|
6070
|
+
test: (s) => hasBreathKeyframes(s),
|
|
3953
6071
|
},
|
|
3954
6072
|
];
|
|
3955
|
-
const found = rules.filter(r => r.
|
|
3956
|
-
const missing = rules.filter(r => !r.
|
|
6073
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6074
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
3957
6075
|
if (found.length >= 2) {
|
|
3958
6076
|
return {
|
|
3959
6077
|
id: 'v08',
|
|
@@ -3982,8 +6100,8 @@ function checkPoiseKeyboardPath(css, html) {
|
|
|
3982
6100
|
const hasFocusVisible = /:focus-visible/i.test(css);
|
|
3983
6101
|
const hasFocus = /:focus[^-]/i.test(css);
|
|
3984
6102
|
// Detect focus styles that strip outline without a replacement ring/box-shadow
|
|
3985
|
-
const stripsOutline =
|
|
3986
|
-
const hasFocusRing =
|
|
6103
|
+
const stripsOutline = focusStripsOutline(css);
|
|
6104
|
+
const hasFocusRing = focusHasRing(css);
|
|
3987
6105
|
const hasTabindex = /tabindex\s*=/i.test(html);
|
|
3988
6106
|
const hasAria = /aria-(label|labelledby|describedby|expanded|selected|pressed)/i.test(html);
|
|
3989
6107
|
const signals = [hasFocusVisible, hasFocus, hasFocusRing, hasTabindex, hasAria].filter(Boolean).length;
|
|
@@ -4025,19 +6143,19 @@ function checkTaktFeelRules(css) {
|
|
|
4025
6143
|
const rules = [
|
|
4026
6144
|
{
|
|
4027
6145
|
name: 'stagger enter animation-delay',
|
|
4028
|
-
|
|
6146
|
+
test: (s) => /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i.test(s),
|
|
4029
6147
|
},
|
|
4030
6148
|
{
|
|
4031
6149
|
name: 'soften exit transform ease-out',
|
|
4032
|
-
|
|
6150
|
+
test: (s) => hasTransformEaseTransition(s),
|
|
4033
6151
|
},
|
|
4034
6152
|
{
|
|
4035
6153
|
name: 'concentric border-radius set',
|
|
4036
|
-
|
|
6154
|
+
test: (s) => /border-radius\s*:\s*\d+/i.test(s),
|
|
4037
6155
|
},
|
|
4038
6156
|
];
|
|
4039
|
-
const found = rules.filter(r => r.
|
|
4040
|
-
const missing = rules.filter(r => !r.
|
|
6157
|
+
const found = rules.filter(r => r.test(css)).map(r => r.name);
|
|
6158
|
+
const missing = rules.filter(r => !r.test(css)).map(r => r.name);
|
|
4041
6159
|
if (found.length >= 2) {
|
|
4042
6160
|
return {
|
|
4043
6161
|
id: 'v10',
|
|
@@ -4074,7 +6192,7 @@ function checkRemScale(css) {
|
|
|
4074
6192
|
return { id: 'v16', item: 'Rem-based scale: all text sizes in rem, root at 16px confirmed', category: 'cadence', status: 'WARN', detail: `${pxMatches} px vs ${remMatches} rem` };
|
|
4075
6193
|
}
|
|
4076
6194
|
async function checkDesignMdSpec(targetUrl) {
|
|
4077
|
-
const ITEM = 'DESIGN.md spec-layer validation (
|
|
6195
|
+
const ITEM = 'DESIGN.md spec-layer validation (DESIGN.md format lint)';
|
|
4078
6196
|
const CATEGORY = 'spec';
|
|
4079
6197
|
const parsed = new URL(targetUrl);
|
|
4080
6198
|
const designMdUrl = `${parsed.protocol}//${parsed.host}/DESIGN.md`;
|
|
@@ -4134,13 +6252,50 @@ async function checkDesignMdSpec(targetUrl) {
|
|
|
4134
6252
|
// (an account name often does); the last one may not, so the sentence after
|
|
4135
6253
|
// a path survives, and trailing punctuation is kept.
|
|
4136
6254
|
function sanitizeErrorText(text) {
|
|
4137
|
-
|
|
4138
|
-
|
|
6255
|
+
// The punctuation a replaced path ended with, read back from its end
|
|
6256
|
+
// (engine 1.5.1: /[.,;:!?)]+$/ was retried from every mark in a run).
|
|
6257
|
+
const tail = (m) => {
|
|
6258
|
+
let k = m.length;
|
|
6259
|
+
while (k > 0 && '.,;:!?)'.indexOf(m[k - 1]) >= 0)
|
|
6260
|
+
k--;
|
|
6261
|
+
return m.slice(k);
|
|
6262
|
+
};
|
|
6263
|
+
const keep = (label) => (m) => `${label}${tail(m)}`;
|
|
6264
|
+
const pathless = text
|
|
4139
6265
|
.replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
|
|
4140
6266
|
.replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
|
|
4141
6267
|
.replace(/(?<![\w\\/])[A-Za-z]:([\\/])(?:[^\\/\r\n'"<>`|*?]+\1)*[^\\/\s'"<>`|*?]*/g, keep('a local path'))
|
|
4142
|
-
.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))}`)
|
|
4143
|
-
|
|
6268
|
+
.replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`);
|
|
6269
|
+
// npm cache segments: a run of characters other than space, quotes, angle
|
|
6270
|
+
// brackets, backtick and pipe that holds npm-cache, /_npx/ or /.npm/ is
|
|
6271
|
+
// replaced whole. Each run is read once (engine 1.5.1: the run pattern was
|
|
6272
|
+
// retried from every character in it).
|
|
6273
|
+
const marker = /npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/]/gi;
|
|
6274
|
+
const stop = /[\s'"<>`|]/;
|
|
6275
|
+
const parts = [];
|
|
6276
|
+
let last = 0;
|
|
6277
|
+
let markerAt = -1;
|
|
6278
|
+
for (let a = 0; a < pathless.length;) {
|
|
6279
|
+
if (stop.test(pathless[a])) {
|
|
6280
|
+
a++;
|
|
6281
|
+
continue;
|
|
6282
|
+
}
|
|
6283
|
+
let b = a + 1;
|
|
6284
|
+
while (b < pathless.length && !stop.test(pathless[b]))
|
|
6285
|
+
b++;
|
|
6286
|
+
if (markerAt < a) {
|
|
6287
|
+
marker.lastIndex = a;
|
|
6288
|
+
const found = marker.exec(pathless);
|
|
6289
|
+
markerAt = found ? found.index : Infinity;
|
|
6290
|
+
}
|
|
6291
|
+
if (markerAt < b) {
|
|
6292
|
+
parts.push(pathless.slice(last, a), keep('the npm cache')(pathless.slice(a, b)));
|
|
6293
|
+
last = b;
|
|
6294
|
+
}
|
|
6295
|
+
a = b;
|
|
6296
|
+
}
|
|
6297
|
+
parts.push(pathless.slice(last));
|
|
6298
|
+
return parts.join('');
|
|
4144
6299
|
}
|
|
4145
6300
|
/** The checks with every detail passed through sanitizeErrorText. */
|
|
4146
6301
|
function sanitizeCheckDetails(checks) {
|
|
@@ -4280,7 +6435,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4280
6435
|
// contrast or horizontal overflow cannot be A-grade no matter how good its
|
|
4281
6436
|
// tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
|
|
4282
6437
|
//
|
|
4283
|
-
// No A while any check FAILs (engine 1.3.0,
|
|
6438
|
+
// No A while any check FAILs (engine 1.3.0, see CHANGELOG). A
|
|
4284
6439
|
// FAIL is the engine's own finding that the page breaks a rule, so any FAIL
|
|
4285
6440
|
// caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
|
|
4286
6441
|
// A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
|
|
@@ -4308,7 +6463,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
4308
6463
|
}
|
|
4309
6464
|
// v44 Status text contrast — the words that report a state fail WCAG
|
|
4310
6465
|
// 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
|
|
4311
|
-
// Same cap as v06 (engine 1.3.0
|
|
6466
|
+
// Same cap as v06 (engine 1.3.0): both are
|
|
4312
6467
|
// measured WCAG contrast failures on text.
|
|
4313
6468
|
if (c.id === 'v44') {
|
|
4314
6469
|
cap = 65;
|
|
@@ -4490,7 +6645,7 @@ export async function scoreFromParts(input) {
|
|
|
4490
6645
|
checks.push(input.offline
|
|
4491
6646
|
? {
|
|
4492
6647
|
id: 'v37',
|
|
4493
|
-
item: 'DESIGN.md spec-layer validation (
|
|
6648
|
+
item: 'DESIGN.md spec-layer validation (DESIGN.md format lint)',
|
|
4494
6649
|
category: 'spec',
|
|
4495
6650
|
status: 'SKIP',
|
|
4496
6651
|
detail: 'not probed: offline fixture run (no network); excluded from the score',
|
|
@@ -4558,40 +6713,13 @@ export async function scoreFromParts(input) {
|
|
|
4558
6713
|
// reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
|
|
4559
6714
|
// graph-paper grid was reported at the maximum severity. Match a 1px stop
|
|
4560
6715
|
// in EITHER order, which is what "hairline" means however it is written.
|
|
4561
|
-
const isGridPattern = (text) =>
|
|
4562
|
-
|| /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text);
|
|
4563
|
-
// Two or more colour stops in some linear-gradient() of the value.
|
|
4564
|
-
const multiStop = (value) => {
|
|
4565
|
-
let from = 0;
|
|
4566
|
-
for (;;) {
|
|
4567
|
-
const at = value.toLowerCase().indexOf('linear-gradient(', from);
|
|
4568
|
-
if (at < 0)
|
|
4569
|
-
return false;
|
|
4570
|
-
let depth = 0;
|
|
4571
|
-
let end = -1;
|
|
4572
|
-
for (let i = at + 15; i < value.length; i++) {
|
|
4573
|
-
if (value[i] === '(')
|
|
4574
|
-
depth++;
|
|
4575
|
-
else if (value[i] === ')' && --depth === 0) {
|
|
4576
|
-
end = i;
|
|
4577
|
-
break;
|
|
4578
|
-
}
|
|
4579
|
-
}
|
|
4580
|
-
if (end < 0)
|
|
4581
|
-
return false;
|
|
4582
|
-
const args = splitCssTopLevel(value.slice(at + 16, end), ',');
|
|
4583
|
-
const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
|
|
4584
|
-
if (stops.length >= 2)
|
|
4585
|
-
return true;
|
|
4586
|
-
from = end;
|
|
4587
|
-
}
|
|
4588
|
-
};
|
|
6716
|
+
const isGridPattern = (text) => hasColorThenHairline(text) || hasHairlineThenColor(text);
|
|
4589
6717
|
const pageGrad = [];
|
|
4590
6718
|
for (const rule of parseCssRules(css).rules) {
|
|
4591
6719
|
if (!/linear-gradient\s*\(/i.test(rule.decls))
|
|
4592
6720
|
continue;
|
|
4593
6721
|
const decls = parseCssDecls(rule.decls);
|
|
4594
|
-
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') &&
|
|
6722
|
+
if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && hasMultiStopGradient(d.value)))
|
|
4595
6723
|
continue;
|
|
4596
6724
|
const text = `${rule.selector}{${rule.decls}}`;
|
|
4597
6725
|
if (isGridPattern(text))
|
|
@@ -4614,7 +6742,7 @@ export async function scoreFromParts(input) {
|
|
|
4614
6742
|
const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
|
|
4615
6743
|
|| (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
|
|
4616
6744
|
|| (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
|
|
4617
|
-
|| (d.prop === 'background' &&
|
|
6745
|
+
|| (d.prop === 'background' && hasSlashThenHairline(d.value)));
|
|
4618
6746
|
const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
|
|
4619
6747
|
if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
|
|
4620
6748
|
pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
|
|
@@ -4632,8 +6760,7 @@ export async function scoreFromParts(input) {
|
|
|
4632
6760
|
}
|
|
4633
6761
|
// S3. Purple/violet gradient
|
|
4634
6762
|
{
|
|
4635
|
-
const
|
|
4636
|
-
const matches = css.match(purplePattern);
|
|
6763
|
+
const matches = purpleGradientDecls(css);
|
|
4637
6764
|
if (matches)
|
|
4638
6765
|
slopFindings.push({ id: 'S3', label: 'Purple/violet AI gradient', severity: 4, instances: matches.length, evidence: matches.slice(0, 3) });
|
|
4639
6766
|
}
|
|
@@ -4645,7 +6772,7 @@ export async function scoreFromParts(input) {
|
|
|
4645
6772
|
// pattern anchored to `(?:^|})` takes 4 ms and matches the same rule.
|
|
4646
6773
|
// Measured 2026-09-19, Node 24. This was the real cause of the 55s cold
|
|
4647
6774
|
// score, not the network (0.9s) and not the sequential CSS fetch.
|
|
4648
|
-
const gradientClips = css
|
|
6775
|
+
const gradientClips = gradientClipRules(css);
|
|
4649
6776
|
const hueKey = (stop) => {
|
|
4650
6777
|
if (/var\(/.test(stop))
|
|
4651
6778
|
return null;
|
|
@@ -4667,7 +6794,7 @@ export async function scoreFromParts(input) {
|
|
|
4667
6794
|
h = Math.round(((h * 60) + 360) % 360);
|
|
4668
6795
|
return 'h' + Math.floor(h / 45);
|
|
4669
6796
|
}
|
|
4670
|
-
const rgb = stop
|
|
6797
|
+
const rgb = rgbArgs(stop);
|
|
4671
6798
|
if (rgb) {
|
|
4672
6799
|
const p = rgb[1].split(',').map(x => parseFloat(x));
|
|
4673
6800
|
if (p.length >= 3) {
|
|
@@ -4690,8 +6817,8 @@ export async function scoreFromParts(input) {
|
|
|
4690
6817
|
return null;
|
|
4691
6818
|
};
|
|
4692
6819
|
const multiColorGradient = gradientClips.filter(block => {
|
|
4693
|
-
const gradientMatch = block
|
|
4694
|
-
const gradText = (gradientMatch && (gradientMatch[
|
|
6820
|
+
const gradientMatch = linearGradientArgs(block);
|
|
6821
|
+
const gradText = (gradientMatch && (gradientMatch.groups[0] || gradientMatch.groups[1])) || '';
|
|
4695
6822
|
const stops = gradText.split(',').map(s => s.trim());
|
|
4696
6823
|
const hueKeys = new Set(stops.map(hueKey).filter((k) => k !== null && k !== 'neutral'));
|
|
4697
6824
|
return hueKeys.size >= 2;
|
|
@@ -4712,8 +6839,7 @@ export async function scoreFromParts(input) {
|
|
|
4712
6839
|
{
|
|
4713
6840
|
const cardPattern = /\.(card|panel|tile|feature|item|box|cell|block)\b/gi;
|
|
4714
6841
|
const cardClasses = css.match(cardPattern) || [];
|
|
4715
|
-
const
|
|
4716
|
-
const gridMatches = css.match(gridCardPattern) || [];
|
|
6842
|
+
const gridMatches = gridRepeatRules(css);
|
|
4717
6843
|
if (cardClasses.length >= 3 && gridMatches.length > 0)
|
|
4718
6844
|
slopFindings.push({ id: 'S6', label: 'Repeated identical card grid', severity: 5, instances: 1, evidence: [`${cardClasses.length} card-like classes with repeating grid layouts`] });
|
|
4719
6845
|
}
|
|
@@ -4727,7 +6853,7 @@ export async function scoreFromParts(input) {
|
|
|
4727
6853
|
// text-default ones promoted with U+FE0F. This also catches ⭐ (U+2B50), which
|
|
4728
6854
|
// the old range missed while it was busy flagging checkmarks.
|
|
4729
6855
|
{
|
|
4730
|
-
const emojiInButtons = html
|
|
6856
|
+
const emojiInButtons = emojiCtaMatches(html);
|
|
4731
6857
|
if (emojiInButtons && emojiInButtons.length >= 2)
|
|
4732
6858
|
slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
|
|
4733
6859
|
}
|
|
@@ -4745,11 +6871,7 @@ export async function scoreFromParts(input) {
|
|
|
4745
6871
|
// Skipped on a page that documents the rules (see isDocumentingSlopRules):
|
|
4746
6872
|
// the anti-slop rule table on /methodology lists these phrases.
|
|
4747
6873
|
{
|
|
4748
|
-
const visible = html
|
|
4749
|
-
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, ' ')
|
|
4750
|
-
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, ' ')
|
|
4751
|
-
.replace(/<template\b[\s\S]*?<\/template\s*>/gi, ' ')
|
|
4752
|
-
.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
6874
|
+
const visible = withoutNonVisibleMarkup(html);
|
|
4753
6875
|
const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
|
|
4754
6876
|
const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
|
|
4755
6877
|
const tagRe = {};
|
|
@@ -4780,7 +6902,7 @@ export async function scoreFromParts(input) {
|
|
|
4780
6902
|
}
|
|
4781
6903
|
if (end < 0)
|
|
4782
6904
|
continue;
|
|
4783
|
-
const text = windowText.slice(0, end)
|
|
6905
|
+
const text = withoutTags(windowText.slice(0, end), ' ', false).replace(/\s+/g, ' ').trim();
|
|
4784
6906
|
if (text && text.length <= 40 && pillPhrase.test(text))
|
|
4785
6907
|
pills.push(text);
|
|
4786
6908
|
}
|
|
@@ -4816,7 +6938,7 @@ export async function scoreFromParts(input) {
|
|
|
4816
6938
|
// S11. Marketing buzzwords
|
|
4817
6939
|
{
|
|
4818
6940
|
const buzzwords = ['streamline', 'empower', 'supercharge', 'world-class', 'enterprise-grade', 'next-generation', 'unlock', 'leverage', 'seamless', 'cutting-edge', 'revolutionize', 'game-chang', 'disrupt', 'synerg'];
|
|
4819
|
-
const bodyText = html
|
|
6941
|
+
const bodyText = withoutTags(html, ' ', true).toLowerCase();
|
|
4820
6942
|
const found = [];
|
|
4821
6943
|
for (const word of buzzwords) {
|
|
4822
6944
|
if (bodyText.includes(word))
|
|
@@ -4842,21 +6964,20 @@ export async function scoreFromParts(input) {
|
|
|
4842
6964
|
const originalitySignals = [];
|
|
4843
6965
|
// O1. Bespoke easing
|
|
4844
6966
|
{
|
|
4845
|
-
const bezierRe = /cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)/gi;
|
|
4846
6967
|
const EASING_PRESETS = new Set(['0.25,0.1,0.25,1', '0.42,0,1,1', '0,0,0.58,1', '0.42,0,0.58,1', '0.4,0,0.2,1', '0.4,0,0.6,1', '0,0,0.2,1', '0.4,0,1,1', '0.68,-0.55,0.265,1.55']);
|
|
4847
6968
|
const distinct = new Set();
|
|
4848
6969
|
let hasOvershoot = false;
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
const key = `${
|
|
6970
|
+
for (const b of cubicBezierMatches(css)) {
|
|
6971
|
+
const [x1, y1s, x2, y2s] = b.groups;
|
|
6972
|
+
const key = withoutTrailingDecimalZeros(`${x1},${y1s},${x2},${y2s}`).replace(/\s+/g, '');
|
|
4852
6973
|
if (EASING_PRESETS.has(key))
|
|
4853
6974
|
continue;
|
|
4854
6975
|
distinct.add(key);
|
|
4855
|
-
const y1 = parseFloat(
|
|
6976
|
+
const y1 = parseFloat(y1s), y2 = parseFloat(y2s);
|
|
4856
6977
|
if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1)
|
|
4857
6978
|
hasOvershoot = true;
|
|
4858
6979
|
}
|
|
4859
|
-
const linearSprings = css
|
|
6980
|
+
const linearSprings = longLinearEasings(css);
|
|
4860
6981
|
if (linearSprings.length > 0)
|
|
4861
6982
|
hasOvershoot = true;
|
|
4862
6983
|
const easingCount = distinct.size;
|
|
@@ -4886,8 +7007,8 @@ export async function scoreFromParts(input) {
|
|
|
4886
7007
|
}
|
|
4887
7008
|
// O4. Tiered reduced-motion
|
|
4888
7009
|
{
|
|
4889
|
-
const reducedMotion =
|
|
4890
|
-
const blanketKill =
|
|
7010
|
+
const reducedMotion = mediaQueryHasReducedMotion(css);
|
|
7011
|
+
const blanketKill = hasBlanketMotionKill(css);
|
|
4891
7012
|
if (reducedMotion && !blanketKill)
|
|
4892
7013
|
originalitySignals.push({ id: 'O4', label: 'Tiered reduced-motion', points: 1, evidence: 'targeted (not blanket) motion reduction' });
|
|
4893
7014
|
}
|
|
@@ -4904,8 +7025,8 @@ export async function scoreFromParts(input) {
|
|
|
4904
7025
|
}
|
|
4905
7026
|
// O6. Bespoke iconography
|
|
4906
7027
|
{
|
|
4907
|
-
const inlineSvg = html
|
|
4908
|
-
const svgSymbols = html
|
|
7028
|
+
const inlineSvg = svgViewBoxTags(html);
|
|
7029
|
+
const svgSymbols = symbolTags(html);
|
|
4909
7030
|
if (inlineSvg.length >= 3 || svgSymbols.length >= 2)
|
|
4910
7031
|
originalitySignals.push({ id: 'O6', label: 'Bespoke iconography', points: 1, evidence: `${inlineSvg.length} inline SVGs${svgSymbols.length ? ` + ${svgSymbols.length} symbols` : ''}` });
|
|
4911
7032
|
}
|
|
@@ -4915,11 +7036,11 @@ export async function scoreFromParts(input) {
|
|
|
4915
7036
|
const shadcnHits = SHADCN_FINGERPRINT.filter((t) => css.includes(t + ':')).length;
|
|
4916
7037
|
const referencesTwPrimitives = /var\(--color-[a-z]+-\d{2,3}\)/i.test(css);
|
|
4917
7038
|
const isShadcnTemplate = shadcnHits >= 6 || (shadcnHits >= 4 && referencesTwPrimitives);
|
|
4918
|
-
const customProps = css
|
|
7039
|
+
const customProps = customPropertyDecls(css);
|
|
4919
7040
|
const semanticTokens = customProps.filter((p) => /--(surface|ink|paper|signal|line|muted|elevation|radius|duration|ease|accent|foreground|space|gap|shadow|canvas|action|feedback|success|warning|danger|error|info|subtle|on-[a-z]+)/i.test(p));
|
|
4920
7041
|
const primitiveHue = /--(?:color|colour)-[a-z]+-\d{2,3}\s*:/i;
|
|
4921
7042
|
const semanticCount = isShadcnTemplate ? 0 : semanticTokens.filter((p) => !primitiveHue.test(p)).length;
|
|
4922
|
-
const hasLayering =
|
|
7043
|
+
const hasLayering = hasTokenLayering(css);
|
|
4923
7044
|
const hasTheming = /light-dark\(|prefers-color-scheme\s*:\s*dark|data-theme/i.test(css);
|
|
4924
7045
|
let tokenPoints = 0;
|
|
4925
7046
|
const tokenEvidence = [];
|
|
@@ -5072,9 +7193,12 @@ export function emitReview(url, result) {
|
|
|
5072
7193
|
continue;
|
|
5073
7194
|
num++;
|
|
5074
7195
|
const severity = c.status === 'FAIL' ? 'HIGH' : 'MEDIUM';
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
7196
|
+
// A cell escapes its backslashes before its pipes: a detail carries text
|
|
7197
|
+
// read from the scored page, where a backslash before a pipe would
|
|
7198
|
+
// otherwise escape the escape and leave a live cell separator.
|
|
7199
|
+
const before = c.detail.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7200
|
+
const after = (c.remediation || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
|
|
7201
|
+
const why = `${c.item} (${c.category})`.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
5078
7202
|
lines.push(`| ${num} | ${severity} | ${c.category} | ${url} | ${before} | ${after} | ${why} |`);
|
|
5079
7203
|
}
|
|
5080
7204
|
if (num === 0)
|