fix(lint): stop false root findings on template-wrapped sub-compositions

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-14 15:59:30 -04:00
parent e05debe1af
commit f6efe25e9f
7 changed files with 145 additions and 37 deletions
+5
View File
@@ -384,6 +384,11 @@
"packages/studio/src/hooks/useGsapSelectionHandlers.ts",
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
// recast and acorn parallel implementations (pre-existing, moved from core).
// project.test.ts: pre-existing parallel fixtures with
// packages/cli/src/utils/lintProject.test.ts from when project linting
// lived in the cli package; this change only prepends new template-root
// tests, and the line shift makes fallow re-flag the old clone.
"packages/lint/src/project.test.ts",
"packages/parsers/src/gsapParser.ts",
// hfIds.ts: 7-line clone with sdk/engine/mutate.ts — pre-existing duplication
// from when hfIds lived in packages/core/src/parsers/. Moving the file to the
+4 -32
View File
@@ -1,11 +1,5 @@
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
import {
parseHtmlStructure,
findRootTag,
collectCompositionIds,
readAttr,
stripHtmlComments,
} from "./utils";
import { findRootTag, collectCompositionIds, readAttr, resolveRootStructure } from "./utils";
import type { OpenTag, ExtractedBlock } from "./utils";
export type { OpenTag, ExtractedBlock };
@@ -19,6 +13,7 @@ export type LintContext = {
compositionIds: Set<string>;
rootTag: OpenTag | null;
rootCompositionId: string | null;
isTemplateWrappedRoot: boolean;
options: HyperframeLinterOptions;
};
@@ -27,31 +22,7 @@ export type { HyperframeLintFinding };
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
const rawSource = html || "";
// Strip HTML comments before scanning so a commented-out <template> or tag can't
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource);
const initialStructure = parseHtmlStructure(source);
const templateTags = initialStructure.tags.filter(
(tag) => tag.name === "template" && tag.closeIndex != null,
);
let sourceWithoutTemplates = source;
for (const template of [...templateTags].reverse()) {
const end = template.endIndex ?? template.index;
sourceWithoutTemplates =
sourceWithoutTemplates.slice(0, template.index) +
" ".repeat(end - template.index) +
sourceWithoutTemplates.slice(end);
}
// Some sub-composition files are HTML shells whose real root lives inside a
// <template>. Keep nested templates intact when the visible document already
// has a composition root; only unwrap when no root exists outside templates.
const template = templateTags[0];
let structure = initialStructure;
if (template && !findRootTag(sourceWithoutTemplates)) {
source = source.slice(template.index + template.raw.length, template.closeIndex);
structure = parseHtmlStructure(source);
}
const { source, structure, isTemplateWrappedRoot } = resolveRootStructure(rawSource);
const tags = structure.tags;
const styles = [
@@ -77,6 +48,7 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
compositionIds,
rootTag,
rootCompositionId,
isTemplateWrappedRoot,
options,
};
}
+55
View File
@@ -40,6 +40,61 @@ afterEach(() => {
dirs = [];
});
describe("multiple_root_compositions", () => {
it("ignores a template-wrapped mountable sub-composition beside its demo host", async () => {
const project = makeProject(`<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /></head><body>
<div id="root" data-composition-id="sub-comp-demo" data-width="1920" data-height="1080" data-start="0" data-duration="4">
<div class="clip" data-composition-id="elastic-badge" data-composition-src="./sub.html" data-start="0" data-duration="4" data-track-index="1" data-width="1920" data-height="1080"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["sub-comp-demo"] = gsap.timeline({ paused: true });
</script>
</body></html>`);
writeFileSync(
join(project, "sub.html"),
`<!doctype html>
<html lang="en" data-composition-id="elastic-badge">
<head><meta charset="UTF-8" /></head>
<body>
<template>
<style>#root { position: absolute; inset: 0; }</style>
<div id="root" data-composition-id="elastic-badge" data-duration="4" data-fps="30">
<span>Elastic badge</span>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["elastic-badge"] = tl;
</script>
</template>
</body>
</html>`,
);
const { results } = await lintProject(project);
const finding = results
.flatMap((entry) => entry.result.findings)
.find((item) => item.code === "multiple_root_compositions");
expect(finding).toBeUndefined();
});
it("reports two standalone root compositions in the same directory", async () => {
const project = makeProject(validHtml("primary"));
writeFileSync(join(project, "alternate.html"), validHtml("alternate"));
const { results } = await lintProject(project);
const finding = results
.flatMap((entry) => entry.result.findings)
.find((item) => item.code === "multiple_root_compositions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `<html><body>
+6 -1
View File
@@ -7,6 +7,7 @@ import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-compositi
import { parseHTML } from "linkedom";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
import { resolveRootStructure } from "./utils.js";
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
@@ -464,7 +465,11 @@ function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding
for (const file of rootHtmlFiles) {
if (file === "caption-skin.html") continue;
const content = readFileSync(join(projectDir, file), "utf-8");
if (/data-composition-id/i.test(content)) {
const isProjectEntry = file === "index.html";
if (
/data-composition-id/i.test(content) &&
(isProjectEntry || !resolveRootStructure(content).isTemplateWrappedRoot)
) {
rootCompositions.push(file);
}
}
+35 -1
View File
@@ -160,7 +160,7 @@ describe("core rules", () => {
expect(finding?.severity).toBe("error");
});
it("reports error when root is missing data-width or data-height", async () => {
it("reports error when a standalone root is missing data-width or data-height", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
@@ -172,6 +172,40 @@ describe("core rules", () => {
expect(finding?.severity).toBe("error");
});
it("allows an elastic root inside a template-wrapped sub-composition", async () => {
const html = `<!doctype html>
<html lang="en" data-composition-id="elastic-badge">
<head><meta charset="UTF-8" /></head>
<body>
<template>
<style>#root { position: absolute; inset: 0; }</style>
<div id="root" data-composition-id="elastic-badge" data-duration="4" data-fps="30">
<span>Elastic badge</span>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["elastic-badge"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("still requires a composition id on a template-wrapped root", async () => {
const html = `<html><body><template>
<div id="root" data-duration="4" data-fps="30"></div>
</template></body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeDefined();
});
it("accepts body as the composition root", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
+7 -2
View File
@@ -201,7 +201,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
// fallow-ignore-next-line complexity
({ rootTag, isTemplateWrappedRoot }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
findings.push({
@@ -213,7 +214,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
snippet: truncateSnippet(rootTag?.raw || ""),
});
}
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
if (
!isTemplateWrappedRoot &&
(!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height"))
) {
findings.push({
code: "root_missing_dimensions",
severity: "error",
@@ -264,6 +268,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// missing_timeline_registry + timeline_registry_missing_init
// fallow-ignore-next-line complexity
({ source, rawSource, rootTag, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
+33 -1
View File
@@ -342,7 +342,7 @@ function stripHtmlCommentsOnce(source: string): string {
// comment can splice adjacent markers into a fresh, complete <!-- … --> (e.g.
// "<<!-- -->!-- … -->" → "<!-- … -->"), which would otherwise survive and let a
// commented-out <template>/tag hijack the linter's tag scan.
export function stripHtmlComments(source: string): string {
function stripHtmlComments(source: string): string {
let out = source;
for (let prev = ""; prev !== out; ) {
prev = out;
@@ -351,6 +351,38 @@ export function stripHtmlComments(source: string): string {
return out;
}
export function resolveRootStructure(rawSource: string): {
source: string;
structure: ReturnType<typeof parseHtmlStructure>;
isTemplateWrappedRoot: boolean;
} {
let source = stripHtmlComments(rawSource);
const initialStructure = parseHtmlStructure(source);
const templateTags = initialStructure.tags.filter(
(tag) => tag.name === "template" && tag.closeIndex != null,
);
let sourceWithoutTemplates = source;
for (const template of [...templateTags].reverse()) {
const end = template.endIndex ?? template.index;
sourceWithoutTemplates =
sourceWithoutTemplates.slice(0, template.index) +
" ".repeat(end - template.index) +
sourceWithoutTemplates.slice(end);
}
const template = templateTags[0];
if (template && !findRootTag(sourceWithoutTemplates)) {
source = source.slice(template.index + template.raw.length, template.closeIndex);
return {
source,
structure: parseHtmlStructure(source),
isTemplateWrappedRoot: true,
};
}
return { source, structure: initialStructure, isTemplateWrappedRoot: false };
}
export function extractScriptTextsAndSrcs(scripts: ExtractedBlock[]): {
texts: string[];
srcs: string[];