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:
Miguel Ángel
2026-04-11 05:00:47 +02:00
committed by GitHub
parent 0da93cea3d
commit 794b02153d
2 changed files with 152 additions and 4 deletions
@@ -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 = `
+9 -4
View File
@@ -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;