@slip-stream-kit/eslint-plugin 0.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/dist/index.d.ts +9 -0
- package/dist/index.js +434 -0
- package/dist/index.js.map +7 -0
- package/dist/rules/component-file-order.d.ts +3 -0
- package/dist/rules/index.d.ts +2 -0
- package/dist/rules/props-destructuring-blank-line.d.ts +3 -0
- package/dist/rules/props-destructuring-newline.d.ts +3 -0
- package/dist/utils/component.d.ts +10 -0
- package/dist/utils/path-match.d.ts +2 -0
- package/package.json +62 -0
- package/readme.md +135 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ESLint, Linter } from 'eslint';
|
|
2
|
+
import { rules } from './rules';
|
|
3
|
+
declare const plugin: ESLint.Plugin & {
|
|
4
|
+
configs: Record<string, Linter.Config>;
|
|
5
|
+
};
|
|
6
|
+
export declare const meta: ESLint.Plugin['meta'];
|
|
7
|
+
export declare const configs: Record<string, Linter.Config>;
|
|
8
|
+
export { rules };
|
|
9
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// src/utils/component.ts
|
|
2
|
+
var COMPONENT_WRAPPER_CALLEES = /* @__PURE__ */ new Set(["memo", "forwardRef", "observer", "React.memo", "React.forwardRef"]);
|
|
3
|
+
var NESTED_SCOPES = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
4
|
+
var isPascalCase = (name) => {
|
|
5
|
+
return /^[A-Z]/.test(name);
|
|
6
|
+
};
|
|
7
|
+
var isJsxNode = (node) => {
|
|
8
|
+
if (!node) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const type = node.type;
|
|
12
|
+
return type === "JSXElement" || type === "JSXFragment";
|
|
13
|
+
};
|
|
14
|
+
var getParent = (node) => {
|
|
15
|
+
return node?.parent;
|
|
16
|
+
};
|
|
17
|
+
var getCalleeName = (callee) => {
|
|
18
|
+
if (callee.type === "Identifier") {
|
|
19
|
+
return callee.name;
|
|
20
|
+
}
|
|
21
|
+
if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
|
|
22
|
+
return `${callee.object.name}.${callee.property.name}`;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
var getComponentName = (node) => {
|
|
27
|
+
if (node.type === "FunctionDeclaration") {
|
|
28
|
+
return node.id?.name ?? null;
|
|
29
|
+
}
|
|
30
|
+
let current = getParent(node);
|
|
31
|
+
while (current?.type === "CallExpression") {
|
|
32
|
+
const calleeName = getCalleeName(current.callee);
|
|
33
|
+
if (!calleeName || !COMPONENT_WRAPPER_CALLEES.has(calleeName)) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
current = getParent(current);
|
|
37
|
+
}
|
|
38
|
+
if (current?.type === "VariableDeclarator" && current.id.type === "Identifier") {
|
|
39
|
+
return current.id.name;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
var returnsJsx = (node) => {
|
|
44
|
+
if (node.body.type !== "BlockStatement") {
|
|
45
|
+
return isJsxNode(node.body);
|
|
46
|
+
}
|
|
47
|
+
let found = false;
|
|
48
|
+
const visit = (current) => {
|
|
49
|
+
if (found || !current || NESTED_SCOPES.has(current.type)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (current.type === "ReturnStatement") {
|
|
53
|
+
if (isJsxNode(current.argument)) {
|
|
54
|
+
found = true;
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (current.type === "IfStatement") {
|
|
59
|
+
visit(current.consequent);
|
|
60
|
+
visit(current.alternate);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (current.type === "BlockStatement") {
|
|
64
|
+
current.body.forEach(visit);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (current.type === "SwitchStatement") {
|
|
68
|
+
for (const switchCase of current.cases) {
|
|
69
|
+
switchCase.consequent.forEach(visit);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (current.type === "TryStatement") {
|
|
74
|
+
visit(current.block);
|
|
75
|
+
visit(current.handler?.body);
|
|
76
|
+
visit(current.finalizer);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (current.type === "ForStatement" || current.type === "ForInStatement" || current.type === "ForOfStatement" || current.type === "WhileStatement" || current.type === "DoWhileStatement") {
|
|
80
|
+
visit(current.body);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
node.body.body.forEach(visit);
|
|
84
|
+
return found;
|
|
85
|
+
};
|
|
86
|
+
var isComponent = (node) => {
|
|
87
|
+
const name = getComponentName(node);
|
|
88
|
+
if (name && isPascalCase(name)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return returnsJsx(node);
|
|
92
|
+
};
|
|
93
|
+
var isComponentFunctionNode = (node) => {
|
|
94
|
+
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration";
|
|
95
|
+
};
|
|
96
|
+
var getComponentFunction = (node) => {
|
|
97
|
+
if (!node) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (isComponentFunctionNode(node)) {
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
if (node.type === "CallExpression") {
|
|
104
|
+
for (const argument of node.arguments) {
|
|
105
|
+
if (argument.type === "SpreadElement") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const found = getComponentFunction(argument);
|
|
109
|
+
if (found) {
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// src/utils/path-match.ts
|
|
118
|
+
var REGEX_METACHARS = /* @__PURE__ */ new Set(["\\", "^", "$", ".", "|", "+", "(", ")", "[", "]", "{", "}"]);
|
|
119
|
+
var globToRegExp = (glob) => {
|
|
120
|
+
let source = "";
|
|
121
|
+
for (let index = 0; index < glob.length; index++) {
|
|
122
|
+
const char = glob[index];
|
|
123
|
+
if (char === "*") {
|
|
124
|
+
if (glob[index + 1] === "*") {
|
|
125
|
+
source += ".*";
|
|
126
|
+
index++;
|
|
127
|
+
if (glob[index + 1] === "/") {
|
|
128
|
+
index++;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
source += "[^/]*";
|
|
132
|
+
}
|
|
133
|
+
} else if (char === "?") {
|
|
134
|
+
source += "[^/]";
|
|
135
|
+
} else if (REGEX_METACHARS.has(char)) {
|
|
136
|
+
source += `\\${char}`;
|
|
137
|
+
} else {
|
|
138
|
+
source += char;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return new RegExp(source);
|
|
142
|
+
};
|
|
143
|
+
var matchesAnyGlob = (filename, patterns) => {
|
|
144
|
+
const normalized = filename.split("\\").join("/");
|
|
145
|
+
return patterns.some((pattern) => {
|
|
146
|
+
return globToRegExp(pattern).test(normalized);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/rules/component-file-order.ts
|
|
151
|
+
var PROPS_SUFFIX = "Props";
|
|
152
|
+
var unwrapExport = (statement) => {
|
|
153
|
+
if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration") {
|
|
154
|
+
return statement.declaration ?? null;
|
|
155
|
+
}
|
|
156
|
+
return statement;
|
|
157
|
+
};
|
|
158
|
+
var isPropsTypeDeclaration = (node) => {
|
|
159
|
+
if (!node) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const named = node;
|
|
163
|
+
if (named.type !== "TSInterfaceDeclaration" && named.type !== "TSTypeAliasDeclaration") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return named.id?.name?.endsWith(PROPS_SUFFIX) ?? false;
|
|
167
|
+
};
|
|
168
|
+
var declaresComponent = (node) => {
|
|
169
|
+
if (!node) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (node.type === "FunctionDeclaration") {
|
|
173
|
+
return isComponent(node);
|
|
174
|
+
}
|
|
175
|
+
if (node.type === "VariableDeclaration") {
|
|
176
|
+
return node.declarations.some((declaration) => {
|
|
177
|
+
const fn2 = getComponentFunction(declaration.init);
|
|
178
|
+
return fn2 ? isComponent(fn2) : false;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const fn = getComponentFunction(node);
|
|
182
|
+
return fn ? isComponent(fn) : false;
|
|
183
|
+
};
|
|
184
|
+
var componentFileOrder = {
|
|
185
|
+
meta: {
|
|
186
|
+
type: "suggestion",
|
|
187
|
+
docs: {
|
|
188
|
+
description: "Enforce a strict top-level order in React component files: imports first, then the component props interface/type, then the component declaration.",
|
|
189
|
+
recommended: true,
|
|
190
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
191
|
+
},
|
|
192
|
+
schema: [
|
|
193
|
+
{
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: {
|
|
196
|
+
paths: {
|
|
197
|
+
type: "array",
|
|
198
|
+
items: { type: "string" },
|
|
199
|
+
description: "Optional glob patterns. When provided, the rule only runs for files whose path matches one of them."
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
additionalProperties: false
|
|
203
|
+
}
|
|
204
|
+
],
|
|
205
|
+
messages: {
|
|
206
|
+
importsFirst: "Imports must come before the component interface and declaration.",
|
|
207
|
+
interfaceBeforeComponent: "The component props interface must be declared before the component."
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
create(context) {
|
|
211
|
+
const options = context.options[0] ?? {};
|
|
212
|
+
const paths = options.paths ?? [];
|
|
213
|
+
if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
Program(program) {
|
|
218
|
+
const body = program.body;
|
|
219
|
+
const importIndices = [];
|
|
220
|
+
const propsIndices = [];
|
|
221
|
+
let componentIndex = -1;
|
|
222
|
+
body.forEach((statement, index) => {
|
|
223
|
+
if (statement.type === "ImportDeclaration") {
|
|
224
|
+
importIndices.push(index);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const declaration = unwrapExport(statement);
|
|
228
|
+
if (isPropsTypeDeclaration(declaration)) {
|
|
229
|
+
propsIndices.push(index);
|
|
230
|
+
}
|
|
231
|
+
if (componentIndex === -1 && declaresComponent(declaration)) {
|
|
232
|
+
componentIndex = index;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
if (componentIndex === -1) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const importBoundary = Math.min(componentIndex, ...propsIndices);
|
|
239
|
+
for (const importIndex of importIndices) {
|
|
240
|
+
if (importIndex > importBoundary) {
|
|
241
|
+
context.report({ node: body[importIndex], messageId: "importsFirst" });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const propsIndex of propsIndices) {
|
|
245
|
+
if (propsIndex > componentIndex) {
|
|
246
|
+
context.report({ node: body[propsIndex], messageId: "interfaceBeforeComponent" });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/rules/props-destructuring-blank-line.ts
|
|
255
|
+
var isPropsDestructuring = (statement) => {
|
|
256
|
+
if (statement.type !== "VariableDeclaration") {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return statement.declarations.some((declaration) => {
|
|
260
|
+
return declaration.id.type === "ObjectPattern" && declaration.init?.type === "Identifier" && declaration.init.name === "props";
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
var propsDestructuringBlankLine = {
|
|
264
|
+
meta: {
|
|
265
|
+
type: "suggestion",
|
|
266
|
+
docs: {
|
|
267
|
+
description: "Require a blank line after the `const { ... } = props` destructuring statement at the top of a React component body.",
|
|
268
|
+
recommended: true,
|
|
269
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
270
|
+
},
|
|
271
|
+
fixable: "whitespace",
|
|
272
|
+
schema: [],
|
|
273
|
+
messages: {
|
|
274
|
+
blankLineAfterProps: "Add a blank line after destructuring props."
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
create(context) {
|
|
278
|
+
const sourceCode = context.sourceCode;
|
|
279
|
+
const check = (node) => {
|
|
280
|
+
if (node.body.type !== "BlockStatement") {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!isComponent(node)) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const statements = node.body.body;
|
|
287
|
+
const index = statements.findIndex(isPropsDestructuring);
|
|
288
|
+
if (index === -1) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const propsStatement = statements[index];
|
|
292
|
+
const nextStatement = statements[index + 1];
|
|
293
|
+
if (!propsStatement || !nextStatement) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const tokenAfter = sourceCode.getTokenAfter(propsStatement, { includeComments: true });
|
|
297
|
+
const referenceLine = (tokenAfter ?? nextStatement).loc.start.line;
|
|
298
|
+
if (referenceLine - propsStatement.loc.end.line >= 2) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
context.report({
|
|
302
|
+
node: propsStatement,
|
|
303
|
+
messageId: "blankLineAfterProps",
|
|
304
|
+
fix(fixer) {
|
|
305
|
+
return fixer.insertTextAfter(propsStatement, "\n");
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
ArrowFunctionExpression: check,
|
|
311
|
+
FunctionDeclaration: check,
|
|
312
|
+
FunctionExpression: check
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// src/rules/props-destructuring-newline.ts
|
|
318
|
+
var propsDestructuringNewline = {
|
|
319
|
+
meta: {
|
|
320
|
+
type: "suggestion",
|
|
321
|
+
docs: {
|
|
322
|
+
description: "Require React components to accept a single props parameter and destructure it on its own line in the body, rather than destructuring inline in the parameter list.",
|
|
323
|
+
recommended: true,
|
|
324
|
+
url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin"
|
|
325
|
+
},
|
|
326
|
+
fixable: "code",
|
|
327
|
+
schema: [],
|
|
328
|
+
messages: {
|
|
329
|
+
destructureOnNewLine: "Accept a single `props` parameter and destructure it on its own line in the component body instead of destructuring in the parameter list."
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
create(context) {
|
|
333
|
+
const sourceCode = context.sourceCode;
|
|
334
|
+
const check = (node) => {
|
|
335
|
+
const firstParam = node.params[0];
|
|
336
|
+
if (!firstParam || firstParam.type !== "ObjectPattern") {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!isComponent(node)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const objectPattern = firstParam;
|
|
343
|
+
context.report({
|
|
344
|
+
node: firstParam,
|
|
345
|
+
messageId: "destructureOnNewLine",
|
|
346
|
+
fix(fixer) {
|
|
347
|
+
const text = sourceCode.getText();
|
|
348
|
+
const annotation = objectPattern.typeAnnotation;
|
|
349
|
+
const patternStart = objectPattern.range[0];
|
|
350
|
+
const patternEnd = annotation ? annotation.range[0] : objectPattern.range[1];
|
|
351
|
+
const fullEnd = annotation ? annotation.range[1] : objectPattern.range[1];
|
|
352
|
+
const patternText = text.slice(patternStart, patternEnd).trim();
|
|
353
|
+
const annotationText = annotation ? sourceCode.getText(annotation) : "";
|
|
354
|
+
const fixes = [fixer.replaceTextRange([patternStart, fullEnd], `props${annotationText}`)];
|
|
355
|
+
const destructureStatement = `const ${patternText} = props`;
|
|
356
|
+
const lines = sourceCode.getLines();
|
|
357
|
+
const declarationLine = lines[node.loc.start.line - 1] ?? "";
|
|
358
|
+
const baseIndent = declarationLine.slice(0, declarationLine.length - declarationLine.trimStart().length);
|
|
359
|
+
const innerIndent = `${baseIndent} `;
|
|
360
|
+
if (node.body.type === "BlockStatement") {
|
|
361
|
+
const [firstStatement] = node.body.body;
|
|
362
|
+
if (firstStatement) {
|
|
363
|
+
const indent = " ".repeat(firstStatement.loc.start.column);
|
|
364
|
+
fixes.push(fixer.insertTextBefore(firstStatement, `${destructureStatement}
|
|
365
|
+
|
|
366
|
+
${indent}`));
|
|
367
|
+
} else {
|
|
368
|
+
const openBrace = sourceCode.getFirstToken(node.body);
|
|
369
|
+
fixes.push(fixer.insertTextAfter(openBrace, `
|
|
370
|
+
${innerIndent}${destructureStatement}
|
|
371
|
+
${baseIndent}`));
|
|
372
|
+
}
|
|
373
|
+
return fixes;
|
|
374
|
+
}
|
|
375
|
+
const bodyText = sourceCode.getText(node.body);
|
|
376
|
+
fixes.push(
|
|
377
|
+
fixer.replaceText(
|
|
378
|
+
node.body,
|
|
379
|
+
`{
|
|
380
|
+
${innerIndent}${destructureStatement}
|
|
381
|
+
|
|
382
|
+
${innerIndent}return ${bodyText}
|
|
383
|
+
${baseIndent}}`
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
return fixes;
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
ArrowFunctionExpression: check,
|
|
392
|
+
FunctionDeclaration: check,
|
|
393
|
+
FunctionExpression: check
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/rules/index.ts
|
|
399
|
+
var rules = {
|
|
400
|
+
"props-destructuring-newline": propsDestructuringNewline,
|
|
401
|
+
"props-destructuring-blank-line": propsDestructuringBlankLine,
|
|
402
|
+
"component-file-order": componentFileOrder
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/index.ts
|
|
406
|
+
var PLUGIN_NAME = "@wl";
|
|
407
|
+
var plugin = {
|
|
408
|
+
meta: {
|
|
409
|
+
name: "@wl/eslint-plugin",
|
|
410
|
+
version: "0.1.0"
|
|
411
|
+
},
|
|
412
|
+
rules,
|
|
413
|
+
configs: {}
|
|
414
|
+
};
|
|
415
|
+
plugin.configs.recommended = {
|
|
416
|
+
plugins: {
|
|
417
|
+
[PLUGIN_NAME]: plugin
|
|
418
|
+
},
|
|
419
|
+
rules: {
|
|
420
|
+
[`${PLUGIN_NAME}/props-destructuring-newline`]: "error",
|
|
421
|
+
[`${PLUGIN_NAME}/props-destructuring-blank-line`]: "error",
|
|
422
|
+
[`${PLUGIN_NAME}/component-file-order`]: "error"
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
var meta = plugin.meta;
|
|
426
|
+
var configs = plugin.configs;
|
|
427
|
+
var index_default = plugin;
|
|
428
|
+
export {
|
|
429
|
+
configs,
|
|
430
|
+
index_default as default,
|
|
431
|
+
meta,
|
|
432
|
+
rules
|
|
433
|
+
};
|
|
434
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/utils/component.ts", "../src/utils/path-match.ts", "../src/rules/component-file-order.ts", "../src/rules/props-destructuring-blank-line.ts", "../src/rules/props-destructuring-newline.ts", "../src/rules/index.ts", "../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["import type * as ESTree from 'estree'\n\nexport type ComponentFunction = ESTree.ArrowFunctionExpression | ESTree.FunctionDeclaration | ESTree.FunctionExpression\n\n// Minimal structural view over the `parent` back-reference ESLint adds to every node.\ninterface WithParent {\n parent?: ESTree.Node\n}\n\n// Calls that wrap a component while preserving its identity (memo, forwardRef, observer, ...).\nconst COMPONENT_WRAPPER_CALLEES = new Set(['memo', 'forwardRef', 'observer', 'React.memo', 'React.forwardRef'])\n\n// Node types that introduce a new function scope \u2014 their returns are not the outer component's.\nconst NESTED_SCOPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'])\n\nconst isPascalCase = (name: string): boolean => {\n return /^[A-Z]/.test(name)\n}\n\nconst isJsxNode = (node: ESTree.Node | null | undefined): boolean => {\n if (!node) {\n return false\n }\n\n const type = node.type as string\n\n return type === 'JSXElement' || type === 'JSXFragment'\n}\n\nconst getParent = (node: ESTree.Node | undefined): ESTree.Node | undefined => {\n return (node as (WithParent & ESTree.Node) | undefined)?.parent\n}\n\n/** Best-effort name of a call's callee: `memo` for `memo(...)`, `React.memo` for `React.memo(...)`. */\nconst getCalleeName = (callee: ESTree.CallExpression['callee']): string | null => {\n if (callee.type === 'Identifier') {\n return callee.name\n }\n\n if (\n callee.type === 'MemberExpression' &&\n callee.object.type === 'Identifier' &&\n callee.property.type === 'Identifier'\n ) {\n return `${callee.object.name}.${callee.property.name}`\n }\n\n return null\n}\n\n/**\n * Resolve the declared name of a function, looking through component wrappers\n * such as `memo`/`forwardRef` so that `const Comp = memo(({ a }) => ...)` is\n * still recognised by its PascalCase variable name.\n */\nconst getComponentName = (node: ComponentFunction): string | null => {\n if (node.type === 'FunctionDeclaration') {\n return node.id?.name ?? null\n }\n\n let current = getParent(node)\n\n // Walk through wrapping call expressions (memo, forwardRef, React.memo, ...).\n while (current?.type === 'CallExpression') {\n const calleeName = getCalleeName(current.callee)\n\n if (!calleeName || !COMPONENT_WRAPPER_CALLEES.has(calleeName)) {\n break\n }\n\n current = getParent(current)\n }\n\n if (current?.type === 'VariableDeclarator' && current.id.type === 'Identifier') {\n return current.id.name\n }\n\n return null\n}\n\n/** Whether a function returns JSX, scanning its own body without descending into nested functions. */\nconst returnsJsx = (node: ComponentFunction): boolean => {\n if (node.body.type !== 'BlockStatement') {\n return isJsxNode(node.body)\n }\n\n let found = false\n\n const visit = (current: ESTree.Node | null | undefined): void => {\n if (found || !current || NESTED_SCOPES.has(current.type)) {\n return\n }\n\n if (current.type === 'ReturnStatement') {\n if (isJsxNode(current.argument)) {\n found = true\n }\n\n return\n }\n\n if (current.type === 'IfStatement') {\n visit(current.consequent)\n visit(current.alternate)\n\n return\n }\n\n if (current.type === 'BlockStatement') {\n current.body.forEach(visit)\n\n return\n }\n\n if (current.type === 'SwitchStatement') {\n for (const switchCase of current.cases) {\n switchCase.consequent.forEach(visit)\n }\n\n return\n }\n\n if (current.type === 'TryStatement') {\n visit(current.block)\n visit(current.handler?.body)\n visit(current.finalizer)\n\n return\n }\n\n if (\n current.type === 'ForStatement' ||\n current.type === 'ForInStatement' ||\n current.type === 'ForOfStatement' ||\n current.type === 'WhileStatement' ||\n current.type === 'DoWhileStatement'\n ) {\n visit(current.body)\n }\n }\n\n node.body.body.forEach(visit)\n\n return found\n}\n\n/** A function is treated as a React component when it is PascalCase-named or returns JSX. */\nexport const isComponent = (node: ComponentFunction): boolean => {\n const name = getComponentName(node)\n\n if (name && isPascalCase(name)) {\n return true\n }\n\n return returnsJsx(node)\n}\n\nconst isComponentFunctionNode = (node: ESTree.Node): node is ComponentFunction => {\n return (\n node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration'\n )\n}\n\n/**\n * Extract the component function from an expression, unwrapping a single layer of\n * component wrappers (`memo(fn)`, `forwardRef(fn)`, `React.memo(fn)`, ...). Returns\n * null when no function is found.\n */\nexport const getComponentFunction = (node: ESTree.Node | null | undefined): ComponentFunction | null => {\n if (!node) {\n return null\n }\n\n if (isComponentFunctionNode(node)) {\n return node\n }\n\n if (node.type === 'CallExpression') {\n for (const argument of node.arguments) {\n if (argument.type === 'SpreadElement') {\n continue\n }\n\n const found = getComponentFunction(argument)\n\n if (found) {\n return found\n }\n }\n }\n\n return null\n}\n", "// Characters that must be escaped when embedded literally into a RegExp source.\nconst REGEX_METACHARS = new Set(['\\\\', '^', '$', '.', '|', '+', '(', ')', '[', ']', '{', '}'])\n\n/**\n * Convert a glob pattern to an (unanchored) RegExp.\n *\n * - `**` matches any characters, including path separators.\n * - `*` matches any characters except a path separator.\n * - `?` matches a single non-separator character.\n *\n * The result is intentionally unanchored so a pattern matches anywhere in the\n * path (e.g. `features/**` matches `/repo/src/features/x/comp.tsx`).\n */\nconst globToRegExp = (glob: string): RegExp => {\n let source = ''\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index]!\n\n if (char === '*') {\n if (glob[index + 1] === '*') {\n source += '.*'\n index++\n\n // Consume a trailing slash so `**/foo` also matches a bare `foo`.\n if (glob[index + 1] === '/') {\n index++\n }\n } else {\n source += '[^/]*'\n }\n } else if (char === '?') {\n source += '[^/]'\n } else if (REGEX_METACHARS.has(char)) {\n source += `\\\\${char}`\n } else {\n source += char\n }\n }\n\n return new RegExp(source)\n}\n\n/** Whether `filename` matches at least one of the provided glob `patterns`. */\nexport const matchesAnyGlob = (filename: string, patterns: readonly string[]): boolean => {\n const normalized = filename.split('\\\\').join('/')\n\n return patterns.some((pattern) => {\n return globToRegExp(pattern).test(normalized)\n })\n}\n", "import type { Rule } from 'eslint'\nimport type * as ESTree from 'estree'\n\nimport { getComponentFunction, isComponent } from '../utils/component'\nimport { matchesAnyGlob } from '../utils/path-match'\n\ninterface Options {\n paths?: string[]\n}\n\n// TS-only nodes are not modelled by estree; access their identifier structurally.\ninterface NamedDeclaration {\n type: string\n id?: { name?: string } | null\n}\n\nconst PROPS_SUFFIX = 'Props'\n\n/** Unwrap an `export ...` statement to the declaration it wraps (or the statement itself). */\nconst unwrapExport = (statement: ESTree.Statement | ESTree.ModuleDeclaration): ESTree.Node | null => {\n if (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') {\n return (statement.declaration as ESTree.Node | null) ?? null\n }\n\n return statement\n}\n\n/** Whether a declaration is a props interface/type alias (`SomethingProps`). */\nconst isPropsTypeDeclaration = (node: ESTree.Node | null): boolean => {\n if (!node) {\n return false\n }\n\n const named = node as NamedDeclaration\n\n if (named.type !== 'TSInterfaceDeclaration' && named.type !== 'TSTypeAliasDeclaration') {\n return false\n }\n\n return named.id?.name?.endsWith(PROPS_SUFFIX) ?? false\n}\n\n/** Whether a top-level declaration declares a React component. */\nconst declaresComponent = (node: ESTree.Node | null): boolean => {\n if (!node) {\n return false\n }\n\n if (node.type === 'FunctionDeclaration') {\n return isComponent(node)\n }\n\n if (node.type === 'VariableDeclaration') {\n return node.declarations.some((declaration) => {\n const fn = getComponentFunction(declaration.init)\n\n return fn ? isComponent(fn) : false\n })\n }\n\n const fn = getComponentFunction(node)\n\n return fn ? isComponent(fn) : false\n}\n\nexport const componentFileOrder: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Enforce a strict top-level order in React component files: imports first, then the component props interface/type, then the component declaration.',\n recommended: true,\n url: 'https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin',\n },\n schema: [\n {\n type: 'object',\n properties: {\n paths: {\n type: 'array',\n items: { type: 'string' },\n description:\n 'Optional glob patterns. When provided, the rule only runs for files whose path matches one of them.',\n },\n },\n additionalProperties: false,\n },\n ],\n messages: {\n importsFirst: 'Imports must come before the component interface and declaration.',\n interfaceBeforeComponent: 'The component props interface must be declared before the component.',\n },\n },\n\n create(context) {\n const options = (context.options[0] ?? {}) as Options\n const paths = options.paths ?? []\n\n if (paths.length > 0 && !matchesAnyGlob(context.filename, paths)) {\n return {}\n }\n\n return {\n Program(program) {\n const body = program.body\n\n const importIndices: number[] = []\n const propsIndices: number[] = []\n let componentIndex = -1\n\n body.forEach((statement, index) => {\n if (statement.type === 'ImportDeclaration') {\n importIndices.push(index)\n\n return\n }\n\n const declaration = unwrapExport(statement)\n\n if (isPropsTypeDeclaration(declaration)) {\n propsIndices.push(index)\n }\n\n if (componentIndex === -1 && declaresComponent(declaration)) {\n componentIndex = index\n }\n })\n\n // The rule only governs files that actually contain a component.\n if (componentIndex === -1) {\n return\n }\n\n // Imports must precede the first props interface and the component.\n const importBoundary = Math.min(componentIndex, ...propsIndices)\n\n for (const importIndex of importIndices) {\n if (importIndex > importBoundary) {\n context.report({ node: body[importIndex]!, messageId: 'importsFirst' })\n }\n }\n\n // The props interface/type must precede the component declaration.\n for (const propsIndex of propsIndices) {\n if (propsIndex > componentIndex) {\n context.report({ node: body[propsIndex]!, messageId: 'interfaceBeforeComponent' })\n }\n }\n },\n }\n },\n}\n\nexport default componentFileOrder\n", "import type { Rule } from 'eslint'\nimport type * as ESTree from 'estree'\n\nimport type { ComponentFunction } from '../utils/component'\nimport { isComponent } from '../utils/component'\n\n/** Whether a statement is `const { ... } = props` (destructuring the `props` identifier). */\nconst isPropsDestructuring = (statement: ESTree.Statement): boolean => {\n if (statement.type !== 'VariableDeclaration') {\n return false\n }\n\n return statement.declarations.some((declaration) => {\n return (\n declaration.id.type === 'ObjectPattern' &&\n declaration.init?.type === 'Identifier' &&\n declaration.init.name === 'props'\n )\n })\n}\n\nexport const propsDestructuringBlankLine: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Require a blank line after the `const { ... } = props` destructuring statement at the top of a React component body.',\n recommended: true,\n url: 'https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin',\n },\n fixable: 'whitespace',\n schema: [],\n messages: {\n blankLineAfterProps: 'Add a blank line after destructuring props.',\n },\n },\n\n create(context) {\n const sourceCode = context.sourceCode\n\n const check = (node: ComponentFunction): void => {\n if (node.body.type !== 'BlockStatement') {\n return\n }\n\n if (!isComponent(node)) {\n return\n }\n\n const statements = node.body.body\n const index = statements.findIndex(isPropsDestructuring)\n\n if (index === -1) {\n return\n }\n\n const propsStatement = statements[index]\n const nextStatement = statements[index + 1]\n\n // `propsStatement` is defined because `index !== -1`; the guard also narrows the type.\n // Nothing follows the destructuring \u2014 no separation needed.\n if (!propsStatement || !nextStatement) {\n return\n }\n\n // The token/comment that follows the destructuring statement; a comment on the\n // next line still counts as \"no blank line\" until it is pushed down.\n const tokenAfter = sourceCode.getTokenAfter(propsStatement, { includeComments: true })\n const referenceLine = (tokenAfter ?? nextStatement).loc!.start.line\n\n if (referenceLine - propsStatement.loc!.end.line >= 2) {\n return\n }\n\n context.report({\n node: propsStatement,\n messageId: 'blankLineAfterProps',\n fix(fixer) {\n return fixer.insertTextAfter(propsStatement, '\\n')\n },\n })\n }\n\n return {\n ArrowFunctionExpression: check,\n FunctionDeclaration: check,\n FunctionExpression: check,\n }\n },\n}\n\nexport default propsDestructuringBlankLine\n", "import type { Rule } from 'eslint'\nimport type * as ESTree from 'estree'\n\nimport type { ComponentFunction } from '../utils/component'\nimport { isComponent } from '../utils/component'\n\n// Minimal structural views over nodes that estree's types do not fully model:\n// the optional TS type annotation and `range` that the parser attaches to params.\ninterface WithRange {\n range?: [number, number]\n}\ntype AnnotatedPattern = ESTree.ObjectPattern & { typeAnnotation?: ESTree.Node & WithRange } & WithRange\n\nexport const propsDestructuringNewline: Rule.RuleModule = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Require React components to accept a single props parameter and destructure it on its own line in the body, rather than destructuring inline in the parameter list.',\n recommended: true,\n url: 'https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin',\n },\n fixable: 'code',\n schema: [],\n messages: {\n destructureOnNewLine:\n 'Accept a single `props` parameter and destructure it on its own line in the component body instead of destructuring in the parameter list.',\n },\n },\n\n create(context) {\n const sourceCode = context.sourceCode\n\n const check = (node: ComponentFunction): void => {\n const firstParam = node.params[0]\n\n if (!firstParam || firstParam.type !== 'ObjectPattern') {\n return\n }\n\n if (!isComponent(node)) {\n return\n }\n\n const objectPattern = firstParam as AnnotatedPattern\n\n context.report({\n node: firstParam,\n messageId: 'destructureOnNewLine',\n fix(fixer) {\n const text = sourceCode.getText()\n const annotation = objectPattern.typeAnnotation\n\n const patternStart = objectPattern.range![0]\n const patternEnd = annotation ? annotation.range![0] : objectPattern.range![1]\n const fullEnd = annotation ? annotation.range![1] : objectPattern.range![1]\n\n const patternText = text.slice(patternStart, patternEnd).trim()\n const annotationText = annotation ? sourceCode.getText(annotation) : ''\n\n const fixes = [fixer.replaceTextRange([patternStart, fullEnd], `props${annotationText}`)]\n\n const destructureStatement = `const ${patternText} = props`\n\n // Indentation of the line the component is declared on, used as the base for inserted code.\n const lines = sourceCode.getLines()\n const declarationLine = lines[node.loc!.start.line - 1] ?? ''\n const baseIndent = declarationLine.slice(0, declarationLine.length - declarationLine.trimStart().length)\n const innerIndent = `${baseIndent} `\n\n if (node.body.type === 'BlockStatement') {\n const [firstStatement] = node.body.body\n\n if (firstStatement) {\n const indent = ' '.repeat(firstStatement.loc!.start.column)\n\n fixes.push(fixer.insertTextBefore(firstStatement, `${destructureStatement}\\n\\n${indent}`))\n } else {\n const openBrace = sourceCode.getFirstToken(node.body)!\n\n fixes.push(fixer.insertTextAfter(openBrace, `\\n${innerIndent}${destructureStatement}\\n${baseIndent}`))\n }\n\n return fixes\n }\n\n // Expression-bodied arrow (implicit return) \u2014 wrap it in a block.\n const bodyText = sourceCode.getText(node.body)\n\n fixes.push(\n fixer.replaceText(\n node.body,\n `{\\n${innerIndent}${destructureStatement}\\n\\n${innerIndent}return ${bodyText}\\n${baseIndent}}`,\n ),\n )\n\n return fixes\n },\n })\n }\n\n return {\n ArrowFunctionExpression: check,\n FunctionDeclaration: check,\n FunctionExpression: check,\n }\n },\n}\n\nexport default propsDestructuringNewline\n", "import type { Rule } from 'eslint'\n\nimport { componentFileOrder } from './component-file-order'\nimport { propsDestructuringBlankLine } from './props-destructuring-blank-line'\nimport { propsDestructuringNewline } from './props-destructuring-newline'\n\nexport const rules: Record<string, Rule.RuleModule> = {\n 'props-destructuring-newline': propsDestructuringNewline,\n 'props-destructuring-blank-line': propsDestructuringBlankLine,\n 'component-file-order': componentFileOrder,\n}\n", "import type { ESLint, Linter } from 'eslint'\n\nimport { rules } from './rules'\n\nconst PLUGIN_NAME = '@wl'\n\nconst plugin: ESLint.Plugin & { configs: Record<string, Linter.Config> } = {\n meta: {\n name: '@wl/eslint-plugin',\n version: '0.1.0',\n },\n rules,\n configs: {},\n}\n\n/**\n * Flat-config preset that registers the plugin and turns every rule on.\n *\n * @example\n * import wl from '@wl/eslint-plugin'\n *\n * export default [wl.configs.recommended]\n */\nplugin.configs.recommended = {\n plugins: {\n [PLUGIN_NAME]: plugin,\n },\n rules: {\n [`${PLUGIN_NAME}/props-destructuring-newline`]: 'error',\n [`${PLUGIN_NAME}/props-destructuring-blank-line`]: 'error',\n [`${PLUGIN_NAME}/component-file-order`]: 'error',\n },\n}\n\nexport const meta: ESLint.Plugin['meta'] = plugin.meta\nexport const configs: Record<string, Linter.Config> = plugin.configs\nexport { rules }\n\nexport default plugin\n"],
|
|
5
|
+
"mappings": ";AAUA,IAAM,4BAA4B,oBAAI,IAAI,CAAC,QAAQ,cAAc,YAAY,cAAc,kBAAkB,CAAC;AAG9G,IAAM,gBAAgB,oBAAI,IAAI,CAAC,uBAAuB,sBAAsB,yBAAyB,CAAC;AAEtG,IAAM,eAAe,CAAC,SAA0B;AAC9C,SAAO,SAAS,KAAK,IAAI;AAC3B;AAEA,IAAM,YAAY,CAAC,SAAkD;AACnE,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,QAAM,OAAO,KAAK;AAElB,SAAO,SAAS,gBAAgB,SAAS;AAC3C;AAEA,IAAM,YAAY,CAAC,SAA2D;AAC5E,SAAQ,MAAiD;AAC3D;AAGA,IAAM,gBAAgB,CAAC,WAA2D;AAChF,MAAI,OAAO,SAAS,cAAc;AAChC,WAAO,OAAO;AAAA,EAChB;AAEA,MACE,OAAO,SAAS,sBAChB,OAAO,OAAO,SAAS,gBACvB,OAAO,SAAS,SAAS,cACzB;AACA,WAAO,GAAG,OAAO,OAAO,IAAI,IAAI,OAAO,SAAS,IAAI;AAAA,EACtD;AAEA,SAAO;AACT;AAOA,IAAM,mBAAmB,CAAC,SAA2C;AACnE,MAAI,KAAK,SAAS,uBAAuB;AACvC,WAAO,KAAK,IAAI,QAAQ;AAAA,EAC1B;AAEA,MAAI,UAAU,UAAU,IAAI;AAG5B,SAAO,SAAS,SAAS,kBAAkB;AACzC,UAAM,aAAa,cAAc,QAAQ,MAAM;AAE/C,QAAI,CAAC,cAAc,CAAC,0BAA0B,IAAI,UAAU,GAAG;AAC7D;AAAA,IACF;AAEA,cAAU,UAAU,OAAO;AAAA,EAC7B;AAEA,MAAI,SAAS,SAAS,wBAAwB,QAAQ,GAAG,SAAS,cAAc;AAC9E,WAAO,QAAQ,GAAG;AAAA,EACpB;AAEA,SAAO;AACT;AAGA,IAAM,aAAa,CAAC,SAAqC;AACvD,MAAI,KAAK,KAAK,SAAS,kBAAkB;AACvC,WAAO,UAAU,KAAK,IAAI;AAAA,EAC5B;AAEA,MAAI,QAAQ;AAEZ,QAAM,QAAQ,CAAC,YAAkD;AAC/D,QAAI,SAAS,CAAC,WAAW,cAAc,IAAI,QAAQ,IAAI,GAAG;AACxD;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,mBAAmB;AACtC,UAAI,UAAU,QAAQ,QAAQ,GAAG;AAC/B,gBAAQ;AAAA,MACV;AAEA;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,eAAe;AAClC,YAAM,QAAQ,UAAU;AACxB,YAAM,QAAQ,SAAS;AAEvB;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,kBAAkB;AACrC,cAAQ,KAAK,QAAQ,KAAK;AAE1B;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,mBAAmB;AACtC,iBAAW,cAAc,QAAQ,OAAO;AACtC,mBAAW,WAAW,QAAQ,KAAK;AAAA,MACrC;AAEA;AAAA,IACF;AAEA,QAAI,QAAQ,SAAS,gBAAgB;AACnC,YAAM,QAAQ,KAAK;AACnB,YAAM,QAAQ,SAAS,IAAI;AAC3B,YAAM,QAAQ,SAAS;AAEvB;AAAA,IACF;AAEA,QACE,QAAQ,SAAS,kBACjB,QAAQ,SAAS,oBACjB,QAAQ,SAAS,oBACjB,QAAQ,SAAS,oBACjB,QAAQ,SAAS,oBACjB;AACA,YAAM,QAAQ,IAAI;AAAA,IACpB;AAAA,EACF;AAEA,OAAK,KAAK,KAAK,QAAQ,KAAK;AAE5B,SAAO;AACT;AAGO,IAAM,cAAc,CAAC,SAAqC;AAC/D,QAAM,OAAO,iBAAiB,IAAI;AAElC,MAAI,QAAQ,aAAa,IAAI,GAAG;AAC9B,WAAO;AAAA,EACT;AAEA,SAAO,WAAW,IAAI;AACxB;AAEA,IAAM,0BAA0B,CAAC,SAAiD;AAChF,SACE,KAAK,SAAS,6BAA6B,KAAK,SAAS,wBAAwB,KAAK,SAAS;AAEnG;AAOO,IAAM,uBAAuB,CAAC,SAAmE;AACtG,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,MAAI,wBAAwB,IAAI,GAAG;AACjC,WAAO;AAAA,EACT;AAEA,MAAI,KAAK,SAAS,kBAAkB;AAClC,eAAW,YAAY,KAAK,WAAW;AACrC,UAAI,SAAS,SAAS,iBAAiB;AACrC;AAAA,MACF;AAEA,YAAM,QAAQ,qBAAqB,QAAQ;AAE3C,UAAI,OAAO;AACT,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;;;AC/LA,IAAM,kBAAkB,oBAAI,IAAI,CAAC,MAAM,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG,CAAC;AAY7F,IAAM,eAAe,CAAC,SAAyB;AAC7C,MAAI,SAAS;AAEb,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;AAChD,UAAM,OAAO,KAAK,KAAK;AAEvB,QAAI,SAAS,KAAK;AAChB,UAAI,KAAK,QAAQ,CAAC,MAAM,KAAK;AAC3B,kBAAU;AACV;AAGA,YAAI,KAAK,QAAQ,CAAC,MAAM,KAAK;AAC3B;AAAA,QACF;AAAA,MACF,OAAO;AACL,kBAAU;AAAA,MACZ;AAAA,IACF,WAAW,SAAS,KAAK;AACvB,gBAAU;AAAA,IACZ,WAAW,gBAAgB,IAAI,IAAI,GAAG;AACpC,gBAAU,KAAK,IAAI;AAAA,IACrB,OAAO;AACL,gBAAU;AAAA,IACZ;AAAA,EACF;AAEA,SAAO,IAAI,OAAO,MAAM;AAC1B;AAGO,IAAM,iBAAiB,CAAC,UAAkB,aAAyC;AACxF,QAAM,aAAa,SAAS,MAAM,IAAI,EAAE,KAAK,GAAG;AAEhD,SAAO,SAAS,KAAK,CAAC,YAAY;AAChC,WAAO,aAAa,OAAO,EAAE,KAAK,UAAU;AAAA,EAC9C,CAAC;AACH;;;AClCA,IAAM,eAAe;AAGrB,IAAM,eAAe,CAAC,cAA+E;AACnG,MAAI,UAAU,SAAS,4BAA4B,UAAU,SAAS,4BAA4B;AAChG,WAAQ,UAAU,eAAsC;AAAA,EAC1D;AAEA,SAAO;AACT;AAGA,IAAM,yBAAyB,CAAC,SAAsC;AACpE,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,QAAM,QAAQ;AAEd,MAAI,MAAM,SAAS,4BAA4B,MAAM,SAAS,0BAA0B;AACtF,WAAO;AAAA,EACT;AAEA,SAAO,MAAM,IAAI,MAAM,SAAS,YAAY,KAAK;AACnD;AAGA,IAAM,oBAAoB,CAAC,SAAsC;AAC/D,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,MAAI,KAAK,SAAS,uBAAuB;AACvC,WAAO,YAAY,IAAI;AAAA,EACzB;AAEA,MAAI,KAAK,SAAS,uBAAuB;AACvC,WAAO,KAAK,aAAa,KAAK,CAAC,gBAAgB;AAC7C,YAAMA,MAAK,qBAAqB,YAAY,IAAI;AAEhD,aAAOA,MAAK,YAAYA,GAAE,IAAI;AAAA,IAChC,CAAC;AAAA,EACH;AAEA,QAAM,KAAK,qBAAqB,IAAI;AAEpC,SAAO,KAAK,YAAY,EAAE,IAAI;AAChC;AAEO,IAAM,qBAAsC;AAAA,EACjD,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,MAAM;AAAA,MACJ,aACE;AAAA,MACF,aAAa;AAAA,MACb,KAAK;AAAA,IACP;AAAA,IACA,QAAQ;AAAA,MACN;AAAA,QACE,MAAM;AAAA,QACN,YAAY;AAAA,UACV,OAAO;AAAA,YACL,MAAM;AAAA,YACN,OAAO,EAAE,MAAM,SAAS;AAAA,YACxB,aACE;AAAA,UACJ;AAAA,QACF;AAAA,QACA,sBAAsB;AAAA,MACxB;AAAA,IACF;AAAA,IACA,UAAU;AAAA,MACR,cAAc;AAAA,MACd,0BAA0B;AAAA,IAC5B;AAAA,EACF;AAAA,EAEA,OAAO,SAAS;AACd,UAAM,UAAW,QAAQ,QAAQ,CAAC,KAAK,CAAC;AACxC,UAAM,QAAQ,QAAQ,SAAS,CAAC;AAEhC,QAAI,MAAM,SAAS,KAAK,CAAC,eAAe,QAAQ,UAAU,KAAK,GAAG;AAChE,aAAO,CAAC;AAAA,IACV;AAEA,WAAO;AAAA,MACL,QAAQ,SAAS;AACf,cAAM,OAAO,QAAQ;AAErB,cAAM,gBAA0B,CAAC;AACjC,cAAM,eAAyB,CAAC;AAChC,YAAI,iBAAiB;AAErB,aAAK,QAAQ,CAAC,WAAW,UAAU;AACjC,cAAI,UAAU,SAAS,qBAAqB;AAC1C,0BAAc,KAAK,KAAK;AAExB;AAAA,UACF;AAEA,gBAAM,cAAc,aAAa,SAAS;AAE1C,cAAI,uBAAuB,WAAW,GAAG;AACvC,yBAAa,KAAK,KAAK;AAAA,UACzB;AAEA,cAAI,mBAAmB,MAAM,kBAAkB,WAAW,GAAG;AAC3D,6BAAiB;AAAA,UACnB;AAAA,QACF,CAAC;AAGD,YAAI,mBAAmB,IAAI;AACzB;AAAA,QACF;AAGA,cAAM,iBAAiB,KAAK,IAAI,gBAAgB,GAAG,YAAY;AAE/D,mBAAW,eAAe,eAAe;AACvC,cAAI,cAAc,gBAAgB;AAChC,oBAAQ,OAAO,EAAE,MAAM,KAAK,WAAW,GAAI,WAAW,eAAe,CAAC;AAAA,UACxE;AAAA,QACF;AAGA,mBAAW,cAAc,cAAc;AACrC,cAAI,aAAa,gBAAgB;AAC/B,oBAAQ,OAAO,EAAE,MAAM,KAAK,UAAU,GAAI,WAAW,2BAA2B,CAAC;AAAA,UACnF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;AChJA,IAAM,uBAAuB,CAAC,cAAyC;AACrE,MAAI,UAAU,SAAS,uBAAuB;AAC5C,WAAO;AAAA,EACT;AAEA,SAAO,UAAU,aAAa,KAAK,CAAC,gBAAgB;AAClD,WACE,YAAY,GAAG,SAAS,mBACxB,YAAY,MAAM,SAAS,gBAC3B,YAAY,KAAK,SAAS;AAAA,EAE9B,CAAC;AACH;AAEO,IAAM,8BAA+C;AAAA,EAC1D,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,MAAM;AAAA,MACJ,aACE;AAAA,MACF,aAAa;AAAA,MACb,KAAK;AAAA,IACP;AAAA,IACA,SAAS;AAAA,IACT,QAAQ,CAAC;AAAA,IACT,UAAU;AAAA,MACR,qBAAqB;AAAA,IACvB;AAAA,EACF;AAAA,EAEA,OAAO,SAAS;AACd,UAAM,aAAa,QAAQ;AAE3B,UAAM,QAAQ,CAAC,SAAkC;AAC/C,UAAI,KAAK,KAAK,SAAS,kBAAkB;AACvC;AAAA,MACF;AAEA,UAAI,CAAC,YAAY,IAAI,GAAG;AACtB;AAAA,MACF;AAEA,YAAM,aAAa,KAAK,KAAK;AAC7B,YAAM,QAAQ,WAAW,UAAU,oBAAoB;AAEvD,UAAI,UAAU,IAAI;AAChB;AAAA,MACF;AAEA,YAAM,iBAAiB,WAAW,KAAK;AACvC,YAAM,gBAAgB,WAAW,QAAQ,CAAC;AAI1C,UAAI,CAAC,kBAAkB,CAAC,eAAe;AACrC;AAAA,MACF;AAIA,YAAM,aAAa,WAAW,cAAc,gBAAgB,EAAE,iBAAiB,KAAK,CAAC;AACrF,YAAM,iBAAiB,cAAc,eAAe,IAAK,MAAM;AAE/D,UAAI,gBAAgB,eAAe,IAAK,IAAI,QAAQ,GAAG;AACrD;AAAA,MACF;AAEA,cAAQ,OAAO;AAAA,QACb,MAAM;AAAA,QACN,WAAW;AAAA,QACX,IAAI,OAAO;AACT,iBAAO,MAAM,gBAAgB,gBAAgB,IAAI;AAAA,QACnD;AAAA,MACF,CAAC;AAAA,IACH;AAEA,WAAO;AAAA,MACL,yBAAyB;AAAA,MACzB,qBAAqB;AAAA,MACrB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACF;;;AC5EO,IAAM,4BAA6C;AAAA,EACxD,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,MAAM;AAAA,MACJ,aACE;AAAA,MACF,aAAa;AAAA,MACb,KAAK;AAAA,IACP;AAAA,IACA,SAAS;AAAA,IACT,QAAQ,CAAC;AAAA,IACT,UAAU;AAAA,MACR,sBACE;AAAA,IACJ;AAAA,EACF;AAAA,EAEA,OAAO,SAAS;AACd,UAAM,aAAa,QAAQ;AAE3B,UAAM,QAAQ,CAAC,SAAkC;AAC/C,YAAM,aAAa,KAAK,OAAO,CAAC;AAEhC,UAAI,CAAC,cAAc,WAAW,SAAS,iBAAiB;AACtD;AAAA,MACF;AAEA,UAAI,CAAC,YAAY,IAAI,GAAG;AACtB;AAAA,MACF;AAEA,YAAM,gBAAgB;AAEtB,cAAQ,OAAO;AAAA,QACb,MAAM;AAAA,QACN,WAAW;AAAA,QACX,IAAI,OAAO;AACT,gBAAM,OAAO,WAAW,QAAQ;AAChC,gBAAM,aAAa,cAAc;AAEjC,gBAAM,eAAe,cAAc,MAAO,CAAC;AAC3C,gBAAM,aAAa,aAAa,WAAW,MAAO,CAAC,IAAI,cAAc,MAAO,CAAC;AAC7E,gBAAM,UAAU,aAAa,WAAW,MAAO,CAAC,IAAI,cAAc,MAAO,CAAC;AAE1E,gBAAM,cAAc,KAAK,MAAM,cAAc,UAAU,EAAE,KAAK;AAC9D,gBAAM,iBAAiB,aAAa,WAAW,QAAQ,UAAU,IAAI;AAErE,gBAAM,QAAQ,CAAC,MAAM,iBAAiB,CAAC,cAAc,OAAO,GAAG,QAAQ,cAAc,EAAE,CAAC;AAExF,gBAAM,uBAAuB,SAAS,WAAW;AAGjD,gBAAM,QAAQ,WAAW,SAAS;AAClC,gBAAM,kBAAkB,MAAM,KAAK,IAAK,MAAM,OAAO,CAAC,KAAK;AAC3D,gBAAM,aAAa,gBAAgB,MAAM,GAAG,gBAAgB,SAAS,gBAAgB,UAAU,EAAE,MAAM;AACvG,gBAAM,cAAc,GAAG,UAAU;AAEjC,cAAI,KAAK,KAAK,SAAS,kBAAkB;AACvC,kBAAM,CAAC,cAAc,IAAI,KAAK,KAAK;AAEnC,gBAAI,gBAAgB;AAClB,oBAAM,SAAS,IAAI,OAAO,eAAe,IAAK,MAAM,MAAM;AAE1D,oBAAM,KAAK,MAAM,iBAAiB,gBAAgB,GAAG,oBAAoB;AAAA;AAAA,EAAO,MAAM,EAAE,CAAC;AAAA,YAC3F,OAAO;AACL,oBAAM,YAAY,WAAW,cAAc,KAAK,IAAI;AAEpD,oBAAM,KAAK,MAAM,gBAAgB,WAAW;AAAA,EAAK,WAAW,GAAG,oBAAoB;AAAA,EAAK,UAAU,EAAE,CAAC;AAAA,YACvG;AAEA,mBAAO;AAAA,UACT;AAGA,gBAAM,WAAW,WAAW,QAAQ,KAAK,IAAI;AAE7C,gBAAM;AAAA,YACJ,MAAM;AAAA,cACJ,KAAK;AAAA,cACL;AAAA,EAAM,WAAW,GAAG,oBAAoB;AAAA;AAAA,EAAO,WAAW,UAAU,QAAQ;AAAA,EAAK,UAAU;AAAA,YAC7F;AAAA,UACF;AAEA,iBAAO;AAAA,QACT;AAAA,MACF,CAAC;AAAA,IACH;AAEA,WAAO;AAAA,MACL,yBAAyB;AAAA,MACzB,qBAAqB;AAAA,MACrB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACF;;;ACrGO,IAAM,QAAyC;AAAA,EACpD,+BAA+B;AAAA,EAC/B,kCAAkC;AAAA,EAClC,wBAAwB;AAC1B;;;ACNA,IAAM,cAAc;AAEpB,IAAM,SAAqE;AAAA,EACzE,MAAM;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,EACX;AAAA,EACA;AAAA,EACA,SAAS,CAAC;AACZ;AAUA,OAAO,QAAQ,cAAc;AAAA,EAC3B,SAAS;AAAA,IACP,CAAC,WAAW,GAAG;AAAA,EACjB;AAAA,EACA,OAAO;AAAA,IACL,CAAC,GAAG,WAAW,8BAA8B,GAAG;AAAA,IAChD,CAAC,GAAG,WAAW,iCAAiC,GAAG;AAAA,IACnD,CAAC,GAAG,WAAW,uBAAuB,GAAG;AAAA,EAC3C;AACF;AAEO,IAAM,OAA8B,OAAO;AAC3C,IAAM,UAAyC,OAAO;AAG7D,IAAO,gBAAQ;",
|
|
6
|
+
"names": ["fn"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type * as ESTree from 'estree';
|
|
2
|
+
export type ComponentFunction = ESTree.ArrowFunctionExpression | ESTree.FunctionDeclaration | ESTree.FunctionExpression;
|
|
3
|
+
/** A function is treated as a React component when it is PascalCase-named or returns JSX. */
|
|
4
|
+
export declare const isComponent: (node: ComponentFunction) => boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Extract the component function from an expression, unwrapping a single layer of
|
|
7
|
+
* component wrappers (`memo(fn)`, `forwardRef(fn)`, `React.memo(fn)`, ...). Returns
|
|
8
|
+
* null when no function is found.
|
|
9
|
+
*/
|
|
10
|
+
export declare const getComponentFunction: (node: ESTree.Node | null | undefined) => ComponentFunction | null;
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slip-stream-kit/eslint-plugin",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Custom ESLint rules enforcing the white-label frontend architecture conventions",
|
|
6
|
+
"author": "Arthur Saenko <arthur.saenz7@gmail.com> (https://github.com/ArthurSaenz)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"eslint",
|
|
10
|
+
"eslintplugin",
|
|
11
|
+
"eslint-plugin",
|
|
12
|
+
"react",
|
|
13
|
+
"props",
|
|
14
|
+
"destructuring"
|
|
15
|
+
],
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"module": "dist/index.js",
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=24.x"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "pnpm run clean-artifacts && node ./scripts/build.js",
|
|
37
|
+
"clean-artifacts": "rm -rf dist",
|
|
38
|
+
"clean-cache": "rm -rf node_modules/.cache .eslintcache tsconfig.tsbuildinfo .turbo",
|
|
39
|
+
"prettier-fix": "pnpm exec prettier **/* --write --no-error-on-unmatched-pattern --log-level silent --ignore-path ../../../.prettierignore",
|
|
40
|
+
"prettier-check": "pnpm exec prettier **/* --check --no-error-on-unmatched-pattern --log-level silent --ignore-path ../../../.prettierignore",
|
|
41
|
+
"eslint-check": "pnpm exec eslint --cache --quiet --report-unused-disable-directives ./src",
|
|
42
|
+
"eslint-fix": "pnpm exec eslint --cache --quiet --report-unused-disable-directives ./src --fix",
|
|
43
|
+
"ts-check": "tsc --noEmit",
|
|
44
|
+
"test": "pnpm exec vitest run --reporter=minimal",
|
|
45
|
+
"test-watch": "pnpm exec vitest --watch --silent passed-only",
|
|
46
|
+
"test-report": "pnpm exec vitest run --coverage --silent passed-only",
|
|
47
|
+
"qa": "pnpm run prettier-check && pnpm run eslint-check && pnpm run ts-check && pnpm run test && echo ✅ Success",
|
|
48
|
+
"fix": "pnpm run prettier-fix && pnpm run eslint-fix && pnpm run qa"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"eslint": ">=9"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "catalog:",
|
|
55
|
+
"@typescript-eslint/parser": "^8.60.1",
|
|
56
|
+
"@wl/eslint-config": "workspace:*",
|
|
57
|
+
"esbuild": "^0.28.0",
|
|
58
|
+
"eslint": "^10.4.1",
|
|
59
|
+
"typescript": "^6.0.3",
|
|
60
|
+
"vitest": "^4.1.8"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @wl/eslint-plugin
|
|
2
|
+
|
|
3
|
+
Custom ESLint rules that enforce the white-label frontend architecture conventions.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add -D @wl/eslint-plugin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage (flat config)
|
|
12
|
+
|
|
13
|
+
Enable everything via the recommended preset:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// eslint.config.js
|
|
17
|
+
import wl from '@wl/eslint-plugin'
|
|
18
|
+
|
|
19
|
+
export default [wl.configs.recommended]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or register the plugin and pick rules manually:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import wl from '@wl/eslint-plugin'
|
|
26
|
+
|
|
27
|
+
export default [
|
|
28
|
+
{
|
|
29
|
+
plugins: { '@wl': wl },
|
|
30
|
+
rules: {
|
|
31
|
+
'@wl/props-destructuring-newline': 'error',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Rules
|
|
38
|
+
|
|
39
|
+
### `props-destructuring-newline`
|
|
40
|
+
|
|
41
|
+
🔧 Automatically fixable.
|
|
42
|
+
|
|
43
|
+
React components must accept a single `props` parameter and destructure it on
|
|
44
|
+
its own line inside the body, instead of destructuring inline in the parameter
|
|
45
|
+
list.
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// ❌ Incorrect
|
|
49
|
+
const UserCard = ({ user, className }: UserCardProps) => {
|
|
50
|
+
return <div className={className}>{user.name}</div>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ✅ Correct
|
|
54
|
+
const UserCard = (props: UserCardProps) => {
|
|
55
|
+
const { user, className } = props
|
|
56
|
+
|
|
57
|
+
return <div className={className}>{user.name}</div>
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A function is treated as a component when its name is PascalCase (looking
|
|
62
|
+
through `memo`/`forwardRef`/`observer` wrappers) or when it returns JSX. Hooks,
|
|
63
|
+
plain helpers, and any function whose first parameter is not an object pattern
|
|
64
|
+
are ignored.
|
|
65
|
+
|
|
66
|
+
### `props-destructuring-blank-line`
|
|
67
|
+
|
|
68
|
+
🔧 Automatically fixable.
|
|
69
|
+
|
|
70
|
+
Require a blank line after the `const { ... } = props` destructuring statement
|
|
71
|
+
at the top of a component body.
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// ❌ Incorrect
|
|
75
|
+
const UserCard = (props: UserCardProps) => {
|
|
76
|
+
const { user, className } = props
|
|
77
|
+
return <div className={className}>{user.name}</div>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ✅ Correct
|
|
81
|
+
const UserCard = (props: UserCardProps) => {
|
|
82
|
+
const { user, className } = props
|
|
83
|
+
|
|
84
|
+
return <div className={className}>{user.name}</div>
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The rule only triggers inside components (same detection as above) and only for
|
|
89
|
+
a statement that destructures the `props` identifier. It is a no-op when the
|
|
90
|
+
destructuring is the last statement in the body.
|
|
91
|
+
|
|
92
|
+
### `component-file-order`
|
|
93
|
+
|
|
94
|
+
Enforce a strict top-level order in files that contain a React component:
|
|
95
|
+
**imports → component props interface/type → component declaration**. Constants
|
|
96
|
+
and helpers between the interface and the component are allowed. Report-only (it
|
|
97
|
+
does not auto-reorder code).
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// ❌ Incorrect — interface before imports, or component before its interface
|
|
101
|
+
interface CardProps { title: string }
|
|
102
|
+
import { cn } from '#root/lib/utils'
|
|
103
|
+
|
|
104
|
+
// ✅ Correct
|
|
105
|
+
import { cn } from '#root/lib/utils'
|
|
106
|
+
|
|
107
|
+
interface CardProps {
|
|
108
|
+
title: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const Card = (props: CardProps) => {
|
|
112
|
+
const { title } = props
|
|
113
|
+
|
|
114
|
+
return <div className={cn('card')}>{title}</div>
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The rule activates only when the file actually contains a component. The "props
|
|
119
|
+
interface" is any top-level `interface`/`type` whose name ends in `Props`.
|
|
120
|
+
|
|
121
|
+
#### Option: `paths` (optional)
|
|
122
|
+
|
|
123
|
+
Restrict the rule to specific files via glob patterns. When omitted, it runs on
|
|
124
|
+
every file (you can also scope it the usual way with flat-config `files`).
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
{
|
|
128
|
+
rules: {
|
|
129
|
+
'@wl/component-file-order': ['error', { paths: ['**/features/**', 'apps/*/ui/**'] }],
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Glob support: `*` matches within a path segment, `**` matches across segments,
|
|
135
|
+
`?` matches a single character. A file matches if any pattern matches its path.
|