Files
WRNexusJS/packages/dev-toolbar/src/rules/helpers.ts
T
2026-07-21 12:30:46 +05:30

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];
}