export { shouldBlockRender } from "./shouldBlockRender.js"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, extname, join, relative, resolve } from "node:path"; import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; import { parseHTML } from "linkedom"; import { cleanAssetUrl, isRemoteOrInlineUrl, isUnresolvedAssetPlaceholder, isWithinProjectRoot, maskNonScannableRanges, resolveExistingLocalAsset, resolveLocalAssetCandidates, } from "@hyperframes/parsers/asset-resolution"; import { collectLocalVideoCandidates, lintHevcPreviewCodec } from "./hevcPreviewLint.js"; 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; contentHash: string }>; totalErrors: number; totalWarnings: number; totalInfos: number; } /** * Short content digest of a linted file. Callers use it to tell "the author * edited this file and the finding survived" (an iteration that did not * converge) from "the same file was linted twice" (no attempt was made). * Truncated because it is only ever compared against the previous run's digest * for the same file, never used as a security boundary. */ function contentDigest(html: string): string { return createHash("sha256").update(html).digest("hex").slice(0, 16); } 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 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, (path) => existsSync(join(projectDir, path))), ); } 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: ProjectLintResult["results"] = []; 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, contentHash: contentDigest(rootHtml) }); 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, contentHash: contentDigest(html), }); 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), ...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))), ]; 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 (isUnresolvedAssetPlaceholder(src)) continue; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path))) : 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: `