@next-friday/eslint-plugin-friday 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +590 -0
- package/package.json +103 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Next Friday
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @next-friday/eslint-plugin-friday
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@next-friday/eslint-plugin-friday) [](https://github.com/next-friday/eslint-plugin-friday/actions/workflows/ci.yml) [](https://codecov.io/gh/next-friday/eslint-plugin-friday/branch/main) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
Next Friday-specific ESLint rules for structural constraints generic lint stacks do not encode.
|
|
6
|
+
|
|
7
|
+
- Rules target module boundaries, React component structure, JSX layout, props shape, and identifier quality.
|
|
8
|
+
- Rules expose focused behavior and documented options.
|
|
9
|
+
- The plugin is intentionally narrow: it adds Next Friday-specific semantics instead of duplicating maintained ecosystem rules.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Register the plugin in an ESLint Flat Config and enable the rules you need:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import friday from "@next-friday/eslint-plugin-friday";
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
{
|
|
20
|
+
plugins: {friday},
|
|
21
|
+
rules: {
|
|
22
|
+
"friday/no-lazy-identifiers": "error",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The package exports rule implementations and their documented options.
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
<!-- begin auto-generated rules list -->
|
|
33
|
+
|
|
34
|
+
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).
|
|
35
|
+
|
|
36
|
+
| Name | Description | 🔧 |
|
|
37
|
+
| :--------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-- |
|
|
38
|
+
| [component-module](docs/rules/component-module.md) | require React component modules to contain only imports, directives, export lists or re-exports, TypeScript interfaces, React function components, and explicitly allowed exported declarations | |
|
|
39
|
+
| [index-export-only](docs/rules/index-export-only.md) | require index files to contain only imports, exports without inline runtime implementation, directives, and type declarations | |
|
|
40
|
+
| [jsx-newline-between-elements](docs/rules/jsx-newline-between-elements.md) | require empty lines between adjacent JSX elements, fragments, or expression containers when either is multi-line | 🔧 |
|
|
41
|
+
| [jsx-no-newline-single-line-elements](docs/rules/jsx-no-newline-single-line-elements.md) | disallow empty lines between adjacent single-line JSX elements or fragments | 🔧 |
|
|
42
|
+
| [named-props](docs/rules/named-props.md) | disallow inline intersections in the first parameter of React function components | |
|
|
43
|
+
| [no-lazy-identifiers](docs/rules/no-lazy-identifiers.md) | disallow lazy placeholder identifiers such as repeated characters and keyboard-row runs | |
|
|
44
|
+
| [object-curly-newline](docs/rules/object-curly-newline.md) | require every non-empty object literal to use multiline braces | 🔧 |
|
|
45
|
+
| [props-in-body](docs/rules/props-in-body.md) | disallow object destructuring in the first parameter of React function components | |
|
|
46
|
+
|
|
47
|
+
<!-- end auto-generated rules list -->
|
|
48
|
+
|
|
49
|
+
## Compatibility
|
|
50
|
+
|
|
51
|
+
| Surface | Supported contract |
|
|
52
|
+
| :------------ | :----------------- |
|
|
53
|
+
| Node.js | `^22.13.0 | | >=24.0.0` |
|
|
54
|
+
| ESLint | `^10.4.0` |
|
|
55
|
+
| Module format | ESM |
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
[MIT](LICENSE) © Next Friday
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
declare const plugin: {
|
|
3
|
+
meta: {
|
|
4
|
+
name: string;
|
|
5
|
+
namespace: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
rules: {
|
|
9
|
+
"component-module": import("eslint").Rule.RuleModule;
|
|
10
|
+
"index-export-only": import("eslint").Rule.RuleModule;
|
|
11
|
+
"jsx-newline-between-elements": import("eslint").Rule.RuleModule;
|
|
12
|
+
"jsx-no-newline-single-line-elements": import("eslint").Rule.RuleModule;
|
|
13
|
+
"named-props": import("eslint").Rule.RuleModule;
|
|
14
|
+
"no-lazy-identifiers": import("eslint").Rule.RuleModule;
|
|
15
|
+
"object-curly-newline": import("eslint").Rule.RuleModule;
|
|
16
|
+
"props-in-body": import("eslint").Rule.RuleModule;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { plugin as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { getFunctionComponentCollector } from "@eslint-react/core";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#endregion
|
|
4
|
+
//#region src/meta.ts
|
|
5
|
+
const meta = {
|
|
6
|
+
name: "@next-friday/eslint-plugin-friday",
|
|
7
|
+
namespace: "friday",
|
|
8
|
+
version: "1.0.0"
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/utils/function-component-visitor.ts
|
|
12
|
+
/**
|
|
13
|
+
* Attach the React component collector to a rule and pass its results on program exit.
|
|
14
|
+
* @param context ESLint rule context.
|
|
15
|
+
* @param onProgramExit Rule-specific check for the program and detected components.
|
|
16
|
+
* @returns The collector's visitors and the program exit handler.
|
|
17
|
+
*/
|
|
18
|
+
function createFunctionComponentVisitor(context, onProgramExit) {
|
|
19
|
+
const collector = getFunctionComponentCollector(context);
|
|
20
|
+
return {
|
|
21
|
+
...collector.visitor,
|
|
22
|
+
"Program:exit": (node) => {
|
|
23
|
+
const program = node;
|
|
24
|
+
onProgramExit(program, collector.api.getAllComponents(program));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/utils/rule-doc-url.ts
|
|
30
|
+
const RULE_DOC_BASE_URL = "https://github.com/next-friday/eslint-plugin-friday/blob/main/docs/rules";
|
|
31
|
+
function getRuleDocumentationUrl(ruleName) {
|
|
32
|
+
return `${RULE_DOC_BASE_URL}/${ruleName}.md`;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/rules/component-module.ts
|
|
36
|
+
const MESSAGE_ID$6 = "componentModule";
|
|
37
|
+
const ALLOWED_MODULE_STATEMENT_TYPES = /* @__PURE__ */ new Set([
|
|
38
|
+
"ExportAllDeclaration",
|
|
39
|
+
"ImportDeclaration",
|
|
40
|
+
"TSInterfaceDeclaration"
|
|
41
|
+
]);
|
|
42
|
+
const EXPORT_STATEMENT_TYPES = /* @__PURE__ */ new Set(["ExportDefaultDeclaration", "ExportNamedDeclaration"]);
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a declaration contains only React function components.
|
|
45
|
+
* @param node Declaration node to evaluate.
|
|
46
|
+
* @param components React function components detected by the React detector.
|
|
47
|
+
* @returns Whether the declaration contains only React function components.
|
|
48
|
+
*/
|
|
49
|
+
function isAllowedComponentDeclaration(node, components) {
|
|
50
|
+
switch (node.type) {
|
|
51
|
+
case "FunctionDeclaration": return components.some((component) => Object.is(component.node, node));
|
|
52
|
+
case "VariableDeclaration": return node.declarations !== void 0 && node.declarations.length > 0 && node.declarations.every((declarator) => components.some((component) => Object.is(component.initPath?.[1], declarator)));
|
|
53
|
+
default: return components.some((component) => Object.is(component.node, node));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check whether a declaration name is explicitly allowed by framework policy.
|
|
58
|
+
* @param node Declaration node to evaluate.
|
|
59
|
+
* @param allowedDeclarations Framework-owned declaration names.
|
|
60
|
+
* @returns Whether every declaration name is allowed.
|
|
61
|
+
*/
|
|
62
|
+
function isAllowedDeclarationName(node, allowedDeclarations) {
|
|
63
|
+
switch (node.type) {
|
|
64
|
+
case "FunctionDeclaration": return node.id?.name !== void 0 && allowedDeclarations.has(node.id.name);
|
|
65
|
+
case "VariableDeclaration": return node.declarations !== void 0 && node.declarations.length > 0 && node.declarations.every((declaration) => declaration.id?.name !== void 0 && allowedDeclarations.has(declaration.id.name));
|
|
66
|
+
default: return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check whether an export contains only allowed declarations.
|
|
71
|
+
* @param node Export statement to evaluate.
|
|
72
|
+
* @param components React function components detected by the React detector.
|
|
73
|
+
* @param allowedDeclarations Framework-owned declaration names.
|
|
74
|
+
* @returns Whether the export belongs in the component module.
|
|
75
|
+
*/
|
|
76
|
+
function isAllowedExportStatement(node, components, allowedDeclarations) {
|
|
77
|
+
if (!EXPORT_STATEMENT_TYPES.has(node.type)) return false;
|
|
78
|
+
if (!node.declaration) return true;
|
|
79
|
+
const declaration = node.declaration;
|
|
80
|
+
return declaration.type === "TSInterfaceDeclaration" || isAllowedComponentDeclaration(declaration, components) || isAllowedDeclarationName(declaration, allowedDeclarations);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check whether the statement is permitted module syntax.
|
|
84
|
+
* @param node Top-level program statement.
|
|
85
|
+
* @returns Whether the statement is an import, re-export, interface, or directive.
|
|
86
|
+
*/
|
|
87
|
+
function isAllowedModuleSyntax(node) {
|
|
88
|
+
return ALLOWED_MODULE_STATEMENT_TYPES.has(node.type) || node.type === "ExpressionStatement" && node.directive !== void 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check whether a top-level statement is permitted in a React component module.
|
|
92
|
+
* @param node Top-level program statement.
|
|
93
|
+
* @param components React function components detected by the React detector.
|
|
94
|
+
* @param allowedDeclarations Framework-owned declaration names.
|
|
95
|
+
* @returns Whether the statement belongs in a JSX or TSX component module.
|
|
96
|
+
*/
|
|
97
|
+
function isAllowedStatement$1(node, components, allowedDeclarations) {
|
|
98
|
+
return isAllowedModuleSyntax(node) || isAllowedComponentDeclaration(node, components) || isAllowedExportStatement(node, components, allowedDeclarations);
|
|
99
|
+
}
|
|
100
|
+
const componentModule = {
|
|
101
|
+
create(context) {
|
|
102
|
+
const [options] = context.options;
|
|
103
|
+
const allowedDeclarations = new Set(options?.allowDeclarations);
|
|
104
|
+
return createFunctionComponentVisitor(context, (node, components) => {
|
|
105
|
+
let index = 0;
|
|
106
|
+
while (index < node.body.length) {
|
|
107
|
+
const statement = node.body[index];
|
|
108
|
+
index += 1;
|
|
109
|
+
if (!isAllowedStatement$1(statement, components, allowedDeclarations)) context.report({
|
|
110
|
+
messageId: MESSAGE_ID$6,
|
|
111
|
+
node: statement
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
meta: {
|
|
117
|
+
languages: ["js/js"],
|
|
118
|
+
type: "suggestion",
|
|
119
|
+
docs: {
|
|
120
|
+
description: "require React component modules to contain only imports, directives, export lists or re-exports, TypeScript interfaces, React function components, and explicitly allowed exported declarations",
|
|
121
|
+
url: getRuleDocumentationUrl("component-module")
|
|
122
|
+
},
|
|
123
|
+
defaultOptions: [{ allowDeclarations: [] }],
|
|
124
|
+
messages: { [MESSAGE_ID$6]: "Move non-component top-level code out of this React component module into a separate module." },
|
|
125
|
+
schema: [{
|
|
126
|
+
type: "object",
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
properties: { allowDeclarations: {
|
|
129
|
+
description: "Framework-owned declaration names allowed in component modules.",
|
|
130
|
+
type: "array",
|
|
131
|
+
uniqueItems: true,
|
|
132
|
+
items: { type: "string" }
|
|
133
|
+
} }
|
|
134
|
+
}]
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/rules/index-export-only.ts
|
|
139
|
+
const MESSAGE_ID$5 = "indexExportOnly";
|
|
140
|
+
/**
|
|
141
|
+
* Check whether a named export contains only type declarations or re-exports.
|
|
142
|
+
* @param node Export statement to inspect.
|
|
143
|
+
* @returns Whether the named export is valid for an index barrel.
|
|
144
|
+
*/
|
|
145
|
+
function isAllowedNamedExport(node) {
|
|
146
|
+
return node.declaration === void 0 || node.declaration === null || node.declaration.type === "TSInterfaceDeclaration" || node.declaration.type === "TSTypeAliasDeclaration";
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Check whether a top-level statement is valid in an index barrel.
|
|
150
|
+
* @param node Top-level statement to inspect.
|
|
151
|
+
* @returns Whether the statement belongs in an index barrel.
|
|
152
|
+
*/
|
|
153
|
+
function isAllowedStatement(node) {
|
|
154
|
+
switch (node.type) {
|
|
155
|
+
case "ExportAllDeclaration": return true;
|
|
156
|
+
case "ExportDefaultDeclaration": return node.declaration?.type === "Identifier";
|
|
157
|
+
case "ExportNamedDeclaration": return isAllowedNamedExport(node);
|
|
158
|
+
case "ExpressionStatement": return node.directive !== void 0;
|
|
159
|
+
case "ImportDeclaration": return true;
|
|
160
|
+
case "TSImportEqualsDeclaration": return node.moduleReference?.type === "TSExternalModuleReference";
|
|
161
|
+
case "TSInterfaceDeclaration": return true;
|
|
162
|
+
case "TSTypeAliasDeclaration": return true;
|
|
163
|
+
default: return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check whether a filename belongs to an index barrel.
|
|
168
|
+
* @param filename Filename reported by ESLint.
|
|
169
|
+
* @returns Whether the filename without its final extension is exactly `index`.
|
|
170
|
+
*/
|
|
171
|
+
function isIndexFile(filename) {
|
|
172
|
+
return path.parse(filename).name === "index";
|
|
173
|
+
}
|
|
174
|
+
const indexExportOnly = {
|
|
175
|
+
create(context) {
|
|
176
|
+
if (!isIndexFile(context.filename)) return {};
|
|
177
|
+
const checkProgram = (node) => {
|
|
178
|
+
const program = node;
|
|
179
|
+
let index = 0;
|
|
180
|
+
while (index < program.body.length) {
|
|
181
|
+
const statement = program.body[index];
|
|
182
|
+
index += 1;
|
|
183
|
+
if (!isAllowedStatement(statement)) context.report({
|
|
184
|
+
messageId: MESSAGE_ID$5,
|
|
185
|
+
node: statement
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
return { Program: checkProgram };
|
|
190
|
+
},
|
|
191
|
+
meta: {
|
|
192
|
+
languages: ["js/js"],
|
|
193
|
+
type: "suggestion",
|
|
194
|
+
docs: {
|
|
195
|
+
description: "require index files to contain only imports, exports without inline runtime implementation, directives, and type declarations",
|
|
196
|
+
url: getRuleDocumentationUrl("index-export-only")
|
|
197
|
+
},
|
|
198
|
+
messages: { [MESSAGE_ID$5]: "Index files must contain only imports, exports without inline runtime implementation, directives, and type declarations. Move runtime implementation to a dedicated module and export it from the index." },
|
|
199
|
+
schema: []
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/utils/jsx-children.ts
|
|
204
|
+
const IGNORABLE_JSX_TEXT = /^[\t\n\r ]*$/u;
|
|
205
|
+
/**
|
|
206
|
+
* Check whether a JSXText child contains only layout whitespace that React can ignore.
|
|
207
|
+
* Unicode spacing characters such as non-breaking space remain significant content.
|
|
208
|
+
* @param child JSX child to inspect.
|
|
209
|
+
* @returns Whether the child is ignorable layout whitespace.
|
|
210
|
+
*/
|
|
211
|
+
function isIgnorableJsxText(child) {
|
|
212
|
+
return child.type === "JSXText" && IGNORABLE_JSX_TEXT.test(child.value ?? "");
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Create visitors for JSX elements and fragments that pass their children to a rule check.
|
|
216
|
+
* @param checkChildren Rule-specific check for the parent's JSX children.
|
|
217
|
+
* @returns JSX element and fragment visitors.
|
|
218
|
+
*/
|
|
219
|
+
function createJsxParentListener(checkChildren) {
|
|
220
|
+
const visitJsxParent = (node) => {
|
|
221
|
+
const { children } = node;
|
|
222
|
+
if (children.length > 0) checkChildren(children);
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
JSXElement: visitJsxParent,
|
|
226
|
+
JSXFragment: visitJsxParent
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/rules/jsx-newline-between-elements.ts
|
|
231
|
+
const MESSAGE_ID$4 = "requireNewline";
|
|
232
|
+
const SIGNIFICANT_JSX_CHILD_TYPES = /* @__PURE__ */ new Set([
|
|
233
|
+
"JSXElement",
|
|
234
|
+
"JSXExpressionContainer",
|
|
235
|
+
"JSXFragment"
|
|
236
|
+
]);
|
|
237
|
+
/**
|
|
238
|
+
* Check whether adjacent significant JSX children need a separating empty line.
|
|
239
|
+
* @param current Previous significant JSX child.
|
|
240
|
+
* @param next Next significant JSX child.
|
|
241
|
+
* @returns Whether at least one child is multi-line and no empty line exists.
|
|
242
|
+
*/
|
|
243
|
+
function canRequireGap(current, next) {
|
|
244
|
+
if (!SIGNIFICANT_JSX_CHILD_TYPES.has(current.type) || !SIGNIFICANT_JSX_CHILD_TYPES.has(next.type)) return false;
|
|
245
|
+
const isCurrentMultiLine = current.loc.start.line !== current.loc.end.line;
|
|
246
|
+
const isNextMultiLine = next.loc.start.line !== next.loc.end.line;
|
|
247
|
+
const hasGap = next.loc.start.line - current.loc.end.line >= 2;
|
|
248
|
+
return (isCurrentMultiLine || isNextMultiLine) && !hasGap;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Enforce blank lines around multi-line JSX siblings.
|
|
252
|
+
* @param context Active ESLint rule context.
|
|
253
|
+
* @param children JSX children belonging to one element or fragment.
|
|
254
|
+
*/
|
|
255
|
+
function checkSiblings$1(context, children) {
|
|
256
|
+
const siblings = children.filter((child) => !isIgnorableJsxText(child));
|
|
257
|
+
let index = 0;
|
|
258
|
+
while (index < siblings.length - 1) {
|
|
259
|
+
const current = siblings[index];
|
|
260
|
+
const next = siblings[index + 1];
|
|
261
|
+
if (current && next && canRequireGap(current, next)) context.report({
|
|
262
|
+
messageId: MESSAGE_ID$4,
|
|
263
|
+
fix(fixer) {
|
|
264
|
+
return fixer.insertTextAfter(current, "\n");
|
|
265
|
+
},
|
|
266
|
+
node: next
|
|
267
|
+
});
|
|
268
|
+
index += 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const jsxNewlineBetweenElements = {
|
|
272
|
+
create(context) {
|
|
273
|
+
return createJsxParentListener((children) => checkSiblings$1(context, children));
|
|
274
|
+
},
|
|
275
|
+
meta: {
|
|
276
|
+
languages: ["js/js"],
|
|
277
|
+
type: "layout",
|
|
278
|
+
docs: {
|
|
279
|
+
description: "require empty lines between adjacent JSX elements, fragments, or expression containers when either is multi-line",
|
|
280
|
+
url: getRuleDocumentationUrl("jsx-newline-between-elements")
|
|
281
|
+
},
|
|
282
|
+
fixable: "whitespace",
|
|
283
|
+
messages: { [MESSAGE_ID$4]: "Expected empty line between adjacent JSX elements, fragments, or expression containers." },
|
|
284
|
+
schema: []
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/rules/jsx-no-newline-single-line-elements.ts
|
|
289
|
+
const MESSAGE_ID$3 = "forbidNewline";
|
|
290
|
+
/**
|
|
291
|
+
* Check whether two adjacent JSX children are single-line element siblings with an empty line.
|
|
292
|
+
* @param current Previous significant JSX child.
|
|
293
|
+
* @param next Next significant JSX child.
|
|
294
|
+
* @returns Whether the gap should be collapsed.
|
|
295
|
+
*/
|
|
296
|
+
function canCollapseGap(current, next) {
|
|
297
|
+
const isCurrentElement = current.type === "JSXElement" || current.type === "JSXFragment";
|
|
298
|
+
const isNextElement = next.type === "JSXElement" || next.type === "JSXFragment";
|
|
299
|
+
if (!isCurrentElement || !isNextElement) return false;
|
|
300
|
+
const isCurrentSingleLine = current.loc.start.line === current.loc.end.line;
|
|
301
|
+
const isNextSingleLine = next.loc.start.line === next.loc.end.line;
|
|
302
|
+
return isCurrentSingleLine && isNextSingleLine && next.loc.start.line - current.loc.end.line >= 2;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Enforce compact spacing between adjacent single-line JSX element siblings.
|
|
306
|
+
* @param context Active ESLint rule context.
|
|
307
|
+
* @param children JSX children belonging to one element or fragment.
|
|
308
|
+
*/
|
|
309
|
+
function checkSiblings(context, children) {
|
|
310
|
+
const elements = children.filter((child) => !isIgnorableJsxText(child));
|
|
311
|
+
let index = 1;
|
|
312
|
+
while (index < elements.length) {
|
|
313
|
+
const current = elements[index - 1];
|
|
314
|
+
const next = elements[index];
|
|
315
|
+
if (current && next && canCollapseGap(current, next)) context.report({
|
|
316
|
+
messageId: MESSAGE_ID$3,
|
|
317
|
+
fix(fixer) {
|
|
318
|
+
const indent = " ".repeat(next.loc.start.column);
|
|
319
|
+
return fixer.replaceTextRange([current.range[1], next.range[0]], `\n${indent}`);
|
|
320
|
+
},
|
|
321
|
+
node: next
|
|
322
|
+
});
|
|
323
|
+
index += 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const jsxNoNewlineSingleLineElements = {
|
|
327
|
+
create(context) {
|
|
328
|
+
return createJsxParentListener((children) => checkSiblings(context, children));
|
|
329
|
+
},
|
|
330
|
+
meta: {
|
|
331
|
+
languages: ["js/js"],
|
|
332
|
+
type: "layout",
|
|
333
|
+
docs: {
|
|
334
|
+
description: "disallow empty lines between adjacent single-line JSX elements or fragments",
|
|
335
|
+
url: getRuleDocumentationUrl("jsx-no-newline-single-line-elements")
|
|
336
|
+
},
|
|
337
|
+
fixable: "whitespace",
|
|
338
|
+
messages: { [MESSAGE_ID$3]: "Unexpected empty line between adjacent single-line JSX elements or fragments." },
|
|
339
|
+
schema: []
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/rules/named-props.ts
|
|
344
|
+
const MESSAGE_ID$2 = "namedProperties";
|
|
345
|
+
/**
|
|
346
|
+
* Get the typed identifier behind a component parameter.
|
|
347
|
+
* @param parameter Component parameter to inspect.
|
|
348
|
+
* @returns Typed identifier parameter, including one wrapped by a default assignment.
|
|
349
|
+
*/
|
|
350
|
+
function getTypedParameter(parameter) {
|
|
351
|
+
if (parameter === void 0) return;
|
|
352
|
+
const typedParameter = parameter;
|
|
353
|
+
if (typedParameter.typeAnnotation !== void 0) return typedParameter;
|
|
354
|
+
return typedParameter.left?.typeAnnotation === void 0 ? void 0 : typedParameter.left;
|
|
355
|
+
}
|
|
356
|
+
const namedProps = {
|
|
357
|
+
create(context) {
|
|
358
|
+
return createFunctionComponentVisitor(context, (_node, components) => {
|
|
359
|
+
let index = 0;
|
|
360
|
+
while (index < components.length) {
|
|
361
|
+
const component = components[index];
|
|
362
|
+
index += 1;
|
|
363
|
+
const [parameter] = component.node.params;
|
|
364
|
+
if ((getTypedParameter(parameter)?.typeAnnotation?.typeAnnotation)?.type === "TSIntersectionType") context.report({
|
|
365
|
+
messageId: MESSAGE_ID$2,
|
|
366
|
+
node: parameter
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
meta: {
|
|
372
|
+
languages: ["js/js"],
|
|
373
|
+
type: "suggestion",
|
|
374
|
+
docs: {
|
|
375
|
+
description: "disallow inline intersections in the first parameter of React function components",
|
|
376
|
+
url: getRuleDocumentationUrl("named-props")
|
|
377
|
+
},
|
|
378
|
+
messages: { [MESSAGE_ID$2]: "Move the inline intersection props type into a named props type and use that type as the component parameter type." },
|
|
379
|
+
schema: []
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/rules/no-lazy-identifiers.ts
|
|
384
|
+
const MESSAGE_ID$1 = "noLazyIdentifier";
|
|
385
|
+
const MIN_LENGTH = 3;
|
|
386
|
+
const MIN_SEQUENCE_LENGTH = 4;
|
|
387
|
+
const CHECKED_DEFINITION_TYPES = /* @__PURE__ */ new Set([
|
|
388
|
+
"CatchClause",
|
|
389
|
+
"ClassName",
|
|
390
|
+
"FunctionName",
|
|
391
|
+
"Parameter",
|
|
392
|
+
"Type",
|
|
393
|
+
"Variable"
|
|
394
|
+
]);
|
|
395
|
+
const KEYBOARD_RUNS = [
|
|
396
|
+
"qwertyuiop",
|
|
397
|
+
"poiuytrewq",
|
|
398
|
+
"asdfghjkl",
|
|
399
|
+
"lkjhgfdsa",
|
|
400
|
+
"zxcvbnm",
|
|
401
|
+
"mnbvcxz",
|
|
402
|
+
"1234567890",
|
|
403
|
+
"0987654321"
|
|
404
|
+
];
|
|
405
|
+
/**
|
|
406
|
+
* Split an identifier into semantic camel-case, acronym, numeric, and separator-delimited words.
|
|
407
|
+
* @param name Identifier name to split.
|
|
408
|
+
* @returns Identifier word segments used for keyboard-run detection.
|
|
409
|
+
*/
|
|
410
|
+
function getIdentifierWords(name) {
|
|
411
|
+
return name.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2").replaceAll(/([A-Z])([A-Z][a-z])/g, "$1 $2").replaceAll(/([a-z])(\d)/gi, "$1 $2").replaceAll(/(\d)([a-z])/gi, "$1 $2").split(/[^a-z0-9]+/i).filter((word) => word.length > 0);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Check whether an identifier contains the same character three times consecutively.
|
|
415
|
+
* @param name Identifier name to inspect.
|
|
416
|
+
* @returns Whether the identifier contains a repeated-character run.
|
|
417
|
+
*/
|
|
418
|
+
function hasRepeatedCharacters(name) {
|
|
419
|
+
const characters = [...name];
|
|
420
|
+
return characters.some((character, index) => index <= characters.length - 3 && character === characters[index + 1] && character === characters[index + 2]);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Check whether an identifier segment is a keyboard-row run in either direction.
|
|
424
|
+
* @param segment Identifier segment to inspect.
|
|
425
|
+
* @returns Whether the segment is a keyboard-row run.
|
|
426
|
+
*/
|
|
427
|
+
function isKeyboardRun(segment) {
|
|
428
|
+
const normalized = segment.toLowerCase();
|
|
429
|
+
return normalized.length >= MIN_SEQUENCE_LENGTH && KEYBOARD_RUNS.some((run) => run.includes(normalized));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Determine whether an identifier is a lazy placeholder name.
|
|
433
|
+
* @param name Identifier name to inspect.
|
|
434
|
+
* @returns Whether the identifier should be rejected.
|
|
435
|
+
*/
|
|
436
|
+
function isLazyIdentifier(name) {
|
|
437
|
+
return name.length >= MIN_LENGTH && !name.startsWith("_") && (hasRepeatedCharacters(name) || getIdentifierWords(name).some((segment) => isKeyboardRun(segment)));
|
|
438
|
+
}
|
|
439
|
+
const noLazyIdentifiers = {
|
|
440
|
+
create(context) {
|
|
441
|
+
const [options] = context.options;
|
|
442
|
+
const allowed = new Set(options?.allow);
|
|
443
|
+
const sourceCode = context.sourceCode;
|
|
444
|
+
const checkIdentifier = (identifier) => {
|
|
445
|
+
if (!allowed.has(identifier.name) && isLazyIdentifier(identifier.name)) context.report({
|
|
446
|
+
messageId: MESSAGE_ID$1,
|
|
447
|
+
data: { name: identifier.name },
|
|
448
|
+
node: identifier
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
const checkProgram = () => {
|
|
452
|
+
const identifiers = (sourceCode.scopeManager?.scopes.flatMap((scope) => scope.variables) ?? []).filter((variable) => variable.defs.some((definition) => CHECKED_DEFINITION_TYPES.has(definition.type))).flatMap((variable) => variable.identifiers);
|
|
453
|
+
const uniqueIdentifiers = [...new Set(identifiers)];
|
|
454
|
+
let index = 0;
|
|
455
|
+
while (index < uniqueIdentifiers.length) {
|
|
456
|
+
const identifier = uniqueIdentifiers[index];
|
|
457
|
+
index += 1;
|
|
458
|
+
checkIdentifier(identifier);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
return { "Program:exit": checkProgram };
|
|
462
|
+
},
|
|
463
|
+
meta: {
|
|
464
|
+
languages: ["js/js"],
|
|
465
|
+
type: "problem",
|
|
466
|
+
docs: {
|
|
467
|
+
description: "disallow lazy placeholder identifiers such as repeated characters and keyboard-row runs",
|
|
468
|
+
url: getRuleDocumentationUrl("no-lazy-identifiers")
|
|
469
|
+
},
|
|
470
|
+
defaultOptions: [{ allow: [] }],
|
|
471
|
+
messages: { [MESSAGE_ID$1]: "Avoid lazy identifier '{{name}}'. Use a descriptive name that clearly indicates its purpose." },
|
|
472
|
+
schema: [{
|
|
473
|
+
type: "object",
|
|
474
|
+
additionalProperties: false,
|
|
475
|
+
properties: { allow: {
|
|
476
|
+
description: "Identifier names explicitly exempt from this rule.",
|
|
477
|
+
type: "array",
|
|
478
|
+
uniqueItems: true,
|
|
479
|
+
items: { type: "string" }
|
|
480
|
+
} }
|
|
481
|
+
}]
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/rules/object-curly-newline.ts
|
|
486
|
+
const REQUIRE_MULTILINE = "requireMultiline";
|
|
487
|
+
/**
|
|
488
|
+
* Enforce multiline braces for every non-empty object literal.
|
|
489
|
+
* @param context Active ESLint rule context.
|
|
490
|
+
* @param node Object expression to validate.
|
|
491
|
+
*/
|
|
492
|
+
function checkObjectExpression(context, node) {
|
|
493
|
+
if (node.properties.length === 0) return;
|
|
494
|
+
const openingBrace = context.sourceCode.getFirstToken(node);
|
|
495
|
+
const closingBrace = context.sourceCode.getLastToken(node);
|
|
496
|
+
const firstContent = context.sourceCode.getTokenAfter(openingBrace, { includeComments: true });
|
|
497
|
+
const lastContent = context.sourceCode.getTokenBefore(closingBrace, { includeComments: true });
|
|
498
|
+
const shouldAddOpeningNewline = firstContent.loc.start.line === openingBrace.loc.end.line;
|
|
499
|
+
const shouldAddClosingNewline = lastContent.loc.end.line === closingBrace.loc.start.line;
|
|
500
|
+
if (!shouldAddOpeningNewline && !shouldAddClosingNewline) return;
|
|
501
|
+
context.report({
|
|
502
|
+
messageId: REQUIRE_MULTILINE,
|
|
503
|
+
node,
|
|
504
|
+
fix(fixer) {
|
|
505
|
+
const fixes = [];
|
|
506
|
+
if (shouldAddOpeningNewline) fixes.push(fixer.insertTextAfter(openingBrace, "\n"));
|
|
507
|
+
if (shouldAddClosingNewline) fixes.push(fixer.insertTextBefore(closingBrace, "\n"));
|
|
508
|
+
return fixes;
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const objectCurlyNewline = {
|
|
513
|
+
create(context) {
|
|
514
|
+
return { ObjectExpression(node) {
|
|
515
|
+
checkObjectExpression(context, node);
|
|
516
|
+
} };
|
|
517
|
+
},
|
|
518
|
+
meta: {
|
|
519
|
+
languages: ["js/js"],
|
|
520
|
+
type: "layout",
|
|
521
|
+
docs: {
|
|
522
|
+
description: "require every non-empty object literal to use multiline braces",
|
|
523
|
+
url: getRuleDocumentationUrl("object-curly-newline")
|
|
524
|
+
},
|
|
525
|
+
fixable: "whitespace",
|
|
526
|
+
messages: { [REQUIRE_MULTILINE]: "Non-empty object literals must use multiline braces." },
|
|
527
|
+
schema: []
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/rules/props-in-body.ts
|
|
532
|
+
const MESSAGE_ID = "propertiesInBody";
|
|
533
|
+
/**
|
|
534
|
+
* Report React component parameters that destructure object props in the signature.
|
|
535
|
+
* @param context ESLint rule context.
|
|
536
|
+
* @param parameter First component parameter, when present.
|
|
537
|
+
*/
|
|
538
|
+
function reportDestructuredParameter(context, parameter) {
|
|
539
|
+
if (parameter === void 0) return;
|
|
540
|
+
if ("properties" in parameter) {
|
|
541
|
+
context.report({
|
|
542
|
+
messageId: MESSAGE_ID,
|
|
543
|
+
node: parameter
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if ("left" in parameter && "properties" in parameter.left) context.report({
|
|
548
|
+
messageId: MESSAGE_ID,
|
|
549
|
+
node: parameter.left
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/index.ts
|
|
554
|
+
const plugin = {
|
|
555
|
+
meta,
|
|
556
|
+
rules: {
|
|
557
|
+
"component-module": componentModule,
|
|
558
|
+
"index-export-only": indexExportOnly,
|
|
559
|
+
"jsx-newline-between-elements": jsxNewlineBetweenElements,
|
|
560
|
+
"jsx-no-newline-single-line-elements": jsxNoNewlineSingleLineElements,
|
|
561
|
+
"named-props": namedProps,
|
|
562
|
+
"no-lazy-identifiers": noLazyIdentifiers,
|
|
563
|
+
"object-curly-newline": objectCurlyNewline,
|
|
564
|
+
"props-in-body": {
|
|
565
|
+
create(context) {
|
|
566
|
+
return createFunctionComponentVisitor(context, (_node, components) => {
|
|
567
|
+
let index = 0;
|
|
568
|
+
while (index < components.length) {
|
|
569
|
+
const component = components[index];
|
|
570
|
+
index += 1;
|
|
571
|
+
const [parameter] = component.node.params;
|
|
572
|
+
reportDestructuredParameter(context, parameter);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
meta: {
|
|
577
|
+
languages: ["js/js"],
|
|
578
|
+
type: "suggestion",
|
|
579
|
+
docs: {
|
|
580
|
+
description: "disallow object destructuring in the first parameter of React function components",
|
|
581
|
+
url: getRuleDocumentationUrl("props-in-body")
|
|
582
|
+
},
|
|
583
|
+
messages: { [MESSAGE_ID]: "Accept props as a named parameter and destructure them inside the React function component body." },
|
|
584
|
+
schema: []
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
//#endregion
|
|
590
|
+
export { plugin as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,106 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@next-friday/eslint-plugin-friday",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Next Friday-specific ESLint rules for shared engineering standards.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eslint",
|
|
7
|
+
"eslint-plugin",
|
|
8
|
+
"eslintplugin",
|
|
9
|
+
"react",
|
|
10
|
+
"typescript",
|
|
11
|
+
"next-friday"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/next-friday/eslint-plugin-friday#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/next-friday/eslint-plugin-friday/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/next-friday/eslint-plugin-friday.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Next Friday",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@eslint-react/core": "5.21.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
39
|
+
"@changesets/cli": "3.0.3",
|
|
40
|
+
"@commitlint/cli": "21.2.3",
|
|
41
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
42
|
+
"@eslint/js": "10.0.1",
|
|
43
|
+
"@types/node": "26.6.3",
|
|
44
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
45
|
+
"eslint": "10.11.0",
|
|
46
|
+
"eslint-config-prettier": "10.1.8",
|
|
47
|
+
"eslint-doc-generator": "3.7.1",
|
|
48
|
+
"eslint-plugin-eslint-plugin": "7.6.2",
|
|
49
|
+
"eslint-plugin-package-json": "1.10.1",
|
|
50
|
+
"eslint-plugin-unicorn": "76.0.0",
|
|
51
|
+
"husky": "9.1.7",
|
|
52
|
+
"knip": "6.38.0",
|
|
53
|
+
"lint-staged": "17.6.0",
|
|
54
|
+
"pkg-pr-new": "0.0.88",
|
|
55
|
+
"prettier": "3.9.9",
|
|
56
|
+
"publint": "0.3.24",
|
|
57
|
+
"sort-package-json": "4.0.0",
|
|
58
|
+
"tsdown": "0.23.0",
|
|
59
|
+
"typescript": "6.0.3",
|
|
60
|
+
"typescript-eslint": "8.70.1",
|
|
61
|
+
"vite": "8.3.1",
|
|
62
|
+
"vitest": "5.0.2"
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"eslint": "^10.4.0"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": "^22.13.0 || >=24.0.0"
|
|
69
|
+
},
|
|
70
|
+
"devEngines": {
|
|
71
|
+
"runtime": {
|
|
72
|
+
"name": "node",
|
|
73
|
+
"version": "24.20.0",
|
|
74
|
+
"onFail": "download"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public",
|
|
79
|
+
"provenance": true
|
|
80
|
+
},
|
|
81
|
+
"scripts": {
|
|
82
|
+
"audit": "pnpm audit",
|
|
83
|
+
"build": "tsdown",
|
|
84
|
+
"changeset": "changeset",
|
|
85
|
+
"changeset:status": "changeset status --since main",
|
|
86
|
+
"check:attw": "attw --pack . --profile esm-only --ignore-rules cjs-resolves-to-esm --no-summary",
|
|
87
|
+
"check:publint": "publint",
|
|
88
|
+
"clean": "rm -rf dist coverage *.tsbuildinfo .eslintcache node_modules/.cache",
|
|
89
|
+
"dev": "tsdown --watch",
|
|
90
|
+
"docs:check": "pnpm build && eslint-doc-generator --check",
|
|
91
|
+
"docs:generate": "pnpm build && eslint-doc-generator",
|
|
92
|
+
"fix": "pnpm lint && pnpm format",
|
|
93
|
+
"format": "prettier --write --cache .",
|
|
94
|
+
"format:check": "prettier --check --cache .",
|
|
95
|
+
"knip": "knip",
|
|
96
|
+
"lint": "eslint . --fix --cache --max-warnings=0",
|
|
97
|
+
"lint:check": "eslint . --cache --max-warnings=0",
|
|
98
|
+
"sort-package-json": "sort-package-json package.json",
|
|
99
|
+
"sort-package-json:check": "sort-package-json --check package.json",
|
|
100
|
+
"test": "vitest run",
|
|
101
|
+
"test:coverage": "vitest run --coverage",
|
|
102
|
+
"test:watch": "vitest",
|
|
103
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
104
|
+
"verify": "pnpm lint:check && pnpm format:check && pnpm sort-package-json:check && pnpm docs:check && pnpm typecheck && pnpm test:coverage && pnpm knip && pnpm build && pnpm check:publint && pnpm check:attw"
|
|
105
|
+
}
|
|
6
106
|
}
|