@murky-web/oxlint-plugin-solid 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+
3
+ import { getSourceCode } from "../compat.mjs";
4
+ import { isDOMElementName } from "../utils.mjs";
5
+ const createRule = ESLintUtils.RuleCreator.withoutDocs;
6
+ function isComponent(node) {
7
+ return (
8
+ (node.name.type === "JSXIdentifier" &&
9
+ !isDOMElementName(node.name.name)) ||
10
+ node.name.type === "JSXMemberExpression"
11
+ );
12
+ }
13
+ const voidDOMElementRegex =
14
+ /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
15
+ function isVoidDOMElementName(name) {
16
+ return voidDOMElementRegex.test(name);
17
+ }
18
+ function childrenIsEmpty(node) {
19
+ return node.parent.children.length === 0;
20
+ }
21
+ function childrenIsMultilineSpaces(node) {
22
+ const childrens = node.parent.children;
23
+ return (
24
+ childrens.length === 1 &&
25
+ childrens[0].type === "JSXText" &&
26
+ childrens[0].value.indexOf("\n") !== -1 &&
27
+ childrens[0].value.replace(/(?!\xA0)\s/g, "") === ""
28
+ );
29
+ }
30
+ /**
31
+ * This rule is adapted from eslint-plugin-react's self-closing-comp rule under the MIT license,
32
+ * with some enhancements. Thank you for your work!
33
+ */
34
+ export default createRule({
35
+ meta: {
36
+ type: "layout",
37
+ docs: {
38
+ description:
39
+ "Disallow extra closing tags for components without children.",
40
+ url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/self-closing-comp.md",
41
+ },
42
+ fixable: "code",
43
+ schema: [
44
+ {
45
+ type: "object",
46
+ properties: {
47
+ component: {
48
+ type: "string",
49
+ description:
50
+ "which Solid components should be self-closing when possible",
51
+ enum: ["all", "none"],
52
+ default: "all",
53
+ },
54
+ html: {
55
+ type: "string",
56
+ description:
57
+ "which native elements should be self-closing when possible",
58
+ enum: ["all", "void", "none"],
59
+ default: "all",
60
+ },
61
+ },
62
+ additionalProperties: false,
63
+ },
64
+ ],
65
+ messages: {
66
+ selfClose: "Empty components are self-closing.",
67
+ dontSelfClose: "This element should not be self-closing.",
68
+ },
69
+ },
70
+ defaultOptions: [],
71
+ create(context) {
72
+ function shouldBeSelfClosedWhenPossible(node) {
73
+ if (isComponent(node)) {
74
+ const whichComponents = context.options[0]?.component ?? "all";
75
+ return whichComponents === "all";
76
+ } else if (
77
+ node.name.type === "JSXIdentifier" &&
78
+ isDOMElementName(node.name.name)
79
+ ) {
80
+ const whichComponents = context.options[0]?.html ?? "all";
81
+ switch (whichComponents) {
82
+ case "all":
83
+ return true;
84
+ case "void":
85
+ return isVoidDOMElementName(node.name.name);
86
+ case "none":
87
+ return false;
88
+ }
89
+ }
90
+ return true; // shouldn't encounter
91
+ }
92
+ return {
93
+ JSXOpeningElement(node) {
94
+ const canSelfClose =
95
+ childrenIsEmpty(node) || childrenIsMultilineSpaces(node);
96
+ if (canSelfClose) {
97
+ const shouldSelfClose =
98
+ shouldBeSelfClosedWhenPossible(node);
99
+ if (shouldSelfClose && !node.selfClosing) {
100
+ context.report({
101
+ node,
102
+ messageId: "selfClose",
103
+ fix(fixer) {
104
+ // Represents the last character of the JSXOpeningElement, the '>' character
105
+ const openingElementEnding = node.range[1] - 1;
106
+ // Represents the last character of the JSXClosingElement, the '>' character
107
+ const closingElementEnding =
108
+ node.parent.closingElement.range[1];
109
+ // Replace />.*<\/.*>/ with '/>'
110
+ const range = [
111
+ openingElementEnding,
112
+ closingElementEnding,
113
+ ];
114
+ return fixer.replaceTextRange(range, " />");
115
+ },
116
+ });
117
+ } else if (!shouldSelfClose && node.selfClosing) {
118
+ context.report({
119
+ node,
120
+ messageId: "dontSelfClose",
121
+ fix(fixer) {
122
+ const sourceCode = getSourceCode(context);
123
+ const tagName = sourceCode.getText(node.name);
124
+ // Represents the last character of the JSXOpeningElement, the '>' character
125
+ const selfCloseEnding = node.range[1];
126
+ // Replace ' />' or '/>' with '></${tagName}>'
127
+ const lastTokens = sourceCode.getLastTokens(
128
+ node,
129
+ { count: 3 },
130
+ ); // JSXIdentifier, '/', '>'
131
+ const isSpaceBeforeSelfClose =
132
+ sourceCode.isSpaceBetween?.(
133
+ lastTokens[0],
134
+ lastTokens[1],
135
+ );
136
+ const range = [
137
+ isSpaceBeforeSelfClose
138
+ ? selfCloseEnding - 3
139
+ : selfCloseEnding - 2,
140
+ selfCloseEnding,
141
+ ];
142
+ return fixer.replaceTextRange(
143
+ range,
144
+ `></${tagName}>`,
145
+ );
146
+ },
147
+ });
148
+ }
149
+ }
150
+ },
151
+ };
152
+ },
153
+ });
@@ -0,0 +1,155 @@
1
+ import { ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
2
+ import kebabCase from "kebab-case";
3
+ import { all as allCssProperties } from "known-css-properties";
4
+ import parse from "style-to-object";
5
+
6
+ import { getScope } from "../compat.mjs";
7
+ import { jsxPropName } from "../utils.mjs";
8
+ const createRule = ESLintUtils.RuleCreator.withoutDocs;
9
+ const { getPropertyName, getStaticValue } = ASTUtils;
10
+ const lengthPercentageRegex =
11
+ /\b(?:width|height|margin|padding|border-width|font-size)\b/i;
12
+ export default createRule({
13
+ meta: {
14
+ type: "problem",
15
+ docs: {
16
+ description:
17
+ "Require CSS properties in the `style` prop to be valid and kebab-cased (ex. 'font-size'), not camel-cased (ex. 'fontSize') like in React, " +
18
+ "and that property values with dimensions are strings, not numbers with implicit 'px' units.",
19
+ url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/style-prop.md",
20
+ },
21
+ fixable: "code",
22
+ schema: [
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ styleProps: {
27
+ description:
28
+ "an array of prop names to treat as a CSS style object",
29
+ default: ["style"],
30
+ type: "array",
31
+ items: {
32
+ type: "string",
33
+ },
34
+ minItems: 1,
35
+ uniqueItems: true,
36
+ },
37
+ allowString: {
38
+ description:
39
+ "if allowString is set to true, this rule will not convert a style string literal into a style object (not recommended for performance)",
40
+ type: "boolean",
41
+ default: false,
42
+ },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ ],
47
+ messages: {
48
+ kebabStyleProp: "Use {{ kebabName }} instead of {{ name }}.",
49
+ invalidStyleProp: "{{ name }} is not a valid CSS property.",
50
+ numericStyleValue:
51
+ 'This CSS property value should be a string with a unit; Solid does not automatically append a "px" unit.',
52
+ stringStyle:
53
+ "Use an object for the style prop instead of a string.",
54
+ },
55
+ },
56
+ defaultOptions: [],
57
+ create(context) {
58
+ const allCssPropertiesSet = new Set(allCssProperties);
59
+ const allowString = Boolean(context.options[0]?.allowString);
60
+ const styleProps = context.options[0]?.styleProps || ["style"];
61
+ return {
62
+ JSXAttribute(node) {
63
+ if (styleProps.indexOf(jsxPropName(node)) === -1) {
64
+ return;
65
+ }
66
+ const style =
67
+ node.value?.type === "JSXExpressionContainer"
68
+ ? node.value.expression
69
+ : node.value;
70
+ if (!style) {
71
+ return;
72
+ } else if (
73
+ style.type === "Literal" &&
74
+ typeof style.value === "string" &&
75
+ !allowString
76
+ ) {
77
+ // Convert style="font-size: 10px" to style={{ "font-size": "10px" }}
78
+ let objectStyles;
79
+ try {
80
+ objectStyles = parse(style.value) ?? undefined;
81
+ } catch {
82
+ // no-op
83
+ }
84
+ context.report({
85
+ node: style,
86
+ messageId: "stringStyle",
87
+ // replace full prop value, wrap in JSXExpressionContainer, more fixes may be applied below
88
+ fix:
89
+ objectStyles &&
90
+ ((fixer) =>
91
+ fixer.replaceText(
92
+ node.value,
93
+ `{${JSON.stringify(objectStyles)}}`,
94
+ )),
95
+ });
96
+ } else if (style.type === "TemplateLiteral" && !allowString) {
97
+ context.report({
98
+ node: style,
99
+ messageId: "stringStyle",
100
+ });
101
+ } else if (style.type === "ObjectExpression") {
102
+ const properties = style.properties.filter(
103
+ (prop) => prop.type === "Property",
104
+ );
105
+ properties.forEach((prop) => {
106
+ const name = getPropertyName(
107
+ prop,
108
+ getScope(context, prop),
109
+ );
110
+ if (
111
+ name &&
112
+ !name.startsWith("--") &&
113
+ !allCssPropertiesSet.has(name)
114
+ ) {
115
+ const kebabName = kebabCase(name);
116
+ if (allCssPropertiesSet.has(kebabName)) {
117
+ // if it's not valid simply because it's camelCased instead of kebab-cased, provide a fix
118
+ context.report({
119
+ node: prop.key,
120
+ messageId: "kebabStyleProp",
121
+ data: { name, kebabName },
122
+ fix: (fixer) =>
123
+ fixer.replaceText(
124
+ prop.key,
125
+ `"${kebabName}"`,
126
+ ), // wrap kebab name in quotes to be a valid object key
127
+ });
128
+ } else {
129
+ context.report({
130
+ node: prop.key,
131
+ messageId: "invalidStyleProp",
132
+ data: { name },
133
+ });
134
+ }
135
+ } else if (
136
+ !name ||
137
+ (!name.startsWith("--") &&
138
+ lengthPercentageRegex.test(name))
139
+ ) {
140
+ // catches numeric values (ex. { "font-size": 12 }) for common <length-percentage> peroperties
141
+ // and suggests quoting or appending 'px'
142
+ const value = getStaticValue(prop.value)?.value;
143
+ if (typeof value === "number" && value !== 0) {
144
+ context.report({
145
+ node: prop.value,
146
+ messageId: "numericStyleValue",
147
+ });
148
+ }
149
+ }
150
+ });
151
+ }
152
+ },
153
+ };
154
+ },
155
+ });
@@ -0,0 +1,16 @@
1
+ export default {
2
+ defaultOptions: [],
3
+ meta: {
4
+ docs: {
5
+ description:
6
+ "Placeholder for the upstream validate-jsx-nesting rule, which currently has no implementation in eslint-plugin-solid.",
7
+ url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/src/rules/validate-jsx-nesting.ts",
8
+ },
9
+ messages: {},
10
+ schema: [],
11
+ type: "problem",
12
+ },
13
+ create() {
14
+ return {};
15
+ },
16
+ };
package/src/utils.mjs ADDED
@@ -0,0 +1,337 @@
1
+ import { findVariable } from "./compat.mjs";
2
+
3
+ const DOM_ELEMENT_REGEX = /^[a-z]/;
4
+ const PROPS_REGEX = /[pP]rops/;
5
+ const FUNCTION_TYPES = [
6
+ "FunctionExpression",
7
+ "ArrowFunctionExpression",
8
+ "FunctionDeclaration",
9
+ ];
10
+ const PROGRAM_OR_FUNCTION_TYPES = ["Program", ...FUNCTION_TYPES];
11
+
12
+ export function isDOMElementName(name) {
13
+ return DOM_ELEMENT_REGEX.test(name);
14
+ }
15
+
16
+ export function isPropsByName(name) {
17
+ return PROPS_REGEX.test(name);
18
+ }
19
+
20
+ export function formatList(strings) {
21
+ if (strings.length === 0) {
22
+ return "";
23
+ }
24
+
25
+ if (strings.length === 1) {
26
+ return `'${strings[0]}'`;
27
+ }
28
+
29
+ if (strings.length === 2) {
30
+ return `'${strings[0]}' and '${strings[1]}'`;
31
+ }
32
+
33
+ const last = strings.length - 1;
34
+ return `${strings
35
+ .slice(0, last)
36
+ .map((stringValue) => {
37
+ return `'${stringValue}'`;
38
+ })
39
+ .join(", ")}, and '${strings[last]}'`;
40
+ }
41
+
42
+ export function find(node, predicate) {
43
+ let currentNode = node;
44
+
45
+ while (currentNode) {
46
+ if (predicate(currentNode)) {
47
+ return currentNode;
48
+ }
49
+
50
+ currentNode = currentNode.parent;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ export function findParent(node, predicate) {
57
+ return node.parent ? find(node.parent, predicate) : null;
58
+ }
59
+
60
+ export function trace(node, context) {
61
+ if (node.type !== "Identifier") {
62
+ return node;
63
+ }
64
+
65
+ const variable = findVariable(context, node);
66
+ if (!variable) {
67
+ return node;
68
+ }
69
+
70
+ const definition = variable.defs[0];
71
+ switch (definition?.type) {
72
+ case "FunctionName":
73
+ case "ClassName":
74
+ case "ImportBinding":
75
+ return definition.node;
76
+ case "Variable":
77
+ if (
78
+ (definition.node.parent.kind === "const" ||
79
+ variable.references.every((reference) => {
80
+ return reference.init || reference.isReadOnly();
81
+ })) &&
82
+ definition.node.id.type === "Identifier" &&
83
+ definition.node.init
84
+ ) {
85
+ return trace(definition.node.init, context);
86
+ }
87
+ break;
88
+ default:
89
+ break;
90
+ }
91
+
92
+ return node;
93
+ }
94
+
95
+ export function ignoreTransparentWrappers(node, up = false) {
96
+ if (
97
+ node.type === "TSAsExpression" ||
98
+ node.type === "TSNonNullExpression" ||
99
+ node.type === "TSSatisfiesExpression"
100
+ ) {
101
+ const nextNode = up ? node.parent : node.expression;
102
+ if (nextNode) {
103
+ return ignoreTransparentWrappers(nextNode, up);
104
+ }
105
+ }
106
+
107
+ return node;
108
+ }
109
+
110
+ export function isFunctionNode(node) {
111
+ return Boolean(node) && FUNCTION_TYPES.includes(node.type);
112
+ }
113
+
114
+ export function isProgramOrFunctionNode(node) {
115
+ return Boolean(node) && PROGRAM_OR_FUNCTION_TYPES.includes(node.type);
116
+ }
117
+
118
+ export function isJSXElementOrFragment(node) {
119
+ return node?.type === "JSXElement" || node?.type === "JSXFragment";
120
+ }
121
+
122
+ export function getFunctionName(node) {
123
+ if (
124
+ (node.type === "FunctionDeclaration" ||
125
+ node.type === "FunctionExpression") &&
126
+ node.id != null
127
+ ) {
128
+ return node.id.name;
129
+ }
130
+
131
+ if (
132
+ node.parent?.type === "VariableDeclarator" &&
133
+ node.parent.id.type === "Identifier"
134
+ ) {
135
+ return node.parent.id.name;
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ export function findInScope(node, scope, predicate) {
142
+ const foundNode = find(node, (innerNode) => {
143
+ return innerNode === scope || predicate(innerNode);
144
+ });
145
+
146
+ return foundNode === scope && !predicate(node) ? null : foundNode;
147
+ }
148
+
149
+ export function getCommentBefore(node, sourceCode) {
150
+ return sourceCode.getCommentsBefore(node).find((comment) => {
151
+ return comment.loc.end.line >= node.loc.start.line - 1;
152
+ });
153
+ }
154
+
155
+ export function getCommentAfter(node, sourceCode) {
156
+ return sourceCode.getCommentsAfter(node).find((comment) => {
157
+ return comment.loc.start.line === node.loc.end.line;
158
+ });
159
+ }
160
+
161
+ export function trackImports(fromModule = /^solid-js(?:\/?|\b)/) {
162
+ const importMap = new Map();
163
+
164
+ function handleImportDeclaration(node) {
165
+ if (!fromModule.test(node.source.value)) {
166
+ return;
167
+ }
168
+
169
+ for (const specifier of node.specifiers) {
170
+ if (specifier.type === "ImportSpecifier") {
171
+ importMap.set(specifier.imported.name, specifier.local.name);
172
+ }
173
+ }
174
+ }
175
+
176
+ function matchImport(imports, value) {
177
+ const importList = Array.isArray(imports) ? imports : [imports];
178
+ return importList.find((importName) => {
179
+ return importMap.get(importName) === value;
180
+ });
181
+ }
182
+
183
+ return {
184
+ handleImportDeclaration,
185
+ matchImport,
186
+ };
187
+ }
188
+
189
+ export function appendImports(fixer, sourceCode, importNode, identifiers) {
190
+ const identifiersString = identifiers.join(", ");
191
+ const namedSpecifier = importNode.specifiers
192
+ .slice()
193
+ .reverse()
194
+ .find((specifier) => {
195
+ return specifier.type === "ImportSpecifier";
196
+ });
197
+
198
+ if (namedSpecifier) {
199
+ return fixer.insertTextAfter(namedSpecifier, `, ${identifiersString}`);
200
+ }
201
+
202
+ const otherSpecifier = importNode.specifiers.find((specifier) => {
203
+ return (
204
+ specifier.type === "ImportDefaultSpecifier" ||
205
+ specifier.type === "ImportNamespaceSpecifier"
206
+ );
207
+ });
208
+
209
+ if (otherSpecifier) {
210
+ return fixer.insertTextAfter(
211
+ otherSpecifier,
212
+ `, { ${identifiersString} }`,
213
+ );
214
+ }
215
+
216
+ if (importNode.specifiers.length === 0) {
217
+ const [importToken, maybeBrace] = sourceCode.getFirstTokens(
218
+ importNode,
219
+ {
220
+ count: 2,
221
+ },
222
+ );
223
+
224
+ if (maybeBrace?.value === "{") {
225
+ return fixer.insertTextAfter(maybeBrace, ` ${identifiersString} `);
226
+ }
227
+
228
+ return importToken
229
+ ? fixer.insertTextAfter(
230
+ importToken,
231
+ ` { ${identifiersString} } from`,
232
+ )
233
+ : null;
234
+ }
235
+
236
+ return null;
237
+ }
238
+
239
+ export function insertImports(
240
+ fixer,
241
+ sourceCode,
242
+ source,
243
+ identifiers,
244
+ aboveImport,
245
+ isType = false,
246
+ ) {
247
+ const identifiersString = identifiers.join(", ");
248
+ const programNode = sourceCode.ast;
249
+ const firstImport =
250
+ aboveImport ||
251
+ programNode.body.find((node) => {
252
+ return node.type === "ImportDeclaration";
253
+ });
254
+
255
+ if (firstImport) {
256
+ return fixer.insertTextBeforeRange(
257
+ (getCommentBefore(firstImport, sourceCode) ?? firstImport).range,
258
+ `import ${isType ? "type " : ""}{ ${identifiersString} } from "${source}";\n`,
259
+ );
260
+ }
261
+
262
+ return fixer.insertTextBeforeRange(
263
+ [0, 0],
264
+ `import ${isType ? "type " : ""}{ ${identifiersString} } from "${source}";\n`,
265
+ );
266
+ }
267
+
268
+ export function removeSpecifier(fixer, sourceCode, specifier, pure = true) {
269
+ const declaration = specifier.parent;
270
+ if (declaration.specifiers.length === 1 && pure) {
271
+ return fixer.remove(declaration);
272
+ }
273
+
274
+ const maybeComma = sourceCode.getTokenAfter(specifier);
275
+ if (maybeComma?.value === ",") {
276
+ return fixer.removeRange([specifier.range[0], maybeComma.range[1]]);
277
+ }
278
+
279
+ return fixer.remove(specifier);
280
+ }
281
+
282
+ export function jsxPropName(prop) {
283
+ if (prop.name.type === "JSXNamespacedName") {
284
+ return `${prop.name.namespace.name}:${prop.name.name.name}`;
285
+ }
286
+
287
+ return prop.name.name;
288
+ }
289
+
290
+ export function* jsxGetAllProps(props) {
291
+ for (const attribute of props) {
292
+ if (
293
+ attribute.type === "JSXSpreadAttribute" &&
294
+ attribute.argument.type === "ObjectExpression"
295
+ ) {
296
+ for (const property of attribute.argument.properties) {
297
+ if (property.type !== "Property") {
298
+ continue;
299
+ }
300
+
301
+ if (property.key.type === "Identifier") {
302
+ yield [property.key.name, property.key];
303
+ continue;
304
+ }
305
+
306
+ if (property.key.type === "Literal") {
307
+ yield [String(property.key.value), property.key];
308
+ }
309
+ }
310
+
311
+ continue;
312
+ }
313
+
314
+ if (attribute.type === "JSXAttribute") {
315
+ yield [jsxPropName(attribute), attribute.name];
316
+ }
317
+ }
318
+ }
319
+
320
+ export function jsxHasProp(props, prop) {
321
+ for (const [propName] of jsxGetAllProps(props)) {
322
+ if (propName === prop) {
323
+ return true;
324
+ }
325
+ }
326
+
327
+ return false;
328
+ }
329
+
330
+ export function jsxGetProp(props, prop) {
331
+ return props.find((attribute) => {
332
+ return (
333
+ attribute.type !== "JSXSpreadAttribute" &&
334
+ prop === jsxPropName(attribute)
335
+ );
336
+ });
337
+ }