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
288 changes: 15 additions & 273 deletions packages/core/src/lint/hyperframeLinter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";

describe("lintHyperframeHtml", () => {
const validComposition = `
Expand Down Expand Up @@ -111,281 +111,23 @@ describe("lintHyperframeHtml", () => {
expect(codes.length).toBe(uniqueCodes.length);
});

it("detects timeline ID mismatch", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="intro" data-start="0" data-duration="3"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
window.__timelines["intro-anim"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(mismatch).toBeDefined();
expect(mismatch?.message).toContain("intro-anim");
});

it("does not flag matching timeline IDs", () => {
const result = lintHyperframeHtml(validComposition);
const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(mismatch).toBeUndefined();
});

it("reports error when timeline assignment has no init guard", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("without initializing");
});

it("does not flag timeline assignment when init guard is present", () => {
const result = lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
});

describe("lintScriptUrls", () => {
it("reports error for script URL returning non-2xx", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
vi.stubGlobal("fetch", mockFetch);

const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://unpkg.com/@hyperframe/player@latest/dist/player.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("404");

vi.unstubAllGlobals();
});

it("reports error for unreachable script URL", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
vi.stubGlobal("fetch", mockFetch);

const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://example.invalid/nonexistent.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();

vi.unstubAllGlobals();
});

it("does not flag accessible script URLs", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);

const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);

vi.unstubAllGlobals();
});

it("skips inline scripts without src", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>console.log("inline")</script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();

vi.unstubAllGlobals();
});

// ── gsap_css_transform_conflict ──────────────────────────────────────────

it("warns when tl.to animates x on an element with CSS translateX", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" style=""></div>
</div>
<style>
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#title");
expect(finding?.fixHint).toMatch(/fromTo/);
expect(finding?.fixHint).toMatch(/xPercent/);
});

it("warns when tl.to animates scale on an element with CSS scale transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: scale(0.8); opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#hero");
});

it("does NOT warn when tl.to targets element without CSS transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card"></div>
</div>
<style>
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});

it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
</div>
<style>
#title { position: absolute; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});

it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: translateX(-50%) scale(0.8); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
});
});

describe("template_literal_selector rule", () => {
it("reports error when querySelector uses template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const compId = "main";
const el = document.querySelector(\`[data-composition-id="\${compId}"] .chart\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});

it("reports error for querySelectorAll with template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const id = "main";
document.querySelectorAll(\`[data-composition-id="\${id}"] .item\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
});

it("does not report error for hardcoded querySelector strings", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
it("strips <template> wrapper before linting composition files", () => {
const html = `<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
style="position:relative;width:1920px;height:1080px;">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const el = document.querySelector('[data-composition-id="main"] .chart');
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined();
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const missing = result.findings.filter(
(f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
);
expect(missing).toHaveLength(0);
});
});
24 changes: 19 additions & 5 deletions packages/core/src/lint/hyperframeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ export function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): HyperframeLintResult {
const source = html || "";
// Strip <template> wrapper if present — composition files are often wrapped in
// <template id="..."> tags that the runtime extracts at load time.
let source = html || "";
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];
const filePath = options.filePath;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
Expand Down Expand Up @@ -456,21 +460,31 @@ export function lintHyperframeHtml(
}

// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
// Skip: elements with data-composition-id (managed by runtime), elements with
// opacity:0 in style (will be animated in by GSAP), and composition host elements.
// Most HyperFrames compositions use GSAP to manage element visibility via opacity
// animations, so this check is only relevant for elements that truly need to be
// hidden before the timeline starts.
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
// Skip composition roots and hosts — the runtime manages their lifecycle
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-composition-src")) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const styleAttr = readAttr(tag.raw, "style") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
const hasHiddenStyle = /visibility\s*:\s*hidden/i.test(styleAttr);
const hasHiddenStyle =
/visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "timed_element_missing_visibility_hidden",
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip" or visibility:hidden. The framework needs elements to start hidden so it can manage their lifecycle.`,
severity: "info",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
elementId,
fixHint: 'Add class="clip" to the element (with CSS: .clip { visibility: hidden; }).',
fixHint:
'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
snippet: truncateSnippet(tag.raw),
});
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lint/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type HyperframeLintSeverity = "error" | "warning";
export type HyperframeLintSeverity = "error" | "warning" | "info";

export type HyperframeLintFinding = {
code: string;
Expand Down
Loading