feat(studio): GSAP tween editing in Design panel (#1102)

* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
This commit is contained in:
Miguel Ángel
2026-05-28 19:16:34 -04:00
committed by GitHub
parent e16f916448
commit fb2e21090f
61 changed files with 4354 additions and 1128 deletions
@@ -18,33 +18,33 @@ describe("lintHyperframeHtml — orchestrator", () => {
</body>
</html>`;
it("reports no errors for a valid composition", () => {
const result = lintHyperframeHtml(validComposition);
it("reports no errors for a valid composition", async () => {
const result = await lintHyperframeHtml(validComposition);
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("attaches filePath to findings when option is set", () => {
it("attaches filePath to findings when option is set", async () => {
const html = "<html><body><div></div></body></html>";
const result = lintHyperframeHtml(html, { filePath: "test.html" });
const result = await lintHyperframeHtml(html, { filePath: "test.html" });
for (const finding of result.findings) {
expect(finding.file).toBe("test.html");
}
});
it("deduplicates identical findings", () => {
it("deduplicates identical findings", async () => {
const html = `
<html><body>
<div id="root"></div>
<script>const tl = gsap.timeline();</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const codes = result.findings.map((f) => `${f.code}|${f.message}`);
const uniqueCodes = [...new Set(codes)];
expect(codes.length).toBe(uniqueCodes.length);
});
it("strips <template> wrapper before linting composition files", () => {
it("strips <template> wrapper before linting composition files", async () => {
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;">
@@ -57,7 +57,7 @@ describe("lintHyperframeHtml — orchestrator", () => {
window.__timelines["my-comp"] = tl;
</script>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const missing = result.findings.filter(
(f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
);
+3 -3
View File
@@ -21,16 +21,16 @@ const ALL_RULES = [
...fontRules,
];
export function lintHyperframeHtml(
export async function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): HyperframeLintResult {
): Promise<HyperframeLintResult> {
const ctx = buildLintContext(html, options);
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
for (const rule of ALL_RULES) {
for (const finding of rule(ctx)) {
for (const finding of await Promise.resolve(rule(ctx))) {
const dedupeKey = [
finding.code,
finding.severity,
+16 -16
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("adapter rules", () => {
it("reports error when GSAP is used without a GSAP script tag", () => {
it("reports error when GSAP is used without a GSAP script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -13,14 +13,14 @@ describe("adapter rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report missing_gsap_script when GSAP CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -32,12 +32,12 @@ describe("adapter rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("reports error when Lottie container exists without a Lottie script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -48,14 +48,14 @@ describe("adapter rules", () => {
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("reports error when lottie.loadAnimation is used without a Lottie script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -65,13 +65,13 @@ describe("adapter rules", () => {
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report missing_lottie_script when Lottie CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -83,12 +83,12 @@ describe("adapter rules", () => {
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("reports error when Three.js is used without a Three.js script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -99,14 +99,14 @@ describe("adapter rules", () => {
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report missing_three_script when Three.js CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -118,12 +118,12 @@ describe("adapter rules", () => {
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report any adapter errors for composition with no adapter usage", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -134,7 +134,7 @@ describe("adapter rules", () => {
window.__timelines["main"] = { totalDuration: function() { return 3; } };
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
+12 -12
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("caption rules", () => {
it("warns when caption exit has no hard kill tl.set", () => {
it("warns when caption exit has no hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
@@ -20,13 +20,13 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption exit has hard kill tl.set", () => {
it("does not warn when caption exit has hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
@@ -45,12 +45,12 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not warn for generic GSAP opacity exits in non-caption loops", () => {
it("does not warn for generic GSAP opacity exits in non-caption loops", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -67,12 +67,12 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
it("warns when caption group has nowrap without max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
@@ -90,13 +90,13 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", () => {
it("does not warn when caption group has nowrap with max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
@@ -115,14 +115,14 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", () => {
it("warns when caption container uses position: relative", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
@@ -138,7 +138,7 @@ describe("caption rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
+108 -104
View File
@@ -3,39 +3,41 @@ import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("composition rules", () => {
describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", () => {
it("warns when any HTML composition file is over 300 lines", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when an HTML composition file is exactly 300 lines", () => {
it("does not warn when an HTML composition file is exactly 300 lines", async () => {
const html = Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("does not count a final trailing newline as an extra physical line", () => {
it("does not count a final trailing newline as an extra physical line", async () => {
const html =
Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n") + "\n";
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("does not count inline style block internals as structural lines", () => {
it("does not count inline style block internals as structural lines", async () => {
const style = `<style>\n${Array.from({ length: 320 }, (_, i) => `.rule-${i} { color: red; }`).join("\n")}\n</style>`;
const html = `<!doctype html>
<html>
@@ -45,29 +47,29 @@ describe("composition rules", () => {
</body>
</html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("does not warn for large registry source block files", () => {
it("does not warn for large registry source block files", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/registry/blocks/data-chart/data-chart.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("warns for large installed block composition files", () => {
it("warns for large installed block composition files", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/data-chart.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
@@ -75,26 +77,26 @@ describe("composition rules", () => {
expect(finding?.severity).toBe("warning");
});
it("does not warn for large registry-installed block composition files", () => {
it("does not warn for large registry-installed block composition files", async () => {
const html =
"<!-- hyperframes-registry-item: data-chart -->\n" +
Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/data-chart.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("uses nested split copy for large sub-composition files", () => {
it("uses nested split copy for large sub-composition files", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
isSubComposition: true,
});
@@ -102,7 +104,7 @@ describe("composition rules", () => {
expect(finding?.fixHint).toContain("Split this sub-composition further");
});
it("warns when more than 3 timed elements share the same track", () => {
it("warns when more than 3 timed elements share the same track", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
@@ -113,14 +115,16 @@ describe("composition rules", () => {
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("Track 0 has 4 timed elements");
});
it("does not warn when 3 timed elements share the same track", () => {
it("does not warn when 3 timed elements share the same track", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
@@ -130,12 +134,12 @@ describe("composition rules", () => {
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not warn when timed elements are split across tracks", () => {
it("does not warn when timed elements are split across tracks", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
@@ -146,12 +150,12 @@ describe("composition rules", () => {
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not count timed media or script/style tags as dense track elements", () => {
it("does not count timed media or script/style tags as dense track elements", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
@@ -163,12 +167,12 @@ describe("composition rules", () => {
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not count root composition or mounted sub-compositions as dense elements", () => {
it("does not count root composition or mounted sub-compositions as dense elements", async () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-track-index="0">
@@ -179,13 +183,13 @@ describe("composition rules", () => {
</div>
</body></html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
});
it("reports info for composition with external CDN script dependency", () => {
it("reports info for composition with external CDN script dependency", async () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
<div id="rocket-container"></div>
@@ -197,7 +201,7 @@ describe("composition rules", () => {
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
const result = await lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
const finding = result.findings.find(
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
);
@@ -208,7 +212,7 @@ describe("composition rules", () => {
expect(result.errorCount).toBe(0);
});
it("does not report external_script_dependency for inline scripts", () => {
it("does not report external_script_dependency for inline scripts", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
@@ -219,11 +223,11 @@ describe("composition rules", () => {
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
});
it("reports error when querySelector uses template literal variable", () => {
it("reports error when querySelector uses template literal variable", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -237,13 +241,13 @@ describe("composition rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("reports error for querySelectorAll with template literal variable", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -255,12 +259,12 @@ describe("composition rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report error for hardcoded querySelector strings", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
@@ -273,12 +277,12 @@ describe("composition rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined();
});
it("reports error when a selector combines data attributes in one bracket", () => {
it("reports error when a selector combines data attributes in one bracket", async () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
@@ -294,7 +298,7 @@ describe("composition rules", () => {
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector");
expect(findings.length).toBe(1);
expect(findings[0]?.severity).toBe("error");
@@ -302,7 +306,7 @@ describe("composition rules", () => {
});
describe("timed_element_missing_clip_class", () => {
it("flags element with data-start but no class='clip'", () => {
it("flags element with data-start but no class='clip'", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -313,13 +317,13 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not flag element that has class='clip'", () => {
it("does not flag element that has class='clip'", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -330,12 +334,12 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeUndefined();
});
it("does not flag audio or video elements", () => {
it("does not flag audio or video elements", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -347,14 +351,14 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeUndefined();
});
});
describe("overlapping_clips_same_track", () => {
it("flags overlapping clips on the same track", () => {
it("flags overlapping clips on the same track", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -366,13 +370,13 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag clips on different tracks", () => {
it("does not flag clips on different tracks", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -384,12 +388,12 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeUndefined();
});
it("does not flag sequential clips on the same track", () => {
it("does not flag sequential clips on the same track", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -401,14 +405,14 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeUndefined();
});
});
describe("root_composition_missing_html_wrapper", () => {
it("flags bare composition div as error", () => {
it("flags bare composition div as error", async () => {
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
const html = `<div
id="comp-main"
@@ -434,7 +438,7 @@ describe("composition rules", () => {
window.__timelines["no-limits"] = tl;
</script>
</div>`;
const result = lintHyperframeHtml(html, { filePath: "index.html" });
const result = await lintHyperframeHtml(html, { filePath: "index.html" });
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
@@ -443,7 +447,7 @@ describe("composition rules", () => {
expect(result.ok).toBe(false);
});
it("does not flag properly wrapped HTML composition", () => {
it("does not flag properly wrapped HTML composition", async () => {
const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
@@ -454,14 +458,14 @@ describe("composition rules", () => {
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag composition starting with <html> (no doctype)", () => {
it("does not flag composition starting with <html> (no doctype)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
<script>
@@ -469,44 +473,44 @@ describe("composition rules", () => {
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag sub-compositions", () => {
it("does not flag sub-compositions", async () => {
const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["sub"] = gsap.timeline({ paused: true });
</script>
</div>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag HTML without composition attributes", () => {
it("does not flag HTML without composition attributes", async () => {
const html = `<div id="hello"><p>Not a composition</p></div>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("includes root tag snippet in finding", () => {
it("includes root tag snippet in finding", async () => {
const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["bare"] = gsap.timeline({ paused: true });
</script>
</div>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
@@ -516,7 +520,7 @@ describe("composition rules", () => {
});
describe("standalone_composition_wrapped_in_template", () => {
it("flags root index.html wrapped in template", () => {
it("flags root index.html wrapped in template", async () => {
const html = `<template id="main-template">
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
@@ -525,7 +529,7 @@ describe("composition rules", () => {
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "standalone_composition_wrapped_in_template",
);
@@ -533,7 +537,7 @@ describe("composition rules", () => {
expect(finding?.severity).toBe("warning");
});
it("does not flag sub-compositions in template", () => {
it("does not flag sub-compositions in template", async () => {
const html = `<template id="sub-template">
<div data-composition-id="sub" data-width="1920" data-height="1080">
<script>
@@ -542,7 +546,7 @@ describe("composition rules", () => {
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find(
(f) => f.code === "standalone_composition_wrapped_in_template",
);
@@ -551,7 +555,7 @@ describe("composition rules", () => {
});
describe("requestanimationframe_in_composition", () => {
it("flags requestAnimationFrame usage in script content", () => {
it("flags requestAnimationFrame usage in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -561,7 +565,7 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "requestanimationframe_in_composition",
);
@@ -569,7 +573,7 @@ describe("composition rules", () => {
expect(finding?.severity).toBe("warning");
});
it("does not flag requestAnimationFrame in comments", () => {
it("does not flag requestAnimationFrame in comments", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -579,7 +583,7 @@ describe("composition rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "requestanimationframe_in_composition",
);
@@ -596,7 +600,7 @@ describe("composition rules", () => {
// GSAP rules); these tests pin the removal so the rule does not silently
// come back.
it("does not warn on a docs-compliant root with no data-duration", () => {
it("does not warn on a docs-compliant root with no data-duration", async () => {
// The documented authoring model: root composition without
// data-duration, runtime derives it from the GSAP timeline.
const html = `<!DOCTYPE html><html><body>
@@ -608,14 +612,14 @@ describe("composition rules", () => {
window.__timelines["docs"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_data_duration",
);
expect(finding).toBeUndefined();
});
it("does not warn even on the original Infinity-risk shape (no media, looping timeline)", () => {
it("does not warn even on the original Infinity-risk shape (no media, looping timeline)", async () => {
// This was the canonical "warn" case under the old rule — root with no
// data-duration, no media, GSAP timeline driven by repeat: -1. The
// looping shape itself is now flagged by `gsap_infinite_repeat`; the
@@ -632,7 +636,7 @@ describe("composition rules", () => {
window.__timelines["loopy"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
// The deprecated rule must not fire.
const removedFinding = result.findings.find(
(f) => f.code === "root_composition_missing_data_duration",
@@ -647,81 +651,81 @@ describe("composition rules", () => {
});
describe("root_composition_missing_data_start", () => {
it("does not warn for template-wrapped sub-composition files", () => {
it("does not warn for template-wrapped sub-composition files", async () => {
const html = `
<template id="foo-template">
<div data-composition-id="foo" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="1"></div>
</div>
</template>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "root_composition_missing_data_start");
expect(finding).toBeUndefined();
});
});
describe("invalid_variable_values_json", () => {
it("warns when data-variable-values is unparseable JSON", () => {
it("warns when data-variable-values is unparseable JSON", async () => {
const html = `<html><body>
<div data-composition-id="card-1" data-composition-src="card.html" data-variable-values='{not json'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("warns when data-variable-values is a JSON array (must be an object)", () => {
it("warns when data-variable-values is a JSON array (must be an object)", async () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='[1,2,3]'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/must be a JSON object/);
});
it("warns when data-variable-values is a JSON string (must be an object)", () => {
it("warns when data-variable-values is a JSON string (must be an object)", async () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='"hello"'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
});
it("does not warn for a valid JSON object", () => {
it("does not warn for a valid JSON object", async () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='{"title":"Hello","count":3}'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});
it("does not warn when data-variable-values is absent", () => {
it("does not warn when data-variable-values is absent", async () => {
const html = `<html><body>
<div data-composition-src="card.html"></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});
});
describe("invalid_composition_variables_declaration", () => {
it("warns when data-composition-variables is unparseable JSON", () => {
it("warns when data-composition-variables is unparseable JSON", async () => {
const html = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
});
it("warns when data-composition-variables is not an array", () => {
it("warns when data-composition-variables is not an array", async () => {
const html = `<html data-composition-variables='{"title":"Hello"}'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
@@ -729,9 +733,9 @@ describe("composition rules", () => {
expect(finding?.message).toMatch(/array of variable declarations/);
});
it("warns per-entry when an entry is missing required fields", () => {
it("warns per-entry when an entry is missing required fields", async () => {
const html = `<html data-composition-variables='[{"id":"ok","type":"string","label":"Ok","default":"x"},{"id":"bad"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "invalid_composition_variables_declaration",
);
@@ -740,9 +744,9 @@ describe("composition rules", () => {
expect(findings[0]?.message).toMatch(/type|label|default/);
});
it("warns when a declaration uses an unknown type", () => {
it("warns when a declaration uses an unknown type", async () => {
const html = `<html data-composition-variables='[{"id":"x","type":"date","label":"X","default":"y"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
@@ -750,22 +754,22 @@ describe("composition rules", () => {
expect(finding?.message).toMatch(/type/);
});
it("does not warn for a fully valid declarations array", () => {
it("does not warn for a fully valid declarations array", async () => {
const html = `<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"},
{"id":"count","type":"number","label":"Count","default":3},
{"id":"theme","type":"enum","label":"Theme","default":"light","options":[{"value":"light","label":"Light"}]}
]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeUndefined();
});
it("does not warn when data-composition-variables is absent", () => {
it("does not warn when data-composition-variables is absent", async () => {
const html = `<html><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
@@ -774,13 +778,13 @@ describe("composition rules", () => {
});
describe("invalid_capture_path", () => {
it("errors when an <img> src uses ../capture/", () => {
it("errors when an <img> src uses ../capture/", async () => {
const html = `<html><body>
<div data-composition-id="x">
<img src="../capture/assets/logo.svg" alt="logo">
</div>
</body></html>`;
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
@@ -788,7 +792,7 @@ describe("composition rules", () => {
expect(finding?.severity).toBe("error");
});
it("errors when a CSS url() uses ../capture/ (counts all occurrences)", () => {
it("errors when a CSS url() uses ../capture/ (counts all occurrences)", async () => {
const html = `<html><body>
<style>
@font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
@@ -796,7 +800,7 @@ describe("composition rules", () => {
</style>
<div data-composition-id="x"></div>
</body></html>`;
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
@@ -804,14 +808,14 @@ describe("composition rules", () => {
expect(finding?.message).toContain("2 asset path(s)");
});
it("does not flag root-relative capture/ paths", () => {
it("does not flag root-relative capture/ paths", async () => {
const html = `<html><body>
<div data-composition-id="x">
<img src="capture/assets/logo.svg" alt="logo">
</div>
<style>.hero { background-image: url('capture/assets/hero.png'); }</style>
</body></html>`;
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
+28 -28
View File
@@ -2,31 +2,31 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("core rules", () => {
it("reports error when root is missing data-composition-id", () => {
it("reports error when root is missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when root is missing data-width or data-height", () => {
it("reports error when root is missing data-width or data-height", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when timeline registry is missing", () => {
it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -34,12 +34,12 @@ describe("core rules", () => {
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("reports error for composition host missing data-composition-id", () => {
it("reports error for composition host missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -47,12 +47,12 @@ describe("core rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
expect(finding).toBeDefined();
});
it("reports error when timeline registry is assigned without initializing", () => {
it("reports error when timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -64,14 +64,14 @@ describe("core rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not flag timeline assignment when init guard is present", async () => {
const validComposition = `
<html>
<body>
@@ -87,12 +87,12 @@ describe("core rules", () => {
</script>
</body>
</html>`;
const result = lintHyperframeHtml(validComposition);
const result = await lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
it("warns when a timeline-visible element has no stable id for Studio editing", () => {
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -100,7 +100,7 @@ describe("core rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
@@ -108,7 +108,7 @@ describe("core rules", () => {
expect(finding?.fixHint).toContain("stable, human-readable id");
});
it("does not warn about the composition root or timeline elements with ids", () => {
it("does not warn about the composition root or timeline elements with ids", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
@@ -116,13 +116,13 @@ describe("core rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeUndefined();
});
describe("non_deterministic_code", () => {
it("detects Math.random() in script content", () => {
it("detects Math.random() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -132,14 +132,14 @@ describe("core rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Math.random");
});
it("detects Date.now() in script content", () => {
it("detects Date.now() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -149,14 +149,14 @@ describe("core rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Date.now");
});
it("does not flag non-deterministic calls inside single-line comments", () => {
it("does not flag non-deterministic calls inside single-line comments", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
@@ -167,14 +167,14 @@ describe("core rules", () => {
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeUndefined();
});
});
describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", () => {
it("warns when inline CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
@@ -186,7 +186,7 @@ describe("core rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "composition_self_attribute_selector",
);
@@ -198,13 +198,13 @@ describe("core rules", () => {
expect(findings[0]?.fixHint).not.toContain("#556");
});
it("warns when external CSS targets the root composition id", () => {
it("warns when external CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html, {
const result = await lintHyperframeHtml(html, {
externalStyles: [
{
href: "scene.css",
@@ -218,7 +218,7 @@ describe("core rules", () => {
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
});
it("does not warn when CSS targets a different composition id", () => {
it("does not warn when CSS targets a different composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
@@ -226,7 +226,7 @@ describe("core rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeUndefined();
+24 -24
View File
@@ -1,82 +1,82 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function findByCode(html: string, code: string, isSubComposition = true) {
const result = lintHyperframeHtml(html, { isSubComposition });
async function findByCode(html: string, code: string, isSubComposition = true) {
const result = await lintHyperframeHtml(html, { isSubComposition });
return result.findings.filter((f) => f.code === code);
}
describe("font rules", () => {
describe("google_fonts_import", () => {
it("flags @import url with fonts.googleapis.com", () => {
it("flags @import url with fonts.googleapis.com", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
</div>`;
const findings = findByCode(html, "google_fonts_import");
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
});
it("flags <link> to fonts.googleapis.com", () => {
it("flags <link> to fonts.googleapis.com", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</div>`;
const findings = findByCode(html, "google_fonts_import");
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
});
it("does not flag local @font-face usage", () => {
it("does not flag local @font-face usage", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
</div>`;
const findings = findByCode(html, "google_fonts_import");
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
});
describe("font_family_without_font_face", () => {
it("flags font-family used without @font-face", () => {
it("flags font-family used without @font-face", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("gt walsheim");
});
it("does not flag when @font-face is declared", () => {
it("does not flag when @font-face is declared", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
body { font-family: 'GT Walsheim', sans-serif; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag generic font families", () => {
it("does not flag generic font families", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: monospace; }</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("reports multiple missing families in one finding", () => {
it("reports multiple missing families in one finding", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
h1 { font-family: 'Aeonik', sans-serif; }
code { font-family: 'Feature Deck', monospace; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("aeonik");
expect(findings[0]!.message).toContain("feature deck");
});
it("does not flag fonts the producer has pre-bundled", () => {
it("does not flag fonts the producer has pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: 'Inter', sans-serif; }
@@ -84,37 +84,37 @@ describe("font rules", () => {
h1 { font-family: 'Roboto', sans-serif; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("still flags Google-Fonts-only fonts not pre-bundled", () => {
it("still flags Google-Fonts-only fonts not pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("geist");
});
it("is case-insensitive when matching @font-face to font-family", () => {
it("is case-insensitive when matching @font-face to font-family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
body { font-family: 'inter', sans-serif; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("ignores font-family inside @font-face blocks", () => {
it("ignores font-family inside @font-face blocks", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
});
+82 -82
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("GSAP rules", () => {
it("does NOT error when GSAP animates opacity on a clip element (by id)", () => {
it("does NOT error when GSAP animates opacity on a clip element (by id)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -17,12 +17,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP targets a clip element with safe properties (by class)", () => {
it("does NOT error when GSAP targets a clip element with safe properties (by class)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -37,12 +37,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
it("does NOT flag GSAP targeting a child of a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -57,12 +57,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", () => {
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -77,12 +77,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP targets a clip element with safe properties (class-only, no id)", () => {
it("does NOT error when GSAP targets a clip element with safe properties (class-only, no id)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -97,12 +97,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates opacity on a clip element", () => {
it("does NOT error when GSAP animates opacity on a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -117,12 +117,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT error when GSAP animates transform props on a clip element", () => {
it("does NOT error when GSAP animates transform props on a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -137,12 +137,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT require a local GSAP script for sub-compositions", () => {
it("does NOT require a local GSAP script for sub-compositions", async () => {
const html = `<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
@@ -155,12 +155,12 @@ describe("GSAP rules", () => {
</div>
</template>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("does NOT require a local GSAP script when a template composition is linted in isolation", () => {
it("does NOT require a local GSAP script when a template composition is linted in isolation", async () => {
const html = `<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title">Hello</div>
@@ -173,12 +173,12 @@ describe("GSAP rules", () => {
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/intro.html" });
const result = await lintHyperframeHtml(html, { filePath: "compositions/intro.html" });
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("ERRORS when GSAP animates visibility on a clip element", () => {
it("ERRORS when GSAP animates visibility on a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -193,7 +193,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
@@ -201,7 +201,7 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain("visibility");
});
it("ERRORS when GSAP animates display on a clip element", () => {
it("ERRORS when GSAP animates display on a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -216,7 +216,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
@@ -224,7 +224,7 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain("display");
});
it("ERRORS when GSAP tween mixes safe properties with visibility on a clip element", () => {
it("ERRORS when GSAP tween mixes safe properties with visibility on a clip element", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -239,14 +239,14 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("visibility");
});
it("warns when tl.to animates x on an element with CSS translateX", () => {
it("warns when tl.to animates x on an element with CSS translateX", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -262,7 +262,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
@@ -271,7 +271,7 @@ describe("GSAP rules", () => {
expect(finding?.fixHint).toMatch(/xPercent/);
});
it("warns when tl.to animates scale on an element with CSS scale transform", () => {
it("warns when tl.to animates scale on an element with CSS scale transform", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -287,14 +287,14 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does NOT warn when tl.to targets element without CSS transform", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -310,12 +310,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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)", () => {
it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -331,12 +331,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -352,7 +352,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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/);
@@ -360,7 +360,7 @@ describe("GSAP rules", () => {
// --- Inline style transform detection tests ---
it("warns when inline style transform: translateX conflicts with GSAP x", () => {
it("warns when inline style transform: translateX conflicts with GSAP x", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -373,13 +373,13 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.selector).toBe("#centered");
});
it("warns when inline style transform: scale conflicts with GSAP scale", () => {
it("warns when inline style transform: scale conflicts with GSAP scale", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -392,13 +392,13 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.selector).toBe("#box");
});
it("does not false-positive on inline transform: rotate when GSAP uses rotation", () => {
it("does not false-positive on inline transform: rotate when GSAP uses rotation", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -411,13 +411,13 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
// rotation doesn't conflict with rotate() — GSAP handles rotation separately
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeUndefined();
});
it("detects conflict via class selector when element has multiple classes", () => {
it("detects conflict via class selector when element has multiple classes", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -430,12 +430,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
});
it("handles both style block and inline style on same selector without crash", () => {
it("handles both style block and inline style on same selector without crash", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -451,12 +451,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
expect(conflicts.length).toBeGreaterThanOrEqual(1);
});
it("reports error when GSAP is used without a GSAP script tag", () => {
it("reports error when GSAP is used without a GSAP script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -467,14 +467,14 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await 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", () => {
it("does not report missing_gsap_script when GSAP CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -486,12 +486,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("does not report missing_gsap_script when GSAP is bundled inline", () => {
it("does not report missing_gsap_script when GSAP is bundled inline", async () => {
// Simulate a large inline GSAP bundle (>5KB) with GreenSock marker
const fakeGsapLib = "/* GreenSock GSAP */" + " ".repeat(6000);
const html = `
@@ -505,12 +505,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("does not report missing_gsap_script when producer inlined CDN script", () => {
it("does not report missing_gsap_script when producer inlined CDN script", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -524,12 +524,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("still reports missing_gsap_script for small inline scripts that use but don't bundle GSAP", () => {
it("still reports missing_gsap_script for small inline scripts that use but don't bundle GSAP", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -540,13 +540,13 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("errors on repeat: -1 (infinite repeat breaks capture engine)", () => {
it("errors on repeat: -1 (infinite repeat breaks capture engine)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -558,14 +558,14 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("repeat: -1");
});
it("does not error on finite repeat values", () => {
it("does not error on finite repeat values", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -577,12 +577,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
expect(finding).toBeUndefined();
});
it("does not error on repeat: -1 inside JavaScript comments", () => {
it("does not error on repeat: -1 inside JavaScript comments", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -598,12 +598,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
expect(finding).toBeUndefined();
});
it("does NOT report overlapping_gsap_tweens when an object-target tween is interleaved (regression)", () => {
it("does NOT report overlapping_gsap_tweens when an object-target tween is interleaved (regression)", async () => {
// Regression: a non-DOM-targeting tween like `tl.to({ _: 0 }, …)` (used to
// anchor timeline duration) was matched by the regex but skipped by the
// parser, drifting the index and making the second tween "see" the first
@@ -624,12 +624,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeUndefined();
});
it("warns when an opacity exit ends at a clip start boundary without a hard kill", () => {
it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
@@ -648,7 +648,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
@@ -656,7 +656,7 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain("3.00s");
});
it("does not warn when a boundary exit has a matching hard kill", () => {
it("does not warn when a boundary exit has a matching hard kill", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
@@ -676,12 +676,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not match sub-composition exits against root clip boundaries", () => {
it("does not match sub-composition exits against root clip boundaries", async () => {
const html = `
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-start="0" data-duration="6">
@@ -701,12 +701,12 @@ describe("GSAP rules", () => {
window.__timelines["sub"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("uses the authored hidden property in hard-kill fix hints", () => {
it("uses the authored hidden property in hard-kill fix hints", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
@@ -725,12 +725,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding?.fixHint).toContain("{ autoAlpha: 0 }");
});
it("does not false-positive on repeat: -10 (invalid GSAP but not infinite)", () => {
it("does not false-positive on repeat: -10 (invalid GSAP but not infinite)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
@@ -742,12 +742,12 @@ describe("GSAP rules", () => {
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
expect(finding).toBeUndefined();
});
it("errors when CSS opacity:0 + gsap.from({opacity:0}) — invisible forever", () => {
it("errors when CSS opacity:0 + gsap.from({opacity:0}) — invisible forever", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -760,14 +760,14 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("error");
expect(finding!.selector).toBe("#title");
});
it("errors when style block has opacity:0 + gsap.from({opacity:0})", () => {
it("errors when style block has opacity:0 + gsap.from({opacity:0})", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -783,12 +783,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeDefined();
});
it("does NOT error when gsap.from({opacity:0}) and CSS has no opacity:0", () => {
it("does NOT error when gsap.from({opacity:0}) and CSS has no opacity:0", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -801,12 +801,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeUndefined();
});
it("does NOT error when gsap.fromTo({opacity:0}, {opacity:1}) — destination overrides CSS", () => {
it("does NOT error when gsap.fromTo({opacity:0}, {opacity:1}) — destination overrides CSS", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -819,12 +819,12 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeUndefined();
});
it("does NOT error when gsap.to() uses opacity:0 (exit animation)", () => {
it("does NOT error when gsap.to() uses opacity:0 (exit animation)", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
@@ -837,7 +837,7 @@ describe("GSAP rules", () => {
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
expect(finding).toBeUndefined();
});
+31 -10
View File
@@ -1,5 +1,22 @@
import { parseGsapScript } from "../../parsers/gsapParser";
import type { LintContext, HyperframeLintFinding } from "../context";
interface LintParsedGsap {
animations: Array<{
targetSelector: string;
position: number | string;
properties: Record<string, number | string>;
}>;
timelineVar: string;
}
// The recast-based GSAP parser lives behind the Node-only
// `@hyperframes/core/gsap-parser` subpath. The linter runs server-side only
// (CLI + studio-api `/lint` route), so loading it via dynamic import keeps
// recast out of any browser/SSR-traced static graph.
async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> {
const mod = await import("../../parsers/gsapParser.js");
return mod.parseGsapScript as unknown as (script: string) => LintParsedGsap;
}
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
@@ -108,8 +125,9 @@ function readRegisteredTimelineCompositionId(script: string): string | null {
return match?.[1] || null;
}
function extractGsapWindows(script: string): GsapWindow[] {
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (!/gsap\.timeline/.test(script)) return [];
const parseGsapScript = await loadParseGsapScript();
const parsed = parseGsapScript(script);
if (parsed.animations.length === 0) return [];
@@ -135,6 +153,9 @@ function extractGsapWindows(script: string): GsapWindow[] {
const animation = parsed.animations[index];
index += 1;
if (!animation) continue;
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
// timing depends on runtime evaluation and can't be statically linted.
if (typeof animation.position !== "number") continue;
windows.push({
targetSelector: animation.targetSelector,
position: animation.position,
@@ -433,9 +454,9 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
// ── GSAP rules ─────────────────────────────────────────────────────────────
export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
export const gsapRules: LintRule<LintContext>[] = [
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
({ source, tags, scripts, rootCompositionId }) => {
async ({ source, tags, scripts, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Build clip element selector map
@@ -463,7 +484,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
const gsapWindows = extractGsapWindows(script.content);
const gsapWindows = await extractGsapWindows(script.content);
const clipStartBoundaries =
clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
@@ -564,7 +585,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// gsap_css_transform_conflict
({ styles, scripts, tags }) => {
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssTranslateSelectors = new Map<string, string>();
const cssScaleSelectors = new Map<string, string>();
@@ -610,7 +631,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = extractGsapWindows(script.content);
const windows = await extractGsapWindows(script.content);
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
const conflicts = new Map<string, Conflict>();
@@ -845,7 +866,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
({ styles, scripts, tags }) => {
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssOpacityZeroSelectors = new Set<string>();
@@ -872,7 +893,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = extractGsapWindows(script.content);
const windows = await extractGsapWindows(script.content);
for (const win of windows) {
if (win.method !== "from") continue;
+28 -28
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("media rules", () => {
it("reports error for duplicate media ids", () => {
it("reports error for duplicate media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -11,14 +11,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for audio with data-start but no id", () => {
it("reports error for audio with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -26,14 +26,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", () => {
it("reports error for video with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -41,14 +41,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("does not flag media elements that have id", () => {
it("does not flag media elements that have id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -57,12 +57,12 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", () => {
it("reports warning for media with preload=none", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -70,13 +70,13 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", () => {
it("reports error for media with id but no src", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -84,13 +84,13 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for media with src but no data-start", () => {
it("reports error for media with src but no data-start", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -98,14 +98,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("allows audible video clips to omit muted when data-has-audio is true", () => {
it("allows audible video clips to omit muted when data-has-audio is true", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -113,14 +113,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
).toBeUndefined();
});
it("reports error for videos that declare audio while muted", () => {
it("reports error for videos that declare audio while muted", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -128,14 +128,14 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("does NOT flag <video> as nested in a void element with data-start (regression)", () => {
it("does NOT flag <video> as nested in a void element with data-start (regression)", async () => {
// Regression: void elements like <img> have no closing tag, so the previous
// implementation kept them on the parent stack indefinitely and flagged any
// later <video> with data-start as "nested" inside them.
@@ -147,12 +147,12 @@ describe("media rules", () => {
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
it("reports imperative play() control on managed media ids", () => {
it("reports imperative play() control on managed media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -163,14 +163,14 @@ describe("media rules", () => {
video.play();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("reports imperative currentTime writes on query-selected managed media", () => {
it("reports imperative currentTime writes on query-selected managed media", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -181,13 +181,13 @@ describe("media rules", () => {
demo.currentTime = 1.5;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports imperative muted/play control on class-selected media without ids", () => {
it("reports imperative muted/play control on class-selected media without ids", async () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
@@ -198,14 +198,14 @@ describe("media rules", () => {
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
expect(imperativeFindings.length).toBe(2);
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
});
it("does not flag play() on non-media elements", () => {
it("does not flag play() on non-media elements", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -216,7 +216,7 @@ describe("media rules", () => {
panel.play?.();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
+20 -20
View File
@@ -24,21 +24,21 @@ const textureCss = `
`;
describe("texture rules", () => {
it("does not warn for a valid texture mask text usage", () => {
it("does not warn for a valid texture mask text usage", async () => {
const html = baseHtml(
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
});
it("warns when a material class is used without hf-texture-text", () => {
it("warns when a material class is used without hf-texture-text", async () => {
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
expect(finding).toBeDefined();
@@ -46,98 +46,98 @@ describe("texture rules", () => {
expect(finding?.fixHint).toContain("hf-texture-text");
});
it("warns when hf-texture-text has no material class or custom mask image", () => {
it("warns when hf-texture-text has no material class or custom mask image", async () => {
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("allows hf-texture-text with an inline custom mask image", () => {
it("allows hf-texture-text with an inline custom mask image", async () => {
const html = baseHtml(
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
textureCss,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeUndefined();
});
it("warns when a texture material class is not defined by local CSS", () => {
it("warns when a texture material class is not defined by local CSS", async () => {
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
expect(finding).toBeDefined();
expect(finding?.message).toContain("hf-texture-marbel");
});
it("warns when drop-shadow is applied inline to the textured text element", () => {
it("warns when drop-shadow is applied inline to the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
textureCss,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("wrapper");
});
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", () => {
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-text");
});
it("warns when drop-shadow targets a material class before the mask rule is declared", () => {
it("warns when drop-shadow targets a material class before the mask rule is declared", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
${textureCss}`,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-lava");
});
it("warns when drop-shadow targets another class on the textured text element", () => {
it("warns when drop-shadow targets another class on the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".headline");
});
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", () => {
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeUndefined();
+5 -3
View File
@@ -25,6 +25,8 @@ export type HyperframeLinterOptions = {
externalStyles?: Array<{ href: string; content: string }>;
};
// A rule is a pure function: receives parsed context, returns zero or more findings.
// Rule modules should receive a LintContext (defined in ./context) as the type parameter.
export type LintRule<TContext> = (ctx: TContext) => HyperframeLintFinding[];
// A rule is a function: receives parsed context, returns zero or more findings.
// Rules may be async (e.g. when lazy-loading heavy dependencies like recast).
export type LintRule<TContext> = (
ctx: TContext,
) => HyperframeLintFinding[] | Promise<HyperframeLintFinding[]>;