@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/LICENSE +21 -0
- package/NOTICE.md +83 -0
- package/README.md +101 -0
- package/index.mjs +156 -0
- package/lib/ast.mjs +527 -0
- package/lib/audit.mjs +91 -0
- package/lib/rules.mjs +1196 -0
- package/lib/source.mjs +212 -0
- package/lib/vendor/parser.mjs +15 -0
- package/package.json +58 -0
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
|
+
}
|