@qq99/eslint-plugin-solid 0.18.0-no-reactive-read-in-effect-callback.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 +289 -0
- package/dist/chunk-4UZNJ5MS.mjs +32 -0
- package/dist/chunk-4UZNJ5MS.mjs.map +1 -0
- package/dist/chunk-EBU4C5NV.mjs +77 -0
- package/dist/chunk-EBU4C5NV.mjs.map +1 -0
- package/dist/chunk-LE6SW2M3.mjs +6231 -0
- package/dist/chunk-LE6SW2M3.mjs.map +1 -0
- package/dist/chunk-MHDQJMLE.mjs +39 -0
- package/dist/chunk-MHDQJMLE.mjs.map +1 -0
- package/dist/configs/recommended.d.mts +109 -0
- package/dist/configs/recommended.d.ts +109 -0
- package/dist/configs/recommended.js +5877 -0
- package/dist/configs/recommended.js.map +1 -0
- package/dist/configs/recommended.mjs +5 -0
- package/dist/configs/recommended.mjs.map +1 -0
- package/dist/configs/typescript.d.mts +103 -0
- package/dist/configs/typescript.d.ts +103 -0
- package/dist/configs/typescript.js +6246 -0
- package/dist/configs/typescript.js.map +1 -0
- package/dist/configs/typescript.mjs +6 -0
- package/dist/configs/typescript.mjs.map +1 -0
- package/dist/configs/v2-strict.d.mts +130 -0
- package/dist/configs/v2-strict.d.ts +130 -0
- package/dist/configs/v2-strict.js +6317 -0
- package/dist/configs/v2-strict.js.map +1 -0
- package/dist/configs/v2-strict.mjs +7 -0
- package/dist/configs/v2-strict.mjs.map +1 -0
- package/dist/configs/v2.d.mts +127 -0
- package/dist/configs/v2.d.ts +127 -0
- package/dist/configs/v2.js +6289 -0
- package/dist/configs/v2.js.map +1 -0
- package/dist/configs/v2.mjs +6 -0
- package/dist/configs/v2.mjs.map +1 -0
- package/dist/index.d.mts +740 -0
- package/dist/index.d.ts +740 -0
- package/dist/index.js +6365 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +42 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +109 -0
- package/src/compat.ts +53 -0
- package/src/configs/recommended.ts +48 -0
- package/src/configs/typescript.ts +21 -0
- package/src/configs/v2-strict.ts +28 -0
- package/src/configs/v2.ts +68 -0
- package/src/deps.d.ts +1 -0
- package/src/index.ts +29 -0
- package/src/plugin.ts +92 -0
- package/src/rules/components-return-once.ts +209 -0
- package/src/rules/event-handlers.ts +397 -0
- package/src/rules/imports.ts +338 -0
- package/src/rules/jsx-no-duplicate-props.ts +110 -0
- package/src/rules/jsx-no-script-url.ts +61 -0
- package/src/rules/jsx-no-undef.ts +230 -0
- package/src/rules/jsx-uses-vars.ts +66 -0
- package/src/rules/no-accessor-as-prop.ts +147 -0
- package/src/rules/no-array-handlers.ts +57 -0
- package/src/rules/no-boolean-enumerated-attribute.ts +209 -0
- package/src/rules/no-browser-globals-in-server-function.ts +108 -0
- package/src/rules/no-destructure.ts +245 -0
- package/src/rules/no-innerhtml.ts +143 -0
- package/src/rules/no-invalid-server-capture.ts +104 -0
- package/src/rules/no-module-scope-reactive-primitive.ts +69 -0
- package/src/rules/no-proxy-apis.ts +96 -0
- package/src/rules/no-react-deps.ts +68 -0
- package/src/rules/no-react-specific-props.ts +60 -0
- package/src/rules/no-reactive-read-in-effect-callback.ts +570 -0
- package/src/rules/no-restated-default-options.ts +96 -0
- package/src/rules/no-single-arg-create-effect.ts +56 -0
- package/src/rules/no-store-mutation-outside-setter.ts +137 -0
- package/src/rules/no-unknown-namespaces.ts +140 -0
- package/src/rules/no-unused-signal.ts +119 -0
- package/src/rules/no-write-in-pure-computation.ts +231 -0
- package/src/rules/prefer-classlist.ts +93 -0
- package/src/rules/prefer-for.ts +93 -0
- package/src/rules/prefer-onSettled-for-side-effects.ts +101 -0
- package/src/rules/prefer-show.ts +102 -0
- package/src/rules/prefer-structured-class.ts +117 -0
- package/src/rules/reactivity.ts +1730 -0
- package/src/rules/removed-api.ts +212 -0
- package/src/rules/require-async-server-function.ts +122 -0
- package/src/rules/self-closing-comp.ts +150 -0
- package/src/rules/style-prop.ts +140 -0
- package/src/rules/valid-use-server.ts +192 -0
- package/src/rules/validate-jsx-nesting.ts +1 -0
- package/src/utils/reactive-imports.ts +382 -0
- package/src/utils/reactive-types.ts +272 -0
- package/src/utils/reactive-values.ts +162 -0
- package/src/utils.ts +442 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
|
|
3
|
+
import { findVariable } from "../compat";
|
|
4
|
+
import { jsxGetProp } from "../utils";
|
|
5
|
+
|
|
6
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
7
|
+
|
|
8
|
+
// Mechanical renames: same semantics, new name. Verified against the Solid 2.0
|
|
9
|
+
// RC source (packages/solid/src/index.ts).
|
|
10
|
+
const RENAMED = new Map<string, string>([
|
|
11
|
+
["batch", "flush"],
|
|
12
|
+
["equalFn", "isEqual"],
|
|
13
|
+
["getListener", "getObserver"],
|
|
14
|
+
["onMount", "onSettled"],
|
|
15
|
+
["mergeProps", "merge"],
|
|
16
|
+
["unwrap", "snapshot"],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Removed APIs whose replacement requires a structural rewrite; the message is
|
|
20
|
+
// prescriptive but no autofix is offered.
|
|
21
|
+
const REMOVED = new Map<string, string>([
|
|
22
|
+
[
|
|
23
|
+
"createResource",
|
|
24
|
+
"Async derivation is built into Solid 2.0 computations. Use an async `createMemo(async () => ...)` and read it where needed",
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
"createComputed",
|
|
28
|
+
"Use `createMemo` for derived values or the two-argument `createEffect(compute, effect)` for side effects",
|
|
29
|
+
],
|
|
30
|
+
["createDeferred", "There is no direct replacement; move the deferral out of the reactive graph"],
|
|
31
|
+
["createSelector", "Use `createProjection` to derive per-key reactive state"],
|
|
32
|
+
[
|
|
33
|
+
"on",
|
|
34
|
+
"Explicit dependencies are built in: use the two-argument `createEffect(compute, effect)` form, where `compute` reads the dependencies",
|
|
35
|
+
],
|
|
36
|
+
["onError", "Use `createErrorBoundary` (or the `<Errored>` component) to handle errors"],
|
|
37
|
+
["catchError", "Use `createErrorBoundary` (or the `<Errored>` component) to handle errors"],
|
|
38
|
+
[
|
|
39
|
+
"resetErrorBoundaries",
|
|
40
|
+
"Error boundaries heal automatically in Solid 2.0 when their sources update",
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"startTransition",
|
|
44
|
+
"Transitions are built in. Read pending state with `isPending(...)` and previous values with `latest(...)`",
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"useTransition",
|
|
48
|
+
"Transitions are built in. Read pending state with `isPending(...)` and previous values with `latest(...)`",
|
|
49
|
+
],
|
|
50
|
+
["from", "Signals interop with async iterables directly in Solid 2.0"],
|
|
51
|
+
["observable", "Signals interop with async iterables directly in Solid 2.0"],
|
|
52
|
+
["indexArray", "Use `mapArray`, which handles index-mode mapping in Solid 2.0"],
|
|
53
|
+
[
|
|
54
|
+
"splitProps",
|
|
55
|
+
'Use `omit` from "solid-js" to exclude props, and pass the original props object along',
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
"renderToStringAsync",
|
|
59
|
+
"`renderToString` awaits async content in Solid 2.0; use it (or `renderToStream`) instead",
|
|
60
|
+
],
|
|
61
|
+
["createMutable", "Use `createStore`; store setters mutate directly in Solid 2.0"],
|
|
62
|
+
["modifyMutable", "Use `createStore`; store setters mutate directly in Solid 2.0"],
|
|
63
|
+
[
|
|
64
|
+
"produce",
|
|
65
|
+
"Store setters receive a mutable draft by default in Solid 2.0; mutate directly in the setter",
|
|
66
|
+
],
|
|
67
|
+
// Components
|
|
68
|
+
["Index", "Use `<For keyed={false}>`, which passes item accessors like `<Index>` did"],
|
|
69
|
+
["ErrorBoundary", "Use `<Errored>` (or `createErrorBoundary`)"],
|
|
70
|
+
["Suspense", "Use `<Loading>` to render a fallback while content is pending"],
|
|
71
|
+
["SuspenseList", "Use `<Reveal>` with `createRevealOrder` to coordinate reveal order"],
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// Exports that moved from "solid-js/store" into core "solid-js" unchanged.
|
|
75
|
+
const STORE_MOVED = new Set(["createStore", "reconcile", "Store", "StoreNode"]);
|
|
76
|
+
|
|
77
|
+
// Includes "@solidjs/web" so 1.x APIs that survived a mechanical source
|
|
78
|
+
// rewrite (e.g. renderToStringAsync) are still reported as removed.
|
|
79
|
+
const SOLID_SOURCE_REGEX = /^(?:solid-js(?:\/web|\/store)?|@solidjs\/web)$/;
|
|
80
|
+
|
|
81
|
+
type MessageIds = "renamed" | "removed" | "webMoved" | "storeMoved" | "classList";
|
|
82
|
+
type Options = [];
|
|
83
|
+
|
|
84
|
+
export default createRule<Options, MessageIds>({
|
|
85
|
+
meta: {
|
|
86
|
+
type: "problem",
|
|
87
|
+
docs: {
|
|
88
|
+
description:
|
|
89
|
+
"Disallow Solid 1.x APIs that were removed or renamed in Solid 2.0, with migration guidance.",
|
|
90
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/removed-api.md",
|
|
91
|
+
},
|
|
92
|
+
fixable: "code",
|
|
93
|
+
schema: [],
|
|
94
|
+
messages: {
|
|
95
|
+
renamed: "'{{name}}' was renamed to '{{replacement}}' in Solid 2.0.",
|
|
96
|
+
removed: "'{{name}}' was removed in Solid 2.0. {{guidance}}.",
|
|
97
|
+
webMoved: 'The "solid-js/web" module is now the "@solidjs/web" package in Solid 2.0.',
|
|
98
|
+
storeMoved:
|
|
99
|
+
'The "solid-js/store" module no longer exists in Solid 2.0; \'{{name}}\' is exported from "solid-js".',
|
|
100
|
+
classList:
|
|
101
|
+
"The `classList` prop was removed in Solid 2.0; `class` accepts the same object form directly.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
defaultOptions: [],
|
|
105
|
+
create(context) {
|
|
106
|
+
/** Rename the import specifier and, when it isn't aliased, all its references. */
|
|
107
|
+
const renameFix = (
|
|
108
|
+
fixer: TSESLint.RuleFixer,
|
|
109
|
+
specifier: T.ImportSpecifier,
|
|
110
|
+
replacement: string
|
|
111
|
+
): Array<TSESLint.RuleFix> => {
|
|
112
|
+
const fixes: Array<TSESLint.RuleFix> = [];
|
|
113
|
+
const aliased = specifier.local.range[0] !== specifier.imported.range[0];
|
|
114
|
+
fixes.push(fixer.replaceText(specifier.imported, replacement));
|
|
115
|
+
if (!aliased) {
|
|
116
|
+
const variable = findVariable(context, specifier.local);
|
|
117
|
+
if (variable) {
|
|
118
|
+
for (const reference of variable.references) {
|
|
119
|
+
if (reference.identifier !== specifier.local) {
|
|
120
|
+
fixes.push(fixer.replaceText(reference.identifier, replacement));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return fixes;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
ImportDeclaration(node) {
|
|
130
|
+
const source = node.source.value;
|
|
131
|
+
if (!SOLID_SOURCE_REGEX.test(source)) return;
|
|
132
|
+
|
|
133
|
+
if (source === "solid-js/web") {
|
|
134
|
+
context.report({
|
|
135
|
+
node: node.source,
|
|
136
|
+
messageId: "webMoved",
|
|
137
|
+
fix: (fixer) =>
|
|
138
|
+
fixer.replaceText(
|
|
139
|
+
node.source,
|
|
140
|
+
node.source.raw.replace("solid-js/web", "@solidjs/web")
|
|
141
|
+
),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const specifier of node.specifiers) {
|
|
146
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
147
|
+
const importedName =
|
|
148
|
+
specifier.imported.type === "Identifier"
|
|
149
|
+
? specifier.imported.name
|
|
150
|
+
: specifier.imported.value;
|
|
151
|
+
|
|
152
|
+
const replacement = RENAMED.get(importedName);
|
|
153
|
+
if (replacement) {
|
|
154
|
+
context.report({
|
|
155
|
+
node: specifier,
|
|
156
|
+
messageId: "renamed",
|
|
157
|
+
data: { name: importedName, replacement },
|
|
158
|
+
fix: (fixer) => renameFix(fixer, specifier, replacement),
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const guidance = REMOVED.get(importedName);
|
|
164
|
+
if (guidance) {
|
|
165
|
+
context.report({
|
|
166
|
+
node: specifier,
|
|
167
|
+
messageId: "removed",
|
|
168
|
+
data: { name: importedName, guidance },
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (source === "solid-js/store" && STORE_MOVED.has(importedName)) {
|
|
174
|
+
context.report({
|
|
175
|
+
node: specifier,
|
|
176
|
+
messageId: "storeMoved",
|
|
177
|
+
data: { name: importedName },
|
|
178
|
+
// Only offer the source rewrite when every specifier in this
|
|
179
|
+
// declaration survives the move; otherwise the fix would create
|
|
180
|
+
// a broken import of a removed API from "solid-js".
|
|
181
|
+
fix: node.specifiers.every(
|
|
182
|
+
(s) =>
|
|
183
|
+
s.type === "ImportSpecifier" &&
|
|
184
|
+
STORE_MOVED.has(
|
|
185
|
+
s.imported.type === "Identifier" ? s.imported.name : s.imported.value
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
? (fixer) =>
|
|
189
|
+
fixer.replaceText(
|
|
190
|
+
node.source,
|
|
191
|
+
node.source.raw.replace("solid-js/store", "solid-js")
|
|
192
|
+
)
|
|
193
|
+
: undefined,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
JSXAttribute(node) {
|
|
199
|
+
if (node.name.type === "JSXIdentifier" && node.name.name === "classList") {
|
|
200
|
+
const element = node.parent as T.JSXOpeningElement;
|
|
201
|
+
const hasClass = !!jsxGetProp(element.attributes, "class");
|
|
202
|
+
context.report({
|
|
203
|
+
node: node.name,
|
|
204
|
+
messageId: "classList",
|
|
205
|
+
// If a `class` prop already exists the two must be merged by hand.
|
|
206
|
+
fix: hasClass ? undefined : (fixer) => fixer.replaceText(node.name, "class"),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { TSESTree as T, ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import {
|
|
11
|
+
FunctionNode,
|
|
12
|
+
isFunctionNode,
|
|
13
|
+
hasUseServerDirective,
|
|
14
|
+
getUseServerEligibility,
|
|
15
|
+
isInsideUseServerFunction,
|
|
16
|
+
programHasUseServerDirective,
|
|
17
|
+
} from "../utils";
|
|
18
|
+
import { findVariable, getSourceCode } from "../compat";
|
|
19
|
+
|
|
20
|
+
const { getFunctionHeadLocation } = ASTUtils;
|
|
21
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
22
|
+
|
|
23
|
+
type MessageIds = "asyncServerFunction";
|
|
24
|
+
type Options = [];
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* The client half of a server function is always asynchronous: calls cross
|
|
28
|
+
* the network and resolve a Promise. The server half deliberately preserves
|
|
29
|
+
* synchronous entry for in-process SSR calls. A non-async server function
|
|
30
|
+
* therefore returns `T` during SSR and `Promise<T>` on the client, and its
|
|
31
|
+
* declared TypeScript type matches only one of them — a split TS cannot see,
|
|
32
|
+
* because it types the source declaration, not the compiled proxy.
|
|
33
|
+
*/
|
|
34
|
+
export default createRule<Options, MessageIds>({
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: {
|
|
38
|
+
description: "Require server functions to be async, matching their client-side contract.",
|
|
39
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/require-async-server-function.md",
|
|
40
|
+
},
|
|
41
|
+
fixable: "code",
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
asyncServerFunction:
|
|
45
|
+
"Server functions should be async: on the client every call resolves a Promise, but during server rendering this function is called in-process and returns synchronously, so the same code observes two different types.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
defaultOptions: [],
|
|
49
|
+
create(context) {
|
|
50
|
+
const sourceCode = getSourceCode(context);
|
|
51
|
+
let moduleLevel = false;
|
|
52
|
+
|
|
53
|
+
const checkFunction = (fn: FunctionNode) => {
|
|
54
|
+
if (fn.async) return;
|
|
55
|
+
context.report({
|
|
56
|
+
loc: getFunctionHeadLocation(fn, sourceCode),
|
|
57
|
+
messageId: "asyncServerFunction",
|
|
58
|
+
fix: (fixer) => {
|
|
59
|
+
const firstToken = sourceCode.getFirstToken(fn);
|
|
60
|
+
return firstToken ? fixer.insertTextBefore(firstToken, "async ") : null;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Resolves `export { name }` specifiers to top-level function nodes. */
|
|
66
|
+
const resolveExportedFunction = (identifier: T.Identifier): FunctionNode | null => {
|
|
67
|
+
const variable = findVariable(context, identifier);
|
|
68
|
+
const def = variable?.defs[0];
|
|
69
|
+
if (!def) return null;
|
|
70
|
+
if (def.type === "FunctionName" && isFunctionNode(def.node)) return def.node;
|
|
71
|
+
if (
|
|
72
|
+
def.type === "Variable" &&
|
|
73
|
+
def.node.type === "VariableDeclarator" &&
|
|
74
|
+
isFunctionNode(def.node.init)
|
|
75
|
+
) {
|
|
76
|
+
return def.node.init;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
Program(node) {
|
|
83
|
+
moduleLevel = programHasUseServerDirective(node);
|
|
84
|
+
},
|
|
85
|
+
":function"(node: T.Node) {
|
|
86
|
+
if (!isFunctionNode(node)) return;
|
|
87
|
+
// Function-level directives. Ineligible positions (object/class
|
|
88
|
+
// methods) are never extracted at all; valid-use-server owns those.
|
|
89
|
+
if (
|
|
90
|
+
!moduleLevel &&
|
|
91
|
+
hasUseServerDirective(node) &&
|
|
92
|
+
getUseServerEligibility(node) === "eligible" &&
|
|
93
|
+
!isInsideUseServerFunction(node)
|
|
94
|
+
) {
|
|
95
|
+
checkFunction(node);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
ExportNamedDeclaration(node) {
|
|
99
|
+
if (!moduleLevel) return;
|
|
100
|
+
if (node.declaration) {
|
|
101
|
+
if (isFunctionNode(node.declaration)) {
|
|
102
|
+
checkFunction(node.declaration);
|
|
103
|
+
} else if (node.declaration.type === "VariableDeclaration") {
|
|
104
|
+
for (const declarator of node.declaration.declarations) {
|
|
105
|
+
if (isFunctionNode(declarator.init)) checkFunction(declarator.init);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
for (const specifier of node.specifiers) {
|
|
110
|
+
if (specifier.local.type !== "Identifier") continue;
|
|
111
|
+
const fn = resolveExportedFunction(specifier.local);
|
|
112
|
+
if (fn) checkFunction(fn);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
ExportDefaultDeclaration(node) {
|
|
117
|
+
if (!moduleLevel) return;
|
|
118
|
+
if (isFunctionNode(node.declaration)) checkFunction(node.declaration);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import { isDOMElementName } from "../utils";
|
|
11
|
+
import { getSourceCode } from "../compat";
|
|
12
|
+
|
|
13
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
14
|
+
|
|
15
|
+
function isComponent(node: T.JSXOpeningElement) {
|
|
16
|
+
return (
|
|
17
|
+
(node.name.type === "JSXIdentifier" && !isDOMElementName(node.name.name)) ||
|
|
18
|
+
node.name.type === "JSXMemberExpression"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const voidDOMElementRegex =
|
|
23
|
+
/^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
|
|
24
|
+
function isVoidDOMElementName(name: string) {
|
|
25
|
+
return voidDOMElementRegex.test(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function childrenIsEmpty(node: T.JSXOpeningElement) {
|
|
29
|
+
return (node.parent as T.JSXElement).children.length === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function childrenIsMultilineSpaces(node: T.JSXOpeningElement) {
|
|
33
|
+
const childrens = (node.parent as T.JSXElement).children;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
childrens.length === 1 &&
|
|
37
|
+
childrens[0].type === "JSXText" &&
|
|
38
|
+
childrens[0].value.indexOf("\n") !== -1 &&
|
|
39
|
+
childrens[0].value.replace(/(?!\xA0)\s/g, "") === ""
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type MessageIds = "selfClose" | "dontSelfClose";
|
|
44
|
+
type Options = [{ component?: "all" | "none"; html?: "all" | "void" | "none" }?];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* This rule is adapted from eslint-plugin-react's self-closing-comp rule under the MIT license,
|
|
48
|
+
* with some enhancements. Thank you for your work!
|
|
49
|
+
*/
|
|
50
|
+
export default createRule<Options, MessageIds>({
|
|
51
|
+
meta: {
|
|
52
|
+
type: "layout",
|
|
53
|
+
docs: {
|
|
54
|
+
description: "Disallow extra closing tags for components without children.",
|
|
55
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/self-closing-comp.md",
|
|
56
|
+
},
|
|
57
|
+
fixable: "code",
|
|
58
|
+
schema: [
|
|
59
|
+
{
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
component: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "which Solid components should be self-closing when possible",
|
|
65
|
+
enum: ["all", "none"],
|
|
66
|
+
default: "all",
|
|
67
|
+
},
|
|
68
|
+
html: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "which native elements should be self-closing when possible",
|
|
71
|
+
enum: ["all", "void", "none"],
|
|
72
|
+
default: "all",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
messages: {
|
|
79
|
+
selfClose: "Empty components are self-closing.",
|
|
80
|
+
dontSelfClose: "This element should not be self-closing.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
defaultOptions: [],
|
|
84
|
+
create(context) {
|
|
85
|
+
function shouldBeSelfClosedWhenPossible(node: T.JSXOpeningElement): boolean {
|
|
86
|
+
if (isComponent(node)) {
|
|
87
|
+
const whichComponents = context.options[0]?.component ?? "all";
|
|
88
|
+
return whichComponents === "all";
|
|
89
|
+
} else if (node.name.type === "JSXIdentifier" && isDOMElementName(node.name.name)) {
|
|
90
|
+
const whichComponents = context.options[0]?.html ?? "all";
|
|
91
|
+
switch (whichComponents) {
|
|
92
|
+
case "all":
|
|
93
|
+
return true;
|
|
94
|
+
case "void":
|
|
95
|
+
return isVoidDOMElementName(node.name.name);
|
|
96
|
+
case "none":
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return true; // shouldn't encounter
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
JSXOpeningElement(node) {
|
|
105
|
+
const canSelfClose = childrenIsEmpty(node) || childrenIsMultilineSpaces(node);
|
|
106
|
+
if (canSelfClose) {
|
|
107
|
+
const shouldSelfClose = shouldBeSelfClosedWhenPossible(node);
|
|
108
|
+
if (shouldSelfClose && !node.selfClosing) {
|
|
109
|
+
context.report({
|
|
110
|
+
node,
|
|
111
|
+
messageId: "selfClose",
|
|
112
|
+
fix(fixer) {
|
|
113
|
+
// Represents the last character of the JSXOpeningElement, the '>' character
|
|
114
|
+
const openingElementEnding = node.range[1] - 1;
|
|
115
|
+
// Represents the last character of the JSXClosingElement, the '>' character
|
|
116
|
+
const closingElementEnding = (node.parent as T.JSXElement).closingElement!.range[1];
|
|
117
|
+
|
|
118
|
+
// Replace />.*<\/.*>/ with '/>'
|
|
119
|
+
const range = [openingElementEnding, closingElementEnding] as const;
|
|
120
|
+
return fixer.replaceTextRange(range, " />");
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
} else if (!shouldSelfClose && node.selfClosing) {
|
|
124
|
+
context.report({
|
|
125
|
+
node,
|
|
126
|
+
messageId: "dontSelfClose",
|
|
127
|
+
fix(fixer) {
|
|
128
|
+
const sourceCode = getSourceCode(context);
|
|
129
|
+
const tagName = sourceCode.getText(node.name);
|
|
130
|
+
// Represents the last character of the JSXOpeningElement, the '>' character
|
|
131
|
+
const selfCloseEnding = node.range[1];
|
|
132
|
+
// Replace ' />' or '/>' with '></${tagName}>'
|
|
133
|
+
const lastTokens = sourceCode.getLastTokens(node, { count: 3 }); // JSXIdentifier, '/', '>'
|
|
134
|
+
const isSpaceBeforeSelfClose = sourceCode.isSpaceBetween?.(
|
|
135
|
+
lastTokens[0],
|
|
136
|
+
lastTokens[1]
|
|
137
|
+
);
|
|
138
|
+
const range = [
|
|
139
|
+
isSpaceBeforeSelfClose ? selfCloseEnding - 3 : selfCloseEnding - 2,
|
|
140
|
+
selfCloseEnding,
|
|
141
|
+
] as const;
|
|
142
|
+
return fixer.replaceTextRange(range, `></${tagName}>`);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FIXME: remove this comments and import when below issue is fixed.
|
|
3
|
+
* This import is necessary for type generation due to a bug in the TypeScript compiler.
|
|
4
|
+
* See: https://github.com/microsoft/TypeScript/issues/42873
|
|
5
|
+
*/
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
8
|
+
|
|
9
|
+
import { TSESTree as T, ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
|
|
10
|
+
import kebabCase from "kebab-case";
|
|
11
|
+
import { all as allCssProperties } from "known-css-properties";
|
|
12
|
+
import parse from "style-to-object";
|
|
13
|
+
import { jsxPropName } from "../utils";
|
|
14
|
+
import { getScope } from "../compat";
|
|
15
|
+
|
|
16
|
+
const createRule = ESLintUtils.RuleCreator.withoutDocs;
|
|
17
|
+
const { getPropertyName, getStaticValue } = ASTUtils;
|
|
18
|
+
|
|
19
|
+
const lengthPercentageRegex = /\b(?:width|height|margin|padding|border-width|font-size)\b/i;
|
|
20
|
+
|
|
21
|
+
type MessageIds = "kebabStyleProp" | "invalidStyleProp" | "numericStyleValue" | "stringStyle";
|
|
22
|
+
type Options = [{ styleProps?: Array<string>; allowString?: boolean }?];
|
|
23
|
+
|
|
24
|
+
export default createRule<Options, MessageIds>({
|
|
25
|
+
meta: {
|
|
26
|
+
type: "problem",
|
|
27
|
+
docs: {
|
|
28
|
+
description:
|
|
29
|
+
"Require CSS properties in the `style` prop to be valid and kebab-cased (ex. 'font-size'), not camel-cased (ex. 'fontSize') like in React, " +
|
|
30
|
+
"and that property values with dimensions are strings, not numbers with implicit 'px' units.",
|
|
31
|
+
url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/style-prop.md",
|
|
32
|
+
},
|
|
33
|
+
fixable: "code",
|
|
34
|
+
schema: [
|
|
35
|
+
{
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
styleProps: {
|
|
39
|
+
description: "an array of prop names to treat as a CSS style object",
|
|
40
|
+
default: ["style"],
|
|
41
|
+
type: "array",
|
|
42
|
+
items: {
|
|
43
|
+
type: "string",
|
|
44
|
+
},
|
|
45
|
+
minItems: 1,
|
|
46
|
+
uniqueItems: true,
|
|
47
|
+
},
|
|
48
|
+
allowString: {
|
|
49
|
+
description:
|
|
50
|
+
"if allowString is set to true, this rule will not convert a style string literal into a style object (not recommended for performance)",
|
|
51
|
+
type: "boolean",
|
|
52
|
+
default: false,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
messages: {
|
|
59
|
+
kebabStyleProp: "Use {{ kebabName }} instead of {{ name }}.",
|
|
60
|
+
invalidStyleProp: "{{ name }} is not a valid CSS property.",
|
|
61
|
+
numericStyleValue:
|
|
62
|
+
'This CSS property value should be a string with a unit; Solid does not automatically append a "px" unit.',
|
|
63
|
+
stringStyle: "Use an object for the style prop instead of a string.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
defaultOptions: [],
|
|
67
|
+
create(context) {
|
|
68
|
+
const allCssPropertiesSet: Set<string> = new Set(allCssProperties);
|
|
69
|
+
const allowString = Boolean(context.options[0]?.allowString);
|
|
70
|
+
const styleProps = context.options[0]?.styleProps || ["style"];
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
JSXAttribute(node) {
|
|
74
|
+
if (styleProps.indexOf(jsxPropName(node)) === -1) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const style =
|
|
78
|
+
node.value?.type === "JSXExpressionContainer" ? node.value.expression : node.value;
|
|
79
|
+
|
|
80
|
+
if (!style) {
|
|
81
|
+
return;
|
|
82
|
+
} else if (style.type === "Literal" && typeof style.value === "string" && !allowString) {
|
|
83
|
+
// Convert style="font-size: 10px" to style={{ "font-size": "10px" }}
|
|
84
|
+
let objectStyles: Record<string, string> | undefined;
|
|
85
|
+
try {
|
|
86
|
+
objectStyles = parse(style.value) ?? undefined;
|
|
87
|
+
} catch {
|
|
88
|
+
// no-op
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
context.report({
|
|
92
|
+
node: style,
|
|
93
|
+
messageId: "stringStyle",
|
|
94
|
+
// replace full prop value, wrap in JSXExpressionContainer, more fixes may be applied below
|
|
95
|
+
fix:
|
|
96
|
+
objectStyles &&
|
|
97
|
+
((fixer) => fixer.replaceText(node.value!, `{${JSON.stringify(objectStyles)}}`)),
|
|
98
|
+
});
|
|
99
|
+
} else if (style.type === "TemplateLiteral" && !allowString) {
|
|
100
|
+
context.report({
|
|
101
|
+
node: style,
|
|
102
|
+
messageId: "stringStyle",
|
|
103
|
+
});
|
|
104
|
+
} else if (style.type === "ObjectExpression") {
|
|
105
|
+
const properties = style.properties.filter(
|
|
106
|
+
(prop) => prop.type === "Property"
|
|
107
|
+
) as Array<T.Property>;
|
|
108
|
+
properties.forEach((prop) => {
|
|
109
|
+
const name: string | null = getPropertyName(prop, getScope(context, prop));
|
|
110
|
+
if (name && !name.startsWith("--") && !allCssPropertiesSet.has(name)) {
|
|
111
|
+
const kebabName: string = kebabCase(name);
|
|
112
|
+
if (allCssPropertiesSet.has(kebabName)) {
|
|
113
|
+
// if it's not valid simply because it's camelCased instead of kebab-cased, provide a fix
|
|
114
|
+
context.report({
|
|
115
|
+
node: prop.key,
|
|
116
|
+
messageId: "kebabStyleProp",
|
|
117
|
+
data: { name, kebabName },
|
|
118
|
+
fix: (fixer) => fixer.replaceText(prop.key, `"${kebabName}"`), // wrap kebab name in quotes to be a valid object key
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
context.report({
|
|
122
|
+
node: prop.key,
|
|
123
|
+
messageId: "invalidStyleProp",
|
|
124
|
+
data: { name },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} else if (!name || (!name.startsWith("--") && lengthPercentageRegex.test(name))) {
|
|
128
|
+
// catches numeric values (ex. { "font-size": 12 }) for common <length-percentage> peroperties
|
|
129
|
+
// and suggests quoting or appending 'px'
|
|
130
|
+
const value: unknown = getStaticValue(prop.value)?.value;
|
|
131
|
+
if (typeof value === "number" && value !== 0) {
|
|
132
|
+
context.report({ node: prop.value, messageId: "numericStyleValue" });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|