astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
179 lines (178 loc) • 6.71 kB
JavaScript
import { settings } from "../../settings.js";
import { positionHighlight } from "../utils/highlight.js";
import { closeOnOutsideClick } from "../utils/window.js";
import { processAnnotations } from "./annotations.js";
import { rulesCategories } from "./rules/index.js";
import { DevToolbarAuditListItem } from "./ui/audit-list-item.js";
import { DevToolbarAuditListWindow } from "./ui/audit-list-window.js";
import { createAuditUI } from "./ui/audit-ui.js";
const icon = "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 1 20 16\" aria-hidden=\"true\"><path fill=\"#fff\" d=\"M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z\"/></svg>";
try {
customElements.define("astro-dev-toolbar-audit-window", DevToolbarAuditListWindow);
customElements.define("astro-dev-toolbar-audit-list-item", DevToolbarAuditListItem);
} catch {}
let showState = false;
export default {
id: "astro:audit",
name: "Audit",
icon,
async init(canvas, eventTarget) {
let audits = [];
let auditWindow = document.createElement("astro-dev-toolbar-audit-window");
let hasCreatedUI = false;
auditWindow.popover = "";
canvas.appendChild(auditWindow);
await run();
let mutationDebounce;
const observer = new MutationObserver(() => {
// We don't want to rerun the audit lints on every single mutation, so we'll debounce it.
if (mutationDebounce) {
clearTimeout(mutationDebounce);
}
mutationDebounce = setTimeout(() => {
settings.logger.verboseLog("Rerunning audit lints because the DOM has been updated.");
// Even though we're ready to run the lints, we'll wait for the next idle period to do so, as it is less likely
// to interfere with any other work the browser is doing post-mutation. For instance, the page or the user might
// be interacting with the newly added elements, or the browser might be doing some work (layout, paint, etc.)
if ("requestIdleCallback" in window) {
window.requestIdleCallback(async () => {
run().then(() => {
if (showState) createAuditsUI();
});
}, { timeout: 300 });
} else {
// Fallback for old versions of Safari, we'll assume that things are less likely to be busy after 150ms.
setTimeout(async () => {
run().then(() => {
if (showState) createAuditsUI();
});
}, 150);
}
}, 250);
});
setupObserver();
document.addEventListener("astro:before-preparation", () => {
observer.disconnect();
});
document.addEventListener("astro:after-swap", async () => {
run();
});
document.addEventListener("astro:page-load", async () => {
refreshLintPositions();
// HACK: View transitions add a route announcer after this event, so we need to wait for it to be added
setTimeout(() => {
setupObserver();
}, 100);
});
eventTarget.addEventListener("app-toggled", (event) => {
if (event.detail.state === true) {
showState = true;
createAuditsUI();
} else {
showState = false;
}
});
closeOnOutsideClick(eventTarget, () => {
const activeAudits = audits.filter((audit) => audit.card?.hasAttribute("active"));
if (activeAudits.length > 0) {
activeAudits.forEach((audit) => {
audit.card?.toggleAttribute("active", false);
});
return true;
}
return false;
});
async function createAuditsUI() {
if (hasCreatedUI) return;
const fragment = document.createDocumentFragment();
for (const audit of audits) {
const { card, highlight } = createAuditUI(audit, audits);
audit.card = card;
audit.highlight = highlight;
fragment.appendChild(highlight);
}
auditWindow.audits = audits;
canvas.appendChild(fragment);
hasCreatedUI = true;
}
async function run() {
processAnnotations();
await lint();
}
async function lint() {
// Clear the previous audits
if (audits.length > 0) {
audits.forEach((audit) => {
audit.highlight?.remove();
audit.card?.remove();
});
audits = [];
hasCreatedUI = false;
}
const selectorCache = new Map();
for (const ruleCategory of rulesCategories) {
for (const rule of ruleCategory.rules) {
const elements = selectorCache.get(rule.selector) ?? document.querySelectorAll(rule.selector);
let matches = [];
if (typeof rule.match === "undefined") {
matches = Array.from(elements);
} else {
for (const element of elements) {
try {
if (await rule.match(element)) {
matches.push(element);
}
} catch (e) {
settings.logger.error(`Error while running audit's match function: ${e}`);
}
}
}
for (const element of matches) {
// Don't audit elements that already have an audit on them
// TODO: This is a naive implementation, it'd be good to show all the audits for an element at the same time.
if (audits.some((audit) => audit.auditedElement === element)) continue;
await createAuditProblem(rule, element);
}
}
}
eventTarget.dispatchEvent(new CustomEvent("toggle-notification", { detail: { state: audits.length > 0 } }));
}
async function createAuditProblem(rule, originalElement) {
const computedStyle = window.getComputedStyle(originalElement);
const targetedElement = originalElement.children[0] || originalElement;
// If the element is hidden, don't do anything
if (targetedElement.offsetParent === null || computedStyle.display === "none") {
return;
}
// If the element is an image that hasn't loaded yet, wait for it so that
// position-based checks (e.g. above/below the fold) have accurate layout data.
if (originalElement.nodeName === "IMG" && !originalElement.complete) {
await new Promise((resolve) => {
originalElement.addEventListener("load", () => resolve(), { once: true });
originalElement.addEventListener("error", () => resolve(), { once: true });
});
}
audits.push({
auditedElement: originalElement,
rule,
card: null,
highlight: null
});
}
function refreshLintPositions() {
audits.forEach(({ highlight, auditedElement }) => {
const rect = auditedElement.getBoundingClientRect();
if (highlight) positionHighlight(highlight, rect);
});
}
["scroll", "resize"].forEach((event) => {
window.addEventListener(event, refreshLintPositions);
});
function setupObserver() {
observer.observe(document.body, {
childList: true,
subtree: true
});
}
}
};