@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -0
  3. package/dist/chunk-4UZNJ5MS.mjs +32 -0
  4. package/dist/chunk-4UZNJ5MS.mjs.map +1 -0
  5. package/dist/chunk-EBU4C5NV.mjs +77 -0
  6. package/dist/chunk-EBU4C5NV.mjs.map +1 -0
  7. package/dist/chunk-LE6SW2M3.mjs +6231 -0
  8. package/dist/chunk-LE6SW2M3.mjs.map +1 -0
  9. package/dist/chunk-MHDQJMLE.mjs +39 -0
  10. package/dist/chunk-MHDQJMLE.mjs.map +1 -0
  11. package/dist/configs/recommended.d.mts +109 -0
  12. package/dist/configs/recommended.d.ts +109 -0
  13. package/dist/configs/recommended.js +5877 -0
  14. package/dist/configs/recommended.js.map +1 -0
  15. package/dist/configs/recommended.mjs +5 -0
  16. package/dist/configs/recommended.mjs.map +1 -0
  17. package/dist/configs/typescript.d.mts +103 -0
  18. package/dist/configs/typescript.d.ts +103 -0
  19. package/dist/configs/typescript.js +6246 -0
  20. package/dist/configs/typescript.js.map +1 -0
  21. package/dist/configs/typescript.mjs +6 -0
  22. package/dist/configs/typescript.mjs.map +1 -0
  23. package/dist/configs/v2-strict.d.mts +130 -0
  24. package/dist/configs/v2-strict.d.ts +130 -0
  25. package/dist/configs/v2-strict.js +6317 -0
  26. package/dist/configs/v2-strict.js.map +1 -0
  27. package/dist/configs/v2-strict.mjs +7 -0
  28. package/dist/configs/v2-strict.mjs.map +1 -0
  29. package/dist/configs/v2.d.mts +127 -0
  30. package/dist/configs/v2.d.ts +127 -0
  31. package/dist/configs/v2.js +6289 -0
  32. package/dist/configs/v2.js.map +1 -0
  33. package/dist/configs/v2.mjs +6 -0
  34. package/dist/configs/v2.mjs.map +1 -0
  35. package/dist/index.d.mts +740 -0
  36. package/dist/index.d.ts +740 -0
  37. package/dist/index.js +6365 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/index.mjs +42 -0
  40. package/dist/index.mjs.map +1 -0
  41. package/package.json +109 -0
  42. package/src/compat.ts +53 -0
  43. package/src/configs/recommended.ts +48 -0
  44. package/src/configs/typescript.ts +21 -0
  45. package/src/configs/v2-strict.ts +28 -0
  46. package/src/configs/v2.ts +68 -0
  47. package/src/deps.d.ts +1 -0
  48. package/src/index.ts +29 -0
  49. package/src/plugin.ts +92 -0
  50. package/src/rules/components-return-once.ts +209 -0
  51. package/src/rules/event-handlers.ts +397 -0
  52. package/src/rules/imports.ts +338 -0
  53. package/src/rules/jsx-no-duplicate-props.ts +110 -0
  54. package/src/rules/jsx-no-script-url.ts +61 -0
  55. package/src/rules/jsx-no-undef.ts +230 -0
  56. package/src/rules/jsx-uses-vars.ts +66 -0
  57. package/src/rules/no-accessor-as-prop.ts +147 -0
  58. package/src/rules/no-array-handlers.ts +57 -0
  59. package/src/rules/no-boolean-enumerated-attribute.ts +209 -0
  60. package/src/rules/no-browser-globals-in-server-function.ts +108 -0
  61. package/src/rules/no-destructure.ts +245 -0
  62. package/src/rules/no-innerhtml.ts +143 -0
  63. package/src/rules/no-invalid-server-capture.ts +104 -0
  64. package/src/rules/no-module-scope-reactive-primitive.ts +69 -0
  65. package/src/rules/no-proxy-apis.ts +96 -0
  66. package/src/rules/no-react-deps.ts +68 -0
  67. package/src/rules/no-react-specific-props.ts +60 -0
  68. package/src/rules/no-reactive-read-in-effect-callback.ts +570 -0
  69. package/src/rules/no-restated-default-options.ts +96 -0
  70. package/src/rules/no-single-arg-create-effect.ts +56 -0
  71. package/src/rules/no-store-mutation-outside-setter.ts +137 -0
  72. package/src/rules/no-unknown-namespaces.ts +140 -0
  73. package/src/rules/no-unused-signal.ts +119 -0
  74. package/src/rules/no-write-in-pure-computation.ts +231 -0
  75. package/src/rules/prefer-classlist.ts +93 -0
  76. package/src/rules/prefer-for.ts +93 -0
  77. package/src/rules/prefer-onSettled-for-side-effects.ts +101 -0
  78. package/src/rules/prefer-show.ts +102 -0
  79. package/src/rules/prefer-structured-class.ts +117 -0
  80. package/src/rules/reactivity.ts +1730 -0
  81. package/src/rules/removed-api.ts +212 -0
  82. package/src/rules/require-async-server-function.ts +122 -0
  83. package/src/rules/self-closing-comp.ts +150 -0
  84. package/src/rules/style-prop.ts +140 -0
  85. package/src/rules/valid-use-server.ts +192 -0
  86. package/src/rules/validate-jsx-nesting.ts +1 -0
  87. package/src/utils/reactive-imports.ts +382 -0
  88. package/src/utils/reactive-types.ts +272 -0
  89. package/src/utils/reactive-values.ts +162 -0
  90. 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
+ });