Skip to content

Commit 587b69a

Browse files
committed
feat: add no-invalid-nesting rule
1 parent 265d068 commit 587b69a

File tree

6 files changed

+150
-13
lines changed

6 files changed

+150
-13
lines changed

.changeset/small-poets-sparkle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pandacss/eslint-plugin": patch
3+
---
4+
5+
Add `no-invalid-nesting` rule

plugin/src/configs/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default {
77
'@pandacss/no-config-function-in-source': 'error',
88
'@pandacss/no-debug': 'warn',
99
'@pandacss/no-dynamic-styling': 'warn',
10+
'@pandacss/no-invalid-nesting': 'error',
1011
'@pandacss/no-invalid-token-paths': 'error',
1112
'@pandacss/no-property-renaming': 'warn',
1213
'@pandacss/prefer-unified-property-style': 'warn',

plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import noDynamicStyling, { RULE_NAME as NoDynamicStyling } from './no-dynamic-st
55
import noEscapeHatch, { RULE_NAME as NoEscapeHatch } from './no-escape-hatch'
66
import noHardCodedColor, { RULE_NAME as NoHardCodedColor } from './no-hardcoded-color'
77
import noImportant, { RULE_NAME as NoImportant } from './no-important'
8+
import noInvalidNesting, { RULE_NAME as NoInvalidNesting } from './no-invalid-nesting'
89
import noInvalidTokenPaths, { RULE_NAME as NoInvalidTokenPaths } from './no-invalid-token-paths'
910
import noPropertyRenaming, { RULE_NAME as NoPropertyRenaming } from './no-property-renaming'
1011
import noUnsafeTokenUsage, { RULE_NAME as NoUnsafeTokenUsage } from './no-unsafe-token-fn-usage'
@@ -23,6 +24,7 @@ export const rules = {
2324
[NoHardCodedColor]: noHardCodedColor,
2425
[NoImportant]: noImportant,
2526
[NoInvalidTokenPaths]: noInvalidTokenPaths,
27+
[NoInvalidNesting]: noInvalidNesting,
2628
[NoPropertyRenaming]: noPropertyRenaming,
2729
[NoUnsafeTokenUsage]: noUnsafeTokenUsage,
2830
[PreferLonghandProperties]: preferLonghandProperties,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { isIdentifier, isLiteral, isObjectExpression, isTemplateLiteral } from '../utils/nodes'
2+
import { type Rule, createRule } from '../utils'
3+
import { isInJSXProp, isInPandaFunction } from '../utils/helpers'
4+
5+
export const RULE_NAME = 'no-invalid-nesting'
6+
7+
const rule: Rule = createRule({
8+
name: RULE_NAME,
9+
meta: {
10+
docs: {
11+
description: "Warn against invalid nesting. i.e. nested styles that don't contain the `&` character.",
12+
},
13+
messages: {
14+
nesting: 'Invalid style nesting. Nested styles must contain the `&` character.',
15+
},
16+
type: 'suggestion',
17+
schema: [],
18+
},
19+
defaultOptions: [],
20+
create(context) {
21+
return {
22+
Property(node) {
23+
if (!isObjectExpression(node.value) || isIdentifier(node.key)) return
24+
if (!isInPandaFunction(node, context) && !isInJSXProp(node, context)) return
25+
26+
const invalidLiteral =
27+
isLiteral(node.key) && typeof node.key.value === 'string' && !node.key.value.includes('&')
28+
const invalidTemplateLiteral = isTemplateLiteral(node.key) && !node.key.quasis[0].value.raw.includes('&')
29+
30+
if (invalidLiteral || invalidTemplateLiteral) {
31+
context.report({
32+
node: node.key,
33+
messageId: 'nesting',
34+
})
35+
}
36+
},
37+
}
38+
},
39+
})
40+
41+
export default rule

plugin/src/utils/helpers.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
isJSXIdentifier,
1313
isJSXMemberExpression,
1414
isJSXOpeningElement,
15+
isLiteral,
1516
isMemberExpression,
17+
isTemplateLiteral,
1618
isVariableDeclarator,
1719
type Node,
1820
} from './nodes'
@@ -161,7 +163,22 @@ export const isPandaProp = (node: TSESTree.JSXAttribute, context: RuleContext<an
161163
return true
162164
}
163165

164-
const isInPandaFunction = (node: TSESTree.Property, context: RuleContext<any, any>) => {
166+
export const isStyledProperty = (node: TSESTree.Property, context: RuleContext<any, any>, calleeName: string) => {
167+
if (!isIdentifier(node.key) && !isLiteral(node.key) && !isTemplateLiteral(node.key)) return
168+
169+
if (isIdentifier(node.key) && !isValidProperty(node.key.name, context, calleeName)) return
170+
if (
171+
isLiteral(node.key) &&
172+
typeof node.key.value === 'string' &&
173+
!isValidProperty(node.key.value, context, calleeName)
174+
)
175+
return
176+
if (isTemplateLiteral(node.key) && !isValidProperty(node.key.quasis[0].value.raw, context, calleeName)) return
177+
178+
return true
179+
}
180+
181+
export const isInPandaFunction = (node: TSESTree.Property, context: RuleContext<any, any>) => {
165182
const callAncestor = getAncestor(isCallExpression, node)
166183
if (!callAncestor) return
167184

@@ -180,20 +197,10 @@ const isInPandaFunction = (node: TSESTree.Property, context: RuleContext<any, an
180197
if (!calleeName) return
181198
if (!isPandaIsh(calleeName, context)) return
182199

183-
// Ensure attribute is a styled attribute
184-
if (!isIdentifier(node.key)) return
185-
const attr = node.key.name
186-
if (!isValidProperty(attr, context, calleeName)) return
187-
188-
return true
200+
return calleeName
189201
}
190202

191-
export const isPandaAttribute = (node: TSESTree.Property, context: RuleContext<any, any>) => {
192-
const callAncestor = getAncestor(isCallExpression, node)
193-
194-
if (callAncestor) return isInPandaFunction(node, context)
195-
196-
// Object could be in JSX prop value i.e css prop or a pseudo
203+
export const isInJSXProp = (node: TSESTree.Property, context: RuleContext<any, any>) => {
197204
const jsxExprAncestor = getAncestor(isJSXExpressionContainer, node)
198205
const jsxAttrAncestor = getAncestor(isJSXAttribute, node)
199206

@@ -204,6 +211,19 @@ export const isPandaAttribute = (node: TSESTree.Property, context: RuleContext<a
204211
return true
205212
}
206213

214+
export const isPandaAttribute = (node: TSESTree.Property, context: RuleContext<any, any>) => {
215+
const callAncestor = getAncestor(isCallExpression, node)
216+
217+
if (callAncestor) {
218+
const callee = isInPandaFunction(node, context)
219+
if (!callee) return
220+
return isStyledProperty(node, context, callee)
221+
}
222+
223+
// Object could be in JSX prop value i.e css prop or a pseudo
224+
return isInJSXProp(node, context)
225+
}
226+
207227
export const resolveLonghand = (name: string, context: RuleContext<any, any>) => {
208228
return syncAction('resolveLongHand', getSyncOpts(context), name)
209229
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { tester } from '../test-utils'
2+
import rule, { RULE_NAME } from '../src/rules/no-invalid-nesting'
3+
4+
const javascript = String.raw
5+
6+
const valids = [
7+
{
8+
code: javascript`
9+
import { css } from './panda/css';
10+
11+
const styles = css({ '&:hover': { marginLeft: '4px' } })`,
12+
},
13+
14+
{
15+
code: javascript`
16+
import { css } from './panda/css';
17+
18+
function App(){
19+
return <div className={css({ '.dark &': { background: 'red.100' } })} />;
20+
}`,
21+
},
22+
23+
{
24+
code: javascript`
25+
import { Circle } from './panda/jsx';
26+
27+
function App(){
28+
return <Circle css={{ '&[data-focus]': { position: 'absolute' } }} />;
29+
}`,
30+
},
31+
]
32+
33+
const invalids = [
34+
{
35+
code: javascript`
36+
import { css } from './panda/css';
37+
38+
const styles = css({ ':hover': { marginLeft: '4px' } })`,
39+
},
40+
41+
{
42+
code: javascript`
43+
import { css } from './panda/css';
44+
45+
function App(){
46+
return <div className={css({ '.dark ': { background: 'red.100' } })} />;
47+
}`,
48+
},
49+
50+
{
51+
code: javascript`
52+
import { Circle } from './panda/jsx';
53+
54+
function App(){
55+
return <Circle css={{ '[data-focus]': { position: 'absolute' } }} />;
56+
}`,
57+
},
58+
]
59+
60+
tester.run(RULE_NAME, rule, {
61+
valid: valids.map(({ code }) => ({
62+
code,
63+
})),
64+
invalid: invalids.map(({ code }) => ({
65+
code,
66+
errors: 1,
67+
})),
68+
})

0 commit comments

Comments
 (0)