154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
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<string, unknown>;
|
|
}): 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];
|
|
}
|