diff --git a/.changeset/small-poets-sparkle.md b/.changeset/small-poets-sparkle.md new file mode 100644 index 0000000..a08014c --- /dev/null +++ b/.changeset/small-poets-sparkle.md @@ -0,0 +1,5 @@ +--- +"@pandacss/eslint-plugin": patch +--- + +Add `no-invalid-nesting` rule diff --git a/plugin/src/configs/recommended.ts b/plugin/src/configs/recommended.ts index c2d160e..f9aa490 100644 --- a/plugin/src/configs/recommended.ts +++ b/plugin/src/configs/recommended.ts @@ -7,6 +7,7 @@ export default { '@pandacss/no-config-function-in-source': 'error', '@pandacss/no-debug': 'warn', '@pandacss/no-dynamic-styling': 'warn', + '@pandacss/no-invalid-nesting': 'error', '@pandacss/no-invalid-token-paths': 'error', '@pandacss/no-property-renaming': 'warn', '@pandacss/prefer-unified-property-style': 'warn', diff --git a/plugin/src/rules/index.ts b/plugin/src/rules/index.ts index 1cf2e5f..7d0e144 100644 --- a/plugin/src/rules/index.ts +++ b/plugin/src/rules/index.ts @@ -5,6 +5,7 @@ import noDynamicStyling, { RULE_NAME as NoDynamicStyling } from './no-dynamic-st import noEscapeHatch, { RULE_NAME as NoEscapeHatch } from './no-escape-hatch' import noHardCodedColor, { RULE_NAME as NoHardCodedColor } from './no-hardcoded-color' import noImportant, { RULE_NAME as NoImportant } from './no-important' +import noInvalidNesting, { RULE_NAME as NoInvalidNesting } from './no-invalid-nesting' import noInvalidTokenPaths, { RULE_NAME as NoInvalidTokenPaths } from './no-invalid-token-paths' import noPropertyRenaming, { RULE_NAME as NoPropertyRenaming } from './no-property-renaming' import noUnsafeTokenUsage, { RULE_NAME as NoUnsafeTokenUsage } from './no-unsafe-token-fn-usage' @@ -23,6 +24,7 @@ export const rules = { [NoHardCodedColor]: noHardCodedColor, [NoImportant]: noImportant, [NoInvalidTokenPaths]: noInvalidTokenPaths, + [NoInvalidNesting]: noInvalidNesting, [NoPropertyRenaming]: noPropertyRenaming, [NoUnsafeTokenUsage]: noUnsafeTokenUsage, [PreferLonghandProperties]: preferLonghandProperties, diff --git a/plugin/src/rules/no-invalid-nesting.ts b/plugin/src/rules/no-invalid-nesting.ts new file mode 100644 index 0000000..55b5421 --- /dev/null +++ b/plugin/src/rules/no-invalid-nesting.ts @@ -0,0 +1,41 @@ +import { isIdentifier, isLiteral, isObjectExpression, isTemplateLiteral } from '../utils/nodes' +import { type Rule, createRule } from '../utils' +import { isInJSXProp, isInPandaFunction } from '../utils/helpers' + +export const RULE_NAME = 'no-invalid-nesting' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: "Warn against invalid nesting. i.e. nested styles that don't contain the `&` character.", + }, + messages: { + nesting: 'Invalid style nesting. Nested styles must contain the `&` character.', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + Property(node) { + if (!isObjectExpression(node.value) || isIdentifier(node.key)) return + if (!isInPandaFunction(node, context) && !isInJSXProp(node, context)) return + + const invalidLiteral = + isLiteral(node.key) && typeof node.key.value === 'string' && !node.key.value.includes('&') + const invalidTemplateLiteral = isTemplateLiteral(node.key) && !node.key.quasis[0].value.raw.includes('&') + + if (invalidLiteral || invalidTemplateLiteral) { + context.report({ + node: node.key, + messageId: 'nesting', + }) + } + }, + } + }, +}) + +export default rule diff --git a/plugin/src/utils/helpers.ts b/plugin/src/utils/helpers.ts index 96f2883..dcc7227 100644 --- a/plugin/src/utils/helpers.ts +++ b/plugin/src/utils/helpers.ts @@ -12,7 +12,9 @@ import { isJSXIdentifier, isJSXMemberExpression, isJSXOpeningElement, + isLiteral, isMemberExpression, + isTemplateLiteral, isVariableDeclarator, type Node, } from './nodes' @@ -161,7 +163,22 @@ export const isPandaProp = (node: TSESTree.JSXAttribute, context: RuleContext) => { +export const isStyledProperty = (node: TSESTree.Property, context: RuleContext, calleeName: string) => { + if (!isIdentifier(node.key) && !isLiteral(node.key) && !isTemplateLiteral(node.key)) return + + if (isIdentifier(node.key) && !isValidProperty(node.key.name, context, calleeName)) return + if ( + isLiteral(node.key) && + typeof node.key.value === 'string' && + !isValidProperty(node.key.value, context, calleeName) + ) + return + if (isTemplateLiteral(node.key) && !isValidProperty(node.key.quasis[0].value.raw, context, calleeName)) return + + return true +} + +export const isInPandaFunction = (node: TSESTree.Property, context: RuleContext) => { const callAncestor = getAncestor(isCallExpression, node) if (!callAncestor) return @@ -180,20 +197,10 @@ const isInPandaFunction = (node: TSESTree.Property, context: RuleContext) => { - const callAncestor = getAncestor(isCallExpression, node) - - if (callAncestor) return isInPandaFunction(node, context) - - // Object could be in JSX prop value i.e css prop or a pseudo +export const isInJSXProp = (node: TSESTree.Property, context: RuleContext) => { const jsxExprAncestor = getAncestor(isJSXExpressionContainer, node) const jsxAttrAncestor = getAncestor(isJSXAttribute, node) @@ -204,6 +211,19 @@ export const isPandaAttribute = (node: TSESTree.Property, context: RuleContext) => { + const callAncestor = getAncestor(isCallExpression, node) + + if (callAncestor) { + const callee = isInPandaFunction(node, context) + if (!callee) return + return isStyledProperty(node, context, callee) + } + + // Object could be in JSX prop value i.e css prop or a pseudo + return isInJSXProp(node, context) +} + export const resolveLonghand = (name: string, context: RuleContext) => { return syncAction('resolveLongHand', getSyncOpts(context), name) } diff --git a/plugin/tests/no-invalid-nesting.test.ts b/plugin/tests/no-invalid-nesting.test.ts new file mode 100644 index 0000000..7d577de --- /dev/null +++ b/plugin/tests/no-invalid-nesting.test.ts @@ -0,0 +1,68 @@ +import { tester } from '../test-utils' +import rule, { RULE_NAME } from '../src/rules/no-invalid-nesting' + +const javascript = String.raw + +const valids = [ + { + code: javascript` +import { css } from './panda/css'; + +const styles = css({ '&:hover': { marginLeft: '4px' } })`, + }, + + { + code: javascript` +import { css } from './panda/css'; + +function App(){ + return
; +}`, + }, + + { + code: javascript` +import { Circle } from './panda/jsx'; + +function App(){ + return ; +}`, + }, +] + +const invalids = [ + { + code: javascript` +import { css } from './panda/css'; + +const styles = css({ ':hover': { marginLeft: '4px' } })`, + }, + + { + code: javascript` +import { css } from './panda/css'; + +function App(){ + return
; +}`, + }, + + { + code: javascript` +import { Circle } from './panda/jsx'; + +function App(){ + return ; +}`, + }, +] + +tester.run(RULE_NAME, rule, { + valid: valids.map(({ code }) => ({ + code, + })), + invalid: invalids.map(({ code }) => ({ + code, + errors: 1, + })), +})