mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): upgrade bare composition HTML to error (#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
This commit is contained in:
@@ -201,6 +201,149 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("root_composition_missing_html_wrapper", () => {
|
||||
it("flags bare composition div as error", () => {
|
||||
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
|
||||
const html = `<div
|
||||
id="comp-main"
|
||||
data-composition-id="no-limits"
|
||||
data-start="0"
|
||||
data-duration="15"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<!-- Sub-composition: the visual spectacle -->
|
||||
<div
|
||||
id="el-visuals"
|
||||
data-composition-id="visuals"
|
||||
data-composition-src="compositions/visuals.html"
|
||||
data-duration="15"
|
||||
data-track-index="0"
|
||||
></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["no-limits"] = tl;
|
||||
</script>
|
||||
</div>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "index.html" });
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag properly wrapped HTML composition", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div class="clip" data-start="0" data-duration="5">Hello</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag composition starting with <html> (no doctype)", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag sub-compositions", () => {
|
||||
const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["sub"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>`;
|
||||
const result = lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag HTML without composition attributes", () => {
|
||||
const html = `<div id="hello"><p>Not a composition</p></div>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes root tag snippet in finding", () => {
|
||||
const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["bare"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "root_composition_missing_html_wrapper",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("data-composition-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("standalone_composition_wrapped_in_template", () => {
|
||||
it("flags root index.html wrapped in template", () => {
|
||||
const html = `<template id="main-template">
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "standalone_composition_wrapped_in_template",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not flag sub-compositions in template", () => {
|
||||
const html = `<template id="sub-template">
|
||||
<div data-composition-id="sub" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["sub"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "standalone_composition_wrapped_in_template",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestanimationframe_in_composition", () => {
|
||||
it("flags requestAnimationFrame usage in script content", () => {
|
||||
const html = `
|
||||
|
||||
@@ -231,21 +231,26 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// root_composition_missing_html_wrapper
|
||||
({ rawSource, options }) => {
|
||||
({ rawSource, rootTag, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (options.isSubComposition) return findings;
|
||||
const trimmed = rawSource.trimStart().toLowerCase();
|
||||
// Compositions inside <template> are caught by standalone_composition_wrapped_in_template
|
||||
if (trimmed.startsWith("<template")) return findings;
|
||||
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
|
||||
const hasComposition = rawSource.includes("data-composition-id");
|
||||
if (hasComposition && !hasDoctype) {
|
||||
findings.push({
|
||||
code: "root_composition_missing_html_wrapper",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition is missing <!DOCTYPE html> and <html> wrapper. " +
|
||||
"The bundler and preview expect a complete HTML document for index.html files.",
|
||||
"Composition starts with a bare element instead of a proper HTML document. " +
|
||||
"An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
|
||||
"is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
|
||||
"the bundler will fail to inject runtime scripts.",
|
||||
fixHint:
|
||||
'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
|
||||
snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
|
||||
Reference in New Issue
Block a user