release: WRNexusJS 0.2.75
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { accessibleName, createIssue } from "./helpers.ts";
|
||||
|
||||
export const accessibilityRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "a11y/image-alt",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "error",
|
||||
description: "Images need alternative text.",
|
||||
run: ({ root }) =>
|
||||
[...root.querySelectorAll("img:not([alt])")].map((element) =>
|
||||
createIssue({
|
||||
ruleId: "a11y/image-alt",
|
||||
category: "accessibility",
|
||||
severity: "error",
|
||||
title: "Image is missing alt text",
|
||||
message: 'Add alt text for meaningful images, or alt="" for decorative images.',
|
||||
element,
|
||||
recommendation: "Describe the image's purpose in a concise alt attribute.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "a11y/control-name",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "error",
|
||||
description: "Interactive controls need accessible names.",
|
||||
run: ({ root }) =>
|
||||
[...root.querySelectorAll("button, a[href], [role='button']")]
|
||||
.filter((element) => !accessibleName(element))
|
||||
.map((element) =>
|
||||
createIssue({
|
||||
ruleId: "a11y/control-name",
|
||||
category: "accessibility",
|
||||
severity: "error",
|
||||
title: "Control has no accessible name",
|
||||
message: "Screen readers cannot identify this control.",
|
||||
element,
|
||||
recommendation: "Add visible text, aria-label, or aria-labelledby.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "a11y/form-label",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "error",
|
||||
description: "Form controls need labels.",
|
||||
run: ({ root }) =>
|
||||
[...root.querySelectorAll("input:not([type='hidden']), select, textarea")]
|
||||
.filter((element) => {
|
||||
const id = element.id;
|
||||
return (
|
||||
!element.getAttribute("aria-label") &&
|
||||
!element.getAttribute("aria-labelledby") &&
|
||||
!(id && root.querySelector(`label[for='${CSS.escape(id)}']`)) &&
|
||||
!element.closest("label")
|
||||
);
|
||||
})
|
||||
.map((element) =>
|
||||
createIssue({
|
||||
ruleId: "a11y/form-label",
|
||||
category: "accessibility",
|
||||
severity: "error",
|
||||
title: "Form control has no label",
|
||||
message: "Users of assistive technology may not know what this field is for.",
|
||||
element,
|
||||
recommendation: "Associate a label using for/id or aria-labelledby.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "a11y/duplicate-id",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "error",
|
||||
description: "IDs must be unique.",
|
||||
run: ({ root }) => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: Element[] = [];
|
||||
for (const element of root.querySelectorAll("[id]")) {
|
||||
if (seen.has(element.id)) duplicates.push(element);
|
||||
else seen.add(element.id);
|
||||
}
|
||||
return duplicates.map((element) =>
|
||||
createIssue({
|
||||
ruleId: "a11y/duplicate-id",
|
||||
category: "accessibility",
|
||||
severity: "error",
|
||||
title: "Duplicate element ID",
|
||||
message: `The ID “${element.id}” is used more than once.`,
|
||||
element,
|
||||
recommendation: "Use a unique ID for every element.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a11y/heading-order",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "warning",
|
||||
description: "Heading levels should not skip.",
|
||||
run: ({ root }) => {
|
||||
const issues = [];
|
||||
let previous = 0;
|
||||
for (const element of root.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
|
||||
const level = Number(element.tagName.slice(1));
|
||||
if (previous && level > previous + 1)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "a11y/heading-order",
|
||||
category: "accessibility",
|
||||
severity: "warning",
|
||||
title: "Heading level is skipped",
|
||||
message: `Heading jumps from h${previous} to h${level}.`,
|
||||
element,
|
||||
recommendation: "Use headings in a logical hierarchy.",
|
||||
}),
|
||||
);
|
||||
previous = level;
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a11y/positive-tabindex",
|
||||
category: "accessibility",
|
||||
defaultSeverity: "warning",
|
||||
description: "Positive tabindex disrupts natural keyboard order.",
|
||||
run: ({ root }) =>
|
||||
[...root.querySelectorAll("[tabindex]")]
|
||||
.filter((el) => Number(el.getAttribute("tabindex")) > 0)
|
||||
.map((element) =>
|
||||
createIssue({
|
||||
ruleId: "a11y/positive-tabindex",
|
||||
category: "accessibility",
|
||||
severity: "warning",
|
||||
title: "Positive tabindex used",
|
||||
message: "Positive tabindex values create an unexpected keyboard focus order.",
|
||||
element,
|
||||
recommendation: 'Use tabindex="0" or rely on native document order.',
|
||||
}),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { contrastRatio, createIssue, effectiveBackground, isVisible, parseRgb } from "./helpers.ts";
|
||||
|
||||
export const colorRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "color/contrast",
|
||||
category: "color",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks basic text contrast against computed solid backgrounds.",
|
||||
run: ({ root }) => {
|
||||
const issues = [];
|
||||
const candidates = root.querySelectorAll(
|
||||
"p,span,a,button,label,input,textarea,select,h1,h2,h3,h4,h5,h6,li,td,th",
|
||||
);
|
||||
for (const element of candidates) {
|
||||
if (
|
||||
!isVisible(element) ||
|
||||
!(element.textContent?.trim() || element instanceof HTMLInputElement)
|
||||
)
|
||||
continue;
|
||||
const style = getComputedStyle(element);
|
||||
const foreground = parseRgb(style.color);
|
||||
const background = effectiveBackground(element);
|
||||
if (!foreground || !background || foreground[3] < 0.95) continue;
|
||||
const ratio = contrastRatio(foreground, background);
|
||||
const fontSize = Number.parseFloat(style.fontSize);
|
||||
const fontWeight = Number.parseInt(style.fontWeight, 10) || 400;
|
||||
const large = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700);
|
||||
const required = large ? 3 : 4.5;
|
||||
if (ratio < required)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "color/contrast",
|
||||
category: "color",
|
||||
severity: ratio < 2 ? "error" : "warning",
|
||||
title: "Text contrast is too low",
|
||||
message: `Computed contrast is ${ratio.toFixed(2)}:1; this text generally needs at least ${required}:1.`,
|
||||
element,
|
||||
recommendation: "Increase the difference between text and background colors.",
|
||||
confidence: "medium",
|
||||
metadata: { ratio, required },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const formRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "forms/basics",
|
||||
category: "forms",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks common form implementation problems.",
|
||||
run: ({ root }) => {
|
||||
const issues = [];
|
||||
for (const input of root.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>("input:not([type='hidden']),textarea,select")) {
|
||||
if (!input.name)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "forms/name-missing",
|
||||
category: "forms",
|
||||
severity: "warning",
|
||||
title: "Form control has no name",
|
||||
message: "This value may not be included in native form submission.",
|
||||
element: input,
|
||||
recommendation: "Add a stable name attribute.",
|
||||
}),
|
||||
);
|
||||
if (
|
||||
input instanceof HTMLInputElement &&
|
||||
["email", "tel", "password", "text"].includes(input.type) &&
|
||||
!input.autocomplete
|
||||
)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "forms/autocomplete",
|
||||
category: "forms",
|
||||
severity: "suggestion",
|
||||
title: "Autocomplete is not configured",
|
||||
message: "Browsers may not provide the most useful autofill behavior.",
|
||||
element: input,
|
||||
recommendation: "Add an appropriate autocomplete token.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>("form")) {
|
||||
if (
|
||||
(form.method || "get").toLowerCase() === "get" &&
|
||||
form.querySelector("input[type='password']")
|
||||
)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "forms/password-get",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Password form uses GET",
|
||||
message: "Sensitive values can appear in URLs and logs.",
|
||||
element: form,
|
||||
recommendation: "Use POST for forms containing secrets.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,153 @@
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const htmlRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "html/structure",
|
||||
category: "html",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks document structure and DOM complexity.",
|
||||
run: ({ document, root }) => {
|
||||
const issues = [];
|
||||
const nodeCount = root.querySelectorAll("*").length;
|
||||
if (nodeCount > 3000)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "html/dom-size",
|
||||
category: "html",
|
||||
severity: "error",
|
||||
title: "DOM is extremely large",
|
||||
message: `The page contains ${nodeCount} elements.`,
|
||||
recommendation:
|
||||
"Reduce wrappers, paginate long lists, and render hidden content on demand.",
|
||||
}),
|
||||
);
|
||||
else if (nodeCount > 1500)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "html/dom-size",
|
||||
category: "html",
|
||||
severity: "warning",
|
||||
title: "DOM is large",
|
||||
message: `The page contains ${nodeCount} elements.`,
|
||||
recommendation: "Review repeated wrappers and off-screen content.",
|
||||
}),
|
||||
);
|
||||
if (!document.querySelector("main"))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "html/main-missing",
|
||||
category: "html",
|
||||
severity: "warning",
|
||||
title: "Main landmark is missing",
|
||||
message: "The document has no main element.",
|
||||
recommendation: "Wrap the primary page content in a main element.",
|
||||
}),
|
||||
);
|
||||
if (document.querySelectorAll("main").length > 1)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "html/multiple-main",
|
||||
category: "html",
|
||||
severity: "error",
|
||||
title: "Multiple main landmarks",
|
||||
message: "The document contains more than one main element.",
|
||||
recommendation: "Use one visible main landmark per document.",
|
||||
}),
|
||||
);
|
||||
for (const element of root.querySelectorAll("button button, button a, a a, a button"))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "html/nested-interactive",
|
||||
category: "html",
|
||||
severity: "error",
|
||||
title: "Interactive elements are nested",
|
||||
message: "Nested buttons and links create invalid and confusing interaction behavior.",
|
||||
element,
|
||||
recommendation:
|
||||
"Use one interactive element and style its inner non-interactive content.",
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue, isVisible } from "./helpers.ts";
|
||||
|
||||
export const imageRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "images/quality",
|
||||
category: "images",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks image loading, sizing and optimization.",
|
||||
run: ({ root, window }) => {
|
||||
const issues = [];
|
||||
for (const image of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||
if (image.complete && image.naturalWidth === 0)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/broken",
|
||||
category: "images",
|
||||
severity: "error",
|
||||
title: "Image failed to load",
|
||||
message: `The image at ${image.currentSrc || image.src || "(empty source)"} could not be loaded.`,
|
||||
element: image,
|
||||
recommendation: "Verify the URL, file path and response content type.",
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!image.hasAttribute("width") &&
|
||||
!image.hasAttribute("height") &&
|
||||
getComputedStyle(image).aspectRatio === "auto"
|
||||
)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/dimensions",
|
||||
category: "images",
|
||||
severity: "warning",
|
||||
title: "Image has no reserved dimensions",
|
||||
message: "This image may cause layout shift while loading.",
|
||||
element: image,
|
||||
recommendation: "Add width and height attributes or CSS aspect-ratio.",
|
||||
}),
|
||||
);
|
||||
const rect = image.getBoundingClientRect();
|
||||
if (isVisible(image) && image.naturalWidth > 0 && rect.width > image.naturalWidth * 1.25)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/upscaled",
|
||||
category: "images",
|
||||
severity: "warning",
|
||||
title: "Image is being enlarged",
|
||||
message: `Rendered width ${Math.round(rect.width)}px exceeds intrinsic width ${image.naturalWidth}px.`,
|
||||
element: image,
|
||||
recommendation: "Use a larger source image to avoid blurring.",
|
||||
}),
|
||||
);
|
||||
if (image.naturalWidth > rect.width * 2.5 && rect.width > 0)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/oversized-dimensions",
|
||||
category: "images",
|
||||
severity: "suggestion",
|
||||
title: "Image source may be oversized",
|
||||
message: `Intrinsic width ${image.naturalWidth}px is much larger than rendered width ${Math.round(rect.width)}px.`,
|
||||
element: image,
|
||||
recommendation: "Use srcset/sizes or a smaller generated image.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
if (rect.top > window.innerHeight * 1.5 && image.loading !== "lazy")
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/lazy-below-fold",
|
||||
category: "images",
|
||||
severity: "suggestion",
|
||||
title: "Below-fold image is not lazy loaded",
|
||||
message: "This image starts far below the initial viewport.",
|
||||
element: image,
|
||||
recommendation: 'Consider loading="lazy".',
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
if (rect.top < window.innerHeight && image.loading === "lazy")
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "images/lazy-above-fold",
|
||||
category: "images",
|
||||
severity: "suggestion",
|
||||
title: "Above-fold image is lazy loaded",
|
||||
message: "Lazy loading a visible hero image can delay rendering.",
|
||||
element: image,
|
||||
recommendation:
|
||||
'Remove lazy loading and consider fetchpriority="high" for the main hero image.',
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DevToolbarRule, DevToolbarRuleContext } from "./types.ts";
|
||||
import type { DevToolbarIssue } from "../types.ts";
|
||||
import { accessibilityRules } from "./accessibility.ts";
|
||||
import { seoRules } from "./seo.ts";
|
||||
import { imageRules } from "./images.ts";
|
||||
import { mediaRules } from "./media.ts";
|
||||
import { colorRules } from "./color.ts";
|
||||
import { htmlRules } from "./html.ts";
|
||||
import { formRules } from "./forms.ts";
|
||||
import { linkRules } from "./links.ts";
|
||||
import { performanceRules } from "./performance.ts";
|
||||
import { responsiveRules } from "./responsive.ts";
|
||||
import { securityRules } from "./security.ts";
|
||||
|
||||
export * from "./types.ts";
|
||||
export * from "./helpers.ts";
|
||||
export {
|
||||
accessibilityRules,
|
||||
seoRules,
|
||||
imageRules,
|
||||
mediaRules,
|
||||
colorRules,
|
||||
htmlRules,
|
||||
formRules,
|
||||
linkRules,
|
||||
performanceRules,
|
||||
responsiveRules,
|
||||
securityRules,
|
||||
};
|
||||
|
||||
export const DEV_TOOLBAR_RULES: DevToolbarRule[] = [
|
||||
...accessibilityRules,
|
||||
...seoRules,
|
||||
...imageRules,
|
||||
...mediaRules,
|
||||
...colorRules,
|
||||
...htmlRules,
|
||||
...formRules,
|
||||
...linkRules,
|
||||
...performanceRules,
|
||||
...responsiveRules,
|
||||
...securityRules,
|
||||
];
|
||||
|
||||
export async function runDevToolbarRules(
|
||||
context: DevToolbarRuleContext,
|
||||
rules = DEV_TOOLBAR_RULES,
|
||||
): Promise<DevToolbarIssue[]> {
|
||||
const settled = await Promise.all(
|
||||
rules.map(async (rule) => {
|
||||
try {
|
||||
return await rule.run(context);
|
||||
} catch (error) {
|
||||
console.warn(`[WRNexus DevToolbar] Rule ${rule.id} failed`, error);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const unique = new Map<string, DevToolbarIssue>();
|
||||
for (const issue of settled.flat()) unique.set(issue.fingerprint, issue);
|
||||
return [...unique.values()];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const linkRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "links/basics",
|
||||
category: "links",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks unsafe and incomplete links.",
|
||||
run: ({ root, url }) => {
|
||||
const issues = [];
|
||||
for (const anchor of root.querySelectorAll<HTMLAnchorElement>("a")) {
|
||||
const raw = anchor.getAttribute("href");
|
||||
if (!raw || raw === "#")
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "links/empty",
|
||||
category: "links",
|
||||
severity: "warning",
|
||||
title: "Link has no useful destination",
|
||||
message: `The href is ${raw ? '"#"' : "missing"}.`,
|
||||
element: anchor,
|
||||
recommendation: "Provide a real URL or use a button for an action.",
|
||||
}),
|
||||
);
|
||||
if (raw?.trim().toLowerCase().startsWith("javascript:"))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "links/javascript-url",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "JavaScript URL used",
|
||||
message: "javascript: links are unsafe and inaccessible.",
|
||||
element: anchor,
|
||||
recommendation: "Use a button and a normal event handler.",
|
||||
}),
|
||||
);
|
||||
if (
|
||||
anchor.target === "_blank" &&
|
||||
!anchor.rel.split(/\s+/).some((value) => value === "noopener" || value === "noreferrer")
|
||||
)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "links/blank-rel",
|
||||
category: "security",
|
||||
severity: "warning",
|
||||
title: "New-tab link lacks rel protection",
|
||||
message: "The opened page may retain access to window.opener.",
|
||||
element: anchor,
|
||||
recommendation: 'Add rel="noopener noreferrer".',
|
||||
}),
|
||||
);
|
||||
if (raw?.startsWith("http://") && url.protocol === "https:")
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "links/mixed-content",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Insecure link on HTTPS page",
|
||||
message: "This link uses HTTP from an HTTPS page.",
|
||||
element: anchor,
|
||||
recommendation: "Use an HTTPS destination.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const mediaRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "media/basics",
|
||||
category: "media",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks video, audio and iframe accessibility.",
|
||||
run: ({ root }) => {
|
||||
const issues = [];
|
||||
for (const video of root.querySelectorAll<HTMLVideoElement>("video")) {
|
||||
if (video.autoplay && !video.muted)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "media/autoplay-sound",
|
||||
category: "media",
|
||||
severity: "error",
|
||||
title: "Autoplay video is not muted",
|
||||
message: "Autoplaying sound is disruptive and commonly blocked by browsers.",
|
||||
element: video,
|
||||
recommendation: "Remove autoplay or add muted.",
|
||||
}),
|
||||
);
|
||||
if (!video.controls && !video.autoplay)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "media/no-controls",
|
||||
category: "media",
|
||||
severity: "warning",
|
||||
title: "Video has no controls",
|
||||
message: "Users may be unable to play, pause or seek.",
|
||||
element: video,
|
||||
recommendation: "Add controls or provide equivalent custom controls.",
|
||||
}),
|
||||
);
|
||||
if (!video.poster)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "media/no-poster",
|
||||
category: "media",
|
||||
severity: "suggestion",
|
||||
title: "Video has no poster",
|
||||
message: "A poster can improve perceived loading and visual quality.",
|
||||
element: video,
|
||||
recommendation: "Provide an optimized poster image.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
if (!video.querySelector('track[kind="captions"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "media/no-captions",
|
||||
category: "media",
|
||||
severity: "warning",
|
||||
title: "Video has no captions track",
|
||||
message: "Spoken content may be inaccessible.",
|
||||
element: video,
|
||||
recommendation: "Add a captions track when the video contains speech.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const iframe of root.querySelectorAll<HTMLIFrameElement>("iframe:not([title])"))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "media/iframe-title",
|
||||
category: "media",
|
||||
severity: "error",
|
||||
title: "Iframe is missing a title",
|
||||
message: "Assistive technology cannot identify the embedded content.",
|
||||
element: iframe,
|
||||
recommendation: "Add a concise title attribute.",
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const performanceRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "performance/resources",
|
||||
category: "performance",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks resource count and transfer sizes.",
|
||||
run: ({ performanceEntries }) => {
|
||||
const resources = performanceEntries.filter(
|
||||
(entry): entry is PerformanceResourceTiming => entry.entryType === "resource",
|
||||
);
|
||||
const issues = [];
|
||||
if (resources.length > 150)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/resource-count",
|
||||
category: "performance",
|
||||
severity: "warning",
|
||||
title: "Page loads many resources",
|
||||
message: `Found ${resources.length} resource requests.`,
|
||||
recommendation:
|
||||
"Remove duplicates, combine tiny assets where useful, and load non-critical resources later.",
|
||||
}),
|
||||
);
|
||||
const total = resources.reduce((sum, entry) => sum + (entry.transferSize || 0), 0);
|
||||
if (total > 5_000_000)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/transfer-size",
|
||||
category: "performance",
|
||||
severity: total > 10_000_000 ? "error" : "warning",
|
||||
title: "Page transfer size is large",
|
||||
message: `Observed transfer size is approximately ${(total / 1_000_000).toFixed(2)} MB.`,
|
||||
recommendation:
|
||||
"Compress images, scripts, styles and fonts; review third-party resources.",
|
||||
}),
|
||||
);
|
||||
for (const entry of resources.filter((item) => item.duration > 2000).slice(0, 20))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/slow-resource",
|
||||
category: "network",
|
||||
severity: "warning",
|
||||
title: "Resource loaded slowly",
|
||||
message: `${entry.name} took ${Math.round(entry.duration)} ms.`,
|
||||
recommendation: "Inspect server timing, caching and payload size.",
|
||||
metadata: { url: entry.name, duration: entry.duration },
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue, isVisible } from "./helpers.ts";
|
||||
|
||||
export const responsiveRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "responsive/overflow",
|
||||
category: "responsive",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks horizontal overflow and viewport escape.",
|
||||
run: ({ document, root, window }) => {
|
||||
const issues = [];
|
||||
if (document.documentElement.scrollWidth > window.innerWidth + 2)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "responsive/document-overflow",
|
||||
category: "responsive",
|
||||
severity: "error",
|
||||
title: "Page has horizontal overflow",
|
||||
message: `Document width ${document.documentElement.scrollWidth}px exceeds viewport ${window.innerWidth}px.`,
|
||||
recommendation: "Inspect fixed widths, transforms, long text and overflowing media.",
|
||||
}),
|
||||
);
|
||||
for (const element of root.querySelectorAll("body *")) {
|
||||
if (!isVisible(element)) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth + 8 || rect.left < -8) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "responsive/element-overflow",
|
||||
category: "responsive",
|
||||
severity: "warning",
|
||||
title: "Element extends outside the viewport",
|
||||
message: `Element bounds are ${Math.round(rect.left)}px to ${Math.round(rect.right)}px in a ${window.innerWidth}px viewport.`,
|
||||
element,
|
||||
recommendation:
|
||||
"Use fluid sizing, wrapping, max-width, or an intentional scroll container.",
|
||||
}),
|
||||
);
|
||||
if (issues.length >= 20) break;
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const securityRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "security/page",
|
||||
category: "security",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks development-visible security mistakes.",
|
||||
run: ({ url, root }) => {
|
||||
const issues = [];
|
||||
for (const [key] of url.searchParams)
|
||||
if (/pass(word)?|token|secret|api[-_]?key/i.test(key))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/secret-query",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Potential secret appears in URL",
|
||||
message: `The query parameter “${key}” may contain sensitive information.`,
|
||||
recommendation:
|
||||
"Send secrets in a secure request body or authorization header, not a URL.",
|
||||
}),
|
||||
);
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/insecure-form",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Form submits over HTTP",
|
||||
message: "Form values may be transmitted without transport encryption.",
|
||||
element: form,
|
||||
recommendation: "Submit to an HTTPS endpoint.",
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
export const seoRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "seo/document",
|
||||
category: "seo",
|
||||
defaultSeverity: "warning",
|
||||
description: "Validates core document metadata.",
|
||||
run: ({ document }) => {
|
||||
const issues = [];
|
||||
const title = document.title.trim();
|
||||
if (!title)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/title-missing",
|
||||
category: "seo",
|
||||
severity: "error",
|
||||
title: "Page title is missing",
|
||||
message: "The document does not have a useful title.",
|
||||
recommendation: "Add a unique title describing this page.",
|
||||
}),
|
||||
);
|
||||
else if (title.length < 20 || title.length > 65)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/title-length",
|
||||
category: "seo",
|
||||
severity: "warning",
|
||||
title: "Page title length may be suboptimal",
|
||||
message: `The title contains ${title.length} characters.`,
|
||||
recommendation: "Keep most titles between about 20 and 65 characters.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
const description = document
|
||||
.querySelector<HTMLMetaElement>('meta[name="description"]')
|
||||
?.content.trim();
|
||||
if (!description)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/description-missing",
|
||||
category: "seo",
|
||||
severity: "warning",
|
||||
title: "Meta description is missing",
|
||||
message: "Search and social previews may not have a useful description.",
|
||||
recommendation: "Add a unique meta description for the page.",
|
||||
}),
|
||||
);
|
||||
else if (description.length < 70 || description.length > 170)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/description-length",
|
||||
category: "seo",
|
||||
severity: "suggestion",
|
||||
title: "Meta description length may be suboptimal",
|
||||
message: `The description contains ${description.length} characters.`,
|
||||
recommendation: "Aim for a concise description of roughly 70–170 characters.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
if (!document.querySelector('meta[name="viewport"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/viewport-missing",
|
||||
category: "seo",
|
||||
severity: "error",
|
||||
title: "Viewport meta tag is missing",
|
||||
message: "The page may render incorrectly on mobile devices.",
|
||||
recommendation: "Add width=device-width, initial-scale=1.",
|
||||
}),
|
||||
);
|
||||
if (!document.querySelector('link[rel="canonical"]'))
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/canonical-missing",
|
||||
category: "seo",
|
||||
severity: "suggestion",
|
||||
title: "Canonical URL is missing",
|
||||
message: "Search engines have no explicit preferred URL for this page.",
|
||||
recommendation: "Add a canonical link for public indexable pages.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
const h1s = document.querySelectorAll("h1");
|
||||
if (h1s.length === 0)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/h1-missing",
|
||||
category: "seo",
|
||||
severity: "warning",
|
||||
title: "Page has no h1 heading",
|
||||
message: "The page lacks a clear primary heading.",
|
||||
recommendation: "Add one descriptive h1.",
|
||||
}),
|
||||
);
|
||||
if (h1s.length > 1)
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "seo/multiple-h1",
|
||||
category: "seo",
|
||||
severity: "suggestion",
|
||||
title: "Page has multiple h1 headings",
|
||||
message: `Found ${h1s.length} h1 elements.`,
|
||||
recommendation:
|
||||
"Use one clear primary page heading unless multiple h1 elements are intentional.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DevToolbarCategory, DevToolbarIssue, DevToolbarSeverity } from "../types.ts";
|
||||
|
||||
export interface DevToolbarRuleContext {
|
||||
document: Document;
|
||||
window: Window;
|
||||
url: URL;
|
||||
root: ParentNode;
|
||||
performanceEntries: PerformanceEntry[];
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DevToolbarRule {
|
||||
id: string;
|
||||
category: DevToolbarCategory;
|
||||
defaultSeverity: DevToolbarSeverity;
|
||||
description: string;
|
||||
run(context: DevToolbarRuleContext): DevToolbarIssue[] | Promise<DevToolbarIssue[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user