import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types"; import { buildLintContext } from "./context"; import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils"; import { coreRules } from "./rules/core"; import { mediaRules } from "./rules/media"; import { gsapRules } from "./rules/gsap"; import { captionRules } from "./rules/captions"; import { compositionRules } from "./rules/composition"; import { adapterRules } from "./rules/adapters"; import { textureRules } from "./rules/textures"; import { fontRules } from "./rules/fonts"; import { slideshowRules } from "./rules/slideshow"; const ALL_RULES = [ ...coreRules, ...mediaRules, ...gsapRules, ...captionRules, ...compositionRules, ...adapterRules, ...textureRules, ...fontRules, ...slideshowRules, ]; export async function lintHyperframeHtml( html: string, options: HyperframeLinterOptions = {}, ): Promise { const ctx = buildLintContext(html, options); const findings: HyperframeLintFinding[] = []; const seen = new Set(); for (const rule of ALL_RULES) { for (const finding of await Promise.resolve(rule(ctx))) { const dedupeKey = [ finding.code, finding.severity, finding.selector || "", finding.elementId || "", finding.message, ].join("|"); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); findings.push(options.filePath ? { ...finding, file: options.filePath } : finding); } } const errorCount = findings.filter((f) => f.severity === "error").length; const warningCount = findings.filter((f) => f.severity === "warning").length; const infoCount = findings.filter((f) => f.severity === "info").length; return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings, }; } // ── Async media URL accessibility checker ───────────────────────────────── function extractMediaUrls(html: string): Array<{ url: string; tagName: string; elementId?: string; snippet: string; }> { const results: Array<{ url: string; tagName: string; elementId?: string; snippet: string; }> = []; for (const { name: tagName, raw } of parseHtmlStructure(html).tags) { if (!/^(?:video|audio|img|source)$/.test(tagName)) continue; const src = readAttr(raw, "src"); if (!src) continue; if (/^https?:\/\//i.test(src)) { results.push({ url: src, tagName, elementId: readAttr(raw, "id") || undefined, snippet: truncateSnippet(raw) ?? "", }); } } return results; } /** * Async lint pass: HEAD-checks every remote media URL in the HTML. * Returns findings for URLs that are unreachable (non-2xx status or network error). * * Call this after `lintHyperframeHtml()` and merge the findings. * * @param timeoutMs - per-request timeout (default 8000ms) */ export async function lintMediaUrls( html: string, options: { timeoutMs?: number } = {}, ): Promise { const urls = extractMediaUrls(html); if (urls.length === 0) return []; const timeout = options.timeoutMs ?? 8000; const findings: HyperframeLintFinding[] = []; const seen = new Set(); const unique = urls.filter((u) => { if (seen.has(u.url)) return false; seen.add(u.url); return true; }); const checks = unique.map(async ({ url, tagName, elementId, snippet }) => { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const resp = await fetch(url, { method: "HEAD", signal: controller.signal, redirect: "follow", }); clearTimeout(timer); if (!resp.ok) { findings.push({ code: "inaccessible_media_url", severity: "error", message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`, elementId, fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.", snippet, }); } } catch (err) { const reason = err instanceof Error ? err.name : "unknown"; findings.push({ code: "inaccessible_media_url", severity: "error", message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`, elementId, fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.", snippet, }); } }); await Promise.all(checks); return findings; }