export { shouldBlockRender } from "./shouldBlockRender.js"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; import { decodeUrlPathVariants } from "@hyperframes/parsers/composition"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; import { parseHTML } from "linkedom"; import { lintHyperframeHtml } from "./hyperframeLinter.js"; import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js"; import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity"; /** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */ function parseSubCompHtml(html: string): ParsableDocumentLike { return parseHTML(html).document as unknown as ParsableDocumentLike; } interface HtmlSource { html: string; compSrcPath?: string; } interface CssSource { content: string; rootRelativePath?: string; } /** Linkedom keeps template contents in a DocumentFragment that is not part of * the document query tree. Lint rules must still see shell styles and links * inside templates, so walk each template's content recursively without * falling back to regex parsing. */ function querySelectorAllIncludingTemplates(root: ParentNode, selector: string): Element[] { const matches: Element[] = [...root.querySelectorAll(selector)]; for (const template of root.querySelectorAll("template")) { const content = (template as HTMLTemplateElement).content; if (content) matches.push(...querySelectorAllIncludingTemplates(content, selector)); } return matches; } 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 MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; function isLocalStylesheetHref(href: string): boolean { return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href); } function collectLocalStylesheets( projectDir: string, document: ParentNode, compSrcPath?: string, ): Array<{ href: string; content: string; rootRelativePath: string }> { const styles: Array<{ href: string; content: string; rootRelativePath: string }> = []; for (const link of querySelectorAllIncludingTemplates(document, "link")) { const rel = link.getAttribute("rel") ?? ""; if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; const href = link.getAttribute("href") ?? ""; 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"), rootRelativePath: stylesheet.rootRelativePath, }); } return styles; } function collectExternalStyles( projectDir: string, html: string, compSrcPath?: string, ): Array<{ href: string; content: string }> { const styles: Array<{ href: string; content: string }> = []; const { document } = parseHTML(html); for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) { styles.push({ href, content }); } return styles; } function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { const sources: CssSource[] = []; const { document } = parseHTML(html); for (const style of querySelectorAllIncludingTemplates(document, "style")) { sources.push({ content: style.textContent ?? "" }); } for (const { content, rootRelativePath } of collectLocalStylesheets( projectDir, document, compSrcPath, )) { sources.push({ content, rootRelativePath }); } for (const element of querySelectorAllIncludingTemplates(document, "[style]")) { const style = element.getAttribute("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); } export async function lintProject( projectDir: string, entryFile?: string, ): Promise { const indexPath = entryFile ? resolve(entryFile) : resolve(projectDir, "index.html"); if (entryFile && !isWithinProjectRoot(projectDir, indexPath)) { throw new Error(`Explicit lint entry is outside the project directory: ${entryFile}`); } const rootFile = relative(resolve(projectDir), indexPath).replace(/\\/g, "/") || "index.html"; const rootCompSrcPath = rootFile === "index.html" ? undefined : rootFile; const results: Array<{ file: string; result: HyperframeLintResult }> = []; let totalErrors = 0; let totalWarnings = 0; let totalInfos = 0; const rootHtml = readFileSync(indexPath, "utf-8"); const rootResult = await lintHyperframeHtml(rootHtml, { filePath: indexPath, externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath), }); results.push({ file: rootFile, result: rootResult }); totalErrors += rootResult.errorCount; totalWarnings += rootResult.warningCount; totalInfos += rootResult.infoCount; const allHtmlSources: HtmlSource[] = [{ html: rootHtml, compSrcPath: rootCompSrcPath }]; const compositionsDir = resolve(projectDir, "compositions"); if (!entryFile && existsSync(compositionsDir)) { const collectHtmlFiles = (dir: string, rel: string): string[] => { const out: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const relPath = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { // Registry components are source snippets, not independently mounted // sub-compositions. Linting every installed template here makes an // unused component fail the assembled project check. if (!rel && entry.name === "components") continue; out.push(...collectHtmlFiles(join(dir, entry.name), relPath)); } else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) { out.push(relPath); } } return out; }; const files = collectHtmlFiles(compositionsDir, "").sort(); for (const file of files) { const filePath = join(compositionsDir, file); const html = readFileSync(filePath, "utf-8"); const compSrcPath = `compositions/${file}`; allHtmlSources.push({ html, compSrcPath }); // Mountable fragments (figma component imports, registry snippets) are // not standalone compositions — composition-root rules don't apply. // Anchored to the file's ROOT element so a real composition that merely // inlines snippet markup (or mentions the token in text) is still linted. if (isSnippetFragment(html)) continue; const result = await lintHyperframeHtml(html, { filePath, isSubComposition: true, externalStyles: collectExternalStyles(projectDir, html, compSrcPath), }); results.push({ file: `compositions/${file}`, result }); totalErrors += result.errorCount; totalWarnings += result.warningCount; totalInfos += result.infoCount; } } const projectFindings = [ ...lintProjectAudioFiles(projectDir, allHtmlSources), ...lintAudioSrcNotFound(projectDir, allHtmlSources), ...lintMissingLocalAsset(projectDir, allHtmlSources), ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources), ...(!entryFile ? lintMultipleRootCompositions(projectDir) : []), ...lintDuplicateAudioTracks(allHtmlSources), ...lintMissingOrEmptySubComposition(projectDir, rootHtml), ]; if (projectFindings.length > 0) { 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 }; } function lintProjectAudioFiles( projectDir: string, htmlSources: HtmlSource[], ): HyperframeLintFinding[] { const findings: HyperframeLintFinding[] = []; let audioFiles: string[]; try { audioFiles = readdirSync(projectDir).filter((f) => AUDIO_EXTENSIONS.has(extname(f).toLowerCase()), ); } catch { return findings; } if (audioFiles.length === 0) return findings; const hasAudioElement = htmlSources.some(({ html }) => / element in any composition. The rendered video will be silent.`, fixHint: 'Add an element inside the composition root. Replace __DURATION__ with the audio length in seconds.', }); } return findings; } function lintAudioSrcNotFound( projectDir: string, htmlSources: HtmlSource[], ): HyperframeLintFinding[] { const findings: HyperframeLintFinding[] = []; const audioSrcRe = /]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; const missingSrcs: string[] = []; for (const { html, compSrcPath } of htmlSources) { let match: RegExpExecArray | null; while ((match = audioSrcRe.exec(html)) !== null) { const src = match[1]!; if (/^(https?:|data:|blob:)/i.test(src)) continue; if (/^__[A-Z_]+__$/.test(src)) continue; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) { missingSrcs.push(src); } } } if (missingSrcs.length > 0) { const unique = [...new Set(missingSrcs)]; findings.push({ code: "audio_src_not_found", severity: "error", message: `