fix(cli): reject blank default composition entries (#3392)

* fix(cli): reject blank default composition entry

* fix(cli): complete blank entry safeguards
This commit is contained in:
Miguel Ángel
2026-08-21 11:00:43 -04:00
committed by GitHub
parent 1b86b56127
commit a9ea07edde
11 changed files with 412 additions and 26 deletions
+46 -2
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { parseUpdateTarget } from "./publish.js"; const publishState = vi.hoisted(() => ({ publish: vi.fn() }));
vi.mock("../utils/publishProject.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/publishProject.js")>()),
publishProjectArchive: publishState.publish,
}));
import publishCommand, { parseUpdateTarget } from "./publish.js";
describe("parseUpdateTarget", () => { describe("parseUpdateTarget", () => {
it("extracts the id from a full published URL", () => { it("extracts the id from a full published URL", () => {
@@ -25,3 +35,37 @@ describe("parseUpdateTarget", () => {
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123"); expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
}); });
}); });
describe("publish default-entry preflight", () => {
it("rejects the real fixture before creating or uploading an archive", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-publish-entry-mismatch-"));
const compositions = join(project, "compositions");
mkdirSync(compositions);
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
);
writeFileSync(
join(compositions, "index.html"),
`<html><body><div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
publishState.publish.mockReset();
publishState.publish.mockResolvedValue({
title: "test",
fileCount: 2,
claimed: true,
projectId: "project-id",
url: "https://hyperframes.dev/p/project-id",
claimToken: "",
});
try {
await expect(
publishCommand.run?.({ args: { dir: project, yes: true, proxy: false } } as never),
).rejects.toMatchObject({ name: "CliRuntimeError" });
expect(publishState.publish).not.toHaveBeenCalled();
} finally {
rmSync(project, { recursive: true, force: true });
}
});
});
+7 -2
View File
@@ -1,12 +1,12 @@
import { join, relative, resolve } from "node:path"; import { join, relative, resolve } from "node:path";
import { setCommandExitCode } from "../utils/commandResult.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import * as clack from "@clack/prompts"; import * as clack from "@clack/prompts";
import type { Example } from "./_examples.js"; import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { lintProject } from "../utils/lintProject.js"; import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js"; import { formatLintFindings } from "../utils/lintFormat.js";
import { import {
buildPublishFileMap, buildPublishFileMap,
@@ -92,6 +92,11 @@ export default defineCommand({
for (const line of formatLintFindings(lintResult)) console.log(line); for (const line of formatLintFindings(lintResult)) console.log(line);
console.log(); console.log();
} }
if (hasDefinitiveEntryMismatch(lintResult)) {
console.log(c.error(" Aborting publish because the default index.html entry is blank."));
console.log();
failCommand();
}
} }
if (args.yes !== true) { if (args.yes !== true) {
+42
View File
@@ -222,6 +222,7 @@ describe("renderLocal browser GPU config", () => {
renderLocal, renderLocal,
resolveBrowserGpuForCli, resolveBrowserGpuForCli,
renderLintContinuationHint, renderLintContinuationHint,
runRenderLint,
__resetDeParallelRouterTrialStateForTests: resetTrialState, __resetDeParallelRouterTrialStateForTests: resetTrialState,
} = renderModule; } = renderModule;
@@ -234,6 +235,47 @@ describe("renderLocal browser GPU config", () => {
expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors"); expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors");
}); });
it("aborts the real render lint preflight on a default-entry mismatch without --strict", async () => {
const lintResult = {
results: [
{
file: "index.html",
contentHash: "abc",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "blank_root_with_standalone_composition",
severity: "error" as const,
message: "wrong entry",
},
],
},
},
],
totalErrors: 1,
totalWarnings: 0,
totalInfos: 0,
};
await expect(
runRenderLint(
{
project: { dir: "/tmp/project" },
entryFile: undefined,
renderTarget: "/tmp/project/index.html",
strictErrors: false,
strictAll: false,
effectiveQuiet: true,
} as never,
async () => lintResult,
),
).rejects.toMatchObject({ name: "CliRuntimeError" });
});
function setEnv(key: string, value: string) { function setEnv(key: string, value: string) {
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
process.env[key] = value; process.env[key] = value;
+2 -2
View File
@@ -5,9 +5,9 @@ import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js"; import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js"; import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { presentRenderPlan } from "./render/present.js"; import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js"; import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests. // Test-only seams retained at the command boundary for render behavior tests.
export { resolveBrowserGpuForCli, renderLintContinuationHint }; export { resolveBrowserGpuForCli, renderLintContinuationHint, runRenderLint };
export const examples: Example[] = [ export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"], ["Render to MP4", "hyperframes render --output output.mp4"],
+33 -15
View File
@@ -3,7 +3,12 @@ import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/c
import { c } from "../../ui/colors.js"; import { c } from "../../ui/colors.js";
import { errorBox, formatBytes } from "../../ui/format.js"; import { errorBox, formatBytes } from "../../ui/format.js";
import { formatLintFindings } from "../../utils/lintFormat.js"; import { formatLintFindings } from "../../utils/lintFormat.js";
import { lintProject, shouldBlockRender } from "../../utils/lintProject.js"; import {
hasDefinitiveEntryMismatch,
lintProject,
shouldBlockRender,
type ProjectLintResult,
} from "../../utils/lintProject.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js"; import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js"; import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
import { import {
@@ -40,6 +45,17 @@ export function renderLintContinuationHint(strictErrors: boolean): string {
: " Continuing render despite lint issues. Use --strict to block errors."; : " Continuing render despite lint issues. Use --strict to block errors.";
} }
function renderLintShouldAbort(
strictErrors: boolean,
strictAll: boolean,
lintResult: ProjectLintResult,
): boolean {
return (
hasDefinitiveEntryMismatch(lintResult) ||
shouldBlockRender(strictErrors, strictAll, lintResult.totalErrors, lintResult.totalWarnings)
);
}
/** Execute a validated plan. Output and process lifecycle stay outside parsing. */ /** Execute a validated plan. Output and process lifecycle stay outside parsing. */
export async function executeRenderPlan( export async function executeRenderPlan(
plan: RenderPlan, plan: RenderPlan,
@@ -158,22 +174,19 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async function runRenderLint(plan: RenderPlan): Promise<void> { export async function runRenderLint(
plan: RenderPlan,
runLint: (projectDir: string, entryFile?: string) => Promise<ProjectLintResult> = lintProject,
): Promise<void> {
// lintProject's explicit-entry contract is an absolute source path; // lintProject's explicit-entry contract is an absolute source path;
// entryFile remains project-relative for the producer. // entryFile remains project-relative for the producer.
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined; const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
const lintResult = await lintProject(plan.project.dir, explicitEntry); const lintResult = await runLint(plan.project.dir, explicitEntry);
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return; if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
presentRenderLintFindings(lintResult, plan.effectiveQuiet); presentRenderLintFindings(lintResult, plan.effectiveQuiet);
if ( const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult);
shouldBlockRender( if (renderLintShouldAbort(plan.strictErrors, plan.strictAll, lintResult)) {
plan.strictErrors, presentRenderLintAbort(plan, definitiveEntryMismatch);
plan.strictAll,
lintResult.totalErrors,
lintResult.totalWarnings,
)
) {
presentRenderLintAbort(plan);
failCommand(); failCommand();
} }
presentRenderLintContinuation(plan); presentRenderLintContinuation(plan);
@@ -188,11 +201,16 @@ function presentRenderLintFindings(
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
} }
function presentRenderLintAbort(plan: RenderPlan): void { function presentRenderLintAbort(plan: RenderPlan, definitiveEntryMismatch: boolean): void {
if (plan.effectiveQuiet) return; if (plan.effectiveQuiet) return;
const mode = plan.strictAll ? "--strict-all" : "--strict";
console.log(""); console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`)); console.log(
c.error(
definitiveEntryMismatch
? " Aborting render because the default index.html entry is blank."
: ` Aborting render due to lint issues (${plan.strictAll ? "--strict-all" : "--strict"} mode).`,
),
);
console.log(""); console.log("");
} }
+60 -3
View File
@@ -1,6 +1,28 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { import { join } from "node:path";
import { tmpdir } from "node:os";
const snapshotState = vi.hoisted(() => ({
openSettledPage: vi.fn(async () => {
throw new Error("browser capture reached");
}),
closeServer: vi.fn(async () => undefined),
}));
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
openSettledCompositionPage: snapshotState.openSettledPage,
}));
vi.mock("../utils/staticProjectServer.js", () => ({
serveStaticProjectHtml: vi.fn(async () => ({
url: "http://127.0.0.1:1",
close: snapshotState.closeServer,
})),
}));
import snapshotCommand, {
computeSnapshotTimes, computeSnapshotTimes,
formatSnapshotTimestamp, formatSnapshotTimestamp,
parseZoomScale, parseZoomScale,
@@ -62,6 +84,41 @@ describe("transparent snapshot capture", () => {
}); });
}); });
describe("snapshot lint preflight", () => {
it("rejects the real fixture before invoking browser capture", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-entry-mismatch-"));
const compositions = join(project, "compositions");
mkdirSync(compositions);
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
);
writeFileSync(
join(compositions, "index.html"),
`<html><body><div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
snapshotState.openSettledPage.mockClear();
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
lines.push(parts.map(String).join(" "));
});
try {
await expect(
snapshotCommand.run?.({ args: { dir: project } } as never),
).rejects.toMatchObject({
name: "CliRuntimeError",
});
expect(snapshotState.openSettledPage).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("hyperframes snapshot");
expect(lines.join("\n")).toContain("compositions");
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
});
});
describe("resolveSnapshotVideoFrameTime", () => { describe("resolveSnapshotVideoFrameTime", () => {
it("keeps media active at the inclusive clip end and samples its last decodable frame", () => { it("keeps media active at the inclusive clip end and samples its last decodable frame", () => {
expect( expect(
+18
View File
@@ -15,6 +15,8 @@ import {
type ZoomTarget, type ZoomTarget,
} from "../capture/captureCompositionFrame.js"; } from "../capture/captureCompositionFrame.js";
import { resolveProject } from "../utils/project.js"; import { resolveProject } from "../utils/project.js";
import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
@@ -650,6 +652,22 @@ export default defineCommand({
}, },
async run({ args }) { async run({ args }) {
const project = resolveProject(args.dir); const project = resolveProject(args.dir);
const lintResult = await lintProject(project.dir);
if (hasDefinitiveEntryMismatch(lintResult)) {
console.log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) {
console.log(line);
}
console.log("");
console.log(c.error(" Aborting snapshot because the default index.html entry is blank."));
console.log(
c.dim(
" Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot <project>/compositions",
),
);
console.log("");
failCommand();
}
const frames = parseInt(args.frames as string, 10) || 5; const frames = parseInt(args.frames as string, 10) || 5;
const timeout = parseInt(args.timeout as string, 10) || 5000; const timeout = parseInt(args.timeout as string, 10) || 5000;
const atTimestamps = args.at const atTimestamps = args.at
+46 -1
View File
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint";
import { lintProject, shouldBlockRender } from "./lintProject.js"; import { hasDefinitiveEntryMismatch, lintProject, shouldBlockRender } from "./lintProject.js";
function tmpProject(name: string): string { function tmpProject(name: string): string {
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`)); return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
@@ -249,6 +249,51 @@ describe("lintProject", () => {
}); });
}); });
describe("hasDefinitiveEntryMismatch", () => {
it("distinguishes the blank-default-entry failure from ordinary lint errors", () => {
const result = {
results: [
{
file: "index.html",
contentHash: "abc",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "blank_root_with_standalone_composition",
severity: "error" as const,
message: "wrong entry",
},
],
},
},
],
totalErrors: 1,
totalWarnings: 0,
totalInfos: 0,
};
expect(hasDefinitiveEntryMismatch(result)).toBe(true);
expect(
hasDefinitiveEntryMismatch({
...result,
results: [
{
...result.results[0]!,
result: {
...result.results[0]!.result,
findings: [{ code: "media_missing_id", severity: "error", message: "missing" }],
},
},
],
}),
).toBe(false);
});
});
function validHtmlWithAudio(compId = "main"): string { function validHtmlWithAudio(compId = "main"): string {
return `<html><body> return `<html><body>
<div data-composition-id="${compId}" data-width="1920" data-height="1080"> <div data-composition-id="${compId}" data-width="1920" data-height="1080">
+11 -1
View File
@@ -1,3 +1,13 @@
// ponytail: thin re-export — lintProject lives in @hyperframes/lint so it's usable without the CLI // CLI facade: the linter stays reusable without the CLI, while command-specific gates live here.
export { lintProject, shouldBlockRender } from "@hyperframes/lint"; export { lintProject, shouldBlockRender } from "@hyperframes/lint";
export type { ProjectLintResult } from "@hyperframes/lint"; export type { ProjectLintResult } from "@hyperframes/lint";
import type { ProjectLintResult } from "@hyperframes/lint";
export function hasDefinitiveEntryMismatch(result: ProjectLintResult): boolean {
return result.results.some((entry) =>
entry.result.findings.some(
(finding) => finding.code === "blank_root_with_standalone_composition",
),
);
}
+106
View File
@@ -69,6 +69,112 @@ describe("external symlink assets", () => {
}); });
}); });
describe("blank_root_with_standalone_composition", () => {
it("errors when the default entry is blank but an authored standalone composition lives under compositions", async () => {
const project = makeProject(validHtml(), {
"index.html": `<!doctype html><html><body>
<div data-composition-id="bona-brand-card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div id="main-clip" class="clip" data-start="0" data-duration="5" data-track-index="0">BONA</div>
</div>
<script>window.__timelines = { "bona-brand-card": gsap.timeline({ paused: true }) };</script>
</body></html>`,
});
const { results, totalErrors } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(totalErrors).toBeGreaterThan(0);
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("compositions/index.html");
expect(finding?.message).toContain("index.html");
expect(finding?.fixHint).toContain("data-composition-src");
});
it("treats non-rendering script, style, link, meta, and template children as blank", async () => {
const shellOnlyRoot = validHtml().replace(
"</div>",
`<script type="application/json">{}</script>
<style>.unused { color: white; }</style>
<link rel="stylesheet" href="data:text/css,.unused%7Bcolor:white%7D">
<meta name="description" content="shell">
<template id="row-template"><div>row</div></template>
</div>`,
);
const project = makeProject(shellOnlyRoot, {
"authored.html": `<!doctype html><html><body>
<div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Visible</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
it("does not fire when index.html already contains authored clip content", async () => {
const authoredRoot = validHtml().replace(
"</div>",
'<div class="clip" data-start="0" data-duration="10">Master content</div></div>',
);
const project = makeProject(authoredRoot, {
"alternate.html": `<!doctype html><html><body>
<div data-composition-id="alternate" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Alternate</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("does not treat a template-wrapped sub-composition as a misplaced standalone entry", async () => {
const project = makeProject(validHtml(), {
"scene.html": `<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="5">Scene</div>
</div>
</template>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("still catches a standalone composition that contains an unrelated nested template", async () => {
const project = makeProject(validHtml(), {
"card.html": `<!doctype html><html><body>
<div data-composition-id="card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Card</div>
<template id="repeated-row"><div class="row">Row</div></template>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
});
describe("missing_or_empty_sub_composition", () => { describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string { function htmlWithSubComp(srcPath: string): string {
return `<html><body> return `<html><body>
+41
View File
@@ -230,6 +230,7 @@ export async function lintProject(
...lintMissingLocalAsset(projectDir, allHtmlSources), ...lintMissingLocalAsset(projectDir, allHtmlSources),
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources), ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []), ...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
...(!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : []),
...lintDuplicateAudioTracks(allHtmlSources), ...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml), ...lintMissingOrEmptySubComposition(projectDir, rootHtml),
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))), ...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
@@ -254,6 +255,46 @@ export async function lintProject(
return { results, totalErrors, totalWarnings, totalInfos }; return { results, totalErrors, totalWarnings, totalInfos };
} }
function lintBlankRootWithStandaloneComposition(
rootHtml: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const { document: rootDocument } = parseHTML(rootHtml);
const root = rootDocument.querySelector("body [data-composition-id]");
// A no-media scaffold has no rendered descendants and can silently mask an authored file below.
// A scaffold that retained its A-roll <video>/<audio> is visibly non-blank, so this rule leaves it
// alone even when another composition is unmounted.
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
return [];
}
const standaloneCandidates: string[] = [];
for (const source of htmlSources) {
if (!source.compSrcPath) continue;
const { document } = parseHTML(source.html);
const composition = document.querySelector("body [data-composition-id]");
if (!composition) continue;
const authoredTimedContent = Array.from(
composition.querySelectorAll(
".clip, [data-start], [data-end], video, audio, img, svg, canvas",
),
).some((element) => !element.hasAttribute("data-composition-src"));
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
}
if (standaloneCandidates.length === 0) return [];
return [
{
code: "blank_root_with_standalone_composition",
severity: "error",
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, and render commands open index.html, so they will capture only its background.`,
fixHint:
`Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. ` +
`If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
},
];
}
function lintProjectAudioFiles( function lintProjectAudioFiles(
projectDir: string, projectDir: string,
htmlSources: HtmlSource[], htmlSources: HtmlSource[],