import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core"; import type { ProjectDir } from "./project.js"; /** * An HTML source paired with the sub-composition path it came from, if any. * Sub-composition relative paths (`../assets/foo.mp3`) need to be resolved * against the sub-composition's directory before checking the filesystem — * the root index.html is the only source where a bare `resolve(projectDir, src)` * is correct. */ interface HtmlSource { html: string; /** `data-composition-src` value (e.g. "compositions/scene.html"); undefined for the root. */ compSrcPath?: string; } interface CssSource { content: string; /** Root-relative path to the CSS file. Undefined means inline HTML CSS. */ rootRelativePath?: string; } export interface ProjectLintResult { results: Array<{ file: string; result: HyperframeLintResult }>; totalErrors: number; totalWarnings: number; totalInfos: number; } const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]); const STYLE_BLOCK_RE = /]*>([\s\S]*?)<\/style>/gi; const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi; const MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; function readHtmlAttr(tag: string, name: string): string | null { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); return match?.[1] ?? match?.[2] ?? null; } function isLocalStylesheetHref(href: string): boolean { return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href); } function collectExternalStyles( projectDir: string, html: string, compSrcPath?: string, ): Array<{ href: string; content: string }> { const styles: Array<{ href: string; content: string }> = []; const linkRe = /]*>/gi; let match: RegExpExecArray | null; while ((match = linkRe.exec(html)) !== null) { const tag = match[0]; const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; if (!isLocalStylesheetHref(href)) continue; const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href; const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative); if (!stylesheet) continue; styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") }); } return styles; } function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { const sources: CssSource[] = []; let styleMatch: RegExpExecArray | null; const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags); while ((styleMatch = stylePattern.exec(html)) !== null) { sources.push({ content: styleMatch[1] ?? "" }); } const linkRe = /]*>/gi; let linkMatch: RegExpExecArray | null; while ((linkMatch = linkRe.exec(html)) !== null) { const tag = linkMatch[0]; const rel = readHtmlAttr(tag, "rel") ?? ""; if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; const href = readHtmlAttr(tag, "href") ?? ""; if (!isLocalStylesheetHref(href)) continue; const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href; const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath); if (!stylesheet) continue; sources.push({ content: readFileSync(stylesheet.resolved, "utf-8"), rootRelativePath: stylesheet.rootRelativePath, }); } let tagMatch: RegExpExecArray | null; const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags); while ((tagMatch = tagPattern.exec(html)) !== null) { const tag = tagMatch[0]; const style = readHtmlAttr(tag, "style"); if (!style) continue; sources.push({ content: style }); } return sources; } function isRemoteOrInlineUrl(url: string): boolean { return /^(https?:|data:|blob:|\/\/|#)/i.test(url); } function cleanAssetUrl(url: string): string { return url.trim().split(/[?#]/, 1)[0] ?? ""; } function isWithinProjectRoot(projectDir: string, candidate: string): boolean { const projectRoot = resolve(projectDir); const relativePath = relative(projectRoot, candidate); return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); } function addCandidate(candidates: string[], candidate: string): void { if (!candidates.includes(candidate)) candidates.push(candidate); } function resolveLocalAssetCandidates(projectDir: string, url: string): string[] { const cleanUrl = cleanAssetUrl(url); const projectRoot = resolve(projectDir); const candidates: string[] = []; for (const variant of decodeUrlPathVariants(cleanUrl)) { const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant; const resolved = resolve(projectRoot, projectRelative); if (isWithinProjectRoot(projectRoot, resolved)) { addCandidate(candidates, resolved); continue; } const normalized = posix.normalize(projectRelative.replace(/\\/g, "/")); const clamped = normalized.replace(/^(\.\.\/)+/, ""); if (clamped && !clamped.startsWith("..")) { addCandidate(candidates, resolve(projectRoot, clamped)); } } return candidates; } function resolveExistingLocalAsset( projectDir: string, url: string, ): { resolved: string; rootRelativePath: string } | null { const projectRoot = resolve(projectDir); const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync); if (!resolved) return null; return { resolved, rootRelativePath: relative(projectRoot, resolved) }; } function resolveCssAssetCandidates( projectDir: string, url: string, htmlCompSrcPath?: string, cssRootRelativePath?: string, ): string[] { if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url); if (cssRootRelativePath) { return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url)); } if (htmlCompSrcPath) { return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url)); } return resolveLocalAssetCandidates(projectDir, url); } /** * Lint the root index.html and all sub-compositions in the compositions/ directory. * Returns aggregated results across all files. */ export function lintProject(project: ProjectDir): ProjectLintResult { const results: Array<{ file: string; result: HyperframeLintResult }> = []; let totalErrors = 0; let totalWarnings = 0; let totalInfos = 0; // Lint root composition const rootHtml = readFileSync(project.indexPath, "utf-8"); const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath, externalStyles: collectExternalStyles(project.dir, rootHtml), }); results.push({ file: "index.html", result: rootResult }); totalErrors += rootResult.errorCount; totalWarnings += rootResult.warningCount; totalInfos += rootResult.infoCount; // Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks const allHtmlSources: HtmlSource[] = [{ html: rootHtml }]; const compositionsDir = resolve(project.dir, "compositions"); if (existsSync(compositionsDir)) { const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html")); for (const file of files) { const filePath = join(compositionsDir, file); const html = readFileSync(filePath, "utf-8"); const compSrcPath = `compositions/${file}`; allHtmlSources.push({ html, compSrcPath }); const result = lintHyperframeHtml(html, { filePath, isSubComposition: true, externalStyles: collectExternalStyles(project.dir, html, compSrcPath), }); results.push({ file: `compositions/${file}`, result }); totalErrors += result.errorCount; totalWarnings += result.warningCount; totalInfos += result.infoCount; } } // ── Project-level checks ────────────────────────────────────────────── const projectFindings = [ ...lintProjectAudioFiles(project.dir, allHtmlSources), ...lintAudioSrcNotFound(project.dir, allHtmlSources), ...lintTextureMaskAssetNotFound(project.dir, allHtmlSources), ...lintMultipleRootCompositions(project.dir), ...lintDuplicateAudioTracks(allHtmlSources), ]; if (projectFindings.length > 0) { // Append project-level findings to the root index.html result for (const finding of projectFindings) { rootResult.findings.push(finding); if (finding.severity === "error") { rootResult.errorCount++; rootResult.ok = false; totalErrors++; } else if (finding.severity === "warning") { rootResult.warningCount++; totalWarnings++; } else { rootResult.infoCount++; totalInfos++; } } } return { results, totalErrors, totalWarnings, totalInfos }; } /** * Check for audio files in the project directory that have no corresponding *