Skip to content

Commit 2362cf9

Browse files
authored
fix(core): recognize dot-property timeline registration (#1138)
1 parent 4f1f99e commit 2362cf9

5 files changed

Lines changed: 109 additions & 6 deletions

File tree

packages/core/src/lint/rules/core.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ describe("core rules", () => {
7171
expect(finding?.message).toContain("without initializing");
7272
});
7373

74+
it("reports error when dot timeline registry is assigned without initializing", async () => {
75+
const html = `
76+
<html><body>
77+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
78+
<div id="stage"></div>
79+
</div>
80+
<script>
81+
const tl = gsap.timeline({ paused: true });
82+
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
83+
window.__timelines.c1 = tl;
84+
</script>
85+
</body></html>`;
86+
const result = await lintHyperframeHtml(html);
87+
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
88+
expect(finding).toBeDefined();
89+
expect(finding?.severity).toBe("error");
90+
});
91+
7492
it("does not flag timeline assignment when init guard is present", async () => {
7593
const validComposition = `
7694
<html>
@@ -92,6 +110,54 @@ describe("core rules", () => {
92110
expect(finding).toBeUndefined();
93111
});
94112

113+
describe("timeline_id_mismatch", () => {
114+
it("accepts dot timeline registration", async () => {
115+
const html = `
116+
<html><body>
117+
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
118+
<script>
119+
window.__timelines = window.__timelines || {};
120+
const tl = gsap.timeline({ paused: true });
121+
window.__timelines.launch = tl;
122+
</script>
123+
</body></html>`;
124+
const result = await lintHyperframeHtml(html);
125+
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
126+
expect(finding).toBeUndefined();
127+
});
128+
129+
it("reports mismatched dot timeline registration", async () => {
130+
const html = `
131+
<html><body>
132+
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
133+
<script>
134+
window.__timelines = window.__timelines || {};
135+
const tl = gsap.timeline({ paused: true });
136+
window.__timelines.intro = tl;
137+
</script>
138+
</body></html>`;
139+
const result = await lintHyperframeHtml(html);
140+
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
141+
expect(finding).toBeDefined();
142+
expect(finding?.message).toContain('Timeline registered as "intro"');
143+
});
144+
145+
it("accepts bracket timeline registration for hyphenated ids", async () => {
146+
const html = `
147+
<html><body>
148+
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
149+
<script>
150+
window.__timelines = window.__timelines || {};
151+
const tl = gsap.timeline({ paused: true });
152+
window.__timelines["product-launch"] = tl;
153+
</script>
154+
</body></html>`;
155+
const result = await lintHyperframeHtml(html);
156+
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
157+
expect(finding).toBeUndefined();
158+
});
159+
});
160+
95161
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
96162
const html = `
97163
<html><body>

packages/core/src/lint/rules/core.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
readAttr,
55
truncateSnippet,
66
extractCompositionIdsFromCss,
7+
extractTimelineRegistryKeys,
78
getInlineScriptSyntaxError,
89
TIMELINE_REGISTRY_INIT_PATTERN,
910
TIMELINE_REGISTRY_ASSIGN_PATTERN,
@@ -118,13 +119,12 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
118119
const htmlCompIds = new Set<string>();
119120
const timelineRegKeys = new Set<string>();
120121
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
121-
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
122122
let m: RegExpExecArray | null;
123123
while ((m = compIdRe.exec(source)) !== null) {
124124
if (m[1]) htmlCompIds.add(m[1]);
125125
}
126-
while ((m = tlKeyRe.exec(source)) !== null) {
127-
if (m[1]) timelineRegKeys.add(m[1]);
126+
for (const key of extractTimelineRegistryKeys(source)) {
127+
timelineRegKeys.add(key);
128128
}
129129
for (const key of timelineRegKeys) {
130130
if (!htmlCompIds.has(key)) {

packages/core/src/lint/rules/gsap.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,25 @@ describe("GSAP rules", () => {
903903
expect(finding).toBeUndefined();
904904
});
905905

906+
it("does NOT warn when timeline is registered with dot property syntax", async () => {
907+
const html = `
908+
<html><body>
909+
<div data-composition-id="root" data-width="1920" data-height="1080">
910+
<div id="box">Hello</div>
911+
</div>
912+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
913+
<script>
914+
window.__timelines = window.__timelines || {};
915+
const tl = gsap.timeline({ paused: true });
916+
tl.to("#box", { opacity: 0.5, duration: 2 });
917+
window.__timelines.root = tl;
918+
</script>
919+
</body></html>`;
920+
const result = await lintHyperframeHtml(html);
921+
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
922+
expect(finding).toBeUndefined();
923+
});
924+
906925
it("does NOT warn for sub-compositions (template-based)", async () => {
907926
const html = `
908927
<template>

packages/core/src/lint/rules/gsap.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function countClassUsage(tags: OpenTag[]): Map<string, number> {
125125

126126
function readRegisteredTimelineCompositionId(script: string): string | null {
127127
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
128-
return match?.[1] || null;
128+
return match?.[1] || match?.[2] || null;
129129
}
130130

131131
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */

packages/core/src/lint/utils.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
2121
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
2222
export const TIMELINE_REGISTRY_INIT_PATTERN =
2323
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
24-
export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
24+
export const TIMELINE_REGISTRY_ASSIGN_PATTERN =
25+
/window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
2526
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
26-
/window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
27+
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
2728
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
2829

30+
const TIMELINE_REGISTRY_KEY_PATTERN =
31+
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
32+
2933
export function extractOpenTags(source: string): OpenTag[] {
3034
const tags: OpenTag[] = [];
3135
let match: RegExpExecArray | null;
@@ -141,6 +145,20 @@ export function extractCompositionIdsFromCss(css: string): string[] {
141145
return [...ids];
142146
}
143147

148+
export function extractTimelineRegistryKeys(source: string): string[] {
149+
const keys = new Set<string>();
150+
let match: RegExpExecArray | null;
151+
const pattern = new RegExp(
152+
TIMELINE_REGISTRY_KEY_PATTERN.source,
153+
TIMELINE_REGISTRY_KEY_PATTERN.flags,
154+
);
155+
while ((match = pattern.exec(source)) !== null) {
156+
const key = match[1] ?? match[2];
157+
if (key) keys.add(key);
158+
}
159+
return [...keys];
160+
}
161+
144162
export function getInlineScriptSyntaxError(source: string): string | null {
145163
if (!source.trim()) return null;
146164
try {

0 commit comments

Comments
 (0)