Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/cli/src/utils/lintProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,49 @@ describe("audio_src_not_found", () => {
const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length;
expect(occurrences).toBe(1);
});

it("resolves sub-composition src relative to the sub-composition file (../assets/...)", () => {
// A sub-composition at compositions/captions.html referencing
// ../assets/bgm.mp3 means {projectRoot}/assets/bgm.mp3 — the bundler
// rewrites that path before serving, so the lint check has to mirror it.
const subComp = `<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<audio id="music" src="../assets/bgm.mp3" data-start="0" data-track-index="0" data-volume="1"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(validHtml(), { "captions.html": subComp });
mkdirSync(join(project.dir, "assets"), { recursive: true });
writeFileSync(join(project.dir, "assets", "bgm.mp3"), "fake");

const { results } = lintProject(project);

const first = results[0];
expect(first).toBeDefined();
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
expect(finding).toBeUndefined();
});

it("flags sub-composition src that resolves to a missing file via ../", () => {
const subComp = `<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<audio id="music" src="../assets/missing.mp3" data-start="0" data-track-index="0" data-volume="1"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(validHtml(), { "captions.html": subComp });
// No assets/ directory at all.

const { results } = lintProject(project);

const first = results[0];
expect(first).toBeDefined();
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
expect(finding).toBeDefined();
// The original (un-rewritten) src is what surfaces in the message so the
// author can grep for it in their HTML.
expect(finding?.message).toContain("../assets/missing.mp3");
});
});

describe("multiple_root_compositions", () => {
Expand Down
43 changes: 34 additions & 9 deletions packages/cli/src/utils/lintProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { 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;
Expand Down Expand Up @@ -32,14 +46,14 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
totalInfos += rootResult.infoCount;

// Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks
const allHtmlSources = [rootHtml];
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");
allHtmlSources.push(html);
allHtmlSources.push({ html, compSrcPath: `compositions/${file}` });
const result = lintHyperframeHtml(html, { filePath, isSubComposition: true });
results.push({ file: `compositions/${file}`, result });
totalErrors += result.errorCount;
Expand Down Expand Up @@ -83,7 +97,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
* placing an audio file in the project but forgetting the <audio> tag, which
* results in a silent render.
*/
function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];

// Scan project root for audio files (non-recursive — only top-level)
Expand All @@ -99,7 +116,7 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
if (audioFiles.length === 0) return findings;

// Check if any HTML source contains an <audio> element
const hasAudioElement = htmlSources.some((html) => /<audio\b/i.test(html));
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));

if (!hasAudioElement) {
findings.push({
Expand All @@ -121,19 +138,27 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
* in the project directory. The renderer will silently skip missing audio,
* producing a silent video with no indication of what went wrong.
*/
function lintAudioSrcNotFound(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] {
function lintAudioSrcNotFound(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];

const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;

const missingSrcs: string[] = [];
for (const html of htmlSources) {
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; // Skip template placeholders
const resolved = resolve(projectDir, src);
// Sub-composition srcs are written relative to the sub-composition file
// (e.g. "../assets/foo.mp3"); the bundler rewrites them to root-relative
// before serving. Mirror that rewrite here so the existence check sees
// the same path the renderer will. Root-html srcs pass through unchanged.
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const resolved = resolve(projectDir, rootRelative);
if (!existsSync(resolved)) {
missingSrcs.push(src);
}
Expand Down Expand Up @@ -192,7 +217,7 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding
* Extracts each attribute independently (order-insensitive) to handle any HTML attribute order.
* Deduplicates by (src, start, duration) to avoid flagging the same audio reached via sub-compositions.
*/
function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[] {
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
function extractAttr(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
Expand All @@ -203,7 +228,7 @@ function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
const seen = new Set<string>();

for (const html of htmlSources) {
for (const { html } of htmlSources) {
// Regex with g flag must be created inside the loop — a shared g-regex
// carries lastIndex across strings, silently skipping matches.
const audioTagRe = /<audio\b[^>]*>/gi;
Expand Down
Loading