Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 4 additions & 32 deletions packages/lint/src/context.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -19,6 +13,7 @@ export type LintContext = {
compositionIds: Set<string>;
rootTag: OpenTag | null;
rootCompositionId: string | null;
isTemplateWrappedRoot: boolean;
options: HyperframeLinterOptions;
};

Expand All @@ -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 = [
Expand All @@ -77,6 +48,7 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
compositionIds,
rootTag,
rootCompositionId,
isTemplateWrappedRoot,
options,
};
}
55 changes: 55 additions & 0 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
7 changes: 6 additions & 1 deletion packages/lint/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
}
Expand Down
36 changes: 35 additions & 1 deletion packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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">
Expand Down
9 changes: 7 additions & 2 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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",
Expand Down Expand Up @@ -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")) {
Expand Down
34 changes: 33 additions & 1 deletion packages/lint/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand Down
Loading