@mehshekari/gsap-motion 3.1.0 → 3.2.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.
- package/README.md +4 -1
- package/bin/gsap-motion.mjs +17 -0
- package/package.json +1 -1
- package/skill/SKILL.md +14 -10
- package/skill/adapter/astro.md +111 -0
- package/skill/adapter/next.md +99 -0
- package/skill/adapter/r3f.md +128 -0
- package/skill/adapter/react.md +133 -0
- package/skill/adapter/svelte.md +105 -0
- package/skill/adapter/three.md +141 -0
- package/skill/adapter/vanilla.md +115 -0
- package/skill/adapter/vue.md +120 -0
- package/skill/example/svg-loader.md +1 -1
- package/skill/reference/core-gsap.md +1 -1
- package/skill/reference/interaction.md +183 -183
- package/skill/reference/routing.md +119 -119
- package/skill/reference/scrolltrigger.md +1 -1
- package/skill/reference/svg.md +1 -1
- package/skill/reference/timeline.md +1 -1
- package/skill/scripts/audit-gsap.mjs +21 -59
- package/skill/scripts/lib/ast.mjs +7 -1
- package/skill/scripts/lib/audit.mjs +91 -0
- package/skill/scripts/lib/rules.mjs +129 -3
- package/skill/scripts/lib/source.mjs +79 -8
- package/skill/reference/frameworks.md +0 -153
- package/skill/reference/react-nextjs.md +0 -234
- package/skill/reference/three-r3f.md +0 -161
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* `not-parsed`, never passed as clean. A wrong finding is a bug in this script;
|
|
21
21
|
* report it rather than working around it.
|
|
22
22
|
*
|
|
23
|
+
* What happens to each file — the rules, waivers, `not-parsed` — is in
|
|
24
|
+
* lib/audit.mjs, which the ESLint plugin runs too. This file finds the files,
|
|
25
|
+
* collects what they register between them, and prints.
|
|
26
|
+
*
|
|
23
27
|
* Usage — paths resolve from the current directory, so run it from the project
|
|
24
28
|
* root, wherever the skill itself is installed:
|
|
25
29
|
* node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
|
|
@@ -31,8 +35,9 @@
|
|
|
31
35
|
import { existsSync } from "node:fs";
|
|
32
36
|
import { resolve } from "node:path";
|
|
33
37
|
|
|
38
|
+
import { check, mentionsGsap, notParsed } from "./lib/audit.mjs";
|
|
34
39
|
import { pluginRegistrations, RULES } from "./lib/rules.mjs";
|
|
35
|
-
import { collect,
|
|
40
|
+
import { collect, load } from "./lib/source.mjs";
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
43
|
* The project being audited is wherever this is run from, not wherever the
|
|
@@ -57,62 +62,29 @@ for (const path of paths) {
|
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
// --- Escape hatch ------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The same shape many repos already use for their own lint waivers, on
|
|
64
|
-
* purpose: one way to say "yes, and here is why", not two.
|
|
65
|
-
*
|
|
66
|
-
* A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
|
|
67
|
-
* above it, and it waives that rule and nothing else — silencing a shared id
|
|
68
|
-
* cannot also silence a layout property on the same line. The lookback is eight
|
|
69
|
-
* because a reason usually stands above a whole declaration rather than beside
|
|
70
|
-
* one line of it.
|
|
71
|
-
*
|
|
72
|
-
* Every hatch must carry its reason. A bare token with no sentence after it is
|
|
73
|
-
* a rule someone turned off rather than answered, and once this gates a build
|
|
74
|
-
* that distinction has to stay visible in review.
|
|
75
|
-
*/
|
|
76
|
-
const LOOKBACK = 8;
|
|
77
|
-
|
|
78
|
-
function waived(source, line, ruleId) {
|
|
79
|
-
const lines = source.split("\n");
|
|
80
|
-
const from = Math.max(0, line - 1 - LOOKBACK);
|
|
81
|
-
return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
65
|
// --- Run ---------------------------------------------------------------------
|
|
85
66
|
|
|
86
67
|
const findings = [];
|
|
87
68
|
const sources = [];
|
|
88
69
|
|
|
70
|
+
/** A finding as the report shows it: under the file's display path, no column. */
|
|
71
|
+
const reported = (source, finding) => ({
|
|
72
|
+
rule: finding.rule,
|
|
73
|
+
level: finding.level,
|
|
74
|
+
file: source.display,
|
|
75
|
+
line: finding.line,
|
|
76
|
+
message: finding.message,
|
|
77
|
+
hint: finding.hint,
|
|
78
|
+
});
|
|
79
|
+
|
|
89
80
|
for (const path of paths) {
|
|
90
81
|
for (const file of collect(path)) {
|
|
91
82
|
const source = load(file, ROOT);
|
|
83
|
+
if (!mentionsGsap(source)) continue;
|
|
92
84
|
|
|
93
|
-
/**
|
|
94
|
-
* A file that never mentions GSAP has nothing these rules can say. Checked
|
|
95
|
-
* once here rather than in every rule.
|
|
96
|
-
*/
|
|
97
|
-
if (!source.usesGsap && !/\bgsap\./.test(source.code)) continue;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* What was not checked is reported as not checked. A file that uses GSAP
|
|
101
|
-
* and does not parse gets no rule's verdict — a silent skip would read as
|
|
102
|
-
* clean.
|
|
103
|
-
*/
|
|
104
85
|
if (source.parseError) {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
findings.push({
|
|
108
|
-
rule: "not-parsed",
|
|
109
|
-
level: "info",
|
|
110
|
-
file: source.display,
|
|
111
|
-
line,
|
|
112
|
-
message: `Not checked: the file could not be parsed (${message}).`,
|
|
113
|
-
hint: "No rule ran on this file. If it is valid JavaScript or TypeScript, the audit's parser is wrong — report it with this line.",
|
|
114
|
-
});
|
|
115
|
-
}
|
|
86
|
+
const finding = notParsed(source);
|
|
87
|
+
if (finding) findings.push(reported(source, finding));
|
|
116
88
|
continue;
|
|
117
89
|
}
|
|
118
90
|
|
|
@@ -132,18 +104,8 @@ for (const source of sources) {
|
|
|
132
104
|
source.registeredElsewhere = registered;
|
|
133
105
|
|
|
134
106
|
for (const rule of RULES) {
|
|
135
|
-
for (const
|
|
136
|
-
|
|
137
|
-
if (waived(source.raw, line, rule.id)) continue;
|
|
138
|
-
|
|
139
|
-
findings.push({
|
|
140
|
-
rule: rule.id,
|
|
141
|
-
level: rule.level,
|
|
142
|
-
file: source.display,
|
|
143
|
-
line,
|
|
144
|
-
message: hit.message,
|
|
145
|
-
hint: hit.hint,
|
|
146
|
-
});
|
|
107
|
+
for (const finding of check(rule, source)) {
|
|
108
|
+
findings.push(reported(source, finding));
|
|
147
109
|
}
|
|
148
110
|
}
|
|
149
111
|
}
|
|
@@ -21,10 +21,16 @@ const OPTIONS = {
|
|
|
21
21
|
* `.ts` is read without JSX, because there `<T>(x) => x` is a generic and
|
|
22
22
|
* `<Type>value` a cast; every other extension is read with it. A `.js` file
|
|
23
23
|
* with JSX is common enough, and JSX never changes how plain JavaScript parses.
|
|
24
|
+
*
|
|
25
|
+
* A single-file component's `<script>` is read without JSX too: it is
|
|
26
|
+
* TypeScript as often as not, and the template where the angle brackets live is
|
|
27
|
+
* blanked out before it reaches here.
|
|
24
28
|
*/
|
|
25
29
|
const TYPESCRIPT = Parser.extend(tsPlugin());
|
|
26
30
|
const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
27
31
|
|
|
32
|
+
const WITHOUT_JSX = /\.(?:[mc]?ts|vue|svelte|astro)$/;
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* Parses `source` as the grammar its `path` implies.
|
|
30
36
|
*
|
|
@@ -34,7 +40,7 @@ const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
|
34
40
|
*/
|
|
35
41
|
export function parse(source, path) {
|
|
36
42
|
const comments = [];
|
|
37
|
-
const parser =
|
|
43
|
+
const parser = WITHOUT_JSX.test(path) ? TYPESCRIPT : TYPESCRIPT_JSX;
|
|
38
44
|
const ast = parser.parse(source, { ...OPTIONS, onComment: comments });
|
|
39
45
|
return { ast, comments };
|
|
40
46
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One file through the audit, shared by the command line and the ESLint plugin.
|
|
3
|
+
*
|
|
4
|
+
* The runner decides which files to read and how to print them; ESLint decides
|
|
5
|
+
* both for the plugin. What happens to a file in between — whether the rules
|
|
6
|
+
* look at it at all, what a file that does not parse reports, where a finding
|
|
7
|
+
* lands and whether a waiver answers it — is decided here, once, so the two
|
|
8
|
+
* entry points cannot disagree.
|
|
9
|
+
*/
|
|
10
|
+
import { lineAt } from "./source.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A file that never mentions GSAP has nothing these rules can say. Checked once
|
|
14
|
+
* per file rather than in every rule, and before anything is parsed.
|
|
15
|
+
*/
|
|
16
|
+
export function mentionsGsap(source) {
|
|
17
|
+
return source.usesGsap || /\bgsap\./.test(source.code);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Escape hatch ------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The same shape many repos already use for their own lint waivers, on
|
|
24
|
+
* purpose: one way to say "yes, and here is why", not two.
|
|
25
|
+
*
|
|
26
|
+
* A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
|
|
27
|
+
* above it, and it waives that rule and nothing else — silencing a shared id
|
|
28
|
+
* cannot also silence a layout property on the same line. The lookback is eight
|
|
29
|
+
* because a reason usually stands above a whole declaration rather than beside
|
|
30
|
+
* one line of it.
|
|
31
|
+
*
|
|
32
|
+
* Every hatch must carry its reason. A bare token with no sentence after it is
|
|
33
|
+
* a rule someone turned off rather than answered, and once this gates a build
|
|
34
|
+
* that distinction has to stay visible in review.
|
|
35
|
+
*/
|
|
36
|
+
const LOOKBACK = 8;
|
|
37
|
+
|
|
38
|
+
export function waived(raw, line, ruleId) {
|
|
39
|
+
const lines = raw.split("\n");
|
|
40
|
+
const from = Math.max(0, line - 1 - LOOKBACK);
|
|
41
|
+
return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Findings ----------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** 0-indexed column of a character offset, as ESLint counts columns. */
|
|
47
|
+
function columnAt(raw, index) {
|
|
48
|
+
return Math.max(0, index - (raw.lastIndexOf("\n", index - 1) + 1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NOT_PARSED = "not-parsed";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What was not checked is reported as not checked. A file that uses GSAP and
|
|
55
|
+
* does not parse gets no rule's verdict — a silent skip would read as clean.
|
|
56
|
+
*
|
|
57
|
+
* Returns null for a file that parsed, and for one whose waiver answers it.
|
|
58
|
+
*/
|
|
59
|
+
export function notParsed(source) {
|
|
60
|
+
const error = source.parseError;
|
|
61
|
+
if (!error || waived(source.raw, error.line, NOT_PARSED)) return null;
|
|
62
|
+
return {
|
|
63
|
+
rule: NOT_PARSED,
|
|
64
|
+
level: "info",
|
|
65
|
+
line: error.line,
|
|
66
|
+
column: error.column,
|
|
67
|
+
message: `Not checked: the file could not be parsed (${error.message}).`,
|
|
68
|
+
hint: "No rule ran on this file. If it is valid JavaScript or TypeScript, the audit's parser is wrong — report it with this line.",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What one rule reports on one parsed file: each finding placed on its line and
|
|
74
|
+
* column, and dropped when a waiver answers it.
|
|
75
|
+
*/
|
|
76
|
+
export function check(rule, source) {
|
|
77
|
+
const findings = [];
|
|
78
|
+
for (const hit of rule.test(source)) {
|
|
79
|
+
const line = lineAt(source.raw, hit.index);
|
|
80
|
+
if (waived(source.raw, line, rule.id)) continue;
|
|
81
|
+
findings.push({
|
|
82
|
+
rule: rule.id,
|
|
83
|
+
level: rule.level,
|
|
84
|
+
line,
|
|
85
|
+
column: columnAt(source.raw, hit.index),
|
|
86
|
+
message: hit.message,
|
|
87
|
+
hint: hit.hint,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
@@ -51,6 +51,8 @@ const HINT = {
|
|
|
51
51
|
"Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
|
|
52
52
|
unmanagedInstance:
|
|
53
53
|
"Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
|
|
54
|
+
unrevertedContext:
|
|
55
|
+
"Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
|
|
54
56
|
danglingListener:
|
|
55
57
|
"A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
|
|
56
58
|
tweenPerEvent:
|
|
@@ -212,7 +214,7 @@ function listenerMethod(node) {
|
|
|
212
214
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
213
215
|
* the file declares exactly one function of that name.
|
|
214
216
|
*/
|
|
215
|
-
function handlersFor(ast, events, { followNames = true } = {}) {
|
|
217
|
+
function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
|
|
216
218
|
const handler = (expression) => {
|
|
217
219
|
const node = unwrap(expression);
|
|
218
220
|
if (node?.type === "CallExpression") return node;
|
|
@@ -242,6 +244,17 @@ function handlersFor(ast, events, { followNames = true } = {}) {
|
|
|
242
244
|
if (node) handlers.push({ name: `on${prop[1]}`, node });
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/**
|
|
248
|
+
* R3F's `useFrame(callback)` is a per-frame scope of its own: what is inside
|
|
249
|
+
* runs on every rendered frame, whatever the pointer is doing.
|
|
250
|
+
*/
|
|
251
|
+
if (perFrame) {
|
|
252
|
+
for (const call of findAll(ast, (node) => calleeName(node) === "useFrame")) {
|
|
253
|
+
const node = handler(call.arguments[0]);
|
|
254
|
+
if (node) handlers.push({ name: "useFrame", node });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
245
258
|
return handlers;
|
|
246
259
|
}
|
|
247
260
|
|
|
@@ -493,11 +506,54 @@ export function pluginRegistrations(file) {
|
|
|
493
506
|
return names;
|
|
494
507
|
}
|
|
495
508
|
|
|
509
|
+
/**
|
|
510
|
+
* The mount hooks each adapter uses. A context created in one of them lives as
|
|
511
|
+
* long as the component does, so something has to revert it when the component
|
|
512
|
+
* goes.
|
|
513
|
+
*/
|
|
514
|
+
const MOUNT_HOOKS = new Set([
|
|
515
|
+
"onMounted",
|
|
516
|
+
"onMount",
|
|
517
|
+
"onActivated",
|
|
518
|
+
"useEffect",
|
|
519
|
+
"useLayoutEffect",
|
|
520
|
+
"$effect",
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Which mount hook `node` was created in, or null. Astro has no hook: its
|
|
525
|
+
* equivalent is a listener for `astro:page-load`, which fires on the first load
|
|
526
|
+
* and after every view transition.
|
|
527
|
+
*/
|
|
528
|
+
function mountHook(ast, node) {
|
|
529
|
+
/** Nearest first, so a function's own parent is the entry after it. */
|
|
530
|
+
const ancestors = ancestorsOf(ast, node);
|
|
531
|
+
|
|
532
|
+
for (let i = 0; i < ancestors.length; i += 1) {
|
|
533
|
+
if (!isFunction(ancestors[i])) continue;
|
|
534
|
+
const parent = ancestors[i + 1];
|
|
535
|
+
if (parent?.type !== "CallExpression") continue;
|
|
536
|
+
|
|
537
|
+
const name = calleeName(parent);
|
|
538
|
+
if (MOUNT_HOOKS.has(name)) return `\`${name}\``;
|
|
539
|
+
if (
|
|
540
|
+
name?.endsWith("addEventListener") &&
|
|
541
|
+
staticString(parent.arguments[0]) === "astro:page-load"
|
|
542
|
+
) {
|
|
543
|
+
return "the `astro:page-load` handler";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
496
550
|
export const RULES = [
|
|
497
551
|
// --- Lifecycle ------------------------------------------------------------
|
|
498
552
|
{
|
|
499
553
|
id: "orphan-tween",
|
|
500
554
|
level: "error",
|
|
555
|
+
description:
|
|
556
|
+
"A tween created outside `useGSAP`, `gsap.context` or `contextSafe` in React — never reverted, and doubled by StrictMode",
|
|
501
557
|
/**
|
|
502
558
|
* A GSAP tween call inside a function but outside every context — module
|
|
503
559
|
* scope is not the target. Contexts are recognised under the names the file
|
|
@@ -537,6 +593,8 @@ export const RULES = [
|
|
|
537
593
|
{
|
|
538
594
|
id: "unmanaged-instance",
|
|
539
595
|
level: "error",
|
|
596
|
+
description:
|
|
597
|
+
"`matchMedia`, `Observer`, `Draggable`, `ScrollSmoother` or `SplitText` created outside a context and never torn down",
|
|
540
598
|
/**
|
|
541
599
|
* Torn down as `tornDown` reads it: kept under a name — through any chain
|
|
542
600
|
* called on it, or a helper's return — that has `.revert()` or `.kill()`
|
|
@@ -571,9 +629,45 @@ export const RULES = [
|
|
|
571
629
|
}));
|
|
572
630
|
},
|
|
573
631
|
},
|
|
632
|
+
{
|
|
633
|
+
id: "unreverted-context",
|
|
634
|
+
level: "warn",
|
|
635
|
+
description:
|
|
636
|
+
"A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts",
|
|
637
|
+
/**
|
|
638
|
+
* Every adapter's teardown rule, in one shape: a context built when the
|
|
639
|
+
* component mounts holds every tween, ScrollTrigger and matchMedia made
|
|
640
|
+
* inside it, and reverting it is the whole cleanup. Nothing reverts it here.
|
|
641
|
+
*
|
|
642
|
+
* Only contexts created in a mount hook are read. A context built in a
|
|
643
|
+
* function the module exports — the vanilla `mountReveal(root)` shape — hands
|
|
644
|
+
* its teardown to the caller, and this file cannot see whether the caller
|
|
645
|
+
* calls it.
|
|
646
|
+
*/
|
|
647
|
+
test(file) {
|
|
648
|
+
const contexts = findAll(
|
|
649
|
+
file.ast,
|
|
650
|
+
(node) => calleeName(node) === "gsap.context",
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
return contexts.flatMap((call) => {
|
|
654
|
+
const hook = mountHook(file.ast, call);
|
|
655
|
+
if (!hook || tornDown(file.ast, call)) return [];
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
index: call.start,
|
|
659
|
+
message: `The \`gsap.context\` created in ${hook} is never reverted.`,
|
|
660
|
+
hint: HINT.unrevertedContext,
|
|
661
|
+
},
|
|
662
|
+
];
|
|
663
|
+
});
|
|
664
|
+
},
|
|
665
|
+
},
|
|
574
666
|
{
|
|
575
667
|
id: "dangling-listener",
|
|
576
668
|
level: "warn",
|
|
669
|
+
description:
|
|
670
|
+
"An event listener that is never removed, including inline handlers that cannot be",
|
|
577
671
|
/**
|
|
578
672
|
* Adds and removes are paired by event and by the handler's name as
|
|
579
673
|
* written, across the file — not by counting calls. A handler that is not a
|
|
@@ -636,6 +730,8 @@ export const RULES = [
|
|
|
636
730
|
{
|
|
637
731
|
id: "tween-per-event",
|
|
638
732
|
level: "error",
|
|
733
|
+
description:
|
|
734
|
+
"A new tween allocated on every pointer, scroll or wheel event",
|
|
639
735
|
/**
|
|
640
736
|
* A GSAP tween call inside a high-frequency handler, followed by name when
|
|
641
737
|
* it can be. A debounced one, one in a completion callback, and one behind
|
|
@@ -660,6 +756,8 @@ export const RULES = [
|
|
|
660
756
|
{
|
|
661
757
|
id: "state-per-event",
|
|
662
758
|
level: "error",
|
|
759
|
+
description:
|
|
760
|
+
"React state set in a high-frequency handler — a re-render per frame",
|
|
663
761
|
/**
|
|
664
762
|
* A bare `setX(…)` call — not a member call such as `el.style.setProperty`,
|
|
665
763
|
* which is exactly what a pointer handler should do, and not a timer —
|
|
@@ -689,14 +787,20 @@ export const RULES = [
|
|
|
689
787
|
);
|
|
690
788
|
});
|
|
691
789
|
|
|
692
|
-
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
790
|
+
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
791
|
+
followNames: false,
|
|
792
|
+
perFrame: true,
|
|
793
|
+
}).flatMap(
|
|
693
794
|
(handler) =>
|
|
694
795
|
setters
|
|
695
796
|
.filter((setter) => contains(handler.node, setter))
|
|
696
797
|
.filter((setter) => !debounced(handler, setter))
|
|
697
798
|
.map((setter) => ({
|
|
698
799
|
index: setter.start,
|
|
699
|
-
message:
|
|
800
|
+
message:
|
|
801
|
+
handler.name === "useFrame"
|
|
802
|
+
? "React state setter inside a `useFrame` callback, which runs every frame."
|
|
803
|
+
: `React state setter inside a ${handler.name} handler.`,
|
|
700
804
|
hint: HINT.statePerEvent,
|
|
701
805
|
})),
|
|
702
806
|
);
|
|
@@ -705,6 +809,8 @@ export const RULES = [
|
|
|
705
809
|
{
|
|
706
810
|
id: "layout-property",
|
|
707
811
|
level: "warn",
|
|
812
|
+
description:
|
|
813
|
+
"Animating `width`, `height`, `top`, `left`, margins or padding, which forces layout every frame",
|
|
708
814
|
/**
|
|
709
815
|
* The layout keys of every vars object a tween carries — both of a
|
|
710
816
|
* `fromTo`'s — on any receiver, so a timeline child counts. One finding per
|
|
@@ -747,6 +853,8 @@ export const RULES = [
|
|
|
747
853
|
{
|
|
748
854
|
id: "trigger-per-item",
|
|
749
855
|
level: "warn",
|
|
856
|
+
description:
|
|
857
|
+
"A ScrollTrigger created per item in a loop",
|
|
750
858
|
/**
|
|
751
859
|
* A `scrollTrigger` property inside a loop body: the callback of `.forEach`
|
|
752
860
|
* or `.map`, or a `for`, `for…of` or `for…in` statement. One finding per
|
|
@@ -788,6 +896,8 @@ export const RULES = [
|
|
|
788
896
|
{
|
|
789
897
|
id: "eased-loop",
|
|
790
898
|
level: "warn",
|
|
899
|
+
description:
|
|
900
|
+
"An infinite repeat with an ease, which makes its own seam visible",
|
|
791
901
|
/**
|
|
792
902
|
* The tween's own vars — a `fromTo`'s to-vars — on any receiver, so a
|
|
793
903
|
* timeline child counts. Keys at the top level only: a `stagger` object's
|
|
@@ -815,6 +925,8 @@ export const RULES = [
|
|
|
815
925
|
{
|
|
816
926
|
id: "eased-scrub",
|
|
817
927
|
level: "warn",
|
|
928
|
+
description:
|
|
929
|
+
"Easing inside a scrubbed ScrollTrigger, which fights the scrollbar",
|
|
818
930
|
/**
|
|
819
931
|
* A GSAP call scrubbed anywhere in its arguments — `scrub: false` aside —
|
|
820
932
|
* with a string ease other than `"none"` in those arguments or, for a
|
|
@@ -870,6 +982,8 @@ export const RULES = [
|
|
|
870
982
|
{
|
|
871
983
|
id: "never-completes",
|
|
872
984
|
level: "error",
|
|
985
|
+
description:
|
|
986
|
+
"An `onComplete` that can never run: on a timeline holding a `repeat: -1` child, or on a tween or timeline that repeats forever",
|
|
873
987
|
/**
|
|
874
988
|
* `onComplete` and `repeat: -1` anywhere in a GSAP call's arguments; for a
|
|
875
989
|
* timeline, its children and any `eventCallback("onComplete", …)` from
|
|
@@ -939,6 +1053,8 @@ export const RULES = [
|
|
|
939
1053
|
{
|
|
940
1054
|
id: "late-transform-origin",
|
|
941
1055
|
level: "warn",
|
|
1056
|
+
description:
|
|
1057
|
+
"A `fromTo` whose origin is only in its to-vars while its from-vars scale, rotate or skew, which leaves an SVG element offset",
|
|
942
1058
|
/**
|
|
943
1059
|
* Any `.fromTo(target, fromVars, toVars)` whose two vars are object
|
|
944
1060
|
* literals. Keys are read at the top level of each, and a transform's
|
|
@@ -986,6 +1102,8 @@ export const RULES = [
|
|
|
986
1102
|
{
|
|
987
1103
|
id: "shared-plugin-id",
|
|
988
1104
|
level: "error",
|
|
1105
|
+
description:
|
|
1106
|
+
"A hardcoded `#id` in MotionPath or MorphSVG config, which two instances of a component will share",
|
|
989
1107
|
/**
|
|
990
1108
|
* Any property naming a target geometry whose value is a fixed `#id`: a
|
|
991
1109
|
* string or a template literal with nothing interpolated, including the
|
|
@@ -1014,6 +1132,8 @@ export const RULES = [
|
|
|
1014
1132
|
{
|
|
1015
1133
|
id: "unregistered-plugin",
|
|
1016
1134
|
level: "error",
|
|
1135
|
+
description:
|
|
1136
|
+
"A plugin imported and never registered, whose properties are silently ignored",
|
|
1017
1137
|
/**
|
|
1018
1138
|
* Registered means named anywhere inside the arguments of a
|
|
1019
1139
|
* `registerPlugin` call — not mentioned anywhere after one, and under its
|
|
@@ -1067,6 +1187,8 @@ export const RULES = [
|
|
|
1067
1187
|
{
|
|
1068
1188
|
id: "missing-reduced-motion",
|
|
1069
1189
|
level: "warn",
|
|
1190
|
+
description:
|
|
1191
|
+
"An animating file with no `prefers-reduced-motion` branch",
|
|
1070
1192
|
/**
|
|
1071
1193
|
* A branch is the query written in code — a string, a template or JSX
|
|
1072
1194
|
* text. A comment that mentions it is not a branch. A file that only sets
|
|
@@ -1101,6 +1223,8 @@ export const RULES = [
|
|
|
1101
1223
|
{
|
|
1102
1224
|
id: "dev-tool-shipped",
|
|
1103
1225
|
level: "error",
|
|
1226
|
+
description:
|
|
1227
|
+
"`GSDevTools` or `MotionPathHelper` imported statically, or unconditional `markers: true`",
|
|
1104
1228
|
/**
|
|
1105
1229
|
* A static import declaration that brings in a dev tool, by a specifier's
|
|
1106
1230
|
* name or, for a bare or default import, by its path. A type-only import
|
|
@@ -1149,6 +1273,8 @@ export const RULES = [
|
|
|
1149
1273
|
{
|
|
1150
1274
|
id: "barrel-import",
|
|
1151
1275
|
level: "warn",
|
|
1276
|
+
description:
|
|
1277
|
+
"Importing from `gsap/all`, which pulls in every plugin",
|
|
1152
1278
|
/** An import or re-export whose module is `gsap/all`. */
|
|
1153
1279
|
test(file) {
|
|
1154
1280
|
return file.ast.body
|
|
@@ -12,7 +12,19 @@ import { extname, join, relative } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { parse } from "./ast.mjs";
|
|
14
14
|
|
|
15
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".mjs",
|
|
21
|
+
".vue",
|
|
22
|
+
".svelte",
|
|
23
|
+
".astro",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** A single-file component: only its `<script>` blocks are code. */
|
|
27
|
+
const SINGLE_FILE = /\.(?:vue|svelte|astro)$/;
|
|
16
28
|
const SKIP_DIRECTORIES = new Set([
|
|
17
29
|
"node_modules",
|
|
18
30
|
".next",
|
|
@@ -131,10 +143,51 @@ export function lineAt(source, index) {
|
|
|
131
143
|
return line;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
/**
|
|
147
|
+
* A single-file component with everything outside its `<script>` blocks blanked
|
|
148
|
+
* out, keeping every newline and every offset — so what reaches the parser is
|
|
149
|
+
* JavaScript, and a finding still points at the right line of the `.vue`,
|
|
150
|
+
* `.svelte` or `.astro` file.
|
|
151
|
+
*
|
|
152
|
+
* A `<script>` whose `type` is not JavaScript — JSON-LD, an import map, a
|
|
153
|
+
* template — is blanked with the markup. Astro's `---` frontmatter runs on the
|
|
154
|
+
* server and animates nothing, so it is left out too; the client `<script>` is
|
|
155
|
+
* what this reads.
|
|
156
|
+
*/
|
|
157
|
+
export function scriptsOnly(raw) {
|
|
158
|
+
const blank = (text) => text.replace(/[^\n]/g, " ");
|
|
159
|
+
const JAVASCRIPT = /^(?:module|text\/javascript|application\/javascript|ts|tsx)$/i;
|
|
160
|
+
const open = /<script\b([^>]*)>/gi;
|
|
161
|
+
|
|
162
|
+
let out = "";
|
|
163
|
+
let index = 0;
|
|
164
|
+
|
|
165
|
+
for (let tag = open.exec(raw); tag; tag = open.exec(raw)) {
|
|
166
|
+
const start = tag.index + tag[0].length;
|
|
167
|
+
const close = raw.indexOf("</script", start);
|
|
168
|
+
const end = close === -1 ? raw.length : close;
|
|
169
|
+
const type = tag[1].match(/\btype\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
170
|
+
const code = !type || JAVASCRIPT.test(type.trim());
|
|
171
|
+
|
|
172
|
+
out += blank(raw.slice(index, start));
|
|
173
|
+
out += code ? raw.slice(start, end) : blank(raw.slice(start, end));
|
|
174
|
+
index = end;
|
|
175
|
+
open.lastIndex = end;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return out + blank(raw.slice(index));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A file plus everything the rules need, from its text.
|
|
183
|
+
*
|
|
184
|
+
* The command line reads the text from disk, through `load`. The ESLint plugin
|
|
185
|
+
* passes the text ESLint already holds, with the file's path, so both entry
|
|
186
|
+
* points run the rules on the same parse of the same characters.
|
|
187
|
+
*/
|
|
188
|
+
export function fromText(raw, path, display = path) {
|
|
189
|
+
const source = SINGLE_FILE.test(path) ? scriptsOnly(raw) : raw;
|
|
190
|
+
const code = stripComments(source);
|
|
138
191
|
|
|
139
192
|
/**
|
|
140
193
|
* Parsed on first use, and once. Most files in a project never mention GSAP,
|
|
@@ -146,12 +199,16 @@ export function load(path, root) {
|
|
|
146
199
|
const tree = () => {
|
|
147
200
|
if (parsed === undefined) {
|
|
148
201
|
try {
|
|
149
|
-
parsed = { ...parse(
|
|
202
|
+
parsed = { ...parse(source, path), error: null };
|
|
150
203
|
} catch (error) {
|
|
151
204
|
parsed = {
|
|
152
205
|
ast: null,
|
|
153
206
|
comments: [],
|
|
154
|
-
error: {
|
|
207
|
+
error: {
|
|
208
|
+
message: error.message,
|
|
209
|
+
line: error.loc?.line ?? 1,
|
|
210
|
+
column: error.loc?.column ?? 0,
|
|
211
|
+
},
|
|
155
212
|
};
|
|
156
213
|
}
|
|
157
214
|
}
|
|
@@ -169,7 +226,7 @@ export function load(path, root) {
|
|
|
169
226
|
return tree().error;
|
|
170
227
|
},
|
|
171
228
|
path,
|
|
172
|
-
display
|
|
229
|
+
display,
|
|
173
230
|
raw,
|
|
174
231
|
code,
|
|
175
232
|
/**
|
|
@@ -187,3 +244,17 @@ export function load(path, root) {
|
|
|
187
244
|
usesGsap: /\bfrom ["']gsap(?:\/|["'])/.test(code),
|
|
188
245
|
};
|
|
189
246
|
}
|
|
247
|
+
|
|
248
|
+
/** A file on disk, read once, shown by its path from `root`. */
|
|
249
|
+
export function load(path, root) {
|
|
250
|
+
return fromText(
|
|
251
|
+
readFileSync(path, "utf8"),
|
|
252
|
+
path,
|
|
253
|
+
relative(root, path).replace(/\\/g, "/"),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether `collect` reads files with this path's extension. */
|
|
258
|
+
export function hasSourceExtension(path) {
|
|
259
|
+
return SOURCE_EXTENSIONS.has(extname(path));
|
|
260
|
+
}
|