fix(core): recognize dot-property timeline registration (#1138)

This commit is contained in:
Carlos de la Cruz
2026-05-30 18:10:15 -04:00
committed by GitHub
parent 4f1f99ef1d
commit 2362cf979d
5 changed files with 109 additions and 6 deletions
+66
View File
@@ -71,6 +71,24 @@ describe("core rules", () => {
expect(finding?.message).toContain("without initializing"); expect(finding?.message).toContain("without initializing");
}); });
it("reports error when dot timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines.c1 = tl;
</script>
</body></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");
});
it("does not flag timeline assignment when init guard is present", async () => { it("does not flag timeline assignment when init guard is present", async () => {
const validComposition = ` const validComposition = `
<html> <html>
@@ -92,6 +110,54 @@ describe("core rules", () => {
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
describe("timeline_id_mismatch", () => {
it("accepts dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.launch = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
it("reports mismatched dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.intro = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeDefined();
expect(finding?.message).toContain('Timeline registered as "intro"');
});
it("accepts bracket timeline registration for hyphenated ids", async () => {
const html = `
<html><body>
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["product-launch"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
});
it("warns when a timeline-visible element has no stable id for Studio editing", async () => { it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = ` const html = `
<html><body> <html><body>
+3 -3
View File
@@ -4,6 +4,7 @@ import {
readAttr, readAttr,
truncateSnippet, truncateSnippet,
extractCompositionIdsFromCss, extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError, getInlineScriptSyntaxError,
TIMELINE_REGISTRY_INIT_PATTERN, TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN, TIMELINE_REGISTRY_ASSIGN_PATTERN,
@@ -118,13 +119,12 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const htmlCompIds = new Set<string>(); const htmlCompIds = new Set<string>();
const timelineRegKeys = new Set<string>(); const timelineRegKeys = new Set<string>();
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi; const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = compIdRe.exec(source)) !== null) { while ((m = compIdRe.exec(source)) !== null) {
if (m[1]) htmlCompIds.add(m[1]); if (m[1]) htmlCompIds.add(m[1]);
} }
while ((m = tlKeyRe.exec(source)) !== null) { for (const key of extractTimelineRegistryKeys(source)) {
if (m[1]) timelineRegKeys.add(m[1]); timelineRegKeys.add(key);
} }
for (const key of timelineRegKeys) { for (const key of timelineRegKeys) {
if (!htmlCompIds.has(key)) { if (!htmlCompIds.has(key)) {
+19
View File
@@ -903,6 +903,25 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
it("does NOT warn when timeline is registered with dot property syntax", async () => {
const html = `
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<div id="box">Hello</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0.5, duration: 2 });
window.__timelines.root = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
expect(finding).toBeUndefined();
});
it("does NOT warn for sub-compositions (template-based)", async () => { it("does NOT warn for sub-compositions (template-based)", async () => {
const html = ` const html = `
<template> <template>
+1 -1
View File
@@ -125,7 +125,7 @@ function countClassUsage(tags: OpenTag[]): Map<string, number> {
function readRegisteredTimelineCompositionId(script: string): string | null { function readRegisteredTimelineCompositionId(script: string): string | null {
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN); const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
return match?.[1] || null; return match?.[1] || match?.[2] || null;
} }
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */ /** Strip a `__raw:` prefix the parser adds to unresolvable values. */
+20 -2
View File
@@ -21,11 +21,15 @@ export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g; const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
export const TIMELINE_REGISTRY_INIT_PATTERN = export const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i; /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i; export const TIMELINE_REGISTRY_ASSIGN_PATTERN =
/window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
export const WINDOW_TIMELINE_ASSIGN_PATTERN = export const WINDOW_TIMELINE_ASSIGN_PATTERN =
/window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i; /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i; export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
const TIMELINE_REGISTRY_KEY_PATTERN =
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
export function extractOpenTags(source: string): OpenTag[] { export function extractOpenTags(source: string): OpenTag[] {
const tags: OpenTag[] = []; const tags: OpenTag[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@@ -141,6 +145,20 @@ export function extractCompositionIdsFromCss(css: string): string[] {
return [...ids]; return [...ids];
} }
export function extractTimelineRegistryKeys(source: string): string[] {
const keys = new Set<string>();
let match: RegExpExecArray | null;
const pattern = new RegExp(
TIMELINE_REGISTRY_KEY_PATTERN.source,
TIMELINE_REGISTRY_KEY_PATTERN.flags,
);
while ((match = pattern.exec(source)) !== null) {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
return [...keys];
}
export function getInlineScriptSyntaxError(source: string): string | null { export function getInlineScriptSyntaxError(source: string): string | null {
if (!source.trim()) return null; if (!source.trim()) return null;
try { try {