diff --git a/packages/lint/src/rules/fonts.ts b/packages/lint/src/rules/fonts.ts
index 42eefb00f0..cba97865f2 100644
--- a/packages/lint/src/rules/fonts.ts
+++ b/packages/lint/src/rules/fonts.ts
@@ -220,7 +220,10 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const googleFonts = collectGoogleFontFamilies(source, styles);
const undeclared = used.filter(
- (name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
+ (name) =>
+ !declared.has(name) &&
+ !FONT_ALIAS_KEYS.has(name) &&
+ !googleFonts.has(name.replace(/\+/g, " ")),
);
if (undeclared.length === 0) return findings;
diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 2365a60d32..5c980ee716 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -1011,6 +1011,300 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});
+ it("reports motionPath usage without MotionPathPlugin", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "missing_gsap_plugin");
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("MotionPathPlugin");
+ });
+
+ it("reports standalone gsap.to motionPath usage without MotionPathPlugin", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("reports ESM motionPath usage without importing MotionPathPlugin", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("accepts motionPath usage when MotionPathPlugin is loaded", async () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("reports MotionPathPlugin loaded after the animation script", async () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it.each(["defer", "async", 'type="module"'])(
+ "does not treat an earlier non-blocking %s plugin script as available to classic code",
+ async (attribute) => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ },
+ );
+
+ it("treats defer on an inline classic tween as blocking", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("accepts a deferred classic plugin before a non-async module tween", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("ignores async and defer text inside quoted attribute values", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("recognizes valid unquoted module attributes", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("treats async on an inline classic plugin definition as blocking", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("does not accept plugin-like substrings in import specifiers", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("accepts a static plugin import in the same async module as the tween", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("reports an inline plugin definition that occurs after the tween", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("does not treat a long comment-only MotionPathPlugin mention as a loaded bundle", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("does not treat a MotionPathPlugin string literal as a loaded bundle", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("still reports motionPath when code registers an unloaded plugin global", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeDefined();
+ });
+
+ it("does not treat an unrelated motionPath object as GSAP plugin usage", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("allows sub-compositions to inherit MotionPathPlugin from their host", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html, { isSubComposition: true });
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("accepts an inline ESM import of MotionPathPlugin", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("accepts a default-aliased ESM import sourced from MotionPathPlugin", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
+ it("accepts a named MotionPathPlugin import from a barrel module", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "missing_gsap_plugin")).toBeUndefined();
+ });
+
it("does NOT report overlapping_gsap_tweens for distinct unresolved-target tweens", async () => {
// Each tween targets a DIFFERENT element via a target the parser cannot resolve
// statically (a helper call). Both collapse to the `__unresolved__` sentinel, but
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index a014ed7249..8b2f113ec6 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -26,6 +26,11 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
}
+
+async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> {
+ const mod = await import("@hyperframes/parsers/gsap-parser-acorn");
+ return mod.gsapScriptMotionPathFirstUseIndex;
+}
import type { LintContext } from "../context";
import type { HyperframeLintFinding, LintRule } from "../types";
import type { OpenTag } from "../utils";
@@ -1420,6 +1425,85 @@ export const gsapRules: LintRule
[] = [
];
},
+ // missing_gsap_plugin
+ async ({ scripts, rawSource, options }) => {
+ const canInheritPluginFromHost =
+ options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("
+ gsapScriptMotionPathFirstUseIndex(script.content),
+ );
+ const firstMotionPathScriptIndex = motionPathUseIndices.findIndex((index) => index !== null);
+ const firstMotionPathUseIndex = motionPathUseIndices[firstMotionPathScriptIndex] ?? null;
+ const firstUseScript = scripts[firstMotionPathScriptIndex];
+ const executionMode = (attrs: string): "blocking" | "defer" | "module" | "async" => {
+ const tag = `