Skip to content
Closed
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
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);
});
});
58 changes: 54 additions & 4 deletions packages/core/src/lint/hyperframeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,8 +713,6 @@ export function lintHyperframeHtml(
// ── Caption lint rules ──────────────────────────────────────────────────

// Rule: caption_exit_missing_hard_kill
// Exit tweens (tl.to with opacity: 0) can fail when karaoke word-level tweens
// conflict, leaving captions stuck on screen. A hard tl.set kill is needed.
for (const script of scripts) {
const content = script.content;
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
Expand All @@ -737,8 +735,61 @@ export function lintHyperframeHtml(
}
}

// ── Missing adapter library script checks ───────────────────────────────
{
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const allScriptSrcs = scripts
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
.filter(Boolean);

const usesGsap = allScriptTexts.some((t) =>
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
);
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));

if (usesGsap && !hasGsapScript) {
pushFinding({
code: "missing_gsap_script",
severity: "error",
message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> before your animation script.',
});
}

const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
const usesLottieApi = allScriptTexts.some((t) =>
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
);
const hasLottieScript = allScriptSrcs.some((src) => /lottie/i.test(src));

if ((hasLottieAttr || usesLottieApi) && !hasLottieScript) {
pushFinding({
code: "missing_lottie_script",
severity: "error",
message:
"Composition uses Lottie but no Lottie script is loaded. The animation will not render.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script> before your Lottie code.',
});
}

const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
const hasThreeScript = allScriptSrcs.some((src) => /three/i.test(src));

if (usesThree && !hasThreeScript) {
pushFinding({
code: "missing_three_script",
severity: "error",
message:
"Composition uses Three.js but no Three.js script is loaded. The 3D scene will not render.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script> before your Three.js code.',
});
}
}

// Rule: caption_text_overflow_risk
// Captions with nowrap text and no max-width will clip off-screen.
for (const style of styles) {
const content = style.content;
const captionBlocks = content.matchAll(
Expand All @@ -763,7 +814,6 @@ export function lintHyperframeHtml(
}

// Rule: caption_container_relative_position
// position: relative on caption containers causes overflow and stacking issues.
for (const style of styles) {
const content = style.content;
const captionBlocks = content.matchAll(
Expand Down
Loading