@mehshekari/gsap-motion 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Maps an animation: what it does, in what order, under what conditions, and
5
+ * what cleans it up.
6
+ *
7
+ * It changes nothing and judges nothing. Every line says where it came from,
8
+ * because that is the difference between a map and a guess:
9
+ *
10
+ * (file) read from the syntax tree — this is what the code says
11
+ * (comment) the reason the author wrote down, quoted
12
+ * (audit) a finding, with the rule that made it
13
+ * (page) read from the running page, which may differ from the source
14
+ * (inferred) a reading, not a fact
15
+ *
16
+ * Given files it reads the code. Given a URL it reads the running page, where
17
+ * the timeline is the one GSAP actually built — after every condition, branch
18
+ * and early return the source has.
19
+ *
20
+ * Usage, from the project root:
21
+ * node <skill-dir>/scripts/explain-motion.mjs <file...> [--json]
22
+ * node <skill-dir>/scripts/explain-motion.mjs <url> [--json] [--wait 600]
23
+ */
24
+ import { existsSync } from "node:fs";
25
+ import { resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import { connect, findChrome } from "./capture-motion.mjs";
29
+ import { check, mentionsGsap } from "./lib/audit.mjs";
30
+ import {
31
+ calleeName,
32
+ dottedName,
33
+ findAll,
34
+ keptUnder,
35
+ methodName,
36
+ propertyOf,
37
+ staticString,
38
+ timelineLinks,
39
+ tweenMethod,
40
+ unwrap,
41
+ } from "./lib/ast.mjs";
42
+ import { RULES } from "./lib/rules.mjs";
43
+ import { collect, lineAt, load } from "./lib/source.mjs";
44
+
45
+ // --- Reading the code --------------------------------------------------------
46
+
47
+ /** The source of a node, on one line, short enough to read in a list. */
48
+ const textOf = (file, node, limit = 48) => {
49
+ if (!node) return null;
50
+ const text = file.raw.slice(node.start, node.end).replace(/\s+/g, " ").trim();
51
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
52
+ };
53
+
54
+ /**
55
+ * The comment block immediately above `node`, which is where the reason usually
56
+ * is. Only a block that ends within two lines counts: a comment further up
57
+ * belongs to something else.
58
+ */
59
+ function reasonFor(file, node) {
60
+ const line = lineAt(file.raw, node.start);
61
+ const above = file.comments
62
+ .filter((comment) => {
63
+ const ends = lineAt(file.raw, comment.end);
64
+ return ends < line && line - ends <= 2;
65
+ })
66
+ .sort((a, b) => b.end - a.end)[0];
67
+ if (!above) return null;
68
+
69
+ const sentence = above.value
70
+ .split("\n")
71
+ .map((row) => row.replace(/^\s*\*?\s?/, "").trim())
72
+ .filter(Boolean)
73
+ .join(" ")
74
+ .trim();
75
+ return sentence.length > 96 ? `${sentence.slice(0, 95)}…` : sentence;
76
+ }
77
+
78
+ /** The position argument of a timeline call: `"<"`, `"-=0.2"`, a number. */
79
+ function positionOf(file, call) {
80
+ const method = methodName(call);
81
+ const index = method === "fromTo" ? 3 : 2;
82
+ return textOf(file, call.arguments[index], 16);
83
+ }
84
+
85
+ /** Where a call keeps its vars: a timeline at 0, a fromTo at 2, everything else at 1. */
86
+ const varsOf = (call) => {
87
+ const method = methodName(call) ?? tweenMethod(call);
88
+ const index = method === "timeline" ? 0 : method === "fromTo" ? 2 : 1;
89
+ const node = unwrap(call.arguments[index]);
90
+ return node?.type === "ObjectExpression" ? node : null;
91
+ };
92
+
93
+ /**
94
+ * Where a call reads as starting. In `tl.from(a).from(b)` both calls begin at
95
+ * `tl`, so the method's own name is what tells the beats apart — and what puts
96
+ * each one's comment above the right beat.
97
+ */
98
+ const startOf = (call) => unwrap(call.callee)?.property?.start ?? call.start;
99
+
100
+ const valueOf = (vars, key) => {
101
+ const property = vars && propertyOf(vars, key);
102
+ if (!property) return null;
103
+ const literal = unwrap(property.value);
104
+ if (literal?.type === "Literal") return String(literal.value);
105
+ return null;
106
+ };
107
+
108
+ /** The scroll configuration of a tween's vars, or of a ScrollTrigger.create. */
109
+ function triggerOf(file, call) {
110
+ const vars = varsOf(call);
111
+ const own = vars && propertyOf(vars, "scrollTrigger");
112
+ const config = own ? unwrap(own.value) : null;
113
+ const direct =
114
+ calleeName(call) === "ScrollTrigger.create"
115
+ ? unwrap(call.arguments[0])
116
+ : null;
117
+ const object = config?.type === "ObjectExpression" ? config : direct?.type === "ObjectExpression" ? direct : null;
118
+ if (!object) return null;
119
+
120
+ const read = (key) => {
121
+ const property = propertyOf(object, key);
122
+ return property ? textOf(file, property.value, 24) : null;
123
+ };
124
+
125
+ return {
126
+ line: lineAt(file.raw, (own ?? call).start),
127
+ trigger: read("trigger"),
128
+ start: read("start"),
129
+ end: read("end"),
130
+ scrub: read("scrub"),
131
+ pin: read("pin"),
132
+ toggleActions: read("toggleActions"),
133
+ };
134
+ }
135
+
136
+ function readFile(file) {
137
+ const map = {
138
+ file: file.display,
139
+ scopes: [],
140
+ branches: [],
141
+ scenes: [],
142
+ loose: [],
143
+ triggers: [],
144
+ findings: [],
145
+ };
146
+
147
+ // Where animation is created, and what owns its cleanup.
148
+ for (const call of findAll(file.ast, (node) => {
149
+ const name = calleeName(node);
150
+ return name === "useGSAP" || name === "gsap.context";
151
+ })) {
152
+ const vars = unwrap(call.arguments[1]);
153
+ map.scopes.push({
154
+ kind: calleeName(call),
155
+ line: lineAt(file.raw, call.start),
156
+ scope:
157
+ vars?.type === "ObjectExpression"
158
+ ? textOf(file, propertyOf(vars, "scope")?.value, 24)
159
+ : textOf(file, call.arguments[1], 24),
160
+ why: reasonFor(file, call),
161
+ });
162
+ }
163
+
164
+ // Device and preference branches.
165
+ for (const call of findAll(file.ast, (node) => methodName(node) === "add")) {
166
+ const conditions = unwrap(call.arguments[0]);
167
+ if (conditions?.type === "ObjectExpression") {
168
+ const named = conditions.properties
169
+ .map((property) => {
170
+ const query = staticString(property.value);
171
+ return query ? `${property.key.name ?? property.key.value}: ${query}` : null;
172
+ })
173
+ .filter(Boolean);
174
+ if (named.length) {
175
+ map.branches.push({ line: lineAt(file.raw, call.start), conditions: named });
176
+ }
177
+ } else {
178
+ const query = staticString(call.arguments[0]);
179
+ if (query && /\(.*:.*\)/.test(query)) {
180
+ map.branches.push({ line: lineAt(file.raw, call.start), conditions: [query] });
181
+ }
182
+ }
183
+ }
184
+
185
+ // Scenes: a timeline, and the beats on it in order.
186
+ const timelines = findAll(file.ast, (node) => calleeName(node) === "gsap.timeline");
187
+ const claimed = new Set();
188
+
189
+ for (const timeline of timelines) {
190
+ const beats = [];
191
+ for (const link of timelineLinks(file.ast, timeline)) {
192
+ claimed.add(link);
193
+ const method = methodName(link);
194
+ if (method === "addLabel") {
195
+ beats.push({ label: staticString(link.arguments[0]) ?? "(label)" });
196
+ continue;
197
+ }
198
+ if (!["to", "from", "fromTo", "set", "call", "add"].includes(method)) continue;
199
+
200
+ const vars = varsOf(link);
201
+ beats.push({
202
+ line: lineAt(file.raw, startOf(link)),
203
+ method,
204
+ target: textOf(file, link.arguments[0], 32),
205
+ duration: valueOf(vars, "duration"),
206
+ ease: valueOf(vars, "ease"),
207
+ at: positionOf(file, link),
208
+ why: reasonFor(file, { start: startOf(link) }),
209
+ });
210
+ }
211
+
212
+ map.scenes.push({
213
+ line: lineAt(file.raw, timeline.start),
214
+ name: keptUnder(file.ast, timeline)?.name ?? null,
215
+ repeat: valueOf(varsOf(timeline), "repeat"),
216
+ why: reasonFor(file, timeline),
217
+ beats,
218
+ });
219
+ }
220
+
221
+ /** The name each scene is kept under, so its own beats find their way home. */
222
+ const byName = new Map(
223
+ map.scenes.filter((scene) => scene.name).map((scene) => [scene.name, scene]),
224
+ );
225
+
226
+ // Beats added to a timeline this file was handed: the helper shape, where the
227
+ // caller owns the timeline and this file only fills it.
228
+ const handed = new Map();
229
+ for (const call of findAll(file.ast, (node) =>
230
+ ["to", "from", "fromTo", "set", "addLabel", "call"].includes(methodName(node)),
231
+ )) {
232
+ if (claimed.has(call) || timelines.includes(call)) continue;
233
+ const owner = dottedName(unwrap(call.callee).object);
234
+ if (!owner || owner === "gsap" || owner.startsWith("gsap.")) continue;
235
+ const own = byName.get(owner);
236
+ if (!own && !handed.has(owner)) handed.set(owner, []);
237
+
238
+ const method = methodName(call);
239
+ const vars = varsOf(call);
240
+ (own ? own.beats : handed.get(owner)).push(
241
+ method === "addLabel"
242
+ ? { label: staticString(call.arguments[0]) ?? "(label)" }
243
+ : {
244
+ line: lineAt(file.raw, startOf(call)),
245
+ method,
246
+ target: textOf(file, call.arguments[0], 32),
247
+ duration: valueOf(vars, "duration"),
248
+ ease: valueOf(vars, "ease"),
249
+ at: positionOf(file, call),
250
+ why: reasonFor(file, { start: startOf(call) }),
251
+ },
252
+ );
253
+ }
254
+ map.handed = [...handed.entries()].map(([owner, beats]) => ({ owner, beats }));
255
+ for (const scene of map.scenes) {
256
+ scene.beats.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
257
+ }
258
+
259
+ // Tweens that belong to no timeline.
260
+ for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
261
+ if (claimed.has(call) || timelines.includes(call)) continue;
262
+ if (calleeName(call)?.startsWith("gsap.") !== true) continue;
263
+ const vars = varsOf(call);
264
+ map.loose.push({
265
+ line: lineAt(file.raw, call.start),
266
+ method: tweenMethod(call),
267
+ target: textOf(file, call.arguments[0], 32),
268
+ duration: valueOf(vars, "duration"),
269
+ ease: valueOf(vars, "ease"),
270
+ repeat: valueOf(vars, "repeat"),
271
+ why: reasonFor(file, call),
272
+ });
273
+ }
274
+
275
+ // Scroll configuration, wherever it is written.
276
+ for (const call of findAll(file.ast, (node) => node.type === "CallExpression")) {
277
+ const trigger = triggerOf(file, call);
278
+ if (trigger) map.triggers.push(trigger);
279
+ }
280
+
281
+ // What the audit says about this file, so a claim can cite a rule.
282
+ for (const rule of RULES) {
283
+ for (const finding of check(rule, file)) {
284
+ map.findings.push({ line: finding.line, rule: finding.rule, level: finding.level, message: finding.message });
285
+ }
286
+ }
287
+
288
+ return map;
289
+ }
290
+
291
+ // --- Reading the page --------------------------------------------------------
292
+
293
+ const LIVE = `(() => {
294
+ const out = { gsap: typeof gsap !== "undefined", children: [], triggers: [], reduced: null };
295
+ out.reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
296
+ if (!out.gsap) return out;
297
+
298
+ out.children = gsap.globalTimeline.getChildren(true, true, true).map((child) => ({
299
+ targets: (child.targets?.() ?? []).map((target) =>
300
+ target?.id ? "#" + target.id : target?.tagName ? target.tagName.toLowerCase() : String(target)
301
+ ),
302
+ duration: child.duration?.() ?? 0,
303
+ repeat: child.repeat?.() ?? 0,
304
+ paused: child.paused?.() ?? false,
305
+ ease: typeof child.vars?.ease === "string" ? child.vars.ease : null,
306
+ }));
307
+
308
+ const ScrollTriggerClass = globalThis.ScrollTrigger ?? gsap.core?.globals?.().ScrollTrigger;
309
+ if (ScrollTriggerClass?.getAll) {
310
+ out.triggers = ScrollTriggerClass.getAll().map((trigger) => ({
311
+ element: trigger.trigger?.id ? "#" + trigger.trigger.id : trigger.trigger?.tagName?.toLowerCase() ?? null,
312
+ start: trigger.start,
313
+ end: trigger.end,
314
+ scrub: Boolean(trigger.vars?.scrub),
315
+ pin: Boolean(trigger.vars?.pin),
316
+ progress: Number((trigger.progress ?? 0).toFixed(3)),
317
+ }));
318
+ }
319
+ return out;
320
+ })()`;
321
+
322
+ async function readPage(url, { wait = 600, chrome = null } = {}) {
323
+ const executable = findChrome(chrome);
324
+ if (!executable) {
325
+ throw new Error("No Chrome found. Install Chrome, pass --chrome <path>, or set CHROME_PATH.");
326
+ }
327
+
328
+ const browser = connect(executable);
329
+ try {
330
+ const { targetId } = await browser.send("Target.createTarget", { url: "about:blank" });
331
+ const { sessionId } = await browser.send("Target.attachToTarget", { targetId, flatten: true });
332
+ const call = (method, params) => browser.send(method, params, sessionId);
333
+
334
+ await call("Page.enable");
335
+ await call("Runtime.enable");
336
+ await call("Page.navigate", { url });
337
+ await new Promise((done) => setTimeout(done, wait));
338
+
339
+ const { result } = await call("Runtime.evaluate", {
340
+ expression: LIVE,
341
+ returnByValue: true,
342
+ awaitPromise: true,
343
+ });
344
+ return { url, ...result.value };
345
+ } finally {
346
+ await browser.close();
347
+ }
348
+ }
349
+
350
+ // --- Reporting ---------------------------------------------------------------
351
+
352
+ function reportFile(map) {
353
+ const lines = [`${map.file}`];
354
+ const say = (text) => lines.push(` ${text}`);
355
+
356
+ if (map.scopes.length) {
357
+ say("");
358
+ say("Where it lives");
359
+ for (const scope of map.scopes) {
360
+ say(` ${scope.line}: ${scope.kind}${scope.scope ? ` scoped to ${scope.scope}` : ""} (file)`);
361
+ if (scope.why) say(` "${scope.why}" (comment)`);
362
+ }
363
+ }
364
+
365
+ if (map.branches.length) {
366
+ say("");
367
+ say("Branches");
368
+ for (const branch of map.branches) {
369
+ say(` ${branch.line}: ${branch.conditions.join(" · ")} (file)`);
370
+ }
371
+ }
372
+
373
+ for (const scene of map.scenes) {
374
+ say("");
375
+ say(`Scene at line ${scene.line}${scene.name ? ` (${scene.name})` : ""}${scene.repeat === "-1" ? ", repeats forever" : ""} (file)`);
376
+ if (scene.why) say(` "${scene.why}" (comment)`);
377
+ for (const beat of scene.beats) {
378
+ if (beat.label) {
379
+ say(` label "${beat.label}"`);
380
+ continue;
381
+ }
382
+ const parts = [
383
+ `${beat.method} ${beat.target ?? ""}`.trim(),
384
+ beat.duration ? `${beat.duration}s` : null,
385
+ beat.ease ? `ease ${beat.ease}` : null,
386
+ beat.at ? `at ${beat.at}` : null,
387
+ ].filter(Boolean);
388
+ say(` ${beat.line}: ${parts.join(" · ")}`);
389
+ if (beat.why) say(` "${beat.why}" (comment)`);
390
+ }
391
+ }
392
+
393
+ for (const scene of map.handed ?? []) {
394
+ say("");
395
+ say(`Beats this file adds to ${scene.owner}, a timeline it was given (file)`);
396
+ for (const beat of scene.beats) {
397
+ if (beat.label) {
398
+ say(` label "${beat.label}"`);
399
+ continue;
400
+ }
401
+ const parts = [
402
+ `${beat.method} ${beat.target ?? ""}`.trim(),
403
+ beat.duration ? `${beat.duration}s` : null,
404
+ beat.ease ? `ease ${beat.ease}` : null,
405
+ beat.at ? `at ${beat.at}` : null,
406
+ ].filter(Boolean);
407
+ say(` ${beat.line}: ${parts.join(" · ")}`);
408
+ if (beat.why) say(` "${beat.why}" (comment)`);
409
+ }
410
+ }
411
+
412
+ if (map.loose.length) {
413
+ say("");
414
+ say("Tweens on no timeline (file)");
415
+ for (const tween of map.loose) {
416
+ const parts = [
417
+ `${tween.method} ${tween.target ?? ""}`.trim(),
418
+ tween.duration ? `${tween.duration}s` : null,
419
+ tween.ease ? `ease ${tween.ease}` : null,
420
+ tween.repeat === "-1" ? "repeats forever" : null,
421
+ ].filter(Boolean);
422
+ say(` ${tween.line}: ${parts.join(" · ")}`);
423
+ }
424
+ }
425
+
426
+ if (map.triggers.length) {
427
+ say("");
428
+ say("Scroll (file)");
429
+ for (const trigger of map.triggers) {
430
+ const parts = [
431
+ trigger.trigger ? `trigger ${trigger.trigger}` : null,
432
+ trigger.start ? `start ${trigger.start}` : null,
433
+ trigger.end ? `end ${trigger.end}` : null,
434
+ trigger.scrub ? `scrub ${trigger.scrub}` : null,
435
+ trigger.pin ? `pin ${trigger.pin}` : null,
436
+ trigger.toggleActions ? `toggleActions ${trigger.toggleActions}` : null,
437
+ ].filter(Boolean);
438
+ say(` ${trigger.line}: ${parts.join(" · ") || "(no options read)"}`);
439
+ }
440
+ }
441
+
442
+ say("");
443
+ if (map.findings.length) {
444
+ say("What the audit says");
445
+ for (const finding of map.findings) {
446
+ say(` ${finding.line}: ${finding.message} [${finding.rule}] (audit)`);
447
+ }
448
+ } else {
449
+ say("The audit reports nothing on this file. (audit)");
450
+ }
451
+
452
+ return lines.join("\n");
453
+ }
454
+
455
+ function reportPage(page) {
456
+ const lines = [`${page.url} (page)`];
457
+ const say = (text) => lines.push(` ${text}`);
458
+
459
+ say("");
460
+ say(`Reduced motion: ${page.reduced ? "the visitor asked for less" : "no preference"}`);
461
+
462
+ say("");
463
+ if (!page.gsap) {
464
+ say("No GSAP on the page. Either it never loaded, or nothing here animates with it.");
465
+ return lines.join("\n");
466
+ }
467
+
468
+ say(`Running now: ${page.children.length} tween(s) or timeline(s) on the global timeline`);
469
+ for (const child of page.children) {
470
+ const parts = [
471
+ child.targets.join(", ") || "(no target)",
472
+ `${Number(child.duration.toFixed(3))}s`,
473
+ child.repeat === -1 ? "repeats forever" : child.repeat ? `repeats ${child.repeat}` : null,
474
+ child.paused ? "paused" : null,
475
+ child.ease ? `ease ${child.ease}` : null,
476
+ ].filter(Boolean);
477
+ say(` ${parts.join(" · ")}`);
478
+ }
479
+
480
+ say("");
481
+ say(`ScrollTriggers: ${page.triggers.length}`);
482
+ for (const trigger of page.triggers) {
483
+ const parts = [
484
+ trigger.element ?? "(no element)",
485
+ `${Math.round(trigger.start)}→${Math.round(trigger.end)}`,
486
+ trigger.scrub ? "scrub" : null,
487
+ trigger.pin ? "pin" : null,
488
+ `progress ${trigger.progress}`,
489
+ ].filter(Boolean);
490
+ say(` ${parts.join(" · ")}`);
491
+ }
492
+
493
+ say("");
494
+ say("A finished tween leaves the global timeline, so what is here is what is running now.");
495
+ return lines.join("\n");
496
+ }
497
+
498
+ // --- Entry -------------------------------------------------------------------
499
+
500
+ export function parseArguments(argv) {
501
+ const options = { targets: [], json: false, wait: 600, chrome: null, errors: [] };
502
+
503
+ for (let index = 0; index < argv.length; index += 1) {
504
+ const argument = argv[index];
505
+ if (argument === "--json") options.json = true;
506
+ else if (argument === "--wait") options.wait = Number(argv[(index += 1)]) || options.wait;
507
+ else if (argument === "--chrome") options.chrome = argv[(index += 1)] ?? null;
508
+ else if (argument.startsWith("--")) options.errors.push(`Unknown option: ${argument}`);
509
+ else options.targets.push(argument);
510
+ }
511
+
512
+ if (options.targets.length === 0) {
513
+ options.errors.push("Nothing to explain. Pass a file, a directory, or a URL.");
514
+ }
515
+
516
+ options.live = options.targets.some((target) => /^https?:|^file:/.test(target));
517
+ if (options.live && options.targets.length > 1) {
518
+ options.errors.push("Explain one URL at a time.");
519
+ }
520
+
521
+ return options;
522
+ }
523
+
524
+ async function main(argv) {
525
+ const options = parseArguments(argv);
526
+ if (options.errors.length) {
527
+ for (const error of options.errors) console.error(error);
528
+ console.error("\nUsage: node explain-motion.mjs <file...|url> [--json]");
529
+ process.exit(2);
530
+ }
531
+
532
+ if (options.live) {
533
+ const page = await readPage(options.targets[0], options);
534
+ console.log(options.json ? JSON.stringify(page, null, 2) : reportPage(page));
535
+ return;
536
+ }
537
+
538
+ const root = process.cwd();
539
+ const maps = [];
540
+
541
+ for (const target of options.targets) {
542
+ const path = resolve(root, target);
543
+ if (!existsSync(path)) {
544
+ console.error(`No such path: ${path}`);
545
+ process.exit(2);
546
+ }
547
+ for (const found of collect(path)) {
548
+ const file = load(found, root);
549
+ if (!mentionsGsap(file)) continue;
550
+ if (file.parseError) {
551
+ maps.push({ file: file.display, parseError: file.parseError.message });
552
+ continue;
553
+ }
554
+ maps.push(readFile(file));
555
+ }
556
+ }
557
+
558
+ if (maps.length === 0) {
559
+ console.log("Nothing here animates with GSAP.");
560
+ return;
561
+ }
562
+
563
+ console.log(
564
+ options.json
565
+ ? JSON.stringify(maps, null, 2)
566
+ : maps
567
+ .map((map) =>
568
+ map.parseError ? `${map.file}\n could not be parsed: ${map.parseError}` : reportFile(map),
569
+ )
570
+ .join("\n\n"),
571
+ );
572
+ }
573
+
574
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
575
+ if (invoked) await main(process.argv.slice(2));