@mehshekari/eslint-plugin-gsap-motion 3.1.1

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/lib/source.mjs ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Source reading for the GSAP audit: finding the files, and reading each one
3
+ * once into what the rules need.
4
+ *
5
+ * Rules read the syntax tree. A few facts about the whole file — is it React,
6
+ * does it use GSAP — are still read from its text with the comments blanked
7
+ * out, because well-commented animation code explains itself at length, and a
8
+ * docblock that mentions `@gsap/react` is not an import of it.
9
+ */
10
+ import { readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { extname, join, relative } from "node:path";
12
+
13
+ import { parse } from "./ast.mjs";
14
+
15
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
16
+ const SKIP_DIRECTORIES = new Set([
17
+ "node_modules",
18
+ ".next",
19
+ ".git",
20
+ "dist",
21
+ "build",
22
+ "coverage",
23
+ ]);
24
+
25
+ /** Every source file under `root`, or `root` itself when it is a file. */
26
+ export function collect(root) {
27
+ if (statSync(root).isFile()) return [root];
28
+
29
+ const files = [];
30
+ const walk = (dir) => {
31
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
32
+ if (entry.name.startsWith(".") || SKIP_DIRECTORIES.has(entry.name)) {
33
+ continue;
34
+ }
35
+ const full = join(dir, entry.name);
36
+ if (entry.isDirectory()) walk(full);
37
+ else if (SOURCE_EXTENSIONS.has(extname(entry.name))) files.push(full);
38
+ }
39
+ };
40
+
41
+ walk(root);
42
+ return files.sort();
43
+ }
44
+
45
+ /**
46
+ * Whether the character at `i` opens a string literal.
47
+ *
48
+ * An apostrophe between two word characters never does: that is JSX text —
49
+ * `<p>Don't</p>` — and in code it would be a syntax error. Read as an opening
50
+ * quote, it inverted every string and comment after it, so a comment survived
51
+ * stripping and a call's span ran past its own closing parenthesis. A backtick
52
+ * after a word character is left alone: that is a tagged template.
53
+ */
54
+ export function opensString(source, i) {
55
+ const c = source[i];
56
+ if (c !== '"' && c !== "'" && c !== "`") return false;
57
+ const word = (ch) => ch !== undefined && /\w/.test(ch);
58
+ return !(c === "'" && word(source[i - 1]) && word(source[i + 1]));
59
+ }
60
+
61
+ /**
62
+ * Replaces comment bodies with spaces, keeping every newline and every offset.
63
+ *
64
+ * Offsets are preserved rather than the text being cut out, so an index into
65
+ * the returned string is still an index into the original and `lineAt` stays
66
+ * correct. Blanking also means a rule cannot accidentally join two tokens that
67
+ * a comment sat between.
68
+ */
69
+ export function stripComments(source) {
70
+ const out = source.split("");
71
+ let mode = "code";
72
+ let quote = "";
73
+
74
+ for (let i = 0; i < source.length; i += 1) {
75
+ const c = source[i];
76
+ const next = source[i + 1];
77
+
78
+ if (mode === "code") {
79
+ if (c === "/" && next === "/") {
80
+ mode = "line";
81
+ out[i] = " ";
82
+ out[i + 1] = " ";
83
+ i += 1;
84
+ } else if (c === "/" && next === "*") {
85
+ mode = "block";
86
+ out[i] = " ";
87
+ out[i + 1] = " ";
88
+ i += 1;
89
+ } else if (opensString(source, i)) {
90
+ mode = "string";
91
+ quote = c;
92
+ }
93
+ continue;
94
+ }
95
+
96
+ if (mode === "line") {
97
+ if (c === "\n") mode = "code";
98
+ else out[i] = " ";
99
+ continue;
100
+ }
101
+
102
+ if (mode === "block") {
103
+ if (c === "*" && next === "/") {
104
+ out[i] = " ";
105
+ out[i + 1] = " ";
106
+ i += 1;
107
+ mode = "code";
108
+ } else if (c !== "\n") {
109
+ out[i] = " ";
110
+ }
111
+ continue;
112
+ }
113
+
114
+ // Inside a string literal: left intact, but escapes must not end it early.
115
+ if (c === "\\") {
116
+ i += 1;
117
+ continue;
118
+ }
119
+ if (c === quote) mode = "code";
120
+ }
121
+
122
+ return out.join("");
123
+ }
124
+
125
+ /** 1-indexed line number for a character offset. */
126
+ export function lineAt(source, index) {
127
+ let line = 1;
128
+ for (let i = 0; i < index && i < source.length; i += 1) {
129
+ if (source[i] === "\n") line += 1;
130
+ }
131
+ return line;
132
+ }
133
+
134
+ /**
135
+ * A file plus everything the rules need, from its text.
136
+ *
137
+ * The command line reads the text from disk, through `load`. The ESLint plugin
138
+ * passes the text ESLint already holds, with the file's path, so both entry
139
+ * points run the rules on the same parse of the same characters.
140
+ */
141
+ export function fromText(raw, path, display = path) {
142
+ const code = stripComments(raw);
143
+
144
+ /**
145
+ * Parsed on first use, and once. Most files in a project never mention GSAP,
146
+ * and the run skips those before any rule asks for a tree. A file that does
147
+ * not parse keeps `ast: null` and says why in `parseError`, so it can be
148
+ * reported as not checked rather than passed as clean.
149
+ */
150
+ let parsed;
151
+ const tree = () => {
152
+ if (parsed === undefined) {
153
+ try {
154
+ parsed = { ...parse(raw, path), error: null };
155
+ } catch (error) {
156
+ parsed = {
157
+ ast: null,
158
+ comments: [],
159
+ error: {
160
+ message: error.message,
161
+ line: error.loc?.line ?? 1,
162
+ column: error.loc?.column ?? 0,
163
+ },
164
+ };
165
+ }
166
+ }
167
+ return parsed;
168
+ };
169
+
170
+ return {
171
+ get ast() {
172
+ return tree().ast;
173
+ },
174
+ get comments() {
175
+ return tree().comments;
176
+ },
177
+ get parseError() {
178
+ return tree().error;
179
+ },
180
+ path,
181
+ display,
182
+ raw,
183
+ code,
184
+ /**
185
+ * "Can this mount more than once?" rather than literally "is this JSX".
186
+ *
187
+ * A hook lives in a `.ts` file and often imports only `@gsap/react`, never
188
+ * `react` itself — so matching on the extension or a React import alone
189
+ * misses exactly the files where the component-instance rules matter most.
190
+ */
191
+ isReact:
192
+ /\.(tsx|jsx)$/.test(path) ||
193
+ /\bfrom ["']react["']/.test(code) ||
194
+ /\bfrom ["']@gsap\/react["']/.test(code),
195
+ isClient: /^\s*["']use client["']/m.test(code),
196
+ usesGsap: /\bfrom ["']gsap(?:\/|["'])/.test(code),
197
+ };
198
+ }
199
+
200
+ /** A file on disk, read once, shown by its path from `root`. */
201
+ export function load(path, root) {
202
+ return fromText(
203
+ readFileSync(path, "utf8"),
204
+ path,
205
+ relative(root, path).replace(/\\/g, "/"),
206
+ );
207
+ }
208
+
209
+ /** Whether `collect` reads files with this path's extension. */
210
+ export function hasSourceExtension(path) {
211
+ return SOURCE_EXTENSIONS.has(extname(path));
212
+ }