@mehshekari/gsap-motion 3.1.1 → 3.3.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.
@@ -51,6 +51,18 @@ 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.",
56
+ tweenPerFrame:
57
+ "A tween per frame allocates an object per frame, and each one fights the last. Create a `gsap.quickTo()` or `quickSetter()` once outside the callback and call it inside — or, for scroll, scrub a timeline instead of writing values by hand.",
58
+ paintProperty:
59
+ "Filters and shadows repaint the layer every frame, and the cost grows with the painted area. Animate `opacity` on a pre-blurred or pre-shadowed copy, or move a transform instead, and measure before defending anything else.",
60
+ unownedLoop:
61
+ "An infinite repeat keeps the ticker busy while it is off screen. Pause it from an IntersectionObserver, or let a ScrollTrigger's `onToggle` own it, so it costs nothing when nobody can see it.",
62
+ ungatedHover:
63
+ "A tap fires `mouseenter` on a touch device and nothing fires the leave, so the effect sticks. Gate it with `gsap.matchMedia()` on `(hover: hover)`, and decide what a coarse pointer gets instead.",
64
+ delayChain:
65
+ "Delays are a choreography nobody can change: moving one beat means re-adding every number after it. Build a timeline and position each beat with a label or a relative offset — `\"<\"`, `\"-=0.2\"`.",
54
66
  danglingListener:
55
67
  "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
68
  tweenPerEvent:
@@ -85,6 +97,10 @@ const HINT = {
85
97
  /** Messages that do not vary with what was found. */
86
98
  const MESSAGE = {
87
99
  easedLoop: "An infinite repeat with an ease other than `none`.",
100
+ tweenPerFrame: "A tween is created here on every frame.",
101
+ unownedLoop: "An infinite repeat that nothing pauses.",
102
+ ungatedHover: "A hover animation with no `(hover: hover)` gate.",
103
+ delayChain: "Three or more tweens in this scope are sequenced by `delay`.",
88
104
  easedScrub: "Easing inside a scrubbed ScrollTrigger timeline.",
89
105
  neverCompletesChild:
90
106
  "An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
@@ -212,7 +228,7 @@ function listenerMethod(node) {
212
228
  * `followNames`, a handler passed by name is followed to its declaration when
213
229
  * the file declares exactly one function of that name.
214
230
  */
215
- function handlersFor(ast, events, { followNames = true } = {}) {
231
+ function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
216
232
  const handler = (expression) => {
217
233
  const node = unwrap(expression);
218
234
  if (node?.type === "CallExpression") return node;
@@ -242,6 +258,17 @@ function handlersFor(ast, events, { followNames = true } = {}) {
242
258
  if (node) handlers.push({ name: `on${prop[1]}`, node });
243
259
  }
244
260
 
261
+ /**
262
+ * R3F's `useFrame(callback)` is a per-frame scope of its own: what is inside
263
+ * runs on every rendered frame, whatever the pointer is doing.
264
+ */
265
+ if (perFrame) {
266
+ for (const call of findAll(ast, (node) => calleeName(node) === "useFrame")) {
267
+ const node = handler(call.arguments[0]);
268
+ if (node) handlers.push({ name: "useFrame", node });
269
+ }
270
+ }
271
+
245
272
  return handlers;
246
273
  }
247
274
 
@@ -401,6 +428,13 @@ function notPerEvent(ast, handler, node) {
401
428
  }
402
429
 
403
430
  /** Listener targets that outlive every component: the window, the document and its root elements. */
431
+ /**
432
+ * Events that fire once and are never removed by anyone: the document's own
433
+ * start-up. The corpus reported two `DOMContentLoaded` handlers in an Astro
434
+ * page as leaks, which is not what a leak is.
435
+ */
436
+ const ONE_SHOT_EVENTS = new Set(["DOMContentLoaded", "load", "pageshow"]);
437
+
404
438
  const GLOBAL_TARGETS = new Set([
405
439
  "window",
406
440
  "document",
@@ -493,6 +527,160 @@ export function pluginRegistrations(file) {
493
527
  return names;
494
528
  }
495
529
 
530
+ /**
531
+ * The mount hooks each adapter uses. A context created in one of them lives as
532
+ * long as the component does, so something has to revert it when the component
533
+ * goes.
534
+ */
535
+ const MOUNT_HOOKS = new Set([
536
+ "onMounted",
537
+ "onMount",
538
+ "onActivated",
539
+ "useEffect",
540
+ "useLayoutEffect",
541
+ "$effect",
542
+ ]);
543
+
544
+ /**
545
+ * Which mount hook `node` was created in, or null. Astro has no hook: its
546
+ * equivalent is a listener for `astro:page-load`, which fires on the first load
547
+ * and after every view transition.
548
+ */
549
+ function mountHook(ast, node) {
550
+ /** Nearest first, so a function's own parent is the entry after it. */
551
+ const ancestors = ancestorsOf(ast, node);
552
+
553
+ for (let i = 0; i < ancestors.length; i += 1) {
554
+ if (!isFunction(ancestors[i])) continue;
555
+ const parent = ancestors[i + 1];
556
+ if (parent?.type !== "CallExpression") continue;
557
+
558
+ const name = calleeName(parent);
559
+ if (MOUNT_HOOKS.has(name)) return `\`${name}\``;
560
+ if (
561
+ name?.endsWith("addEventListener") &&
562
+ staticString(parent.arguments[0]) === "astro:page-load"
563
+ ) {
564
+ return "the `astro:page-load` handler";
565
+ }
566
+ }
567
+
568
+ return null;
569
+ }
570
+
571
+ /**
572
+ * Properties whose animation repaints the element rather than moving a layer
573
+ * the compositor already has.
574
+ *
575
+ * `clipPath` is deliberately absent. An inset or circle reveal is the technique
576
+ * this skill recommends for image reveals, it is cheap on a simple shape, and a
577
+ * rule that fires on its own guidance is noise.
578
+ */
579
+ const PAINT_PROPERTIES = new Set([
580
+ "filter",
581
+ "webkitFilter",
582
+ "backdropFilter",
583
+ "boxShadow",
584
+ ]);
585
+
586
+ const HOVER_EVENTS = new Set([
587
+ "pointerenter",
588
+ "pointerover",
589
+ "mouseenter",
590
+ "mouseover",
591
+ ]);
592
+
593
+ /**
594
+ * Every function that runs once per frame: the ticker, R3F's `useFrame`, an
595
+ * `onUpdate`/`onMove`/`onChange` callback, and a function that schedules
596
+ * itself with `requestAnimationFrame`.
597
+ */
598
+ function perFrameScopes(ast) {
599
+ const scopes = [];
600
+ const add = (node) => {
601
+ const fn = node && unwrap(node);
602
+ if (isFunction(fn)) scopes.push(fn);
603
+ };
604
+
605
+ for (const call of findAll(ast, (node) => {
606
+ const name = calleeName(node);
607
+ return name === "gsap.ticker.add" || name === "useFrame";
608
+ })) {
609
+ add(call.arguments[0]);
610
+ }
611
+
612
+ /**
613
+ * `onUpdate`, `onMove` and `onChange` are per-frame in a tween, a
614
+ * ScrollTrigger, an Observer or a Draggable — and nowhere else. Every control
615
+ * and form library in the world has an `onChange`, and the corpus duly
616
+ * reported a Three.js `traverse` inside one of them as a per-frame tween.
617
+ */
618
+ const PER_FRAME_KEYS = new Set(["onUpdate", "onMove", "onChange"]);
619
+ const GSAP_CONFIG = /^(?:gsap\.|ScrollTrigger\.|Observer\.|Draggable\.|ScrollSmoother\.|Flip\.)/;
620
+
621
+ for (const property of findAll(
622
+ ast,
623
+ (node) => node.type === "Property" && PER_FRAME_KEYS.has(keyName(node)),
624
+ )) {
625
+ const owner = ancestorsOf(ast, property).find(
626
+ (node) => node.type === "CallExpression",
627
+ );
628
+ if (!owner) continue;
629
+ const name = calleeName(owner);
630
+ if (tweenMethod(owner) === null && !(name && GSAP_CONFIG.test(name))) continue;
631
+ add(property.value);
632
+ }
633
+
634
+ for (const call of findAll(
635
+ ast,
636
+ (node) => calleeName(node) === "requestAnimationFrame",
637
+ )) {
638
+ const fn = resolveFunction(ast, call.arguments[0]);
639
+ if (isFunction(fn) && contains(fn, call)) scopes.push(fn);
640
+ }
641
+
642
+ return scopes;
643
+ }
644
+
645
+ /** The handlers a hover starts: a listener's callback, or a JSX `on*` prop. */
646
+ function hoverHandlers(ast) {
647
+ const handlers = [];
648
+
649
+ for (const call of findAll(
650
+ ast,
651
+ (node) => listenerMethod(node) === "addEventListener",
652
+ )) {
653
+ const event = staticString(call.arguments[0]);
654
+ if (event === null || !HOVER_EVENTS.has(event)) continue;
655
+ const fn = resolveFunction(ast, call.arguments[1]);
656
+ if (isFunction(fn)) handlers.push(fn);
657
+ }
658
+
659
+ for (const attribute of findAll(
660
+ ast,
661
+ (node) => node.type === "JSXAttribute" && node.name.type === "JSXIdentifier",
662
+ )) {
663
+ if (!/^on(?:Mouse|Pointer)(?:Enter|Over)$/.test(attribute.name.name)) continue;
664
+ if (attribute.value?.type !== "JSXExpressionContainer") continue;
665
+ const fn = resolveFunction(ast, attribute.value.expression);
666
+ if (isFunction(fn)) handlers.push(fn);
667
+ }
668
+
669
+ return handlers;
670
+ }
671
+
672
+ /** Whether anything in the file pauses, kills or reverts what is kept as `name`. */
673
+ function stopped(ast, name) {
674
+ if (!name) return false;
675
+ return (
676
+ findAll(ast, (node) => {
677
+ const method = methodName(node);
678
+ if (method !== "pause" && !TEARDOWN.has(method)) return false;
679
+ return dottedName(unwrap(node.callee).object) === name;
680
+ }).length > 0
681
+ );
682
+ }
683
+
496
684
  export const RULES = [
497
685
  // --- Lifecycle ------------------------------------------------------------
498
686
  {
@@ -575,6 +763,40 @@ export const RULES = [
575
763
  }));
576
764
  },
577
765
  },
766
+ {
767
+ id: "unreverted-context",
768
+ level: "warn",
769
+ description:
770
+ "A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts",
771
+ /**
772
+ * Every adapter's teardown rule, in one shape: a context built when the
773
+ * component mounts holds every tween, ScrollTrigger and matchMedia made
774
+ * inside it, and reverting it is the whole cleanup. Nothing reverts it here.
775
+ *
776
+ * Only contexts created in a mount hook are read. A context built in a
777
+ * function the module exports — the vanilla `mountReveal(root)` shape — hands
778
+ * its teardown to the caller, and this file cannot see whether the caller
779
+ * calls it.
780
+ */
781
+ test(file) {
782
+ const contexts = findAll(
783
+ file.ast,
784
+ (node) => calleeName(node) === "gsap.context",
785
+ );
786
+
787
+ return contexts.flatMap((call) => {
788
+ const hook = mountHook(file.ast, call);
789
+ if (!hook || tornDown(file.ast, call)) return [];
790
+ return [
791
+ {
792
+ index: call.start,
793
+ message: `The \`gsap.context\` created in ${hook} is never reverted.`,
794
+ hint: HINT.unrevertedContext,
795
+ },
796
+ ];
797
+ });
798
+ },
799
+ },
578
800
  {
579
801
  id: "dangling-listener",
580
802
  level: "warn",
@@ -599,6 +821,7 @@ export const RULES = [
599
821
  const event = staticString(call.arguments[0]);
600
822
  const handler = unwrap(call.arguments[1]);
601
823
  if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
824
+ if (ONE_SHOT_EVENTS.has(event)) return [];
602
825
  const options = unwrap(call.arguments[2]);
603
826
  const once = unwrap(propertyOf(options, "once")?.value);
604
827
  const callee = unwrap(call.callee);
@@ -699,14 +922,20 @@ export const RULES = [
699
922
  );
700
923
  });
701
924
 
702
- return handlersFor(file.ast, STATE_HOT_EVENTS, { followNames: false }).flatMap(
925
+ return handlersFor(file.ast, STATE_HOT_EVENTS, {
926
+ followNames: false,
927
+ perFrame: true,
928
+ }).flatMap(
703
929
  (handler) =>
704
930
  setters
705
931
  .filter((setter) => contains(handler.node, setter))
706
932
  .filter((setter) => !debounced(handler, setter))
707
933
  .map((setter) => ({
708
934
  index: setter.start,
709
- message: `React state setter inside a ${handler.name} handler.`,
935
+ message:
936
+ handler.name === "useFrame"
937
+ ? "React state setter inside a `useFrame` callback, which runs every frame."
938
+ : `React state setter inside a ${handler.name} handler.`,
710
939
  hint: HINT.statePerEvent,
711
940
  })),
712
941
  );
@@ -1090,6 +1319,177 @@ export const RULES = [
1090
1319
  return findings;
1091
1320
  },
1092
1321
  },
1322
+ {
1323
+ id: "tween-per-frame",
1324
+ level: "warn",
1325
+ description:
1326
+ "A tween created every frame — in `onUpdate`, the ticker, an Observer callback, `useFrame` or a `requestAnimationFrame` loop",
1327
+ /**
1328
+ * `gsap.set` is left out: writing a value straight to an element is what a
1329
+ * per-frame callback is for, and `quickSetter` is the faster shape of the
1330
+ * same thing rather than a different one. What this catches is a tween —
1331
+ * an object with its own duration and ease — being built sixty times a
1332
+ * second, each one overwriting the last before it can finish.
1333
+ */
1334
+ test(file) {
1335
+ const scopes = perFrameScopes(file.ast);
1336
+ if (scopes.length === 0) return [];
1337
+
1338
+ const tweens = findAll(file.ast, (node) => {
1339
+ const method = tweenMethod(node);
1340
+ return method !== null && method !== "set";
1341
+ });
1342
+
1343
+ const reported = new Set();
1344
+ const findings = [];
1345
+ for (const scope of scopes) {
1346
+ for (const tween of tweens) {
1347
+ if (!contains(scope, tween) || reported.has(tween.start)) continue;
1348
+ reported.add(tween.start);
1349
+ findings.push({
1350
+ index: tween.start,
1351
+ message: MESSAGE.tweenPerFrame,
1352
+ hint: HINT.tweenPerFrame,
1353
+ });
1354
+ }
1355
+ }
1356
+ return findings;
1357
+ },
1358
+ },
1359
+ {
1360
+ id: "paint-property",
1361
+ level: "warn",
1362
+ description:
1363
+ "Animating `filter`, `backdropFilter` or `boxShadow`, which repaints the element every frame",
1364
+ /** Read exactly as layout-property reads its own keys, and `set` is skipped too. */
1365
+ test(file) {
1366
+ const animates = (node) => {
1367
+ const method = tweenMethod(node);
1368
+ return method !== null && method !== "set";
1369
+ };
1370
+
1371
+ return findAll(file.ast, animates).flatMap((call) => {
1372
+ const seen = new Set();
1373
+ return varsObjects(call, tweenMethod(call))
1374
+ .flatMap((vars) => vars.properties)
1375
+ .filter((property) => {
1376
+ const key = keyName(property);
1377
+ if (!PAINT_PROPERTIES.has(key) || seen.has(key)) return false;
1378
+ seen.add(key);
1379
+ return true;
1380
+ })
1381
+ .map((property) => ({
1382
+ index: property.start,
1383
+ message: `Animating \`${keyName(property)}\` repaints on every frame.`,
1384
+ hint: HINT.paintProperty,
1385
+ }));
1386
+ });
1387
+ },
1388
+ },
1389
+ {
1390
+ id: "unowned-loop",
1391
+ level: "info",
1392
+ description:
1393
+ "An infinite repeat that nothing pauses, which keeps the ticker busy off screen",
1394
+ /**
1395
+ * Info, because "nothing pauses it" is read from one file and a loop can be
1396
+ * owned from elsewhere. A loop is taken as owned when its name is paused,
1397
+ * killed or reverted anywhere here, when the file builds an
1398
+ * IntersectionObserver, when the tween carries a `scrollTrigger` that
1399
+ * toggles it, or when a context holds it — unmount reverts that one.
1400
+ *
1401
+ * The last of those came from a real site's loader, where three loops sit
1402
+ * in a `useGSAP` body inside `matchMedia`. Reporting them would have meant
1403
+ * "nothing stops this" about an animation the component's own unmount stops.
1404
+ * What is left — a loop running while it is off screen but still mounted —
1405
+ * is a measurement, and 3.4's `inspect` is where that belongs.
1406
+ */
1407
+ test(file) {
1408
+ const observed = findAll(
1409
+ file.ast,
1410
+ (node) =>
1411
+ node.type === "NewExpression" &&
1412
+ dottedName(node.callee) === "IntersectionObserver",
1413
+ ).length > 0;
1414
+ if (observed) return [];
1415
+
1416
+ const inside = gsapContexts(file.ast);
1417
+
1418
+ return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap((call) => {
1419
+ if (inside(call)) return [];
1420
+ const vars = ownVars(call, tweenMethod(call));
1421
+ if (!vars) return [];
1422
+ const repeat = propertyOf(vars, "repeat");
1423
+ if (!repeat || numberValue(repeat.value) !== -1) return [];
1424
+ if (propertyOf(vars, "scrollTrigger")) return [];
1425
+ if (stopped(file.ast, keptUnder(file.ast, call)?.name)) return [];
1426
+ return [
1427
+ {
1428
+ index: repeat.start,
1429
+ message: MESSAGE.unownedLoop,
1430
+ hint: HINT.unownedLoop,
1431
+ },
1432
+ ];
1433
+ });
1434
+ },
1435
+ },
1436
+ {
1437
+ id: "ungated-hover",
1438
+ level: "warn",
1439
+ description:
1440
+ "A hover animation with no `(hover: hover)` gate, which a tap starts and nothing ends",
1441
+ /**
1442
+ * The gate is looked for as text, anywhere in the file: a `matchMedia`
1443
+ * condition, a CSS string, a constant. One finding per handler, on its first
1444
+ * tween, because the handler is what needs the gate.
1445
+ */
1446
+ test(file) {
1447
+ if (/\(\s*(?:any-)?hover\s*:\s*hover\s*\)/.test(file.code)) return [];
1448
+
1449
+ return hoverHandlers(file.ast).flatMap((handler) => {
1450
+ const [tween] = findAll(file.ast, (node) => tweenMethod(node) !== null).filter(
1451
+ (node) => contains(handler, node),
1452
+ );
1453
+ if (!tween) return [];
1454
+ return [
1455
+ { index: tween.start, message: MESSAGE.ungatedHover, hint: HINT.ungatedHover },
1456
+ ];
1457
+ });
1458
+ },
1459
+ },
1460
+ {
1461
+ id: "delay-chain",
1462
+ level: "warn",
1463
+ description:
1464
+ "Three or more tweens in one scope sequenced by `delay`, which is a timeline nobody can retime",
1465
+ /**
1466
+ * Three is the threshold because two tweens with delays are a pair, and a
1467
+ * pair is still readable. Counted per scope — the function they share — so
1468
+ * three components each with one delayed tween are not a chain. A tween on a
1469
+ * timeline is not counted: a timeline already has positions.
1470
+ */
1471
+ test(file) {
1472
+ const scopes = new Map();
1473
+
1474
+ for (const call of findAll(file.ast, (node) => tweenMethod(node) !== null)) {
1475
+ if (chainStart(call) !== call) continue;
1476
+ const vars = ownVars(call, tweenMethod(call));
1477
+ const delay = vars && propertyOf(vars, "delay");
1478
+ if (!delay || !(numberValue(delay.value) > 0)) continue;
1479
+ const scope = scopeOf(file.ast, call) ?? file.ast;
1480
+ if (!scopes.has(scope)) scopes.set(scope, []);
1481
+ scopes.get(scope).push(call);
1482
+ }
1483
+
1484
+ return [...scopes.values()]
1485
+ .filter((calls) => calls.length >= 3)
1486
+ .map((calls) => ({
1487
+ index: calls[0].start,
1488
+ message: MESSAGE.delayChain,
1489
+ hint: HINT.delayChain,
1490
+ }));
1491
+ },
1492
+ },
1093
1493
  {
1094
1494
  id: "missing-reduced-motion",
1095
1495
  level: "warn",
@@ -1097,23 +1497,28 @@ export const RULES = [
1097
1497
  "An animating file with no `prefers-reduced-motion` branch",
1098
1498
  /**
1099
1499
  * A branch is the query written in code — a string, a template or JSX
1100
- * text. A comment that mentions it is not a branch. A file that only sets
1101
- * values is not animating anything.
1500
+ * text. A comment that mentions it is not a branch.
1501
+ *
1502
+ * Only a file that builds an animation of its own is reported, and the
1503
+ * finding lands on the first one. Three things are not that:
1504
+ * `registerPlugin` and `killTweensOf`, which animate nothing; `set`, which
1505
+ * has no duration; and a method called on a timeline the file was handed,
1506
+ * because a helper that adds beats to its caller's timeline does not own
1507
+ * the reduced-motion decision — the caller does.
1102
1508
  */
1103
1509
  test(file) {
1104
1510
  if (!file.usesGsap) return [];
1105
1511
  if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1106
1512
  return [];
1107
1513
  }
1108
- if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
1109
1514
 
1110
- const first = findAll(
1111
- file.ast,
1112
- (node) =>
1113
- node.type === "MemberExpression" &&
1114
- unwrap(node.object)?.type === "Identifier" &&
1115
- unwrap(node.object).name === "gsap",
1116
- ).sort((a, b) => a.start - b.start)[0];
1515
+ const [first] = findAll(file.ast, (node) => {
1516
+ const method = tweenMethod(node);
1517
+ if (method === null || method === "set") return false;
1518
+ /** Built here, rather than added to something passed in. */
1519
+ return calleeName(node)?.startsWith("gsap.") === true;
1520
+ }).sort((a, b) => a.start - b.start);
1521
+ if (!first) return [];
1117
1522
 
1118
1523
  return [
1119
1524
  {
@@ -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([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
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,6 +143,41 @@ export function lineAt(source, index) {
131
143
  return line;
132
144
  }
133
145
 
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
+
134
181
  /**
135
182
  * A file plus everything the rules need, from its text.
136
183
  *
@@ -139,7 +186,8 @@ export function lineAt(source, index) {
139
186
  * points run the rules on the same parse of the same characters.
140
187
  */
141
188
  export function fromText(raw, path, display = path) {
142
- const code = stripComments(raw);
189
+ const source = SINGLE_FILE.test(path) ? scriptsOnly(raw) : raw;
190
+ const code = stripComments(source);
143
191
 
144
192
  /**
145
193
  * Parsed on first use, and once. Most files in a project never mention GSAP,
@@ -151,7 +199,7 @@ export function fromText(raw, path, display = path) {
151
199
  const tree = () => {
152
200
  if (parsed === undefined) {
153
201
  try {
154
- parsed = { ...parse(raw, path), error: null };
202
+ parsed = { ...parse(source, path), error: null };
155
203
  } catch (error) {
156
204
  parsed = {
157
205
  ast: null,