Skip to content

Commit 794b021

Browse files
fix(lint): upgrade bare composition HTML to error (heygen-com#242)
## Summary - Upgrades `root_composition_missing_html_wrapper` from **warning** to **error** — a bare `<div data-composition-id>` as `index.html` without `<!DOCTYPE html>/<html>/<body>` causes browsers to quirks-mode, the preview server to fail, and the bundler to silently skip runtime injection - Improves the error message to explain _why_ this is bad, and includes a snippet of the offending root element - Skips `<template>`\-wrapped compositions (already caught by the separate `standalone_composition_wrapped_in_template` rule) - Adds 8 tests covering the exact screenshot scenario, proper HTML, sub-compositions, plain HTML, and template wrappers ## Test plan - [x] All 441 existing tests pass (`vitest run`) - [x] 8 new tests for `root_composition_missing_html_wrapper` and `standalone_composition_wrapped_in_template` - [x] TypeScript build clean (`tsc --noEmit`) - [x] oxlint + oxfmt pass - [x] Run `npx hyperframes lint` on a bare composition `index.html` and verify it now reports an error
1 parent 0da93ce commit 794b021

2 files changed

Lines changed: 152 additions & 4 deletions

File tree

packages/core/src/lint/rules/composition.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,149 @@ describe("composition rules", () => {
201201
});
202202
});
203203

204+
describe("root_composition_missing_html_wrapper", () => {
205+
it("flags bare composition div as error", () => {
206+
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
207+
const html = `<div
208+
id="comp-main"
209+
data-composition-id="no-limits"
210+
data-start="0"
211+
data-duration="15"
212+
data-width="1920"
213+
data-height="1080"
214+
>
215+
<!-- Sub-composition: the visual spectacle -->
216+
<div
217+
id="el-visuals"
218+
data-composition-id="visuals"
219+
data-composition-src="compositions/visuals.html"
220+
data-duration="15"
221+
data-track-index="0"
222+
></div>
223+
224+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
225+
<script>
226+
window.__timelines = window.__timelines || {};
227+
const tl = gsap.timeline({ paused: true });
228+
window.__timelines["no-limits"] = tl;
229+
</script>
230+
</div>`;
231+
const result = lintHyperframeHtml(html, { filePath: "index.html" });
232+
const finding = result.findings.find(
233+
(f) => f.code === "root_composition_missing_html_wrapper",
234+
);
235+
expect(finding).toBeDefined();
236+
expect(finding?.severity).toBe("error");
237+
expect(result.ok).toBe(false);
238+
});
239+
240+
it("does not flag properly wrapped HTML composition", () => {
241+
const html = `<!DOCTYPE html>
242+
<html><head><meta charset="UTF-8"></head><body>
243+
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
244+
<div class="clip" data-start="0" data-duration="5">Hello</div>
245+
</div>
246+
<script>
247+
window.__timelines = window.__timelines || {};
248+
window.__timelines["main"] = gsap.timeline({ paused: true });
249+
</script>
250+
</body></html>`;
251+
const result = lintHyperframeHtml(html);
252+
const finding = result.findings.find(
253+
(f) => f.code === "root_composition_missing_html_wrapper",
254+
);
255+
expect(finding).toBeUndefined();
256+
});
257+
258+
it("does not flag composition starting with <html> (no doctype)", () => {
259+
const html = `<html><body>
260+
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
261+
<script>
262+
window.__timelines = window.__timelines || {};
263+
window.__timelines["main"] = gsap.timeline({ paused: true });
264+
</script>
265+
</body></html>`;
266+
const result = lintHyperframeHtml(html);
267+
const finding = result.findings.find(
268+
(f) => f.code === "root_composition_missing_html_wrapper",
269+
);
270+
expect(finding).toBeUndefined();
271+
});
272+
273+
it("does not flag sub-compositions", () => {
274+
const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
275+
<script>
276+
window.__timelines = window.__timelines || {};
277+
window.__timelines["sub"] = gsap.timeline({ paused: true });
278+
</script>
279+
</div>`;
280+
const result = lintHyperframeHtml(html, { isSubComposition: true });
281+
const finding = result.findings.find(
282+
(f) => f.code === "root_composition_missing_html_wrapper",
283+
);
284+
expect(finding).toBeUndefined();
285+
});
286+
287+
it("does not flag HTML without composition attributes", () => {
288+
const html = `<div id="hello"><p>Not a composition</p></div>`;
289+
const result = lintHyperframeHtml(html);
290+
const finding = result.findings.find(
291+
(f) => f.code === "root_composition_missing_html_wrapper",
292+
);
293+
expect(finding).toBeUndefined();
294+
});
295+
296+
it("includes root tag snippet in finding", () => {
297+
const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
298+
<script>
299+
window.__timelines = window.__timelines || {};
300+
window.__timelines["bare"] = gsap.timeline({ paused: true });
301+
</script>
302+
</div>`;
303+
const result = lintHyperframeHtml(html);
304+
const finding = result.findings.find(
305+
(f) => f.code === "root_composition_missing_html_wrapper",
306+
);
307+
expect(finding).toBeDefined();
308+
expect(finding?.snippet).toContain("data-composition-id");
309+
});
310+
});
311+
312+
describe("standalone_composition_wrapped_in_template", () => {
313+
it("flags root index.html wrapped in template", () => {
314+
const html = `<template id="main-template">
315+
<div data-composition-id="main" data-width="1920" data-height="1080">
316+
<script>
317+
window.__timelines = window.__timelines || {};
318+
window.__timelines["main"] = gsap.timeline({ paused: true });
319+
</script>
320+
</div>
321+
</template>`;
322+
const result = lintHyperframeHtml(html);
323+
const finding = result.findings.find(
324+
(f) => f.code === "standalone_composition_wrapped_in_template",
325+
);
326+
expect(finding).toBeDefined();
327+
expect(finding?.severity).toBe("warning");
328+
});
329+
330+
it("does not flag sub-compositions in template", () => {
331+
const html = `<template id="sub-template">
332+
<div data-composition-id="sub" data-width="1920" data-height="1080">
333+
<script>
334+
window.__timelines = window.__timelines || {};
335+
window.__timelines["sub"] = gsap.timeline({ paused: true });
336+
</script>
337+
</div>
338+
</template>`;
339+
const result = lintHyperframeHtml(html, { isSubComposition: true });
340+
const finding = result.findings.find(
341+
(f) => f.code === "standalone_composition_wrapped_in_template",
342+
);
343+
expect(finding).toBeUndefined();
344+
});
345+
});
346+
204347
describe("requestanimationframe_in_composition", () => {
205348
it("flags requestAnimationFrame usage in script content", () => {
206349
const html = `

packages/core/src/lint/rules/composition.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,21 +231,26 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
231231
},
232232

233233
// root_composition_missing_html_wrapper
234-
({ rawSource, options }) => {
234+
({ rawSource, rootTag, options }) => {
235235
const findings: HyperframeLintFinding[] = [];
236236
if (options.isSubComposition) return findings;
237237
const trimmed = rawSource.trimStart().toLowerCase();
238+
// Compositions inside <template> are caught by standalone_composition_wrapped_in_template
239+
if (trimmed.startsWith("<template")) return findings;
238240
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
239241
const hasComposition = rawSource.includes("data-composition-id");
240242
if (hasComposition && !hasDoctype) {
241243
findings.push({
242244
code: "root_composition_missing_html_wrapper",
243-
severity: "warning",
245+
severity: "error",
244246
message:
245-
"Composition is missing <!DOCTYPE html> and <html> wrapper. " +
246-
"The bundler and preview expect a complete HTML document for index.html files.",
247+
"Composition starts with a bare element instead of a proper HTML document. " +
248+
"An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
249+
"is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
250+
"the bundler will fail to inject runtime scripts.",
247251
fixHint:
248252
'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
253+
snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
249254
});
250255
}
251256
return findings;

0 commit comments

Comments
 (0)