fix(cli): resolve sub-composition <audio> src relative to its own file (#489)

`lintAudioSrcNotFound` resolves every `<audio src>` against the project
root, which is correct for index.html but wrong for sub-compositions —
their srcs are written relative to the sub-composition file (e.g.
`../assets/foo.mp3`), and the bundler rewrites them at compile time.

Carry the sub-composition path alongside each html source and run
`<audio src>` strings starting with `../` through the existing
`rewriteAssetPath` helper before checking existence on disk. Mirrors the
runtime/bundler behaviour so the lint check sees the same path the
renderer will fetch.

Original (un-rewritten) src is still surfaced in the finding message so
authors can grep for it in their HTML.
This commit is contained in:
Mu-Tsun Tsai
2026-04-25 16:55:28 +02:00
committed by GitHub
parent 82fab98e69
commit 1782cebfc4
2 changed files with 77 additions and 9 deletions
@@ -324,6 +324,49 @@ describe("audio_src_not_found", () => {
const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length; const occurrences = (finding?.message.match(/song\.mp3/g) ?? []).length;
expect(occurrences).toBe(1); 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", () => { describe("multiple_root_compositions", () => {
+34 -9
View File
@@ -2,8 +2,22 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve, extname } from "node:path"; import { join, resolve, extname } from "node:path";
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint";
import { rewriteAssetPath } from "@hyperframes/core";
import type { ProjectDir } from "./project.js"; 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 { export interface ProjectLintResult {
results: Array<{ file: string; result: HyperframeLintResult }>; results: Array<{ file: string; result: HyperframeLintResult }>;
totalErrors: number; totalErrors: number;
@@ -32,14 +46,14 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
totalInfos += rootResult.infoCount; totalInfos += rootResult.infoCount;
// Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks // 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"); const compositionsDir = resolve(project.dir, "compositions");
if (existsSync(compositionsDir)) { if (existsSync(compositionsDir)) {
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html")); const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
for (const file of files) { for (const file of files) {
const filePath = join(compositionsDir, file); const filePath = join(compositionsDir, file);
const html = readFileSync(filePath, "utf-8"); const html = readFileSync(filePath, "utf-8");
allHtmlSources.push(html); allHtmlSources.push({ html, compSrcPath: `compositions/${file}` });
const result = lintHyperframeHtml(html, { filePath, isSubComposition: true }); const result = lintHyperframeHtml(html, { filePath, isSubComposition: true });
results.push({ file: `compositions/${file}`, result }); results.push({ file: `compositions/${file}`, result });
totalErrors += result.errorCount; totalErrors += result.errorCount;
@@ -83,7 +97,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
* placing an audio file in the project but forgetting the <audio> tag, which * placing an audio file in the project but forgetting the <audio> tag, which
* results in a silent render. * results in a silent render.
*/ */
function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): HyperframeLintFinding[] { function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = []; const findings: HyperframeLintFinding[] = [];
// Scan project root for audio files (non-recursive — only top-level) // Scan project root for audio files (non-recursive — only top-level)
@@ -99,7 +116,7 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
if (audioFiles.length === 0) return findings; if (audioFiles.length === 0) return findings;
// Check if any HTML source contains an <audio> element // 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) { if (!hasAudioElement) {
findings.push({ findings.push({
@@ -121,19 +138,27 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper
* in the project directory. The renderer will silently skip missing audio, * in the project directory. The renderer will silently skip missing audio,
* producing a silent video with no indication of what went wrong. * 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 findings: HyperframeLintFinding[] = [];
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const missingSrcs: string[] = []; const missingSrcs: string[] = [];
for (const html of htmlSources) { for (const { html, compSrcPath } of htmlSources) {
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = audioSrcRe.exec(html)) !== null) { while ((match = audioSrcRe.exec(html)) !== null) {
const src = match[1]!; const src = match[1]!;
if (/^(https?:|data:|blob:)/i.test(src)) continue; if (/^(https?:|data:|blob:)/i.test(src)) continue;
if (/^__[A-Z_]+__$/.test(src)) continue; // Skip template placeholders 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)) { if (!existsSync(resolved)) {
missingSrcs.push(src); missingSrcs.push(src);
} }
@@ -192,7 +217,7 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding
* Extracts each attribute independently (order-insensitive) to handle any HTML attribute order. * 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. * 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[] = []; const findings: HyperframeLintFinding[] = [];
function extractAttr(tag: string, name: string): string | null { function extractAttr(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i"); const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
@@ -203,7 +228,7 @@ function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = []; const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
const seen = new Set<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 // Regex with g flag must be created inside the loop — a shared g-regex
// carries lastIndex across strings, silently skipping matches. // carries lastIndex across strings, silently skipping matches.
const audioTagRe = /<audio\b[^>]*>/gi; const audioTagRe = /<audio\b[^>]*>/gi;