@monoes/monodesign 1.2.20 → 1.2.23
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/README.md +2 -2
- package/cli/engine/cli/main.mjs +21 -1
- package/cli/engine/design-system-checks.mjs +381 -0
- package/cli/engine/design-system-parse.mjs +433 -0
- package/cli/engine/design-system.mjs +20 -736
- package/cli/engine/detect-antipatterns-browser.js +28 -5
- package/cli/engine/detect-antipatterns.mjs +1 -0
- package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
- package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
- package/cli/engine/engines/regex/detect-text.mjs +11 -523
- package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
- package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
- package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
- package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
- package/cli/engine/engines/static-html/detect-html.mjs +6 -1
- package/cli/engine/findings-group.mjs +94 -0
- package/cli/engine/fix/index.mjs +1 -26
- package/cli/engine/fix/unified-diff.mjs +107 -0
- package/cli/engine/rules/checks-background.mjs +17 -3
- package/cli/engine/rules/checks-element.mjs +2 -0
- package/cli/engine/rules/checks-pure.mjs +9 -2
- package/cli/lib/monodesign-config-consent.mjs +93 -0
- package/cli/lib/monodesign-config-core.mjs +37 -0
- package/cli/lib/monodesign-config-detection.mjs +133 -0
- package/cli/lib/monodesign-config-filter.mjs +198 -0
- package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
- package/cli/lib/monodesign-config.mjs +21 -623
- package/package.json +2 -2
- package/skill/scripts/context-fields.mjs +65 -0
- package/skill/scripts/context-resolve.mjs +425 -0
- package/skill/scripts/context-update.mjs +151 -0
- package/skill/scripts/context-utils.mjs +68 -0
- package/skill/scripts/context-workspace.mjs +245 -0
- package/skill/scripts/context.mjs +17 -891
- package/skill/scripts/hook-admin-config.mjs +152 -0
- package/skill/scripts/hook-admin-ignore.mjs +131 -0
- package/skill/scripts/hook-admin-manifest.mjs +292 -0
- package/skill/scripts/hook-admin.mjs +8 -541
- package/skill/scripts/hook-lib-cache.mjs +145 -0
- package/skill/scripts/hook-lib-config.mjs +292 -0
- package/skill/scripts/hook-lib-detector.mjs +72 -0
- package/skill/scripts/hook-lib-events.mjs +182 -0
- package/skill/scripts/hook-lib-findings.mjs +201 -0
- package/skill/scripts/hook-lib-ignore.mjs +205 -0
- package/skill/scripts/hook-lib-render.mjs +273 -0
- package/skill/scripts/hook-lib-run.mjs +281 -0
- package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
- package/skill/scripts/hook-lib.mjs +71 -1747
- package/skill/scripts/lib/design-parser-colors.mjs +182 -0
- package/skill/scripts/lib/design-parser-extract.mjs +333 -0
- package/skill/scripts/lib/design-parser-sections.mjs +214 -0
- package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
- package/skill/scripts/lib/design-parser.mjs +10 -783
- package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
- package/skill/scripts/live/manual-apply-compact.mjs +122 -0
- package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
- package/skill/scripts/live/manual-apply-log.mjs +179 -0
- package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
- package/skill/scripts/live/manual-apply.mjs +65 -640
- package/skill/scripts/live/svelte-component-accept.mjs +248 -0
- package/skill/scripts/live/svelte-component-css.mjs +230 -0
- package/skill/scripts/live/svelte-component-markup.mjs +104 -0
- package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
- package/skill/scripts/live/svelte-component-session.mjs +158 -0
- package/skill/scripts/live/svelte-component.mjs +32 -792
- package/skill/scripts/live-accept-mutate.mjs +152 -0
- package/skill/scripts/live-accept-parse.mjs +370 -0
- package/skill/scripts/live-accept-scrub.mjs +59 -0
- package/skill/scripts/live-accept-search.mjs +58 -0
- package/skill/scripts/live-accept.mjs +15 -631
- package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
- package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
- package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
- package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
- package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
- package/skill/scripts/live-commit-manual-edits.mjs +24 -881
- package/skill/scripts/live-copy-edit-agent.mjs +16 -551
- package/skill/scripts/live-copy-edit-compact.mjs +137 -0
- package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
- package/skill/scripts/live-copy-edit-runners.mjs +297 -0
- package/skill/scripts/live-inject-csp.mjs +132 -0
- package/skill/scripts/live-inject-files.mjs +229 -0
- package/skill/scripts/live-inject-tag.mjs +114 -0
- package/skill/scripts/live-inject.mjs +13 -440
- package/skill/scripts/live-server-poll.mjs +232 -0
- package/skill/scripts/live-server-routes.mjs +449 -0
- package/skill/scripts/live-server-state.mjs +361 -0
- package/skill/scripts/live-server.mjs +10 -946
- package/skill/scripts/live-wrap-css.mjs +132 -0
- package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
- package/skill/scripts/live-wrap-search.mjs +247 -0
- package/skill/scripts/live-wrap.mjs +21 -484
- package/skill/scripts/palette-seeds.mjs +401 -0
- package/skill/scripts/palette.mjs +1 -395
|
@@ -787,7 +787,7 @@ function isEmojiOnlyText(text) {
|
|
|
787
787
|
}
|
|
788
788
|
|
|
789
789
|
function checkColors(opts) {
|
|
790
|
-
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
|
|
790
|
+
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList, isSvgText } = opts;
|
|
791
791
|
if (SAFE_TAGS.has(tag)) {
|
|
792
792
|
// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
|
|
793
793
|
// exists to suppress contrast noise on inline links and unstyled controls,
|
|
@@ -835,7 +835,14 @@ function checkColors(opts) {
|
|
|
835
835
|
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
|
836
836
|
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
|
837
837
|
if (!isAlphaFallbackFP) {
|
|
838
|
-
|
|
838
|
+
const snippet = `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}`;
|
|
839
|
+
// Static analysis can't composite translucent text/surfaces, see
|
|
840
|
+
// through gradients, or read SVG `fill`, so those ratios are guesses:
|
|
841
|
+
// report them as unverified rather than definite failures (#424).
|
|
842
|
+
const unverified = !DETECTOR_IS_BROWSER && (
|
|
843
|
+
(textColor.a ?? 1) < 1 || !effectiveBg || (effectiveBg.a ?? 1) < 1 || effectiveBg.uncertain || isSvgText
|
|
844
|
+
);
|
|
845
|
+
findings.push(unverified ? { id: 'low-contrast', snippet, unverified: true } : { id: 'low-contrast', snippet });
|
|
839
846
|
}
|
|
840
847
|
}
|
|
841
848
|
}
|
|
@@ -1395,9 +1402,22 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|
|
1395
1402
|
return bg;
|
|
1396
1403
|
}
|
|
1397
1404
|
|
|
1405
|
+
// Static mode marks the result `uncertain` when the colour is a guess: a
|
|
1406
|
+
// translucent layer was stacked on (or skipped over) the surface we found, a
|
|
1407
|
+
// body-level gradient was assumed white, or the walk never reached <body> and
|
|
1408
|
+
// fell back to the default white (a fragment's real surface is unknown).
|
|
1409
|
+
// Contrast measured against an uncertain surface is reported as unverified.
|
|
1410
|
+
function markUncertain(bg, uncertain) {
|
|
1411
|
+
if (!DETECTOR_IS_BROWSER && uncertain) bg.uncertain = true;
|
|
1412
|
+
return bg;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1398
1415
|
function resolveBackground(el, win, customPropMap) {
|
|
1399
1416
|
let current = el;
|
|
1417
|
+
let sawTranslucent = false;
|
|
1418
|
+
let sawBody = false;
|
|
1400
1419
|
while (current && current.nodeType === 1) {
|
|
1420
|
+
if (current.tagName === 'BODY' || current.tagName === 'HTML') sawBody = true;
|
|
1401
1421
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
|
1402
1422
|
const bgImage = style.backgroundImage || '';
|
|
1403
1423
|
const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
|
|
@@ -1429,8 +1449,9 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
1429
1449
|
}
|
|
1430
1450
|
|
|
1431
1451
|
if (bg && bg.a > 0.1) {
|
|
1432
|
-
if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
|
|
1452
|
+
if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return markUncertain(bg, sawTranslucent || bg.a < 1);
|
|
1433
1453
|
}
|
|
1454
|
+
if (bg && bg.a > 0) sawTranslucent = true;
|
|
1434
1455
|
// No solid bg-color at this level. If THIS level has a gradient/url
|
|
1435
1456
|
// with no underlying solid color we can read:
|
|
1436
1457
|
// • on body/html: assume white. Body-level gradients are almost
|
|
@@ -1445,13 +1466,13 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
1445
1466
|
// bgs worth checking against).
|
|
1446
1467
|
if (hasGradientOrUrl) {
|
|
1447
1468
|
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
|
|
1448
|
-
return { r: 255, g: 255, b: 255, a: 1 };
|
|
1469
|
+
return markUncertain({ r: 255, g: 255, b: 255, a: 1 }, true);
|
|
1449
1470
|
}
|
|
1450
1471
|
return null;
|
|
1451
1472
|
}
|
|
1452
1473
|
current = current.parentElement;
|
|
1453
1474
|
}
|
|
1454
|
-
return { r: 255, g: 255, b: 255 };
|
|
1475
|
+
return markUncertain({ r: 255, g: 255, b: 255 }, sawTranslucent || !sawBody);
|
|
1455
1476
|
}
|
|
1456
1477
|
|
|
1457
1478
|
// Walk parents looking for a gradient background and return its color stops.
|
|
@@ -2563,6 +2584,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
2563
2584
|
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
2564
2585
|
bgImage: style.backgroundImage || '',
|
|
2565
2586
|
classList: el.getAttribute?.('class') || el.className || '',
|
|
2587
|
+
// SVG text paints with `fill`, not `color`, so its CSS colour says nothing.
|
|
2588
|
+
isSvgText: tag === 'text' || tag === 'tspan' || tag === 'textpath',
|
|
2566
2589
|
});
|
|
2567
2590
|
}
|
|
2568
2591
|
|
|
@@ -31,6 +31,7 @@ export {
|
|
|
31
31
|
collectStaticDesignSystemFindings,
|
|
32
32
|
} from './design-system.mjs';
|
|
33
33
|
export { detectHtml } from './engines/static-html/detect-html.mjs';
|
|
34
|
+
export { groupFindings } from './findings-group.mjs';
|
|
34
35
|
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
|
|
35
36
|
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
|
|
36
37
|
export {
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.): line/content
|
|
2
|
+
// predicates plus the REGEX_MATCHERS and REGEX_ANALYZERS rule tables. Split
|
|
3
|
+
// out of detect-text.mjs (file-size sweep — pure move, no behaviour change).
|
|
4
|
+
|
|
5
|
+
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
|
6
|
+
import { isNeutralColor } from '../../shared/color.mjs';
|
|
7
|
+
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
|
8
|
+
import { isFullPage } from '../../shared/page.mjs';
|
|
9
|
+
import { finding } from '../../findings.mjs';
|
|
10
|
+
|
|
11
|
+
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
|
12
|
+
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
|
13
|
+
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
|
14
|
+
|
|
15
|
+
/** Strip HTML to plain text — drops script/style/comments/tags so
|
|
16
|
+
* content-text analyzers don't false-positive on code or CSS. */
|
|
17
|
+
function stripHtmlToText(html) {
|
|
18
|
+
return html
|
|
19
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
20
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
21
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
22
|
+
.replace(/<[^>]+>/g, ' ')
|
|
23
|
+
.replace(/\s+/g, ' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
|
|
27
|
+
|
|
28
|
+
function extFromFilePath(filePath) {
|
|
29
|
+
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shouldRunPageAnalyzers(content, filePath) {
|
|
33
|
+
if (!isFullPage(content)) return false;
|
|
34
|
+
const ext = extFromFilePath(filePath);
|
|
35
|
+
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function firstOverusedGoogleFont(text) {
|
|
39
|
+
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isNeutralBorderColor(str) {
|
|
43
|
+
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
|
44
|
+
if (!m) return false;
|
|
45
|
+
const c = m[1].toLowerCase();
|
|
46
|
+
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
|
47
|
+
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
|
48
|
+
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
|
49
|
+
if (hex) {
|
|
50
|
+
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
|
51
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
|
52
|
+
}
|
|
53
|
+
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
54
|
+
if (shex) {
|
|
55
|
+
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
|
|
56
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const REGEX_MATCHERS = [
|
|
62
|
+
// --- Side-tab ---
|
|
63
|
+
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
|
64
|
+
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
|
|
65
|
+
fmt: (m) => m[0] },
|
|
66
|
+
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
|
|
67
|
+
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
|
|
68
|
+
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
|
|
69
|
+
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
|
|
70
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
71
|
+
fmt: (m) => m[0] },
|
|
72
|
+
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
|
|
73
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
74
|
+
fmt: (m) => m[0] },
|
|
75
|
+
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
|
|
76
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
77
|
+
fmt: (m) => m[0] },
|
|
78
|
+
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
|
|
79
|
+
test: (m) => +m[1] >= 3,
|
|
80
|
+
fmt: (m) => m[0] },
|
|
81
|
+
// --- Border accent on rounded ---
|
|
82
|
+
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
|
|
83
|
+
test: (m, line) => hasRounded(line) && +m[1] >= 1,
|
|
84
|
+
fmt: (m) => m[0] },
|
|
85
|
+
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
|
|
86
|
+
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
|
|
87
|
+
fmt: (m) => m[0] },
|
|
88
|
+
// --- Overused font ---
|
|
89
|
+
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
|
|
90
|
+
test: () => true,
|
|
91
|
+
fmt: (m) => m[0] },
|
|
92
|
+
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
|
93
|
+
test: (m) => {
|
|
94
|
+
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
|
95
|
+
return Boolean(m.overusedGoogleFont);
|
|
96
|
+
},
|
|
97
|
+
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
|
98
|
+
// --- Gradient text ---
|
|
99
|
+
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
|
100
|
+
test: (_m, line) => /gradient/i.test(line),
|
|
101
|
+
fmt: () => 'background-clip: text + gradient' },
|
|
102
|
+
// --- Gradient text (Tailwind) ---
|
|
103
|
+
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
|
104
|
+
test: (_m, line) => /\bbg-gradient-to-/i.test(line),
|
|
105
|
+
fmt: () => 'bg-clip-text + bg-gradient' },
|
|
106
|
+
// --- Tailwind gray on colored bg ---
|
|
107
|
+
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
|
108
|
+
test: (_m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
|
109
|
+
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
|
110
|
+
// --- Tailwind AI palette ---
|
|
111
|
+
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
|
112
|
+
test: (_m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
|
113
|
+
fmt: (m) => `${m[0]} on heading` },
|
|
114
|
+
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
|
|
115
|
+
test: (_m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
|
|
116
|
+
fmt: (m) => `${m[0]} gradient` },
|
|
117
|
+
// --- Bounce/elastic easing ---
|
|
118
|
+
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
|
|
119
|
+
test: () => true,
|
|
120
|
+
fmt: () => 'animate-bounce (Tailwind)' },
|
|
121
|
+
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
|
|
122
|
+
test: () => true,
|
|
123
|
+
fmt: (m) => {
|
|
124
|
+
const token = m[1]
|
|
125
|
+
.split(/[,\s]+/)
|
|
126
|
+
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
127
|
+
return `animation: ${token || m[1].trim()}`;
|
|
128
|
+
} },
|
|
129
|
+
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
|
|
130
|
+
test: (m) => {
|
|
131
|
+
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
|
132
|
+
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
|
|
133
|
+
},
|
|
134
|
+
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
|
135
|
+
// --- Layout property transition ---
|
|
136
|
+
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
|
137
|
+
test: (m) => {
|
|
138
|
+
const val = m[1].toLowerCase();
|
|
139
|
+
if (/\ball\b/.test(val)) return false;
|
|
140
|
+
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
|
141
|
+
},
|
|
142
|
+
fmt: (m) => {
|
|
143
|
+
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
144
|
+
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
|
145
|
+
} },
|
|
146
|
+
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
|
147
|
+
test: (m) => {
|
|
148
|
+
const val = m[1].toLowerCase();
|
|
149
|
+
if (/\ball\b/.test(val)) return false;
|
|
150
|
+
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
|
151
|
+
},
|
|
152
|
+
fmt: (m) => {
|
|
153
|
+
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
154
|
+
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
|
155
|
+
} },
|
|
156
|
+
// --- Broken image: src="" or src="#" or src=" " ---
|
|
157
|
+
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
|
|
158
|
+
test: () => true,
|
|
159
|
+
fmt: (m) => m[0].slice(0, 100) },
|
|
160
|
+
// --- Broken image: <img> with no src attribute at all ---
|
|
161
|
+
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
|
|
162
|
+
test: (m) => !/\bsrc\s*=/i.test(m[0]),
|
|
163
|
+
fmt: (m) => m[0].slice(0, 100) },
|
|
164
|
+
// --- Image missing dimensions (CLS): <img> without width+height and
|
|
165
|
+
// without a CSS aspect-ratio or explicit height. Covers HTML (width="…")
|
|
166
|
+
// and JSX (width={…}) attribute forms. ---
|
|
167
|
+
{ id: 'image-missing-dimensions', regex: /<img\b[^>]*>/gi,
|
|
168
|
+
test: (m) => {
|
|
169
|
+
const tag = m[0];
|
|
170
|
+
const hasHeight = /(?:^|[\s{;"'])height\s*[=:]/i.test(tag);
|
|
171
|
+
const hasAspect = /aspect-?ratio/i.test(tag);
|
|
172
|
+
return !hasHeight && !hasAspect;
|
|
173
|
+
},
|
|
174
|
+
fmt: (m) => m[0].slice(0, 100) },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const REGEX_ANALYZERS = [
|
|
178
|
+
// Single font
|
|
179
|
+
(content, filePath) => {
|
|
180
|
+
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
181
|
+
const fonts = new Set();
|
|
182
|
+
let m;
|
|
183
|
+
while ((m = fontFamilyRe.exec(content)) !== null) {
|
|
184
|
+
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
|
185
|
+
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
|
|
189
|
+
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
|
190
|
+
const name = [...fonts][0];
|
|
191
|
+
const lines = content.split('\n');
|
|
192
|
+
let line = 1;
|
|
193
|
+
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
|
|
194
|
+
return [finding('single-font', filePath, `only font used is ${name}`, line)];
|
|
195
|
+
},
|
|
196
|
+
// Flat type hierarchy
|
|
197
|
+
(content, filePath) => {
|
|
198
|
+
const sizes = new Set();
|
|
199
|
+
const REM = 16;
|
|
200
|
+
let m;
|
|
201
|
+
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
|
|
202
|
+
while ((m = sizeRe.exec(content)) !== null) {
|
|
203
|
+
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
|
|
204
|
+
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
|
|
205
|
+
}
|
|
206
|
+
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
|
|
207
|
+
while ((m = clampRe.exec(content)) !== null) {
|
|
208
|
+
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
|
|
209
|
+
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
|
|
210
|
+
}
|
|
211
|
+
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
|
|
212
|
+
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
|
|
213
|
+
if (sizes.size < 3) return [];
|
|
214
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
215
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
216
|
+
if (ratio >= 2.0) return [];
|
|
217
|
+
const lines = content.split('\n');
|
|
218
|
+
let line = 1;
|
|
219
|
+
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
|
220
|
+
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => `${s}px`).join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
|
221
|
+
},
|
|
222
|
+
// Monotonous spacing (regex)
|
|
223
|
+
(content, filePath) => {
|
|
224
|
+
const vals = [];
|
|
225
|
+
let m;
|
|
226
|
+
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
|
227
|
+
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
|
228
|
+
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
|
229
|
+
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
|
230
|
+
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
|
231
|
+
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
|
232
|
+
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
|
233
|
+
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
|
234
|
+
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
|
235
|
+
if (rounded.length < 10) return [];
|
|
236
|
+
const counts = {};
|
|
237
|
+
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
|
238
|
+
const maxCount = Math.max(...Object.values(counts));
|
|
239
|
+
const pct = maxCount / rounded.length;
|
|
240
|
+
const unique = [...new Set(rounded)].filter(v => v > 0);
|
|
241
|
+
if (pct <= 0.6 || unique.length > 3) return [];
|
|
242
|
+
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
243
|
+
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
|
244
|
+
},
|
|
245
|
+
// Em-dash overuse: 5+ em-dashes or "--" in body text content
|
|
246
|
+
// (occasional em-dash use in prose is fine; the pattern fires only
|
|
247
|
+
// when count crosses into AI-cadence territory).
|
|
248
|
+
(content, filePath) => {
|
|
249
|
+
const text = stripHtmlToText(content);
|
|
250
|
+
let count = 0;
|
|
251
|
+
const re = /[—]|--(?=\S)/g;
|
|
252
|
+
while (re.exec(text) !== null) count++;
|
|
253
|
+
if (count < 5) return [];
|
|
254
|
+
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
|
|
255
|
+
},
|
|
256
|
+
// Marketing buzzwords: SaaS phrase list
|
|
257
|
+
(content, filePath) => {
|
|
258
|
+
const text = stripHtmlToText(content);
|
|
259
|
+
const lower = text.toLowerCase();
|
|
260
|
+
const BUZZWORDS = [
|
|
261
|
+
'streamline your', 'empower your', 'supercharge your',
|
|
262
|
+
'unleash your', 'unleash the power', 'leverage the power',
|
|
263
|
+
'built for the modern', 'trusted by leading', 'trusted by the world',
|
|
264
|
+
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
|
|
265
|
+
'next-generation', 'cutting-edge', 'transform your business',
|
|
266
|
+
'revolutionize', 'game-changer', 'game changing',
|
|
267
|
+
'mission-critical', 'best of breed', 'future-proof', 'future proof',
|
|
268
|
+
'seamless experience', 'seamlessly integrate',
|
|
269
|
+
'drive engagement', 'drive growth', 'drive results',
|
|
270
|
+
'harness the power',
|
|
271
|
+
];
|
|
272
|
+
let count = 0;
|
|
273
|
+
let firstSample = '';
|
|
274
|
+
for (const phrase of BUZZWORDS) {
|
|
275
|
+
let from = 0;
|
|
276
|
+
while (true) {
|
|
277
|
+
const idx = lower.indexOf(phrase, from);
|
|
278
|
+
if (idx === -1) break;
|
|
279
|
+
count++;
|
|
280
|
+
if (!firstSample) {
|
|
281
|
+
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
|
|
282
|
+
}
|
|
283
|
+
from = idx + phrase.length;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (count === 0) return [];
|
|
287
|
+
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
|
|
288
|
+
},
|
|
289
|
+
// Numbered section markers (01 / 02 / 03 ...)
|
|
290
|
+
(content, filePath) => {
|
|
291
|
+
const text = stripHtmlToText(content);
|
|
292
|
+
const re = /\b(0[1-9]|1[0-2])\b/g;
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
let m;
|
|
295
|
+
while ((m = re.exec(text)) !== null) seen.add(m[1]);
|
|
296
|
+
if (seen.size < 3) return [];
|
|
297
|
+
const sorted = [...seen].sort();
|
|
298
|
+
let sequential = 0;
|
|
299
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
300
|
+
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
|
|
301
|
+
}
|
|
302
|
+
if (sequential < 2) return [];
|
|
303
|
+
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
|
|
304
|
+
},
|
|
305
|
+
// Aphoristic cadence: manufactured-contrast + short-rebuttal
|
|
306
|
+
(content, filePath) => {
|
|
307
|
+
const text = stripHtmlToText(content);
|
|
308
|
+
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
|
|
309
|
+
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
|
|
310
|
+
let count = 0;
|
|
311
|
+
let firstSample = '';
|
|
312
|
+
let m;
|
|
313
|
+
NOT_A_RE.lastIndex = 0;
|
|
314
|
+
while ((m = NOT_A_RE.exec(text)) !== null) {
|
|
315
|
+
count++;
|
|
316
|
+
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
|
317
|
+
}
|
|
318
|
+
SHORT_REBUTTAL_RE.lastIndex = 0;
|
|
319
|
+
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
|
|
320
|
+
count++;
|
|
321
|
+
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
|
|
322
|
+
}
|
|
323
|
+
if (count < 3) return [];
|
|
324
|
+
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
|
|
325
|
+
},
|
|
326
|
+
// Dark glow (page-level: dark bg + colored box-shadow with blur)
|
|
327
|
+
(content, filePath) => {
|
|
328
|
+
// Check if page has a dark background
|
|
329
|
+
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
|
|
330
|
+
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
|
|
331
|
+
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
|
|
332
|
+
if (!hasDarkBg) return [];
|
|
333
|
+
|
|
334
|
+
// Check for colored box-shadow with blur > 4px
|
|
335
|
+
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
|
336
|
+
let m;
|
|
337
|
+
while ((m = shadowRe.exec(content)) !== null) {
|
|
338
|
+
const val = m[1];
|
|
339
|
+
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
340
|
+
if (!colorMatch) continue;
|
|
341
|
+
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
|
342
|
+
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
|
|
343
|
+
// Check blur: look for pattern like "0 0 20px" (third number > 4)
|
|
344
|
+
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
|
345
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
346
|
+
const lines = content.substring(0, m.index).split('\n');
|
|
347
|
+
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return [];
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Style block extraction (Vue/Svelte <style> blocks)
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
export {
|
|
360
|
+
hasRounded,
|
|
361
|
+
hasBorderRadius,
|
|
362
|
+
isSafeElement,
|
|
363
|
+
stripHtmlToText,
|
|
364
|
+
PAGE_ANALYZER_EXTS,
|
|
365
|
+
extFromFilePath,
|
|
366
|
+
shouldRunPageAnalyzers,
|
|
367
|
+
firstOverusedGoogleFont,
|
|
368
|
+
isNeutralBorderColor,
|
|
369
|
+
REGEX_MATCHERS,
|
|
370
|
+
REGEX_ANALYZERS,
|
|
371
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Running the regex matcher/analyzer tables against source text: style-block
|
|
2
|
+
// and CSS-in-JS extraction, and the three run* entry points (regex matchers,
|
|
3
|
+
// text-content analyzers, stylesheet analyzers). Split out of detect-text.mjs
|
|
4
|
+
// (file-size sweep — pure move, no behaviour change).
|
|
5
|
+
|
|
6
|
+
import { isFullPage } from '../../shared/page.mjs';
|
|
7
|
+
import { finding } from '../../findings.mjs';
|
|
8
|
+
import { profileFindings } from '../../profile/profiler.mjs';
|
|
9
|
+
import {
|
|
10
|
+
checkFocusVisible,
|
|
11
|
+
checkHoverOnlyAffordance,
|
|
12
|
+
checkDarkSchemeContrast,
|
|
13
|
+
} from '../../rules/checks.mjs';
|
|
14
|
+
import { REGEX_MATCHERS, REGEX_ANALYZERS, shouldRunPageAnalyzers, extFromFilePath } from './detect-text-matchers.mjs';
|
|
15
|
+
|
|
16
|
+
function extractStyleBlocks(content, ext) {
|
|
17
|
+
ext = ext.toLowerCase();
|
|
18
|
+
if (ext !== '.vue' && ext !== '.svelte') return [];
|
|
19
|
+
const blocks = [];
|
|
20
|
+
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
21
|
+
let m;
|
|
22
|
+
while ((m = re.exec(content)) !== null) {
|
|
23
|
+
const before = content.substring(0, m.index);
|
|
24
|
+
const startLine = before.split('\n').length + 1;
|
|
25
|
+
blocks.push({ content: m[1], startLine });
|
|
26
|
+
}
|
|
27
|
+
return blocks;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// CSS-in-JS extraction (styled-components, emotion)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
|
35
|
+
|
|
36
|
+
function extractCSSinJS(content, ext) {
|
|
37
|
+
ext = ext.toLowerCase();
|
|
38
|
+
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
|
39
|
+
const blocks = [];
|
|
40
|
+
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = re.exec(content)) !== null) {
|
|
43
|
+
const before = content.substring(0, m.index);
|
|
44
|
+
const startLine = before.split('\n').length;
|
|
45
|
+
blocks.push({ content: m[1], startLine });
|
|
46
|
+
}
|
|
47
|
+
return blocks;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
|
|
51
|
+
const { profile, phase = 'regex-matchers' } = options || {};
|
|
52
|
+
const findings = [];
|
|
53
|
+
if (!profile) {
|
|
54
|
+
for (const matcher of REGEX_MATCHERS) {
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
matcher.regex.lastIndex = 0;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = matcher.regex.exec(line)) !== null) {
|
|
60
|
+
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
|
61
|
+
const context = blockContext
|
|
62
|
+
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
|
63
|
+
: line;
|
|
64
|
+
if (matcher.test(m, context)) {
|
|
65
|
+
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const matcher of REGEX_MATCHERS) {
|
|
74
|
+
const matcherFindings = profileFindings(profile, {
|
|
75
|
+
engine: 'regex',
|
|
76
|
+
phase,
|
|
77
|
+
ruleId: matcher.id,
|
|
78
|
+
target: filePath,
|
|
79
|
+
}, () => {
|
|
80
|
+
const matches = [];
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
matcher.regex.lastIndex = 0;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = matcher.regex.exec(line)) !== null) {
|
|
86
|
+
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
|
87
|
+
const context = blockContext
|
|
88
|
+
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
|
89
|
+
: line;
|
|
90
|
+
if (matcher.test(m, context)) {
|
|
91
|
+
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return matches;
|
|
96
|
+
});
|
|
97
|
+
findings.push(...matcherFindings);
|
|
98
|
+
}
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Page-level analyzers that scan rendered text content (em-dash use,
|
|
103
|
+
* buzzword phrases, numbered section markers, aphoristic cadence).
|
|
104
|
+
* These are detector-agnostic — they work on any HTML/text source
|
|
105
|
+
* and don't need a parsed DOM. Exported so detectHtml can call them
|
|
106
|
+
* for `.html` files (which otherwise skip the regex engine). */
|
|
107
|
+
const TEXT_CONTENT_ANALYZER_IDS = [
|
|
108
|
+
'em-dash-overuse',
|
|
109
|
+
'marketing-buzzword',
|
|
110
|
+
'numbered-section-markers',
|
|
111
|
+
'aphoristic-cadence',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function runTextContentAnalyzers(content, filePath, options = {}) {
|
|
115
|
+
const profile = options?.profile;
|
|
116
|
+
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
|
117
|
+
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
|
|
118
|
+
const findings = [];
|
|
119
|
+
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
|
120
|
+
const analyzer = REGEX_ANALYZERS[3 + i];
|
|
121
|
+
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
|
|
122
|
+
findings.push(...profileFindings(profile, {
|
|
123
|
+
engine: 'regex',
|
|
124
|
+
phase: 'text-content',
|
|
125
|
+
ruleId,
|
|
126
|
+
target: filePath,
|
|
127
|
+
}, () => analyzer(content, filePath)));
|
|
128
|
+
}
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Extract raw CSS text from a source file for the stylesheet-level analyzers
|
|
133
|
+
// (focus-visible, hover-only, dark-scheme). CSS-like files are CSS wholesale;
|
|
134
|
+
// everything else contributes only its <style> block contents so tag markup
|
|
135
|
+
// and inline styles don't confuse the flat CSS parser.
|
|
136
|
+
const CSS_LIKE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
137
|
+
function extractStylesheetText(content, ext) {
|
|
138
|
+
if (CSS_LIKE_EXTS.has(ext)) return content;
|
|
139
|
+
const blocks = [];
|
|
140
|
+
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
|
|
143
|
+
return blocks.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const STYLESHEET_ANALYZERS = [
|
|
147
|
+
{ id: 'missing-focus-visible', run: checkFocusVisible },
|
|
148
|
+
{ id: 'hover-only-affordance', run: checkHoverOnlyAffordance },
|
|
149
|
+
{ id: 'dark-scheme-contrast-blindspot', run: checkDarkSchemeContrast },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
// Run the stylesheet-level analyzers over a source file's CSS. Applies to
|
|
153
|
+
// CSS-like sources and to full HTML pages (which carry <style> blocks).
|
|
154
|
+
function runStylesheetAnalyzers(content, filePath, options = {}) {
|
|
155
|
+
const profile = options?.profile;
|
|
156
|
+
const ext = extFromFilePath(filePath);
|
|
157
|
+
const isCssLike = CSS_LIKE_EXTS.has(ext);
|
|
158
|
+
if (!isCssLike && !isFullPage(content)) return [];
|
|
159
|
+
const cssText = extractStylesheetText(content, ext);
|
|
160
|
+
if (!cssText.trim()) return [];
|
|
161
|
+
const findings = [];
|
|
162
|
+
for (const analyzer of STYLESHEET_ANALYZERS) {
|
|
163
|
+
const hits = profileFindings(profile, {
|
|
164
|
+
engine: 'regex',
|
|
165
|
+
phase: 'stylesheet',
|
|
166
|
+
ruleId: analyzer.id,
|
|
167
|
+
target: filePath,
|
|
168
|
+
}, () => analyzer.run(cssText));
|
|
169
|
+
for (const h of hits) findings.push(finding(h.id, filePath, h.snippet));
|
|
170
|
+
}
|
|
171
|
+
return findings;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
export {
|
|
176
|
+
extractStyleBlocks,
|
|
177
|
+
CSS_IN_JS_EXTENSIONS,
|
|
178
|
+
extractCSSinJS,
|
|
179
|
+
runRegexMatchers,
|
|
180
|
+
TEXT_CONTENT_ANALYZER_IDS,
|
|
181
|
+
runTextContentAnalyzers,
|
|
182
|
+
CSS_LIKE_EXTS,
|
|
183
|
+
extractStylesheetText,
|
|
184
|
+
STYLESHEET_ANALYZERS,
|
|
185
|
+
runStylesheetAnalyzers,
|
|
186
|
+
};
|