diff --git a/bun.lock b/bun.lock index 797509c8ee..f277c12249 100644 --- a/bun.lock +++ b/bun.lock @@ -176,6 +176,7 @@ "htmlparser2": "^10.1.0", "linkedom": "^0.18.12", "postcss": "^8.5.8", + "postcss-selector-parser": "^7.1.4", }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/packages/lint/package.json b/packages/lint/package.json index b896363047..e48bce0526 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -56,7 +56,8 @@ "@hyperframes/parsers": "workspace:*", "htmlparser2": "^10.1.0", "linkedom": "^0.18.12", - "postcss": "^8.5.8" + "postcss": "^8.5.8", + "postcss-selector-parser": "^7.1.4" }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 057e4b482b..bcfd5f94aa 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -848,6 +848,108 @@ body { }); }); + describe("repeated_id_descendant_selector", () => { + it("reports a selector that nests the same id inside itself", async () => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "repeated_id_descendant_selector"); + expect(finding?.severity).toBe("error"); + expect(finding?.selector).toBe("#scene_01 #scene_01 .headline"); + }); + + it("does not report distinct descendant ids", async () => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "repeated_id_descendant_selector"), + ).toBeUndefined(); + }); + + it.each(["#scene_01 > #scene_01", "#scene_01 .wrapper #scene_01"])( + "reports repeated ids across descendant combinators: %s", + async (selector) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "repeated_id_descendant_selector"), + ).toBeDefined(); + }, + ); + + it.each([ + ":is(#scene_01) #scene_01", + ":where(#scene_01) #scene_01", + "#scene_01 :is(#scene_01)", + ])("reports repeated ids required by selector pseudos: %s", async (selector) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "repeated_id_descendant_selector"), + ).toBeDefined(); + }); + + it.each([ + ":is(#scene_01, .scene) #scene_01", + ":not(#scene_01) #scene_01", + ":has(#scene_01) #scene_01", + ])("does not report ids that are not required by a selector pseudo: %s", async (selector) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "repeated_id_descendant_selector"), + ).toBeUndefined(); + }); + + it.each(["& #scene_01 .headline", "#scene_01 .headline"])( + "reports repeated ids created by nested CSS: %s", + async (nestedSelector) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (candidate) => candidate.code === "repeated_id_descendant_selector", + ); + expect(finding).toBeDefined(); + expect(finding?.selector).toContain("#scene_01 #scene_01"); + }, + ); + + it("preserves dollar sequences while resolving nested selectors", async () => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (candidate) => candidate.code === "repeated_id_descendant_selector", + ); + expect(finding?.selector).toBe('#scene_01[data-query="$1"] #scene_01'); + }); + + it.each(['[data-query="#scene_01 #scene_01"]', String.raw`#scene_01 #scene_01\:child`])( + "does not report non-repeated parsed ids: %s", + async (selector) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "repeated_id_descendant_selector"), + ).toBeUndefined(); + }, + ); + }); + it("warns when a timeline-visible element has no stable id for Studio editing", async () => { const html = ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 1e0c80e342..15bc8f8fbb 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -1,5 +1,6 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import postcss from "postcss"; +import selectorParser from "postcss-selector-parser"; import { readAttr, readDecodedAttr, @@ -25,6 +26,87 @@ function selectorTargetsCompositionId(selector: string, compositionId: string): ).test(selector); } +function repeatedDescendantId(selector: string): string | null { + let repeated: string | null = null; + + const requiredPseudoIds = (pseudo: selectorParser.Pseudo): Set => { + if (![":is", ":where"].includes(pseudo.value.toLowerCase()) || pseudo.nodes.length === 0) { + return new Set(); + } + + const optionIdSets: Set[] = []; + for (const option of pseudo.nodes) { + // Only promote ids from a single compound. For selector-list branches with + // combinators, determining which compound is the subject requires fuller + // selector semantics; skipping them avoids false positives. + if (option.nodes.some((node) => node.type === "combinator")) return new Set(); + const optionIds = new Set( + option.nodes.filter((node) => node.type === "id").map((node) => node.value), + ); + optionIdSets.push(optionIds); + } + const [firstOptionIds, ...remainingOptionIds] = optionIdSets; + return new Set( + [...(firstOptionIds ?? [])].filter((id) => + remainingOptionIds.every((optionIds) => optionIds.has(id)), + ), + ); + }; + + try { + selectorParser((root) => { + root.each((selectorNode) => { + const firstCompoundById = new Map(); + let compound = 0; + selectorNode.each((node) => { + if (repeated) return; + if (node.type === "combinator") { + compound += 1; + return; + } + const requiredIds = + node.type === "id" + ? [node.value] + : node.type === "pseudo" + ? [...requiredPseudoIds(node)] + : []; + for (const id of requiredIds) { + const firstCompound = firstCompoundById.get(id); + if (firstCompound !== undefined && firstCompound !== compound) { + repeated = id; + return; + } + firstCompoundById.set(id, compound); + } + }); + }); + }).processSync(selector); + } catch { + return null; + } + return repeated; +} + +function resolvedRuleSelectors(rule: postcss.Rule): string[] { + let ancestor: postcss.AnyNode | undefined = rule.parent; + while (ancestor && ancestor.type !== "rule") ancestor = ancestor.parent; + if (!ancestor || ancestor.type !== "rule") return rule.selectors; + + const parentSelectors = resolvedRuleSelectors(ancestor); + return parentSelectors.flatMap((parentSelector) => + rule.selectors.map((childSelector) => { + const nestingToken = /(^|[\s>+~,(])&/g; + if (nestingToken.test(childSelector)) { + return childSelector.replace( + nestingToken, + (_, separator: string) => separator + parentSelector, + ); + } + return `${parentSelector} ${childSelector}`; + }), + ); +} + function isStudioTimelineElement(tag: { raw: string; name: string }): boolean { if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) { return false; @@ -321,6 +403,35 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // repeated_id_descendant_selector + ({ styles }) => { + const findings: HyperframeLintFinding[] = []; + const reported = new Set(); + for (const style of styles) { + let root: postcss.Root; + try { + root = postcss.parse(style.content); + } catch { + continue; + } + root.walkRules((rule) => { + for (const selector of resolvedRuleSelectors(rule)) { + const repeatedId = repeatedDescendantId(selector); + if (!repeatedId || reported.has(repeatedId)) continue; + reported.add(repeatedId); + findings.push({ + code: "repeated_id_descendant_selector", + severity: "error", + message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`, + selector, + fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`, + }); + } + }); + } + return findings; + }, + // invalid_inline_script_syntax (malformed close tag) ({ source }) => { if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return []; diff --git a/packages/lint/src/rules/fonts.test.ts b/packages/lint/src/rules/fonts.test.ts index eb90f7ba80..8facee61c9 100644 --- a/packages/lint/src/rules/fonts.test.ts +++ b/packages/lint/src/rules/fonts.test.ts @@ -262,6 +262,34 @@ describe("font rules", () => { expect(result.errorCount).toBe(0); }); + it("accepts a URL-style plus alias used literally as the CSS family", async () => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + expect( + result.findings.filter((f) => f.code === "font_family_without_font_face"), + ).toHaveLength(0); + expect(result.errorCount).toBe(0); + }); + + it("does not decode percent escapes in a literal CSS family", async () => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + expect( + result.findings.filter((f) => f.code === "font_family_without_font_face"), + ).toHaveLength(1); + }); + it("still flags non-bundled families not covered by the Google Fonts URL", async () => { const html = `
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 = `