-
Notifications
You must be signed in to change notification settings - Fork 5.4k
feat(lint): add cta-hierarchy-mismatch rule to lintArtifact #2287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
42e4328
abc3346
453e1bb
4e779e5
3840e9c
eb0f47c
2be5fc6
e03ea6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -445,6 +445,15 @@ export function lintArtifact(rawHtml: unknown): LintFinding[] { | |
| }); | ||
| } | ||
|
|
||
| // ── P1-4: CTA hierarchy mismatch in commerce-critical regions ───── | ||
| // Read the artifact's own CSS to identify the primary button class | ||
| // (the first selector that paints a non-transparent background), then | ||
| // flag commerce CTAs in <header>/<nav>/<section class="hero"> whose | ||
| // class list doesn't include it. Advisory P1 — false positives are | ||
| // expected (outline CTA in header is a legitimate design choice). | ||
| const ctaFinding = detectCtaHierarchyMismatch(html); | ||
| if (ctaFinding) out.push(ctaFinding); | ||
|
|
||
| // ── P2-1: missing comment-mode anchor on <section> ──────────────── | ||
| // Either `data-od-id` (web/mobile prototypes) or `data-screen-label` | ||
| // (decks) counts. Whichever the artifact uses, every <section> should | ||
|
|
@@ -998,3 +1007,226 @@ function isGlobalThemeScopeSelector(s: string): boolean { | |
| } | ||
| return false; | ||
| } | ||
|
|
||
| const COMMERCE_CTA_VERBS = [ | ||
| /\bbuy\b/i, | ||
| /\bpurchase\b/i, | ||
| /\bcheckout\b/i, | ||
| /\border\s+now\b/i, | ||
| /\bplace\s+order\b/i, | ||
| /\bplace\s+your\s+order\b/i, | ||
| /\bget\s+started\b/i, | ||
| /\bshop\s+now\b/i, | ||
| /\badd\s+to\s+cart\b/i, | ||
| /\bsubscribe\b/i, | ||
| /\bsign\s+up\b/i, | ||
| /\bstart\s+free\b/i, | ||
| /\bbook\s+now\b/i, | ||
| /\breserve\b/i, | ||
| ]; | ||
|
|
||
| const PRIMARY_NAME_HINTS = ['primary', 'cta', 'accent', 'buy', 'checkout']; | ||
|
|
||
| function detectCtaHierarchyMismatch(html: string): LintFinding | null { | ||
| const primaryClass = findPrimaryButtonClass(html); | ||
| if (!primaryClass) return null; | ||
| const regions = extractCommerceRegions(html); | ||
| if (regions.length === 0) return null; | ||
| const offenders: string[] = []; | ||
| const seen = new Set<string>(); | ||
| const ctaRe = /<(?:a|button)\b[^>]*\bclass\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/(?:a|button)>/gi; | ||
| for (const region of regions) { | ||
| for (const m of region.body.matchAll(ctaRe)) { | ||
| const classAttr = m[1] ?? ''; | ||
| const text = stripTags(m[2] ?? ''); | ||
| if (!isCommerceCtaText(text)) continue; | ||
| const classes = classAttr.split(/\s+/).filter(Boolean); | ||
| if (classes.includes(primaryClass)) continue; | ||
| const absolute = region.start + (m.index ?? 0); | ||
| const key = `cta:${absolute}`; | ||
| if (seen.has(key)) continue; | ||
| seen.add(key); | ||
| offenders.push(m[0]); | ||
| } | ||
| } | ||
| if (offenders.length === 0) return null; | ||
| const first = offenders[0] ?? ''; | ||
| return { | ||
| severity: 'P1', | ||
| id: 'cta-hierarchy-mismatch', | ||
| message: `${offenders.length} commerce CTA(s) in header/nav/hero use a non-primary button class while .${primaryClass} is the page primary.`, | ||
| fix: `Promote the header/hero purchase or sign-up CTA to .${primaryClass}. If an outline CTA in the header is intentional, ignore this warning.`, | ||
| snippet: clip(first), | ||
| }; | ||
| } | ||
|
|
||
| // Scan every <style> block (mirroring the all-caps-no-tracking rule) and | ||
| // return the first class selector whose body paints a non-transparent | ||
| // background. "First with a background" is preferred over "highest | ||
| // specificity" because the linter has no DOM and cannot compute | ||
| // specificity; the first painted button rule is a robust proxy for the | ||
| // page primary across the templates this lint actually sees, and a name | ||
| // hint (primary / cta / accent / buy / checkout) tiebreaks when multiple | ||
| // candidates exist. | ||
| function findPrimaryButtonClass(html: string): string | null { | ||
| const candidates: { name: string; hasColor: boolean }[] = []; | ||
| for (const block of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) { | ||
| const css = (block[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, ''); | ||
| for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { | ||
| const prelude = m[1] ?? ''; | ||
| const body = m[2] ?? ''; | ||
| const decls = parseDeclarations(body); | ||
| const bg = findLastDecl(decls, 'background') ?? findLastDecl(decls, 'background-color'); | ||
|
EthanGuo-coder marked this conversation as resolved.
Outdated
|
||
| if (!bg) continue; | ||
| if (!isPaintedBackground(bg.value)) continue; | ||
| const hasColor = decls.some((d) => d.prop === 'color'); | ||
| const classNames = extractSelectorClassNames(prelude); | ||
| for (const name of classNames) { | ||
| candidates.push({ name, hasColor }); | ||
| } | ||
| } | ||
| } | ||
| if (candidates.length === 0) return null; | ||
| const interactiveClasses = collectInteractiveClasses(html); | ||
| const eligible = candidates.filter((c) => interactiveClasses.has(c.name)); | ||
| if (eligible.length === 0) return null; | ||
| const hinted = eligible.find((c) => | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hint pass is stronger than the comment above describes. |
||
| PRIMARY_NAME_HINTS.some((h) => c.name.toLowerCase().includes(h)), | ||
| ); | ||
| if (hinted) return hinted.name; | ||
| const withColor = eligible.find((c) => c.hasColor); | ||
| return (withColor ?? eligible[0])?.name ?? null; | ||
| } | ||
|
|
||
| function collectInteractiveClasses(html: string): Set<string> { | ||
| const classes = new Set<string>(); | ||
| for (const m of html.matchAll(/<(?:a|button)\b[^>]*\bclass\s*=\s*["']([^"']*)["']/gi)) { | ||
| for (const token of (m[1] ?? '').split(/\s+/)) { | ||
| if (token) classes.add(token); | ||
| } | ||
| } | ||
| return classes; | ||
| } | ||
|
|
||
| function extractSelectorClassNames(prelude: string): string[] { | ||
| const out: string[] = []; | ||
| const seen = new Set<string>(); | ||
| for (const branch of prelude.split(',')) { | ||
| for (const tok of branch.matchAll(/\.([a-zA-Z][\w-]*)/g)) { | ||
|
EthanGuo-coder marked this conversation as resolved.
Outdated
|
||
| const name = tok[1]; | ||
| if (!name || seen.has(name)) continue; | ||
| seen.add(name); | ||
| out.push(name); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function extractBalancedArgs(value: string, from: number): string | null { | ||
| let depth = 1; | ||
| for (let i = from; i < value.length; i++) { | ||
| const ch = value[i]; | ||
| if (ch === '(') depth++; | ||
| else if (ch === ')') { | ||
| depth--; | ||
| if (depth === 0) return value.slice(from, i); | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function findTopLevelSlash(args: string): number { | ||
| let depth = 0; | ||
| for (let i = args.length - 1; i >= 0; i--) { | ||
| const ch = args[i]; | ||
| if (ch === ')') depth++; | ||
| else if (ch === '(') depth--; | ||
| else if (ch === '/' && depth === 0) return i; | ||
| } | ||
| return -1; | ||
| } | ||
|
|
||
| function splitTopLevel(args: string, sep: string): string[] { | ||
| const out: string[] = []; | ||
| let depth = 0; | ||
| let start = 0; | ||
| for (let i = 0; i < args.length; i++) { | ||
| const ch = args[i]; | ||
| if (ch === '(') depth++; | ||
| else if (ch === ')') depth--; | ||
| else if (ch === sep && depth === 0) { | ||
| out.push(args.slice(start, i).trim()); | ||
| start = i + 1; | ||
| } | ||
| } | ||
| out.push(args.slice(start).trim()); | ||
| return out; | ||
| } | ||
|
|
||
| function parseAlpha(token: string | undefined): number | null { | ||
| if (!token) return null; | ||
| const trimmed = token.trim(); | ||
| const calc = /^calc\(\s*(-?\d+(?:\.\d+)?)\s*%?\s*\)$/i.exec(trimmed); | ||
| if (calc) { | ||
| const n = Number.parseFloat(calc[1] ?? ''); | ||
| return Number.isFinite(n) ? n : null; | ||
| } | ||
| const n = Number.parseFloat(trimmed); | ||
| return Number.isFinite(n) ? n : null; | ||
| } | ||
|
|
||
| function isPaintedBackground(value: string): boolean { | ||
| const v = value.trim().toLowerCase(); | ||
| if (!v) return false; | ||
| if (v === 'transparent' || v === 'none' || v === 'inherit' || v === 'initial' || v === 'unset') return false; | ||
| if (/^#[0-9a-f]{3,8}\b/.test(v)) return true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (/var\(--[\w-]+/.test(v)) return true; | ||
| if (/^rgb\(|^rgba\(|^hsl\(|^hsla\(/.test(v)) { | ||
|
EthanGuo-coder marked this conversation as resolved.
|
||
| const fnMatch = v.match(/^(rgba?|hsla?)\(/); | ||
| if (!fnMatch) return true; | ||
| const fn = fnMatch[1]; | ||
| const args = extractBalancedArgs(v, fnMatch[0].length); | ||
| if (args === null) return true; | ||
| const slashIdx = findTopLevelSlash(args); | ||
| if (slashIdx >= 0) { | ||
| const alpha = parseAlpha(args.slice(slashIdx + 1)); | ||
| if (alpha === null) return true; | ||
| return alpha !== 0; | ||
| } | ||
| const parts = splitTopLevel(args, ','); | ||
| if ((fn === 'rgba' || fn === 'hsla') && parts.length === 4) { | ||
| const alpha = parseAlpha(parts[3]); | ||
| if (alpha === null) return true; | ||
| return alpha !== 0; | ||
| } | ||
| return true; | ||
| } | ||
| if (/^linear-gradient\(|^radial-gradient\(/.test(v)) return true; | ||
| return false; | ||
| } | ||
|
|
||
| function extractCommerceRegions(html: string): { body: string; start: number }[] { | ||
| const regions: { body: string; start: number }[] = []; | ||
| const patterns = [ | ||
| /<header\b[^>]*>([\s\S]*?)<\/header>/gi, | ||
| /<nav\b[^>]*>([\s\S]*?)<\/nav>/gi, | ||
| /<section\b[^>]*\bclass\s*=\s*["'][^"']*\bhero\b[^"']*["'][^>]*>([\s\S]*?)<\/section>/gi, | ||
| ]; | ||
| for (const re of patterns) { | ||
| for (const m of html.matchAll(re)) { | ||
| const body = m[1]; | ||
| if (!body) continue; | ||
| const start = (m.index ?? 0) + m[0].indexOf(body); | ||
| regions.push({ body, start }); | ||
| } | ||
| } | ||
| return regions; | ||
| } | ||
|
|
||
| function isCommerceCtaText(text: string): boolean { | ||
| return COMMERCE_CTA_VERBS.some((re) => re.test(text)); | ||
| } | ||
|
|
||
| function stripTags(s: string): string { | ||
| return s.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.