Skip to content

Commit be1fda2

Browse files
committed
add eslint compatibility
1 parent 0ce4a9b commit be1fda2

11 files changed

Lines changed: 628 additions & 19 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,54 @@ function TestComponent(_props) {
202202
```bash
203203
bun test
204204
```
205+
206+
## ESLint Integration
207+
208+
Since `eslint-plugin-solid`'s `solid/reactivity` rule doesn't know about this plugin, it will flag destructured props as non-reactive. The bundled ESLint processor fixes this by teaching the rule about destructured props.
209+
210+
### Setup
211+
212+
Install `eslint-plugin-solid`:
213+
214+
```bash
215+
bun add -D eslint-plugin-solid
216+
```
217+
218+
Add the processor to your ESLint config:
219+
220+
```js
221+
// eslint.config.js
222+
import solid from 'eslint-plugin-solid'
223+
import solidUndestructure from 'vite-plugin-solid-undestructure/eslint'
224+
225+
export default [
226+
solidUndestructure.configs.recommended,
227+
solid.configs['flat/typescript'],
228+
{
229+
rules: {
230+
'solid/no-destructure': 'off'
231+
}
232+
}
233+
]
234+
```
235+
236+
### How it works
237+
238+
The processor transparently rewrites destructured props into `props.X` member expressions before the linter runs, so the existing `solid/reactivity` rule can correctly identify untracked reactive usages. Error messages are adjusted to reference the original destructured name.
239+
240+
```tsx
241+
// Without the processor, solid/reactivity ignores `size` (not recognized as reactive)
242+
// With the processor, it correctly warns:
243+
function ExampleComponent({ size }: { size: 'sm' | 'lg' }) {
244+
const dimensions =
245+
// ↓ The reactive variable 'size' should be used within JSX, a tracked scope
246+
// (like createEffect), or inside an event handler. [solid/reactivity]
247+
size === 'sm' ? { width: 4, height: 4 } : { width: 8, height: 8 }
248+
249+
// Correct usages that don't cause warnings:
250+
// const dimensions = () => size === 'sm' ? ...
251+
// const dimensions = createMemo(() => size === 'sm' ? ...)
252+
253+
return <img src="..." alt="..." {...dimensions()} />
254+
}
255+
```

package.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
{
22
"name": "vite-plugin-solid-undestructure",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Automatically transforms props destructuring in SolidJS components",
55
"type": "module",
66
"main": "./dist/index.js",
77
"module": "./dist/index.js",
88
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"import": "./dist/index.js",
12+
"types": "./dist/index.d.ts"
13+
},
14+
"./eslint": {
15+
"import": "./dist/eslint/index.js",
16+
"types": "./dist/eslint/index.d.ts"
17+
}
18+
},
919
"files": [
1020
"dist"
1121
],
@@ -21,12 +31,13 @@
2131
],
2232
"license": "MIT",
2333
"scripts": {
24-
"build": "bun build src --minify --packages=external --outfile=dist/index.js && tsc --declaration --emitDeclarationOnly --outDir dist",
34+
"build": "bun build src/index.ts --minify --packages=external --outfile=dist/index.js && bun build src/eslint/index.ts --minify --packages=external --outfile=dist/eslint/index.js && tsc --declaration --emitDeclarationOnly --outDir dist",
2535
"type": "tsc --noEmit",
2636
"lint": "eslint --cache src",
2737
"format": "prettier --write *",
2838
"test": "bun test tests",
29-
"check:format": "prettier --check *"
39+
"check:format": "prettier --check *",
40+
"pre": "bun type && bun lint && bun format"
3041
},
3142
"dependencies": {
3243
"@babel/generator": "^7.29.1",
@@ -47,7 +58,16 @@
4758
"typescript-eslint": "^8.56.1"
4859
},
4960
"peerDependencies": {
61+
"eslint": "^9.0.0",
5062
"eslint-plugin-solid": "^0.14.5",
5163
"vite": "^7.3.1"
64+
},
65+
"peerDependenciesMeta": {
66+
"eslint": {
67+
"optional": true
68+
},
69+
"eslint-plugin-solid": {
70+
"optional": true
71+
}
5272
}
5373
}

src/eslint/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { processor } from './modules/processor'
2+
3+
const plugin = {
4+
meta: {
5+
name: 'eslint-plugin-solid-undestructure',
6+
version: '0.1.1'
7+
},
8+
processors: {
9+
'solid-undestructure': processor
10+
},
11+
configs: {} as Record<string, { plugins: Record<string, unknown>; processor: string }>
12+
}
13+
14+
plugin.configs['recommended'] = {
15+
plugins: {
16+
'solid-undestructure': plugin
17+
},
18+
processor: 'solid-undestructure/solid-undestructure'
19+
}
20+
21+
export default plugin

src/eslint/modules/processor.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { transformForLinting, TransformResult } from './transform'
2+
3+
// Store transform metadata per file for postprocess to use
4+
const transformCache = new Map<string, TransformResult>()
5+
6+
export const processor = {
7+
meta: {
8+
name: 'solid-undestructure',
9+
version: '0.1.1'
10+
},
11+
12+
preprocess(text: string, filename: string) {
13+
// Only process files that could contain JSX components
14+
if (!/\.(tsx?|jsx?)$/.test(filename)) {
15+
return [text]
16+
}
17+
18+
const result = transformForLinting(text)
19+
if (!result) {
20+
transformCache.delete(filename)
21+
return [text]
22+
}
23+
24+
transformCache.set(filename, result)
25+
return [result.code]
26+
},
27+
28+
postprocess(messages: { ruleId: string | null; message: string }[][], filename: string) {
29+
const result = transformCache.get(filename)
30+
if (!result) {
31+
return messages[0]
32+
}
33+
34+
transformCache.delete(filename)
35+
36+
// Build a regex to replace 'props.X' references in messages with the original local name
37+
const { propMappings } = result
38+
if (propMappings.size === 0) {
39+
return messages[0]
40+
}
41+
42+
// Create reverse mapping: propKey → localName
43+
const keyToLocal = new Map<string, string>()
44+
for (const [localName, propKey] of propMappings) {
45+
keyToLocal.set(propKey, localName)
46+
}
47+
48+
return messages[0].map((msg) => {
49+
let { message } = msg
50+
// Replace 'props.X' with 'X' (the original destructured name) in error messages
51+
for (const [localName, propKey] of propMappings) {
52+
// Handle both 'props.X' (top-level) and 'props.a.b' (nested) patterns
53+
const propsAccess = propKey.includes('.') ? `props.${propKey}` : `props.${propKey}`
54+
message = message.replaceAll(`'${propsAccess}'`, `'${localName}'`)
55+
}
56+
return { ...msg, message }
57+
})
58+
},
59+
60+
supportsAutofix: false as const
61+
}

src/eslint/modules/transform.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { checkIfComponent } from '@/modules/component-detector'
2+
import generateImport from '@babel/generator'
3+
import { parse } from '@babel/parser'
4+
import traverseImport, { NodePath, Visitor } from '@babel/traverse'
5+
import * as t from '@babel/types'
6+
7+
// Handle ESM/CJS interop
8+
const traverse =
9+
(traverseImport as unknown as { default?: typeof traverseImport }).default ?? traverseImport
10+
const generate =
11+
(generateImport as unknown as { default?: typeof generateImport }).default ?? generateImport
12+
13+
export type TransformResult = {
14+
code: string
15+
/** Maps original local name → prop key (e.g. "localName" → "propKey") */
16+
propMappings: Map<string, string>
17+
}
18+
19+
/**
20+
* Transforms destructured component props into `props.X` member expressions
21+
* for linting purposes. This is a minimal transformation — no mergeProps/splitProps/imports
22+
* are added, only the patterns the eslint-plugin-solid reactivity rule needs to see.
23+
*/
24+
export function transformForLinting(code: string): TransformResult | null {
25+
// Quick check for destructuring pattern
26+
if (!/\(\s*\{/.test(code)) {
27+
return null
28+
}
29+
30+
const propMappings = new Map<string, string>()
31+
32+
let transformed = false
33+
34+
try {
35+
const ast = parse(code, {
36+
sourceType: 'module',
37+
plugins: ['typescript', 'jsx']
38+
})
39+
40+
const astNode = ast as unknown as t.Node
41+
traverse(astNode, {
42+
Function(path: NodePath<t.Function>) {
43+
const params = path.node.params
44+
if (params.length !== 1) return
45+
46+
const firstParam = params[0]
47+
if (!t.isObjectPattern(firstParam)) return
48+
49+
if (!checkIfComponent(path)) return
50+
51+
transformDestructuredProps(path, firstParam, propMappings)
52+
transformed = true
53+
}
54+
})
55+
56+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
57+
if (!transformed) {
58+
return null
59+
}
60+
61+
const output = generate(astNode, {
62+
retainLines: true,
63+
compact: false
64+
})
65+
66+
return { code: output.code, propMappings }
67+
} catch (error) {
68+
console.warn('Failed to transform:', error)
69+
return null
70+
}
71+
}
72+
73+
function transformDestructuredProps(
74+
path: NodePath<t.Function>,
75+
objectPattern: t.ObjectPattern,
76+
propMappings: Map<string, string>
77+
) {
78+
const propsIdentifier = t.identifier('props')
79+
80+
// Preserve TypeAnnotation from the destructured param
81+
if (objectPattern.typeAnnotation) {
82+
propsIdentifier.typeAnnotation = objectPattern.typeAnnotation
83+
}
84+
85+
// Extract prop names and build mappings
86+
const localNames: string[] = []
87+
const localToKey = new Map<string, string>()
88+
const nestedPropPaths = new Map<string, string[]>()
89+
90+
function processObjectPattern(pattern: t.ObjectPattern, parentPath: string[] = []) {
91+
for (const prop of pattern.properties) {
92+
if (t.isRestElement(prop)) {
93+
// Rest elements are not reactive — leave as-is
94+
continue
95+
}
96+
97+
if (!t.isObjectProperty(prop)) continue
98+
99+
let key: string | null = null
100+
if (t.isIdentifier(prop.key)) {
101+
key = prop.key.name
102+
} else if (t.isStringLiteral(prop.key)) {
103+
key = prop.key.value
104+
}
105+
if (!key) continue
106+
107+
const currentPath = [...parentPath, key]
108+
109+
// Handle nested object patterns
110+
if (t.isObjectPattern(prop.value)) {
111+
processObjectPattern(prop.value, currentPath)
112+
continue
113+
}
114+
115+
// Extract local name
116+
let localName: string | null = null
117+
if (t.isIdentifier(prop.value)) {
118+
localName = prop.value.name
119+
} else if (t.isAssignmentPattern(prop.value) && t.isIdentifier(prop.value.left)) {
120+
localName = prop.value.left.name
121+
}
122+
123+
if (!localName) continue
124+
125+
if (parentPath.length === 0) {
126+
localNames.push(localName)
127+
localToKey.set(localName, key)
128+
propMappings.set(localName, key)
129+
} else {
130+
nestedPropPaths.set(localName, currentPath)
131+
propMappings.set(localName, currentPath.join('.'))
132+
}
133+
}
134+
}
135+
136+
processObjectPattern(objectPattern)
137+
138+
// Replace parameter with `props` identifier
139+
path.node.params[0] = propsIdentifier
140+
141+
// Replace all references to destructured prop names with props.X
142+
const bodyPath = path.get('body')
143+
if (Array.isArray(bodyPath)) return
144+
145+
const visitor: Visitor = {
146+
Identifier(identPath) {
147+
const parent = identPath.parent
148+
// Skip property keys and computed member expression properties
149+
if (
150+
(t.isMemberExpression(parent) && parent.property === identPath.node && !parent.computed) ||
151+
(t.isObjectProperty(parent) && parent.key === identPath.node && !parent.computed)
152+
) {
153+
return
154+
}
155+
156+
// Skip binding positions (declarations)
157+
if (identPath.isBindingIdentifier()) return
158+
159+
const idPath = identPath as NodePath<t.Identifier>
160+
const name = idPath.node.name
161+
162+
// Handle nested property paths
163+
const propPath = nestedPropPaths.get(name)
164+
if (propPath) {
165+
let memberExpr: t.MemberExpression = t.memberExpression(
166+
t.identifier('props'),
167+
t.identifier(propPath[0])
168+
)
169+
for (let i = 1; i < propPath.length; i++) {
170+
memberExpr = t.memberExpression(memberExpr, t.identifier(propPath[i]))
171+
}
172+
idPath.replaceWith(memberExpr)
173+
} else if (localNames.includes(name)) {
174+
const propKey = localToKey.get(name) ?? name
175+
idPath.replaceWith(t.memberExpression(t.identifier('props'), t.identifier(propKey)))
176+
}
177+
}
178+
}
179+
bodyPath.traverse(visitor)
180+
}

0 commit comments

Comments
 (0)