@monoes/monodesign 1.2.18 → 1.2.22

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.
Files changed (104) hide show
  1. package/README.md +3 -3
  2. package/cli/engine/design-system-checks.mjs +381 -0
  3. package/cli/engine/design-system-parse.mjs +433 -0
  4. package/cli/engine/design-system.mjs +20 -736
  5. package/cli/engine/detect-antipatterns-browser.js +12 -1
  6. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  7. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  8. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  9. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  10. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  11. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  13. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  14. package/cli/engine/registry/antipatterns-provider.mjs +60 -0
  15. package/cli/engine/registry/antipatterns-quality.mjs +218 -0
  16. package/cli/engine/registry/antipatterns-slop.mjs +226 -0
  17. package/cli/engine/registry/antipatterns.mjs +7 -494
  18. package/cli/engine/rules/checks-background.mjs +155 -0
  19. package/cli/engine/rules/checks-css-values.mjs +137 -0
  20. package/cli/engine/rules/checks-dom-effects.mjs +243 -0
  21. package/cli/engine/rules/checks-dom.mjs +148 -0
  22. package/cli/engine/rules/checks-element.mjs +286 -0
  23. package/cli/engine/rules/checks-html.mjs +204 -0
  24. package/cli/engine/rules/checks-overflow.mjs +263 -0
  25. package/cli/engine/rules/checks-page.mjs +465 -0
  26. package/cli/engine/rules/checks-pure.mjs +453 -0
  27. package/cli/engine/rules/checks-quality.mjs +447 -0
  28. package/cli/engine/rules/checks-stylesheet.mjs +324 -0
  29. package/cli/engine/rules/checks.mjs +98 -2939
  30. package/cli/lib/monodesign-config-consent.mjs +93 -0
  31. package/cli/lib/monodesign-config-core.mjs +37 -0
  32. package/cli/lib/monodesign-config-detection.mjs +133 -0
  33. package/cli/lib/monodesign-config-filter.mjs +198 -0
  34. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  35. package/cli/lib/monodesign-config.mjs +21 -623
  36. package/package.json +2 -2
  37. package/skill/scripts/context-fields.mjs +65 -0
  38. package/skill/scripts/context-resolve.mjs +425 -0
  39. package/skill/scripts/context-update.mjs +151 -0
  40. package/skill/scripts/context-utils.mjs +68 -0
  41. package/skill/scripts/context-workspace.mjs +245 -0
  42. package/skill/scripts/context.mjs +17 -891
  43. package/skill/scripts/hook-admin-config.mjs +152 -0
  44. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  45. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  46. package/skill/scripts/hook-admin.mjs +8 -541
  47. package/skill/scripts/hook-before-edit-shell.mjs +184 -0
  48. package/skill/scripts/hook-before-edit.mjs +9 -165
  49. package/skill/scripts/hook-lib-cache.mjs +145 -0
  50. package/skill/scripts/hook-lib-config.mjs +292 -0
  51. package/skill/scripts/hook-lib-detector.mjs +72 -0
  52. package/skill/scripts/hook-lib-events.mjs +182 -0
  53. package/skill/scripts/hook-lib-findings.mjs +201 -0
  54. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  55. package/skill/scripts/hook-lib-render.mjs +273 -0
  56. package/skill/scripts/hook-lib-run.mjs +281 -0
  57. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  58. package/skill/scripts/hook-lib.mjs +71 -1747
  59. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  60. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  61. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  62. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  63. package/skill/scripts/lib/design-parser.mjs +10 -783
  64. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  65. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  66. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  67. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  68. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  69. package/skill/scripts/live/manual-apply.mjs +65 -640
  70. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  71. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  72. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  73. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  74. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  75. package/skill/scripts/live/svelte-component.mjs +32 -792
  76. package/skill/scripts/live-accept-mutate.mjs +152 -0
  77. package/skill/scripts/live-accept-parse.mjs +370 -0
  78. package/skill/scripts/live-accept-scrub.mjs +59 -0
  79. package/skill/scripts/live-accept-search.mjs +58 -0
  80. package/skill/scripts/live-accept.mjs +15 -631
  81. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  82. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  83. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  84. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  85. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  86. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  87. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  88. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  89. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  90. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  91. package/skill/scripts/live-inject-csp.mjs +132 -0
  92. package/skill/scripts/live-inject-files.mjs +229 -0
  93. package/skill/scripts/live-inject-tag.mjs +114 -0
  94. package/skill/scripts/live-inject.mjs +13 -440
  95. package/skill/scripts/live-server-poll.mjs +232 -0
  96. package/skill/scripts/live-server-routes.mjs +449 -0
  97. package/skill/scripts/live-server-state.mjs +361 -0
  98. package/skill/scripts/live-server.mjs +10 -946
  99. package/skill/scripts/live-wrap-css.mjs +132 -0
  100. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  101. package/skill/scripts/live-wrap-search.mjs +247 -0
  102. package/skill/scripts/live-wrap.mjs +21 -484
  103. package/skill/scripts/palette-seeds.mjs +401 -0
  104. package/skill/scripts/palette.mjs +1 -395
@@ -742,7 +742,7 @@ function extractGoogleFontFamilies(text) {
742
742
  return families;
743
743
  }
744
744
 
745
- // --- cli/engine/rules/checks.mjs ---
745
+ // --- cli/engine/rules/checks-pure.mjs ---
746
746
  const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
747
747
 
748
748
  // ─── Section 3: Pure Detection ──────────────────────────────────────────────
@@ -1162,6 +1162,7 @@ function checkGlow(opts) {
1162
1162
  return [];
1163
1163
  }
1164
1164
 
1165
+ // --- cli/engine/rules/checks-html.mjs ---
1165
1166
  /**
1166
1167
  * Regex-on-HTML checks shared between browser and Node page-level detection.
1167
1168
  * These don't need DOM access, just the raw HTML string.
@@ -1363,6 +1364,7 @@ function checkHtmlPatterns(html) {
1363
1364
  return findings;
1364
1365
  }
1365
1366
 
1367
+ // --- cli/engine/rules/checks-background.mjs ---
1366
1368
  // ─── Section 4: resolveBackground (unified) ─────────────────────────────────
1367
1369
 
1368
1370
  // Read the element's own background color, computed-style first, with a
@@ -1507,6 +1509,7 @@ function resolveBorderRadiusPx(_el, style, widthPx, _win) {
1507
1509
  return 0;
1508
1510
  }
1509
1511
 
1512
+ // --- cli/engine/rules/checks-dom.mjs ---
1510
1513
  // ─── Section 5: Element Adapters ────────────────────────────────────────────
1511
1514
 
1512
1515
  // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
@@ -1630,6 +1633,8 @@ function checkElementHeroEyebrowDOM(el) {
1630
1633
  // resolution every style-based check silently fails on Tailwind v4
1631
1634
  // builds — the values come back as literal "var(--font-weight-bold)"
1632
1635
  // strings and parseFloat returns NaN.
1636
+
1637
+ // --- cli/engine/rules/checks-css-values.mjs ---
1633
1638
  function buildCustomPropMap(document) {
1634
1639
  const map = new Map();
1635
1640
  let sheets;
@@ -1760,6 +1765,7 @@ function parseColorResolved(str, customPropMap) {
1760
1765
  return parseAnyColor(resolved);
1761
1766
  }
1762
1767
 
1768
+ // --- cli/engine/rules/checks-dom-effects.mjs ---
1763
1769
  const REPEATED_KICKER_SKIP_SELECTOR = [
1764
1770
  'nav',
1765
1771
  'form',
@@ -1977,6 +1983,7 @@ function checkElementAIPaletteDOM(el) {
1977
1983
  return findings;
1978
1984
  }
1979
1985
 
1986
+ // --- cli/engine/rules/checks-quality.mjs ---
1980
1987
  const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1981
1988
 
1982
1989
  // Resolve a CSS font-size value to pixels by walking up the parent chain.
@@ -2416,6 +2423,7 @@ function checkQuality(opts) {
2416
2423
  return findings;
2417
2424
  }
2418
2425
 
2426
+ // --- cli/engine/rules/checks-element.mjs ---
2419
2427
  function checkElementQualityDOM(el) {
2420
2428
  const tag = el.tagName.toLowerCase();
2421
2429
  const style = getComputedStyle(el);
@@ -2661,6 +2669,7 @@ function checkElementGlow(tag, style, effectiveBg) {
2661
2669
  return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
2662
2670
  }
2663
2671
 
2672
+ // --- cli/engine/rules/checks-page.mjs ---
2664
2673
  // ─── Section 6: Page-Level Checks ───────────────────────────────────────────
2665
2674
 
2666
2675
  // Browser page-level checks — use document/getComputedStyle globals
@@ -3097,6 +3106,7 @@ function checkElementGptBorderShadowDOM(el) {
3097
3106
  return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
3098
3107
  }
3099
3108
 
3109
+ // --- cli/engine/rules/checks-overflow.mjs ---
3100
3110
  // ─── Clipped overflow container ───────────────────────────────────────────────
3101
3111
  // A clipping container (overflow hidden/clip, not a scroll region) wrapping an
3102
3112
  // absolutely/fixed-positioned descendant clips popovers/menus that must escape.
@@ -3351,6 +3361,7 @@ function checkElementTextOverflowDOM(el) {
3351
3361
  return [];
3352
3362
  }
3353
3363
 
3364
+ // --- cli/engine/rules/checks-stylesheet.mjs ---
3354
3365
  // ─── Section 7: Stylesheet-level accessibility rules ─────────────────────────
3355
3366
  // These operate on raw CSS text (concatenated <style> blocks + linked sheets),
3356
3367
  // not per-element computed style — because :hover / :focus-visible / @media
@@ -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
+ };