import type { DevToolbarCategory, DevToolbarIssue, DevToolbarSeverity } from "../types.ts"; let issueCounter = 0; export function getStableSelector(element: Element): string { if (element.id) return `#${CSS.escape(element.id)}`; const parts: string[] = []; let current: Element | null = element; while (current && current !== document.documentElement && parts.length < 5) { let part = current.tagName.toLowerCase(); const classes = [...current.classList] .slice(0, 2) .map((value) => `.${CSS.escape(value)}`) .join(""); part += classes; const parent: Element | null = current.parentElement; if (parent) { const same = [...parent.children].filter((child) => child.tagName === current!.tagName); if (same.length > 1) part += `:nth-of-type(${same.indexOf(current) + 1})`; } parts.unshift(part); current = parent; } return parts.join(" > "); } export function createFingerprint( ruleId: string, selector = "", source = "", message = "", ): string { return `${ruleId}|${location.pathname}|${selector}|${source}|${message}`; } export function createIssue(input: { ruleId: string; category: DevToolbarCategory; severity: DevToolbarSeverity; title: string; message: string; element?: Element; recommendation?: string; explanation?: string; confidence?: "high" | "medium" | "low"; metadata?: Record; }): DevToolbarIssue { const selector = input.element ? getStableSelector(input.element) : undefined; const source = input.element?.getAttribute("data-wrnexus-source") ?? undefined; return { id: `wrn-issue-${Date.now()}-${++issueCounter}`, ruleId: input.ruleId, category: input.category, severity: input.severity, title: input.title, message: input.message, recommendation: input.recommendation, explanation: input.explanation, confidence: input.confidence ?? "high", target: input.element ? { selector, tagName: input.element.tagName.toLowerCase(), id: input.element.id || undefined, classes: [...input.element.classList], text: input.element.textContent?.trim().slice(0, 120), } : undefined, source: source ? parseSource(source) : undefined, metadata: input.metadata, fingerprint: createFingerprint(input.ruleId, selector, source, input.message), createdAt: Date.now(), }; } export function parseSource(value: string) { const match = /^(.*?):(\d+)(?::(\d+))?$/.exec(value); if (!match) return { file: value }; return { file: match[1], line: Number(match[2]), column: match[3] ? Number(match[3]) : undefined, }; } export function isVisible(element: Element): boolean { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return ( style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0 ); } export function accessibleName(element: Element): string { const labelledBy = element.getAttribute("aria-labelledby"); if (labelledBy) { const value = labelledBy .split(/\s+/) .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") .join(" ") .trim(); if (value) return value; } return ( element.getAttribute("aria-label")?.trim() || element.getAttribute("alt")?.trim() || element.getAttribute("title")?.trim() || element.textContent?.trim() || "" ); } export function parseRgb(input: string): [number, number, number, number] | null { const match = input.match(/rgba?\(([^)]+)\)/i); if (!match) return null; const parts = match[1] .split(/[,/ ]+/) .filter(Boolean) .map(Number); if (parts.length < 3 || parts.some(Number.isNaN)) return null; return [parts[0], parts[1], parts[2], parts[3] ?? 1]; } export function luminance([r, g, b]: [number, number, number, number]): number { const values = [r, g, b].map((v) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; } export function contrastRatio( a: [number, number, number, number], b: [number, number, number, number], ): number { const l1 = luminance(a); const l2 = luminance(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } export function effectiveBackground(element: Element): [number, number, number, number] | null { let current: Element | null = element; while (current) { const parsed = parseRgb(getComputedStyle(current).backgroundColor); if (parsed && parsed[3] > 0.01) return parsed; current = current.parentElement; } return [255, 255, 255, 1]; }