@mehshekari/gsap-motion 2.1.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/LICENSE +21 -0
- package/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- package/skill/template/useTimeline.ts +110 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Three.js and React Three Fiber
|
|
2
|
+
|
|
3
|
+
GSAP is the **orchestration layer**, not the renderer. Three owns the scene,
|
|
4
|
+
the camera, and the draw loop. GSAP owns *when* values change and *how* they
|
|
5
|
+
ease between states.
|
|
6
|
+
|
|
7
|
+
Neither library comes with GSAP, and most projects have neither. Check
|
|
8
|
+
`package.json` before writing code against them, and say so if they are not
|
|
9
|
+
there.
|
|
10
|
+
|
|
11
|
+
## The division
|
|
12
|
+
|
|
13
|
+
| GSAP | Three / R3F |
|
|
14
|
+
|---|---|
|
|
15
|
+
| timing, easing, sequencing | geometry, materials, lights |
|
|
16
|
+
| camera moves between positions | camera projection, the render loop |
|
|
17
|
+
| object transforms over time | the transforms themselves |
|
|
18
|
+
| material property transitions | shaders |
|
|
19
|
+
| tying it all to scroll | rendering the result |
|
|
20
|
+
|
|
21
|
+
Do not rebuild a physics engine or a particle system in GSAP. Do not hand-write
|
|
22
|
+
easing in `useFrame` when a tween would say it in one line.
|
|
23
|
+
|
|
24
|
+
## The core pattern
|
|
25
|
+
|
|
26
|
+
Three objects are plain JS objects, so GSAP tweens them directly. The only thing
|
|
27
|
+
to remember is that nested properties are their own targets:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
gsap.to(mesh.position, { x: 2, y: 1, duration: 1.2, ease: "power3.inOut" });
|
|
31
|
+
gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 3, ease: "none", repeat: -1 });
|
|
32
|
+
gsap.to(mesh.material, { opacity: 0, duration: 0.6 });
|
|
33
|
+
gsap.to(mesh.scale, { x: 1.4, y: 1.4, z: 1.4, duration: 0.5 });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`gsap.to(mesh, { position: { x: 2 } })` does not work. Target `mesh.position`.
|
|
37
|
+
|
|
38
|
+
For a material property to animate opacity, `transparent` must already be true —
|
|
39
|
+
setting it mid-tween causes a shader recompile.
|
|
40
|
+
|
|
41
|
+
## Uniforms
|
|
42
|
+
|
|
43
|
+
Shader uniforms are `{ value }` objects, which tween cleanly:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
gsap.to(material.uniforms.uProgress, { value: 1, duration: 1.5, ease: "power2.inOut" });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is the usual bridge between a GSAP timeline and a custom shader effect —
|
|
50
|
+
one uniform, driven by the timeline, interpreted by the shader.
|
|
51
|
+
|
|
52
|
+
## R3F
|
|
53
|
+
|
|
54
|
+
The renderer runs its own loop, so nothing needs invalidating on demand unless
|
|
55
|
+
the canvas is set to `frameloop="demand"` — in which case call `invalidate()`
|
|
56
|
+
from `onUpdate`.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
"use client";
|
|
60
|
+
|
|
61
|
+
import { useGSAP } from "@gsap/react";
|
|
62
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
63
|
+
import gsap from "gsap";
|
|
64
|
+
import { useRef } from "react";
|
|
65
|
+
import type { Mesh } from "three";
|
|
66
|
+
|
|
67
|
+
export function Box() {
|
|
68
|
+
const mesh = useRef<Mesh>(null);
|
|
69
|
+
|
|
70
|
+
useGSAP(() => {
|
|
71
|
+
if (!mesh.current) return;
|
|
72
|
+
gsap.to(mesh.current.rotation, {
|
|
73
|
+
y: Math.PI * 2,
|
|
74
|
+
duration: 6,
|
|
75
|
+
ease: "none",
|
|
76
|
+
repeat: -1,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<mesh ref={mesh}>
|
|
82
|
+
<boxGeometry />
|
|
83
|
+
<meshStandardMaterial />
|
|
84
|
+
</mesh>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Never drive a 3D transform through React state.** `useState` per frame
|
|
90
|
+
re-renders the React tree for a value Three reads directly. Refs and GSAP only.
|
|
91
|
+
|
|
92
|
+
`useFrame` and GSAP are complementary, not competing: `useFrame` for continuous
|
|
93
|
+
per-frame logic that depends on the clock, GSAP for transitions between states.
|
|
94
|
+
|
|
95
|
+
## Camera
|
|
96
|
+
|
|
97
|
+
Camera moves are where GSAP earns its place — easing a camera by hand is how
|
|
98
|
+
scroll-driven 3D ends up feeling mechanical.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const { camera } = useThree();
|
|
102
|
+
|
|
103
|
+
gsap.timeline({ defaults: { ease: "power2.inOut", duration: 2 } })
|
|
104
|
+
.to(camera.position, { x: 0, y: 1.5, z: 4 })
|
|
105
|
+
.to(camera.rotation, { x: -0.2 }, "<");
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If the camera uses `lookAt`, tween a proxy target and call `lookAt` in
|
|
109
|
+
`onUpdate` — otherwise the rotation you tween is overwritten every frame:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const look = { x: 0, y: 0, z: 0 };
|
|
113
|
+
gsap.to(look, {
|
|
114
|
+
x: 2,
|
|
115
|
+
duration: 2,
|
|
116
|
+
onUpdate: () => camera.lookAt(look.x, look.y, look.z),
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Scroll-driven 3D
|
|
121
|
+
|
|
122
|
+
Scrub a timeline, do not compute from `scrollY` in `useFrame`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
gsap.timeline({
|
|
126
|
+
scrollTrigger: { trigger: section, start: "top top", end: "+=2000",
|
|
127
|
+
pin: true, scrub: 1 },
|
|
128
|
+
})
|
|
129
|
+
.to(camera.position, { z: 3, ease: "none" })
|
|
130
|
+
.to(material.uniforms.uProgress, { value: 1, ease: "none" }, "<");
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`ease: "none"` on every child, as with any scrub. See
|
|
134
|
+
[scrolltrigger.md](scrolltrigger.md).
|
|
135
|
+
|
|
136
|
+
## Cost and accessibility
|
|
137
|
+
|
|
138
|
+
3D is the most expensive thing on a page, and the constraints are harder than
|
|
139
|
+
for DOM animation:
|
|
140
|
+
|
|
141
|
+
- A WebGL canvas runs the GPU continuously. On a phone that is battery and heat.
|
|
142
|
+
- Give the canvas a real fallback — a static image — for low-power devices and
|
|
143
|
+
for `prefers-reduced-motion`. Do not merely stop the animation and leave a
|
|
144
|
+
running canvas.
|
|
145
|
+
- Dynamically import the 3D component so Three is not in the initial bundle.
|
|
146
|
+
- `frameloop="demand"` when the scene is static between interactions.
|
|
147
|
+
- Dispose geometries, materials and textures on unmount; GSAP's cleanup does not
|
|
148
|
+
do that.
|
|
149
|
+
- Nothing essential — text, navigation, product information — should exist only
|
|
150
|
+
inside the canvas.
|
|
151
|
+
|
|
152
|
+
## Cleanup
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
useGSAP(() => {
|
|
156
|
+
const tl = gsap.timeline({ scrollTrigger: { ... } });
|
|
157
|
+
return () => { tl.scrollTrigger?.kill(); };
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Plus Three's own disposal, which is separate and yours to do.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Timeline architecture
|
|
2
|
+
|
|
3
|
+
A timeline is how choreography stays editable. Five tweens with hand-tuned
|
|
4
|
+
delays encode the same animation in a form nobody can retime without redoing
|
|
5
|
+
the arithmetic.
|
|
6
|
+
|
|
7
|
+
## Position
|
|
8
|
+
|
|
9
|
+
The third argument places a child on the timeline. This is the whole API.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
tl.to(a, { ... }) // after everything before it
|
|
13
|
+
.to(b, { ... }, "<") // with the previous tween's start
|
|
14
|
+
.to(c, { ... }, "<0.2") // 0.2s after the previous start
|
|
15
|
+
.to(d, { ... }, "-=0.3") // 0.3s before the timeline's current end
|
|
16
|
+
.to(e, { ... }, ">") // at the previous tween's end (explicit)
|
|
17
|
+
.to(f, { ... }, 1.5) // absolute time
|
|
18
|
+
.to(g, { ... }, "reveal") // at a label
|
|
19
|
+
.to(h, { ... }, "reveal+=0.4"); // relative to a label
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`<` is the one to reach for most. "Start with the previous thing, slightly
|
|
23
|
+
after" is what overlap sounds like in code, and it survives a duration change
|
|
24
|
+
upstream — `-=0.3` does not.
|
|
25
|
+
|
|
26
|
+
## Labels
|
|
27
|
+
|
|
28
|
+
Labels turn a timeline into something you can read and something you can seek.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const tl = gsap.timeline();
|
|
32
|
+
|
|
33
|
+
tl.addLabel("mark")
|
|
34
|
+
.fromTo("[data-outline]", { drawSVG: "0%" }, { drawSVG: "100%", duration: 1 })
|
|
35
|
+
.addLabel("word")
|
|
36
|
+
.to("[data-letter]", { autoAlpha: 1, stagger: 0.05 }, "word")
|
|
37
|
+
.to(dot, { motionPath: { ... } }, "word")
|
|
38
|
+
.addLabel("hold");
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Two children sharing a label start together and stay together when the beat
|
|
42
|
+
before them changes length. That is the point — a label is a commitment about
|
|
43
|
+
*structure*, where a number is a commitment about *time*.
|
|
44
|
+
|
|
45
|
+
`tl.seek("word")` and `tl.tweenTo("hold")` then work without counting.
|
|
46
|
+
|
|
47
|
+
## Nesting
|
|
48
|
+
|
|
49
|
+
Build each beat as its own timeline and assemble them. This is what keeps a
|
|
50
|
+
long sequence readable, and it lets you reuse a beat.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const enter = () =>
|
|
54
|
+
gsap.timeline().from("[data-line]", { yPercent: 110, stagger: 0.06 });
|
|
55
|
+
|
|
56
|
+
const master = gsap.timeline()
|
|
57
|
+
.add(enter())
|
|
58
|
+
.add(revealImage(), "-=0.4")
|
|
59
|
+
.add(settle());
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A nested timeline keeps its own internal timing when the parent's `timeScale`
|
|
63
|
+
changes, which is how you slow a whole sequence without touching each beat.
|
|
64
|
+
|
|
65
|
+
## Defaults
|
|
66
|
+
|
|
67
|
+
Repeating `duration` and `ease` on every child is noise and drifts:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const tl = gsap.timeline({
|
|
71
|
+
defaults: { duration: 0.6, ease: "power3.out" },
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Children override individually. Set the house values once here.
|
|
76
|
+
|
|
77
|
+
## Where `repeat` belongs
|
|
78
|
+
|
|
79
|
+
On the child, not the parent, when only part of the sequence loops:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
tl.from("[data-mark]", { ... }) // plays once
|
|
83
|
+
.to(dot, { rotation: 360, repeat: -1, ease: "none" }); // loops forever
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`repeat: -1` on the timeline replays the entrance too. And a timeline containing
|
|
87
|
+
an infinitely repeating child **never completes**, so its `onComplete` never
|
|
88
|
+
fires — if something downstream waits on that, give the timeline a finite beat
|
|
89
|
+
of its own to end on, or drive the handover from elsewhere.
|
|
90
|
+
|
|
91
|
+
## Paused timelines as state
|
|
92
|
+
|
|
93
|
+
Build once, play on demand. Far better than creating a tween per event.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const hover = gsap.timeline({ paused: true })
|
|
97
|
+
.to(el, { scale: 1.05, duration: 0.3 });
|
|
98
|
+
|
|
99
|
+
onMouseEnter = () => hover.play();
|
|
100
|
+
onMouseLeave = () => hover.reverse();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`reverse()` uses the same easing backwards, so the return matches the arrival
|
|
104
|
+
without a second definition. In React, anything built outside the `useGSAP`
|
|
105
|
+
body must go through `contextSafe` — see [react-nextjs.md](react-nextjs.md).
|
|
106
|
+
|
|
107
|
+
## Holds
|
|
108
|
+
|
|
109
|
+
An empty tween is a pause that is visible in the structure:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
tl.to(shape, { morphSVG: "#b" })
|
|
113
|
+
.to({}, { duration: 0.4 }) // hold, legible in the timeline
|
|
114
|
+
.to(shape, { morphSVG: "#c" });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Clearer than `delay` on the next tween, because the hold is a beat rather than a
|
|
118
|
+
property of something else.
|
|
119
|
+
|
|
120
|
+
## Scrubbing
|
|
121
|
+
|
|
122
|
+
A paused timeline driven by an external value is the pattern behind every
|
|
123
|
+
progress-bound animation:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const tl = gsap.timeline({ paused: true });
|
|
127
|
+
tl.fromTo(ring, { drawSVG: "0%" }, { drawSVG: "100%", ease: "none" });
|
|
128
|
+
|
|
129
|
+
gsap.to(tl, { progress, duration: 0.4, ease: "power2.out", overwrite: true });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Tween the progress rather than setting it — that is what smooths a value
|
|
133
|
+
arriving as 0 → 40 → 100. `overwrite: true` stops two updates in flight from
|
|
134
|
+
fighting. `ease: "none"` inside, because the outer tween owns the feel.
|
|
135
|
+
|
|
136
|
+
ScrollTrigger's `scrub` is this same mechanism with scroll as the value.
|
|
137
|
+
|
|
138
|
+
## Common failures
|
|
139
|
+
|
|
140
|
+
| Symptom | Cause |
|
|
141
|
+
|---|---|
|
|
142
|
+
| Reads like a slideshow | no overlap — nothing uses `<` or a negative offset |
|
|
143
|
+
| Retiming one beat breaks the rest | `-=` offsets instead of labels |
|
|
144
|
+
| `onComplete` never fires | an infinitely repeating child |
|
|
145
|
+
| Entrance replays on loop | `repeat: -1` on the timeline, not the child |
|
|
146
|
+
| Flash of the end state before it runs | `from` without a `set`, outside a layout effect |
|
|
147
|
+
| Two beats drift apart after an edit | both timed by number instead of sharing a label |
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audits GSAP animation code for the failures that are silent.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is a matter of taste. Every rule catches something that
|
|
7
|
+
* produces no error and often no visible symptom until much later, on a slower
|
|
8
|
+
* device, or after a navigation: a tween that outlives its component, a tween
|
|
9
|
+
* allocated per pointer event, a layout property animated every frame, an ease
|
|
10
|
+
* on a loop that makes its own seam visible, a hardcoded id that makes two
|
|
11
|
+
* instances drive one another's geometry.
|
|
12
|
+
*
|
|
13
|
+
* Rhythm, easing choice and whether an animation earns its place are not
|
|
14
|
+
* checkable and are not attempted — reference/motion-design.md owns those.
|
|
15
|
+
*
|
|
16
|
+
* This is static analysis without a parser, so it is deliberately conservative
|
|
17
|
+
* and will still be wrong sometimes. `info` findings have the loosest match.
|
|
18
|
+
* A wrong finding is a bug in this script; report it rather than working
|
|
19
|
+
* around it.
|
|
20
|
+
*
|
|
21
|
+
* Usage — paths resolve from the current directory, so run it from the project
|
|
22
|
+
* root, wherever the skill itself is installed:
|
|
23
|
+
* node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
|
|
24
|
+
* node <skill-dir>/scripts/audit-gsap.mjs src/components --json
|
|
25
|
+
* node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
|
|
26
|
+
*
|
|
27
|
+
* Exit code is 1 when any error-level finding is reported, 0 otherwise.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync } from "node:fs";
|
|
30
|
+
import { resolve } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { RULES } from "./lib/rules.mjs";
|
|
33
|
+
import { collect, lineAt, load } from "./lib/source.mjs";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The project being audited is wherever this is run from, not wherever the
|
|
37
|
+
* skill is installed. It used to be derived from this file's own location,
|
|
38
|
+
* which only worked while the skill sat exactly four levels inside the project:
|
|
39
|
+
* installed per user under `~/.claude/skills/`, it resolved `src` against the
|
|
40
|
+
* home directory and every run exited with "No such path".
|
|
41
|
+
*/
|
|
42
|
+
const ROOT = process.cwd();
|
|
43
|
+
|
|
44
|
+
const argv = process.argv.slice(2);
|
|
45
|
+
const asJson = argv.includes("--json");
|
|
46
|
+
const quiet = argv.includes("--quiet");
|
|
47
|
+
const targets = argv.filter((a) => !a.startsWith("--"));
|
|
48
|
+
|
|
49
|
+
const paths = (targets.length ? targets : ["src"]).map((p) => resolve(ROOT, p));
|
|
50
|
+
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
console.error(`No such path: ${path}`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Escape hatch ------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The same shape many repos already use for their own lint waivers, on
|
|
62
|
+
* purpose: one way to say "yes, and here is why", not two.
|
|
63
|
+
*
|
|
64
|
+
* A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
|
|
65
|
+
* above it, and it waives that rule and nothing else — silencing a shared id
|
|
66
|
+
* cannot also silence a layout property on the same line. The lookback is eight
|
|
67
|
+
* because a reason usually stands above a whole declaration rather than beside
|
|
68
|
+
* one line of it.
|
|
69
|
+
*
|
|
70
|
+
* Every hatch must carry its reason. A bare token with no sentence after it is
|
|
71
|
+
* a rule someone turned off rather than answered, and once this gates a build
|
|
72
|
+
* that distinction has to stay visible in review.
|
|
73
|
+
*/
|
|
74
|
+
const LOOKBACK = 8;
|
|
75
|
+
|
|
76
|
+
function waived(source, line, ruleId) {
|
|
77
|
+
const lines = source.split("\n");
|
|
78
|
+
const from = Math.max(0, line - 1 - LOOKBACK);
|
|
79
|
+
return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Run ---------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const findings = [];
|
|
85
|
+
|
|
86
|
+
for (const path of paths) {
|
|
87
|
+
for (const file of collect(path)) {
|
|
88
|
+
const source = load(file, ROOT);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A file that never mentions GSAP has nothing these rules can say. Checked
|
|
92
|
+
* once here rather than in every rule.
|
|
93
|
+
*/
|
|
94
|
+
if (!source.usesGsap && !/\bgsap\./.test(source.code)) continue;
|
|
95
|
+
|
|
96
|
+
for (const rule of RULES) {
|
|
97
|
+
for (const hit of rule.test(source)) {
|
|
98
|
+
const line = lineAt(source.raw, hit.index);
|
|
99
|
+
if (waived(source.raw, line, rule.id)) continue;
|
|
100
|
+
|
|
101
|
+
findings.push({
|
|
102
|
+
rule: rule.id,
|
|
103
|
+
level: rule.level,
|
|
104
|
+
file: source.display,
|
|
105
|
+
line,
|
|
106
|
+
message: hit.message,
|
|
107
|
+
hint: hit.hint,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Report ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const ORDER = { error: 0, warn: 1, info: 2 };
|
|
117
|
+
const ICON = { error: "✗", warn: "!", info: "i" };
|
|
118
|
+
|
|
119
|
+
findings.sort(
|
|
120
|
+
(a, b) =>
|
|
121
|
+
ORDER[a.level] - ORDER[b.level] ||
|
|
122
|
+
a.file.localeCompare(b.file) ||
|
|
123
|
+
a.line - b.line,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const shown = quiet ? findings.filter((f) => f.level === "error") : findings;
|
|
127
|
+
|
|
128
|
+
if (asJson) {
|
|
129
|
+
console.log(JSON.stringify({ findings: shown }, null, 2));
|
|
130
|
+
} else if (!shown.length) {
|
|
131
|
+
console.log(
|
|
132
|
+
findings.length
|
|
133
|
+
? `gsap-audit: no errors (${findings.length} lower-severity finding(s) hidden by --quiet)`
|
|
134
|
+
: "gsap-audit: clean",
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
let current = "";
|
|
138
|
+
for (const f of shown) {
|
|
139
|
+
if (f.file !== current) {
|
|
140
|
+
current = f.file;
|
|
141
|
+
console.log(`\n${current}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(` ${ICON[f.level]} ${f.line}: ${f.message} [${f.rule}]`);
|
|
144
|
+
console.log(` ${f.hint}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const counts = ORDER;
|
|
148
|
+
const tally = Object.keys(counts)
|
|
149
|
+
.map((level) => [level, findings.filter((f) => f.level === level).length])
|
|
150
|
+
.filter(([, n]) => n > 0)
|
|
151
|
+
.map(([level, n]) => `${n} ${level}`)
|
|
152
|
+
.join(", ");
|
|
153
|
+
|
|
154
|
+
console.log(`\n${tally}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
process.exit(findings.some((f) => f.level === "error") ? 1 : 0);
|