mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
fix(lint): stop false root findings on template-wrapped sub-compositions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user