import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join, resolve, extname } from "node:path"; import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import { 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; } 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"]); 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 resolved = resolve(projectDir, rootRelative); if (!existsSync(resolved)) continue; styles.push({ href, content: readFileSync(resolved, "utf-8") }); } return styles; } /** * 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), ...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 *