Skip to content
Merged
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
51 changes: 51 additions & 0 deletions packages/core/src/lint/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
import {
extractBlocks,
extractOpenTags,
findRootTag,
collectCompositionIds,
readAttr,
STYLE_BLOCK_PATTERN,
SCRIPT_BLOCK_PATTERN,
} from "./utils";
import type { OpenTag, ExtractedBlock } from "./utils";

export type { OpenTag, ExtractedBlock };

export type LintContext = {
source: string;
tags: OpenTag[];
styles: ExtractedBlock[];
scripts: ExtractedBlock[];
compositionIds: Set<string>;
rootTag: OpenTag | null;
rootCompositionId: string | null;
options: HyperframeLinterOptions;
};

// Re-export for convenience so rule modules only need one import for the finding type
export type { HyperframeLintFinding };

export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
let source = html || "";
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];

const tags = extractOpenTags(source);
const styles = extractBlocks(source, STYLE_BLOCK_PATTERN);
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source);
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");

return {
source,
tags,
styles,
scripts,
compositionIds,
rootTag,
rootCompositionId,
options,
};
}
147 changes: 145 additions & 2 deletions packages/core/src/lint/hyperframeLinter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe("lintHyperframeHtml", () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
<div id="rocket-container"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
Expand All @@ -123,10 +124,11 @@ describe("lintHyperframeHtml", () => {
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
const finding = result.findings.find((f) => f.code === "external_script_dependency");
const finding = result.findings.find(
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("info");
expect(finding?.message).toContain("cdnjs.cloudflare.com");
// info findings do not count as errors — ok should still be true
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
Expand Down Expand Up @@ -725,4 +727,145 @@ describe("template_literal_selector rule", () => {
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});

// ── Missing adapter library script checks ──────────────────────────────

it("reports error when GSAP is used without a GSAP script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("GSAP");
});

it("does not report missing_gsap_script when GSAP CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></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 });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});

it("reports error when Lottie container exists without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></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 === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Lottie");
});

it("reports error when lottie.loadAnimation is used without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});

it("does not report missing_lottie_script when Lottie CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
<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 === "missing_lottie_script");
expect(finding).toBeUndefined();
});

it("reports error when Three.js is used without a Three.js script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Three.js");
});

it("does not report missing_three_script when Three.js CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});

it("does not report any adapter errors for composition with no adapter usage", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="content">Hello World</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = { totalDuration: function() { return 3; } };
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
expect(adapterFindings).toHaveLength(0);
});
});
Loading
Loading