diff --git a/.changeset/small-dogs-jump.md b/.changeset/small-dogs-jump.md new file mode 100644 index 0000000000..7a3ec2bec8 --- /dev/null +++ b/.changeset/small-dogs-jump.md @@ -0,0 +1,22 @@ +--- +'@pandacss/eslint-plugin': minor +--- + +Add Panda Eslint Plugin. + +Install the package: + +```bash +pnpm add -D @pandacss/eslint-plugin +``` + +Add it to your `.eslintrc.json` file, then configure the rules you want to use under the rules section: + +```json +{ + "plugins": ["@pandacss"], + "rules": { + "@pandacss/no-shorthand-prop": "warn" + } +} +``` diff --git a/.changeset/smart-dolphins-argue.md b/.changeset/smart-dolphins-argue.md new file mode 100644 index 0000000000..ed8ee424ad --- /dev/null +++ b/.changeset/smart-dolphins-argue.md @@ -0,0 +1,5 @@ +--- +'@pandacss/shared': patch +--- + +Update `getArbitraryValue` so it works for values that start on a new line diff --git a/package.json b/package.json index 7e71b2ea71..a643e3a029 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:playground": "pnpm --filter {./playground}... build", "prepare": "husky install && pnpm build-fast", "dev": "pnpm --parallel --filter=./packages/** dev", - "build-fast": "pnpm -r --parallel --filter=./packages/** build-fast", + "build-fast": "pnpm -r --filter=./packages/** build-fast", "build": "pnpm -r --filter=./packages/** build", "check": "pnpm build && pnpm typecheck && pnpm lint && pnpm test run", "clean": "pnpm -r --parallel exec rimraf dist .turbo *.log", @@ -56,6 +56,7 @@ "@types/node": "20.4.5", "@typescript-eslint/eslint-plugin": "6.2.1", "@typescript-eslint/parser": "6.2.1", + "@typescript-eslint/rule-tester": "^6.19.1", "eslint": "^8.54.0", "eslint-config-prettier": "^8.9.0", "prettier": "^2.8.8" diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json new file mode 100644 index 0000000000..a6985a1ca5 --- /dev/null +++ b/packages/eslint-plugin/package.json @@ -0,0 +1,43 @@ +{ + "name": "@pandacss/eslint-plugin", + "version": "0.0.1", + "description": "Eslint plugin for Panda CSS", + "author": "Abraham Aremu ", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "files": [ + "dist" + ], + "dependencies": { + "@pandacss/config": "workspace:*", + "@pandacss/core": "workspace:*", + "@pandacss/node": "workspace:*", + "@pandacss/shared": "workspace:*", + "@pandacss/types": "workspace:*", + "synckit": "^0.9.0" + }, + "peerDependencies": { + "eslint": "*" + } +} diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts new file mode 100644 index 0000000000..0ddf3a6009 --- /dev/null +++ b/packages/eslint-plugin/src/configs/all.ts @@ -0,0 +1,19 @@ +import { rules } from '../rules' +import { RULE_NAME as FileNotIncluded } from '../rules/file-not-included' +import { RULE_NAME as NoConfigunctionInSource } from '../rules/no-config-function-in-source' +import { RULE_NAME as NoInvalidTokenPaths } from '../rules/no-invalid-token-paths' + +const errorRules = [FileNotIncluded, NoConfigunctionInSource, NoInvalidTokenPaths] + +const allRules = Object.fromEntries( + Object.entries(rules).map(([name]) => { + return [`@pandacss/${name}`, errorRules.includes(name) ? 'error' : 'warn'] + }), +) + +export default { + parser: '@typescript-eslint/parser', + parserOptions: { sourceType: 'module' }, + plugins: ['@pandacss'], + rules: allRules, +} diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts new file mode 100644 index 0000000000..0ee60fc563 --- /dev/null +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -0,0 +1,12 @@ +export default { + parser: '@typescript-eslint/parser', + parserOptions: { sourceType: 'module' }, + plugins: ['@pandacss'], + rules: { + '@pandacss/file-not-included': 'error', + '@pandacss/no-config-function-in-source': 'error', + '@pandacss/no-debug': 'warn', + '@pandacss/no-dynamic-styling': 'warn', + '@pandacss/no-invalid-token-paths': 'error', + }, +} diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts new file mode 100644 index 0000000000..5d0427524d --- /dev/null +++ b/packages/eslint-plugin/src/index.ts @@ -0,0 +1,20 @@ +import { name, version } from '../package.json' + +import all from './configs/all' +import recommended from './configs/recommended' + +import { rules } from './rules' + +const plugin = { + meta: { + name, + version, + }, + rules, + configs: { + all, + recommended, + }, +} + +module.exports = plugin diff --git a/packages/eslint-plugin/src/rules/file-not-included.test.ts b/packages/eslint-plugin/src/rules/file-not-included.test.ts new file mode 100644 index 0000000000..50abf27b98 --- /dev/null +++ b/packages/eslint-plugin/src/rules/file-not-included.test.ts @@ -0,0 +1,31 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './file-not-included' + +const code = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +tester.run(RULE_NAME, rule as any, { + valid: [ + { + code, + filename: './src/valid.tsx', + }, + ], + invalid: [ + { + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'include', + suggestions: null, + }, + { + messageId: 'include', + suggestions: null, + }, + ], + }, + ], +}) diff --git a/packages/eslint-plugin/src/rules/file-not-included.ts b/packages/eslint-plugin/src/rules/file-not-included.ts new file mode 100644 index 0000000000..698c1c46a6 --- /dev/null +++ b/packages/eslint-plugin/src/rules/file-not-included.ts @@ -0,0 +1,35 @@ +import { type Rule, createRule } from '../utils' +import { isPandaImport, isValidFile } from '../utils/helpers' + +export const RULE_NAME = 'file-not-included' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: + 'Disallow the use of panda css in files that are not included in the specified panda `include` config.', + }, + messages: { + include: 'The use of Panda CSS is not allowed in this file. Please check the specified `include` config.', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node) { + if (!isPandaImport(node, context)) return + if (isValidFile(context)) return + + context.report({ + node, + messageId: 'include', + }) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts new file mode 100644 index 0000000000..ad430e1090 --- /dev/null +++ b/packages/eslint-plugin/src/rules/index.ts @@ -0,0 +1,23 @@ +import fileNotIncluded, { RULE_NAME as FileNotIncluded } from './file-not-included' +import noConfigunctionInSource, { RULE_NAME as NoConfigunctionInSource } from './no-config-function-in-source' +import noDebug, { RULE_NAME as NoDebug } from './no-debug' +import noDynamicStyling, { RULE_NAME as NoDynamicStyling } from './no-dynamic-styling' +import noEscapeHatch, { RULE_NAME as NoEscapeHatch } from './no-escape-hatch' +import noHardCodedColor, { RULE_NAME as NoHardCodedColor } from './no-hardcoded-color' +import noInvalidTokenPaths, { RULE_NAME as NoInvalidTokenPaths } from './no-invalid-token-paths' +import noShorthandProp, { RULE_NAME as NoShorthandProp } from './no-shorthand-prop' +import noUnsafeTokenUsage, { RULE_NAME as NoUnsafeTokenUsage } from './no-unsafe-token-fn-usage' +import preferAtomicProperties, { RULE_NAME as PreferAtomicProperties } from './prefer-atomic-properties' + +export const rules = { + [FileNotIncluded]: fileNotIncluded, + [NoConfigunctionInSource]: noConfigunctionInSource, + [NoDebug]: noDebug, + [NoDynamicStyling]: noDynamicStyling, + [NoEscapeHatch]: noEscapeHatch, + [NoHardCodedColor]: noHardCodedColor, + [NoInvalidTokenPaths]: noInvalidTokenPaths, + [NoShorthandProp]: noShorthandProp, + [NoUnsafeTokenUsage]: noUnsafeTokenUsage, + [PreferAtomicProperties]: preferAtomicProperties, +} as any diff --git a/packages/eslint-plugin/src/rules/no-config-function-in-source.test.ts b/packages/eslint-plugin/src/rules/no-config-function-in-source.test.ts new file mode 100644 index 0000000000..f8816e3107 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-config-function-in-source.test.ts @@ -0,0 +1,33 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-config-function-in-source' + +const imports = `import { defineKeyframes } from '@pandacss/dev';` +const code = `const keyframes = defineKeyframes({ + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, +}) +` + +tester.run(RULE_NAME, rule as any, { + valid: [ + { + code: imports + code.trim(), + filename: './panda.config.ts', + }, + ], + invalid: [ + { + code: imports + code.trim(), + filename: './src/valid.tsx', + errors: [ + { + messageId: 'configFunction', + suggestions: null, + }, + ], + output: imports, + }, + ], +}) diff --git a/packages/eslint-plugin/src/rules/no-config-function-in-source.ts b/packages/eslint-plugin/src/rules/no-config-function-in-source.ts new file mode 100644 index 0000000000..2196e179a5 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-config-function-in-source.ts @@ -0,0 +1,62 @@ +import { isIdentifier, isVariableDeclaration } from '../utils/nodes' +import { type Rule, createRule } from '../utils' +import { getAncestor, isValidFile } from '../utils/helpers' + +export const RULE_NAME = 'no-config-function-in-source' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Prohibit the use of config functions outside the Panda config.', + }, + messages: { + configFunction: 'Remove `{{name}}` usage. Config functions should only be used in panda config', + }, + type: 'suggestion', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (!isIdentifier(node.callee)) return + if (!CONFIG_FUNCTIONS.includes(node.callee.name)) return + + if (!isValidFile(context)) return + + context.report({ + node, + messageId: 'configFunction', + data: { + name: node.callee.name, + }, + fix(fixer) { + const declaration = getAncestor(isVariableDeclaration, node) + return fixer.remove(declaration ?? node) + }, + }) + }, + } + }, +}) + +export default rule + +const CONFIG_FUNCTIONS = [ + 'defineConfig', + 'defineRecipe', + 'defineSlotRecipe', + 'defineParts', + 'definePattern', + 'definePreset', + 'defineKeyframes', + 'defineGlobalStyles', + 'defineUtility', + 'defineTextStyles', + 'defineLayerStyles', + 'defineStyles', + 'defineTokens', + 'defineSemanticTokens', +] diff --git a/packages/eslint-plugin/src/rules/no-debug.test.ts b/packages/eslint-plugin/src/rules/no-debug.test.ts new file mode 100644 index 0000000000..609f90aec8 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-debug.test.ts @@ -0,0 +1,58 @@ +import rule, { RULE_NAME } from './no-debug' +import { tester } from '../../test-utils' + +const imports = `import { css } from './panda/css'; +import { styled, Circle } from './panda/jsx';` + +const valids = [ + 'const styles = { debug: true }', + 'const styles = css({ bg: "red" })', + 'const styles = css.raw({ bg: "red" })', + 'const randomFunc = f({ debug: true })', + '', + 'content', + `const a = 1; const PandaComp = styled(div); `, +] + +const invalids = [ + { code: 'const styles = css({ bg: "red", debug: true })', output: 'const styles = css({ bg: "red", })' }, + { + code: 'const styles = css.raw({ bg: "red", debug: true })', + output: 'const styles = css.raw({ bg: "red", })', + }, + { + code: 'const styles = css({ bg: "red", "&:hover": { debug: true } })', + output: 'const styles = css({ bg: "red", "&:hover": { } })', + }, + { + code: 'const styles = css({ bg: "red", "&:hover": { "&:disabled": { debug: true } } })', + output: 'const styles = css({ bg: "red", "&:hover": { "&:disabled": { } } })', + }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, + { + code: `const PandaComp = styled(div); `, + output: 'const PandaComp = styled(div); ', + }, +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map(({ code, output }) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'debug', + suggestions: null, + }, + ], + output: imports + output, + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-debug.ts b/packages/eslint-plugin/src/rules/no-debug.ts new file mode 100644 index 0000000000..824fc4a356 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-debug.ts @@ -0,0 +1,49 @@ +import { isIdentifier } from '../utils/nodes' +import { type Rule, createRule } from '../utils' +import { isPandaAttribute, isPandaProp } from '../utils/helpers' + +export const RULE_NAME = 'no-debug' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Disallow the inclusion of the debug attribute when shipping code to the production environment.', + }, + messages: { + debug: 'Remove the debug utility.', + }, + type: 'suggestion', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + JSXIdentifier(node) { + if (node.name !== 'debug') return + if (!isPandaProp(node, context)) return + + context.report({ + node, + messageId: 'debug', + fix: (fixer) => fixer.remove(node.parent), + }) + }, + + Property(node) { + if (!isIdentifier(node.key) || node.key.name !== 'debug') return + + if (!isPandaAttribute(node, context)) return + + context.report({ + node: node.key, + messageId: 'debug', + fix: (fixer) => fixer.removeRange([node.range[0], node.range[1] + 1]), + }) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-dynamic-styling.test.ts b/packages/eslint-plugin/src/rules/no-dynamic-styling.test.ts new file mode 100644 index 0000000000..50472fe37c --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-dynamic-styling.test.ts @@ -0,0 +1,35 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-dynamic-styling' + +const imports = `import { css } from './panda/css' +import { styled, Circle } from './panda/jsx' +` + +const valids = [ + 'const styles = css({ bg: "red" })', + 'const styles = css({ bg: `red` })', + 'const styles = css({ bg: 1 })', + 'const styles = css({ debug: true })', + '', + '', + '', +] + +const invalids = ['const styles = css({ bg: color })', '', ''] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map((code) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'dynamic', + suggestions: null, + }, + ], + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-dynamic-styling.ts b/packages/eslint-plugin/src/rules/no-dynamic-styling.ts new file mode 100644 index 0000000000..70c0c58a40 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-dynamic-styling.ts @@ -0,0 +1,62 @@ +import { type Rule, createRule } from '../utils' +import { isPandaAttribute, isPandaProp } from '../utils/helpers' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral } from '../utils/nodes' + +export const RULE_NAME = 'no-dynamic-styling' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: + "Ensure user doesn't use dynamic styling at any point. Prefer to use static styles, leverage css variables or recipes for known dynamic styles.", + }, + messages: { + dynamic: 'Remove dynamic value. Prefer static styles', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + JSXAttribute(node) { + if (!node.value) return + if (isLiteral(node.value)) return + if (isJSXExpressionContainer(node.value) && isLiteral(node.value.expression)) return + + // For syntax like: + if ( + isJSXExpressionContainer(node.value) && + isTemplateLiteral(node.value.expression) && + node.value.expression.expressions.length === 0 + ) + return + + if (!isPandaProp(node.name, context)) return + + context.report({ + node: node.value, + messageId: 'dynamic', + }) + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (isLiteral(node.value)) return + + // For syntax like: { property: `value that could be multiline` } + if (isTemplateLiteral(node.value) && node.value.expressions.length === 0) return + + if (!isPandaAttribute(node, context)) return + + context.report({ + node: node.value, + messageId: 'dynamic', + }) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-escape-hatch.test.ts b/packages/eslint-plugin/src/rules/no-escape-hatch.test.ts new file mode 100644 index 0000000000..26e3c8f4ee --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-escape-hatch.test.ts @@ -0,0 +1,78 @@ +import { getArbitraryValue } from '@pandacss/shared' +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-escape-hatch' + +const imports = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +const namedGridLines = ` +[ + [full-start] + minmax(16px, 1fr) + [breakout-start] + minmax(0, 16px) + [content-start] + minmax(min-content, 1024px) + [content-end] + minmax(0, 16px) + [breakout-end] + minmax(16px, 1fr) + [full-end] +] +` + +const valids = [ + 'const styles = css({ marginLeft: "4" })', + `const layout = css({ + display: "grid", + gridTemplateColumns: \`${getArbitraryValue(namedGridLines)}\`, + }); + `, + '
', + '', + ``, +] + +const invalids = [ + { code: 'const styles = css({ marginLeft: "[4px]" })', output: 'const styles = css({ marginLeft: "4px" })' }, + { + code: `const layout = css({ + display: "grid", + gridTemplateColumns: \`${namedGridLines}\`, + }); + `, + output: `const layout = css({ + display: "grid", + gridTemplateColumns: \`${getArbitraryValue(namedGridLines)}\`, + }); + `, + }, + { + code: '
', + output: '
', + }, + { code: '', output: '' }, + { + code: ``, + output: ``, + }, +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map(({ code, output }) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'escapeHatch', + suggestions: null, + }, + ], + output: imports + output, + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-escape-hatch.ts b/packages/eslint-plugin/src/rules/no-escape-hatch.ts new file mode 100644 index 0000000000..c4e4ff12c4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-escape-hatch.ts @@ -0,0 +1,83 @@ +import { isPandaAttribute, isPandaProp } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { getArbitraryValue } from '@pandacss/shared' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral, type Node } from '../utils/nodes' + +export const RULE_NAME = 'no-escape-hatch' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Prohibit the use of escape hatch syntax in the code.', + }, + messages: { + escapeHatch: + 'Avoid using the escape hatch [value] for undefined tokens. Define a corresponding token in your design system for better consistency and maintainability.', + }, + type: 'suggestion', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const removeQuotes = ([start, end]: readonly [number, number]) => [start + 1, end - 1] as const + + const hasEscapeHatch = (value?: string) => { + if (!value) return false + return getArbitraryValue(value) !== value.trim() + } + + const handleLiteral = (node: Node) => { + if (!isLiteral(node)) return + if (!hasEscapeHatch(node.value?.toString())) return + + sendReport(node) + } + + const handleTemplateLiteral = (node: Node) => { + if (!isTemplateLiteral(node)) return + if (node.expressions.length > 0) return + if (!hasEscapeHatch(node.quasis[0].value.raw)) return + + sendReport(node.quasis[0], node.quasis[0].value.raw) + } + + const sendReport = (node: any, _value?: string) => { + const value = _value ?? node.value?.toString() + + return context.report({ + node, + messageId: 'escapeHatch', + fix: (fixer) => { + return fixer.replaceTextRange(removeQuotes(node.range), getArbitraryValue(value)) + }, + }) + } + + return { + JSXAttribute(node) { + if (!node.value) return + if (!isPandaProp(node, context)) return + + handleLiteral(node.value) + + if (!isJSXExpressionContainer(node.value)) return + + handleLiteral(node.value.expression) + handleTemplateLiteral(node.value.expression) + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (!isLiteral(node.value) && !isTemplateLiteral(node.value)) return + if (!isPandaAttribute(node, context)) return + + handleLiteral(node.value) + handleTemplateLiteral(node.value) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-hardcoded-color.test.ts b/packages/eslint-plugin/src/rules/no-hardcoded-color.test.ts new file mode 100644 index 0000000000..700308fb1b --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-hardcoded-color.test.ts @@ -0,0 +1,40 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-hardcoded-color' + +const imports = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +// Watch out for color opacity in the future +const valids = [ + 'const styles = css({ color: "red.100" })', + '
', + '', + '', + '', +] + +const invalids = [ + 'const styles = css({ color: "skyblue" })', + '
', + '', + '', + '', +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map((code) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'invalidColor', + suggestions: null, + }, + ], + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-hardcoded-color.ts b/packages/eslint-plugin/src/rules/no-hardcoded-color.ts new file mode 100644 index 0000000000..61a00f34f2 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-hardcoded-color.ts @@ -0,0 +1,86 @@ +import { extractTokens, isColorAttribute, isColorToken, isPandaAttribute, isPandaProp } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { isIdentifier, isJSXExpressionContainer, isJSXIdentifier, isLiteral } from '../utils/nodes' + +export const RULE_NAME = 'no-hardcoded-color' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Enforce the exclusive use of design tokens as values for colors within the codebase.', + }, + messages: { + invalidColor: '`{{color}}` is not a valid color token.', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + const isTokenFn = (value?: string) => { + if (!value) return false + const tokens = extractTokens(value) + return tokens.length > 0 + } + + return { + JSXAttribute(node) { + if (!isJSXIdentifier(node.name)) return + if (!isPandaProp(node, context) || !node.value) return + + if ( + isLiteral(node.value) && + isColorAttribute(node.name.name, context) && + !isTokenFn(node.value.value?.toString()) && + !isColorToken(node.value.value?.toString(), context) + ) { + context.report({ + node: node.value, + messageId: 'invalidColor', + data: { + color: node.value.value?.toString(), + }, + }) + } + + if (!isJSXExpressionContainer(node.value)) return + + if ( + isLiteral(node.value.expression) && + isColorAttribute(node.name.name, context) && + !isTokenFn(node.value.expression.value?.toString()) && + !isColorToken(node.value.expression.value?.toString(), context) + ) { + context.report({ + node: node.value.expression, + messageId: 'invalidColor', + data: { + color: node.value.expression.value?.toString(), + }, + }) + } + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (!isLiteral(node.value)) return + + if (!isPandaAttribute(node, context)) return + if (!isColorAttribute(node.key.name, context)) return + if (isTokenFn(node.value.value?.toString())) return + if (isColorToken(node.value.value?.toString(), context)) return + + context.report({ + node: node.value, + messageId: 'invalidColor', + data: { + color: node.value.value?.toString(), + }, + }) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-invalid-token-paths.test.ts b/packages/eslint-plugin/src/rules/no-invalid-token-paths.test.ts new file mode 100644 index 0000000000..fea233fff2 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-invalid-token-paths.test.ts @@ -0,0 +1,34 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-invalid-token-paths' + +const imports = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +const valids = [ + `const styles = css({ bg: 'token(colors.red.300) 50%' })`, + `const styles = css({ bg: 'token(colors.red.300, red) 50%' })`, + '
', + ``, + ``, +] + +const invalids = [ + { code: `const styles = css({ bg: 'token(colors.red0.300) 50%' })`, errors: 1 }, + { code: `const styles = css({ bg: \`token(colors.red0.300) 50%\` })`, errors: 1 }, + { code: `const styles = css({ bg: 'token(colors.red.3004, red) 50%' })`, errors: 1 }, + { code: '
', errors: 2 }, + { code: ``, errors: 1 }, +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map(({ code, errors }) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: Array.from({ length: errors }).map(() => ({ messageId: 'noInvalidTokenPaths' })), + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-invalid-token-paths.ts b/packages/eslint-plugin/src/rules/no-invalid-token-paths.ts new file mode 100644 index 0000000000..a4d01c9d96 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-invalid-token-paths.ts @@ -0,0 +1,75 @@ +import { getInvalidTokens, isPandaAttribute, isPandaProp } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import { isNodeOfTypes } from '@typescript-eslint/utils/ast-utils' +import { isIdentifier, isJSXExpressionContainer, isLiteral, isTemplateLiteral, type Node } from '../utils/nodes' + +export const RULE_NAME = 'no-invalid-token-paths' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Disallow the use of invalid token paths within token function syntax.', + }, + messages: { + noInvalidTokenPaths: '`{{token}}` is an invalid token path.', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + const handleLiteral = (node: Node) => { + if (!isLiteral(node)) return + + sendReport(node) + } + + const handleTemplateLiteral = (node: Node) => { + if (!isTemplateLiteral(node)) return + if (node.expressions.length > 0) return + sendReport(node.quasis[0], node.quasis[0].value.raw) + } + + const sendReport = (node: any, _value?: string) => { + const value = _value ?? node.value?.toString() + const tokens = getInvalidTokens(value, context) + + if (tokens.length > 0) { + tokens.forEach((token) => { + context.report({ + node, + messageId: 'noInvalidTokenPaths', + data: { token }, + }) + }) + } + } + + return { + JSXAttribute(node) { + if (!node.value) return + if (!isPandaProp(node, context)) return + + handleLiteral(node.value) + + if (!isJSXExpressionContainer(node.value)) return + + handleLiteral(node.value.expression) + handleTemplateLiteral(node.value.expression) + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (!isNodeOfTypes([AST_NODE_TYPES.Literal, AST_NODE_TYPES.TemplateLiteral])(node.value)) return + if (!isPandaAttribute(node, context)) return + + handleLiteral(node.value) + handleTemplateLiteral(node.value) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-shorthand-prop.test.ts b/packages/eslint-plugin/src/rules/no-shorthand-prop.test.ts new file mode 100644 index 0000000000..e1a0e41323 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-shorthand-prop.test.ts @@ -0,0 +1,36 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-shorthand-prop' + +const imports = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +const valids = [ + 'const styles = css({ marginLeft: "4" })', + '
', + '', +] + +const invalids = [ + { code: 'const styles = css({ ml: "4" })', output: 'const styles = css({ marginLeft: "4" })' }, + { code: '
', output: '
' }, + { code: '', output: '' }, +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map(({ code, output }) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'longhand', + suggestions: null, + }, + ], + output: imports + output, + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-shorthand-prop.ts b/packages/eslint-plugin/src/rules/no-shorthand-prop.ts new file mode 100644 index 0000000000..ae46f1f5ad --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-shorthand-prop.ts @@ -0,0 +1,60 @@ +import { isPandaAttribute, isPandaProp, resolveLonghand } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { isIdentifier } from '../utils/nodes' + +export const RULE_NAME = 'no-shorthand-prop' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: + 'Discourage the use of shorthand properties and promote the preference for longhand CSS properties in the codebase.', + }, + messages: { + longhand: 'Use longhand property of `{{shorthand}}` instead. Prefer `{{longhand}}`', + }, + type: 'suggestion', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const sendReport = (node: any, name: string) => { + const longhand = resolveLonghand(name, context)! + + return context.report({ + node, + messageId: 'longhand' as const, + data: { + shorthand: name, + longhand, + }, + fix: (fixer) => { + return fixer.replaceTextRange(node.range, longhand) + }, + }) + } + + return { + JSXIdentifier(node) { + if (!isPandaProp(node, context)) return + const longhand = resolveLonghand(node.name, context) + if (!longhand) return + + sendReport(node, node.name) + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (!isPandaAttribute(node, context)) return + const longhand = resolveLonghand(node.key.name, context) + if (!longhand) return + + sendReport(node.key, node.key.name) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.test.ts b/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.test.ts new file mode 100644 index 0000000000..14c207f7e8 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.test.ts @@ -0,0 +1,42 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './no-unsafe-token-fn-usage' + +const imports = `import { css } from './panda/css'; +import { Circle } from './panda/jsx'; +import { tokens as tk } from './panda/tokens'; +` + +const valids = [ + 'const styles = css({ bg: "token(colors.red.300) 50%" })', + '
', +] + +const invalids = [ + { code: 'const styles = css({ bg: tk("colors.red.300") })', output: 'const styles = css({ bg: "red.300" })' }, + { code: 'const styles = css({ bg: tk(`colors.red.300`) })', output: 'const styles = css({ bg: "red.300" })' }, + { code: 'const styles = css({ bg: "token(colors.red.300)" })', output: 'const styles = css({ bg: "red.300" })' }, + { code: '
', output: '
' }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, + { code: '', output: '' }, +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map(({ code, output }) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'noUnsafeTokenFnUsage', + suggestions: null, + }, + ], + output: imports + output, + })), +}) diff --git a/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.ts b/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.ts new file mode 100644 index 0000000000..68e43eae3f --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-token-fn-usage.ts @@ -0,0 +1,114 @@ +import { extractTokens, getTokenImport, isPandaAttribute, isPandaProp } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { TSESTree } from '@typescript-eslint/utils' +import { + isCallExpression, + isIdentifier, + isJSXExpressionContainer, + isLiteral, + isTemplateLiteral, + type Node, +} from '../utils/nodes' + +export const RULE_NAME = 'no-unsafe-token-fn-usage' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: + 'Prevent users from using the token function in situations where they could simply use the raw design token.', + }, + messages: { + noUnsafeTokenFnUsage: 'Unneccessary token function usage. Prefer design token', + }, + type: 'suggestion', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + const isUnsafeCallExpression = (node: TSESTree.CallExpression) => { + const tkImport = getTokenImport(context) + return isIdentifier(node.callee) && node.callee.name === tkImport?.alias + } + + const tokenWrap = (value?: string) => (value ? `token(${value})` : '') + + const handleRuntimeFm = (node: Node) => { + if (!isCallExpression(node)) return + if (!isUnsafeCallExpression(node)) return + + const value = node.arguments[0] + + if (isLiteral(value)) { + sendReport(node, tokenWrap(value.value?.toString())) + } + if (isTemplateLiteral(value)) { + sendReport(node, tokenWrap(value.quasis[0].value.raw)) + } + } + + const isCompositeValue = (input?: string) => { + if (!input) return + // Regular expression to match token only values. i.e. token('space.2') or {space.2} + // TODO We'll need to update this when we implement the format tokens feature cause then format will be dynamic + const tokenRegex = /^(?:token\([^)]*\)|\{[^}]*\})$/ + return !tokenRegex.test(input) + } + + const handleLiteral = (node: Node) => { + if (!isLiteral(node)) return + if (isCompositeValue(node.value?.toString())) return + + sendReport(node) + } + + const handleTemplateLiteral = (node: Node) => { + if (!isTemplateLiteral(node)) return + if (node.expressions.length > 0) return + + sendReport(node, node.quasis[0].value.raw) + } + + const sendReport = (node: any, _value?: string) => { + const value = _value ?? node.value?.toString() + const tkImports = extractTokens(value) + const token = tkImports[0].replace(/^[^.]*\./, '') + + return context.report({ + node, + messageId: 'noUnsafeTokenFnUsage', + fix: (fixer) => { + return fixer.replaceTextRange(node.range, `"${token}"`) + }, + }) + } + + return { + JSXAttribute(node) { + if (!node.value) return + if (!isPandaProp(node, context)) return + + handleLiteral(node.value) + + if (!isJSXExpressionContainer(node.value)) return + + handleLiteral(node.value.expression) + handleTemplateLiteral(node.value.expression) + handleRuntimeFm(node.value.expression) + }, + + Property(node) { + if (!isCallExpression(node.value) && !isLiteral(node.value) && !isTemplateLiteral(node.value)) return + if (!isPandaAttribute(node, context)) return + + handleRuntimeFm(node.value) + handleLiteral(node.value) + handleTemplateLiteral(node.value) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/rules/prefer-atomic-properties.test.ts b/packages/eslint-plugin/src/rules/prefer-atomic-properties.test.ts new file mode 100644 index 0000000000..1df0d65bf4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-atomic-properties.test.ts @@ -0,0 +1,35 @@ +import { tester } from '../../test-utils' +import rule, { RULE_NAME } from './prefer-atomic-properties' + +const imports = `import { css } from './panda/css' +import { Circle } from './panda/jsx' +` + +const valids = [ + 'const styles = css({ rowGap: "4", columnGap: "4" })', + '
', + '', +] + +const invalids = [ + 'const styles = css({ gap: "4" })', + '
', + '', +] + +tester.run(RULE_NAME, rule as any, { + valid: valids.map((code) => ({ + code: imports + code, + filename: './src/valid.tsx', + })), + invalid: invalids.map((code) => ({ + code: imports + code, + filename: './src/invalid.tsx', + errors: [ + { + messageId: 'atomic', + suggestions: null, + }, + ], + })), +}) diff --git a/packages/eslint-plugin/src/rules/prefer-atomic-properties.ts b/packages/eslint-plugin/src/rules/prefer-atomic-properties.ts new file mode 100644 index 0000000000..c0bcb12619 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-atomic-properties.ts @@ -0,0 +1,65 @@ +import { isPandaAttribute, isPandaProp, isValidProperty, resolveShorthand } from '../utils/helpers' +import { type Rule, createRule } from '../utils' +import { shorthandProperties } from '../utils/shorthand-properties' +import { isIdentifier } from '../utils/nodes' + +export const RULE_NAME = 'prefer-atomic-properties' + +const rule: Rule = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Encourage the use of atomic properties instead of composite shorthand properties in the codebase.', + }, + messages: { + atomic: 'Use atomic properties of `{{composite}}` instead. Prefer: \n{{atomics}}', + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + const resolveCompositeProperty = (name: string) => { + if (Object.hasOwn(shorthandProperties, name)) return name + + const longhand = resolveShorthand(name, context) + if (isValidProperty(longhand, context) && Object.hasOwn(shorthandProperties, longhand)) return longhand + } + + const sendReport = (node: any, name: string) => { + const cpd = resolveCompositeProperty(name)! + + const atomics = shorthandProperties[cpd].map((name) => `\`${name}\``).join(',\n') + + return context.report({ + node, + messageId: 'atomic', + data: { + composite: name, + atomics, + }, + }) + } + + return { + JSXIdentifier(node) { + if (!isPandaProp(node, context)) return + const cpd = resolveCompositeProperty(node.name) + if (!cpd) return + + sendReport(node, node.name) + }, + + Property(node) { + if (!isIdentifier(node.key)) return + if (!isPandaAttribute(node, context)) return + const cpd = resolveCompositeProperty(node.key.name) + if (!cpd) return + + sendReport(node.key, node.key.name) + }, + } + }, +}) + +export default rule diff --git a/packages/eslint-plugin/src/utils/helpers.ts b/packages/eslint-plugin/src/utils/helpers.ts new file mode 100644 index 0000000000..2c52dfe42b --- /dev/null +++ b/packages/eslint-plugin/src/utils/helpers.ts @@ -0,0 +1,208 @@ +import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' +import type { ImportResult } from '@pandacss/core' +import type { TSESTree } from '@typescript-eslint/utils' +import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import { syncAction } from './' +import { + isCallExpression, + isIdentifier, + isImportDeclaration, + isImportSpecifier, + isJSXAttribute, + isJSXExpressionContainer, + isJSXIdentifier, + isJSXMemberExpression, + isJSXOpeningElement, + isMemberExpression, + isVariableDeclaration, + type Node, +} from './nodes' + +export const getAncestor = >( + ofType: (node: N) => node is N, + for_: Node, +): N | undefined => { + let current: Node | undefined = for_.parent + while (current) { + if (ofType(current as N)) return current as N + current = current.parent + } + + return +} + +const getSyncOpts = (context: RuleContext) => { + return { + currentFile: context.getFilename(), + configPath: context.settings['@pandacss/configPath'] as string | undefined, + } +} + +const _getImports = (context: RuleContext) => { + const imports: ImportResult[] = [] + + context.getSourceCode().ast.body.forEach((node) => { + if (!isImportDeclaration(node)) return + + const mod = node.source.value + if (!mod) return + + node.specifiers.forEach((specifier) => { + if (!isImportSpecifier(specifier)) return + + const name = specifier.imported.name + const alias = specifier.local.name + + const result = { name, alias, mod } + + imports.push(result) + }) + }) + + return imports +} + +const getImports = (context: RuleContext) => { + const imports = _getImports(context) + + return imports.filter((imp) => syncAction('matchImports', getSyncOpts(context), imp)) +} + +const isValidStyledProp = (node: T, context: RuleContext) => { + if (typeof node === 'string') return + return isJSXIdentifier(node) && isValidProperty(node.name, context) +} + +const isPandaIsh = (name: string, context: RuleContext) => { + const imports = getImports(context) + return syncAction('matchFile', getSyncOpts(context), name, imports) +} + +const findDeclaration = (name: string, context: RuleContext) => { + let decl: TSESTree.VariableDeclarator | undefined + context.getSourceCode().ast.body.forEach((node) => { + if (!isVariableDeclaration(node)) return + decl = node.declarations.find((decl) => isIdentifier(decl.id) && decl.id.name === name) + }) + return decl +} + +const isLocalStyledFactory = (node: TSESTree.JSXOpeningElement, context: RuleContext) => { + if (!isJSXIdentifier(node.name)) return + const decl = findDeclaration(node.name.name, context) + + if (!decl) return + if (!isCallExpression(decl.init)) return + if (!isIdentifier(decl.init.callee)) return + if (!isPandaIsh(decl.init.callee.name, context)) return + + return true +} + +export const isValidFile = (context: RuleContext) => { + return syncAction('isValidFile', getSyncOpts(context)) +} + +export const isValidProperty = (name: string, context: RuleContext) => { + return syncAction('isValidProperty', getSyncOpts(context), name) +} + +export const isPandaImport = (node: TSESTree.ImportDeclaration, context: RuleContext) => { + const imports = getImports(context) + return imports.some((imp) => imp.mod === node.source.value) +} + +export const isPandaProp = (node: T, context: RuleContext) => { + const jsxAncestor = getAncestor(isJSXOpeningElement, node) + + if (!jsxAncestor) return + + if ( + isJSXMemberExpression(jsxAncestor.name) && + isJSXIdentifier(jsxAncestor.name.object) && + isPandaIsh(jsxAncestor.name.object.name, context) + ) + return true + else if (!isJSXIdentifier(jsxAncestor.name)) return + + // Ensure component is a panda component + if (!isPandaIsh(jsxAncestor.name.name, context) && !isLocalStyledFactory(jsxAncestor, context)) return + return true +} + +export const isPandaAttribute = (node: T, context: RuleContext) => { + const callAncestor = getAncestor(isCallExpression, node) + + // Object could be in JSX prop value i.e css prop or a pseudo + if (!callAncestor) { + const jsxExprAncestor = getAncestor(isJSXExpressionContainer, node) + const jsxAttrAncestor = getAncestor(isJSXAttribute, node) + + if (!jsxExprAncestor || !jsxAttrAncestor) return + if (!isPandaProp(jsxAttrAncestor.name, context)) return + if (!isValidStyledProp(jsxAttrAncestor.name, context)) return + + return true + } + + // E.g. css({...}) + if (isIdentifier(callAncestor.callee)) { + return isPandaIsh(callAncestor.callee.name, context) + } + + // css.raw({...}) + if (isMemberExpression(callAncestor.callee) && isIdentifier(callAncestor.callee.object)) { + return isPandaIsh(callAncestor.callee.object.name, context) + } + + return +} + +export const resolveLonghand = (name: string, context: RuleContext) => { + return syncAction('resolveLongHand', getSyncOpts(context), name) +} + +export const resolveShorthand = (name: string, context: RuleContext) => { + return syncAction('resolveShorthand', getSyncOpts(context), name) +} + +export const isColorAttribute = (attr: string, context: RuleContext) => { + return syncAction('isColorAttribute', getSyncOpts(context), attr) +} + +export const isColorToken = (value: string | undefined, context: RuleContext) => { + if (!value) return + return syncAction('isColorToken', getSyncOpts(context), value) +} + +export const extractTokens = (value: string) => { + const regex = /token\(([^"'(),]+)(?:,\s*([^"'(),]+))?\)|\{([^{}]+)\}/g + const matches = [] + let match + + while ((match = regex.exec(value)) !== null) { + const tokenFromFirstSyntax = match[1] || match[2] || match[3] + const tokensFromSecondSyntax = match[4] && match[4].match(/(\w+\.\w+(\.\w+)?)/g) + + if (tokenFromFirstSyntax) { + matches.push(tokenFromFirstSyntax) + } + + if (tokensFromSecondSyntax) { + matches.push(...tokensFromSecondSyntax) + } + } + + return matches.filter(Boolean) +} + +export const getInvalidTokens = (value: string, context: RuleContext) => { + const tokens = extractTokens(value) + if (!tokens.length) return [] + return syncAction('filterInvalidTokenz', getSyncOpts(context), tokens) +} + +export const getTokenImport = (context: RuleContext) => { + const imports = _getImports(context) + return imports.find((imp) => imp.name === 'tokens') +} diff --git a/packages/eslint-plugin/src/utils/index.ts b/packages/eslint-plugin/src/utils/index.ts new file mode 100644 index 0000000000..efa5ba371e --- /dev/null +++ b/packages/eslint-plugin/src/utils/index.ts @@ -0,0 +1,15 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { createSyncFn } from 'synckit' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { run } from './worker' + +export const createRule: ReturnType<(typeof ESLintUtils)['RuleCreator']> = ESLintUtils.RuleCreator( + (name) => `https://panda-css.com/docs/references/eslint#${name}`, +) + +export type Rule = ReturnType> + +export const distDir = fileURLToPath(new URL(process.env.MODE === 'test' ? '../../dist' : './', import.meta.url)) + +export const syncAction = createSyncFn(join(distDir, 'utils/worker.mjs')) as typeof run diff --git a/packages/eslint-plugin/src/utils/nodes.ts b/packages/eslint-plugin/src/utils/nodes.ts new file mode 100644 index 0000000000..ccd2207582 --- /dev/null +++ b/packages/eslint-plugin/src/utils/nodes.ts @@ -0,0 +1,32 @@ +import type { TSESTree } from '@typescript-eslint/utils' + +import { isNodeOfType } from '@typescript-eslint/utils/ast-utils' +import { AST_NODE_TYPES } from '@typescript-eslint/utils' + +export type Node = TSESTree.Node + +export const isIdentifier = isNodeOfType(AST_NODE_TYPES.Identifier) + +export const isLiteral = isNodeOfType(AST_NODE_TYPES.Literal) + +export const isTemplateLiteral = isNodeOfType(AST_NODE_TYPES.TemplateLiteral) + +export const isMemberExpression = isNodeOfType(AST_NODE_TYPES.MemberExpression) + +export const isVariableDeclaration = isNodeOfType(AST_NODE_TYPES.VariableDeclaration) + +export const isJSXMemberExpression = isNodeOfType(AST_NODE_TYPES.JSXMemberExpression) + +export const isJSXOpeningElement = isNodeOfType(AST_NODE_TYPES.JSXOpeningElement) + +export const isJSXExpressionContainer = isNodeOfType(AST_NODE_TYPES.JSXExpressionContainer) + +export const isJSXAttribute = isNodeOfType(AST_NODE_TYPES.JSXAttribute) + +export const isJSXIdentifier = isNodeOfType(AST_NODE_TYPES.JSXIdentifier) + +export const isCallExpression = isNodeOfType(AST_NODE_TYPES.CallExpression) + +export const isImportDeclaration = isNodeOfType(AST_NODE_TYPES.ImportDeclaration) + +export const isImportSpecifier = isNodeOfType(AST_NODE_TYPES.ImportSpecifier) diff --git a/packages/eslint-plugin/src/utils/shorthand-properties.ts b/packages/eslint-plugin/src/utils/shorthand-properties.ts new file mode 100644 index 0000000000..deda51f2fd --- /dev/null +++ b/packages/eslint-plugin/src/utils/shorthand-properties.ts @@ -0,0 +1,111 @@ +export const shorthandProperties: Record = { + animation: [ + 'animationName', + 'animationDuration', + 'animationTimingFunction', + 'animationDelay', + 'animationIterationCount', + 'animationDirection', + 'animationFillMode', + 'animationPlayState', + ], + background: [ + 'backgroundImage', + 'backgroundPosition', + 'backgroundSize', + 'backgroundRepeat', + 'backgroundAttachment', + 'backgroundOrigin', + 'backgroundClip', + 'backgroundColor', + ], + backgroundPosition: ['backgroundPositionX', 'backgroundPositionY'], + border: ['borderWidth', 'borderStyle', 'borderColor'], + borderBlockEnd: ['borderBlockEndWidth', 'borderBlockEndStyle', 'borderBlockEndColor'], + borderBlockStart: ['borderBlockStartWidth', 'borderBlockStartStyle', 'borderBlockStartColor'], + borderBottom: ['borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'], + borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], + borderImage: ['borderImageSource', 'borderImageSlice', 'borderImageWidth', 'borderImageOutset', 'borderImageRepeat'], + borderInlineEnd: ['borderInlineEndWidth', 'borderInlineEndStyle', 'borderInlineEndColor'], + borderInlineStart: ['borderInlineStartWidth', 'borderInlineStartStyle', 'borderInlineStartColor'], + borderLeft: ['borderLeftWidth', 'borderLeftStyle', 'borderLeftColor'], + borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], + borderRight: ['borderRightWidth', 'borderRightStyle', 'borderRightColor'], + borderStyle: ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'], + borderTop: ['borderTopWidth', 'borderTopStyle', 'borderTopColor'], + borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'], + columnRule: ['columnRuleWidth', 'columnRuleStyle', 'columnRuleColor'], + columns: ['columnWidth', 'columnCount'], + container: ['contain', 'content'], + containIntrinsicSize: ['containIntrinsicSizeInline', 'containIntrinsicSizeBlock'], + cue: ['cueBefore', 'cueAfter'], + flex: ['flexGrow', 'flexShrink', 'flexBasis'], + flexFlow: ['flexDirection', 'flexWrap'], + font: [ + 'fontStyle', + 'fontVariantCaps', + 'fontVariantEastAsian', + 'fontVariantLigatures', + 'fontVariantNumeric', + 'fontVariantPosition', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'lineHeight', + 'fontFamily', + ], + fontSynthesis: ['fontSynthesisWeight', 'fontSynthesisStyle', 'fontSynthesisSmallCaps'], + fontVariant: [ + 'fontVariantCaps', + 'fontVariantEastAsian', + 'fontVariantLigatures', + 'fontVariantNumeric', + 'fontVariantPosition', + ], + gap: ['columnGap', 'rowGap'], + grid: [ + 'gridTemplateColumns', + 'gridTemplateRows', + 'gridTemplateAreas', + 'gridAutoColumns', + 'gridAutoRows', + 'gridAutoFlow', + ], + gridArea: ['gridRowStart', 'gridColumnStart', 'gridRowEnd', 'gridColumnEnd'], + gridColumn: ['gridColumnStart', 'gridColumnEnd'], + gridGap: ['gridColumnGap', 'gridRowGap'], + gridRow: ['gridRowStart', 'gridRowEnd'], + gridTemplate: ['gridTemplateColumns', 'gridTemplateRows', 'gridTemplateAreas'], + inset: ['top', 'right', 'bottom', 'left'], + listStyle: ['listStyleType', 'listStylePosition', 'listStyleImage'], + margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], + mask: ['maskImage', 'maskMode', 'maskRepeat', 'maskPosition', 'maskClip', 'maskOrigin', 'maskSize', 'maskComposite'], + maskBorder: [ + 'maskBorderSource', + 'maskBorderMode', + 'maskBorderSlice', + 'maskBorderWidth', + 'maskBorderOutset', + 'maskBorderRepeat', + ], + offset: ['offsetPosition', 'offsetPath', 'offsetDistance', 'offsetRotate', 'offsetAnchor'], + outline: ['outlineWidth', 'outlineStyle', 'outlineColor'], + overflow: ['overflowX', 'overflowY'], + padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + pause: ['pauseBefore', 'pauseAfter'], + placeContent: ['alignContent', 'justifyContent'], + placeItems: ['alignItems', 'justifyItems'], + placeSelf: ['alignSelf', 'justifySelf'], + rest: ['restBefore', 'restAfter'], + scrollMargin: ['scrollMarginTop', 'scrollMarginRight', 'scrollMarginBottom', 'scrollMarginLeft'], + scrollPadding: ['scrollPaddingTop', 'scrollPaddingRight', 'scrollPaddingBottom', 'scrollPaddingLeft'], + scrollPaddingBlock: ['scrollPaddingBlockStart', 'scrollPaddingBlockEnd'], + scrollPaddingInline: ['scrollPaddingInlineStart', 'scrollPaddingInlineEnd'], + scrollSnapMargin: ['scrollSnapMarginTop', 'scrollSnapMarginRight', 'scrollSnapMarginBottom', 'scrollSnapMarginLeft'], + scrollSnapMarginBlock: ['scrollSnapMarginBlockStart', 'scrollSnapMarginBlockEnd'], + scrollSnapMarginInline: ['scrollSnapMarginInlineStart', 'scrollSnapMarginInlineEnd'], + scrollTimeline: ['scrollTimelineSource', 'scrollTimelineOrientation'], + textDecoration: ['textDecorationLine', 'textDecorationStyle', 'textDecorationColor'], + textEmphasis: ['textEmphasisStyle', 'textEmphasisColor'], + transition: ['transitionProperty', 'transitionDuration', 'transitionTimingFunction', 'transitionDelay'], +} diff --git a/packages/eslint-plugin/src/utils/worker.ts b/packages/eslint-plugin/src/utils/worker.ts new file mode 100644 index 0000000000..8b5f38edbc --- /dev/null +++ b/packages/eslint-plugin/src/utils/worker.ts @@ -0,0 +1,152 @@ +import { PandaContext, loadConfigAndCreateContext } from '@pandacss/node' +import { runAsWorker } from 'synckit' +import { createContext } from '@pandacss/fixture' +import { resolveTsPathPattern } from '@pandacss/config/ts-path' +import type { ImportResult } from '@pandacss/core' +import { findConfig } from '@pandacss/config' +import path from 'path' + +let promise: Promise | undefined +let configPath: string | undefined + +async function _getContext(configPath: string | undefined) { + if (!configPath) throw new Error('Invalid config path') + + const cwd = path.dirname(configPath) + + const ctx = await loadConfigAndCreateContext({ configPath, cwd }) + return ctx +} + +export async function getContext(opts: Opts) { + if (process.env.NODE_ENV === 'test') { + const ctx = createContext({ importMap: './panda' }) + ctx.getFiles = () => ['./src/valid.tsx'] + return ctx + } else { + configPath = configPath || findConfig({ cwd: opts.configPath ?? opts.currentFile }) + promise = promise || _getContext(configPath) + return await promise + } +} + +async function filterInvalidTokenz(ctx: PandaContext, paths: string[]): Promise { + return paths.filter((path) => !ctx.utility.tokens.get(path)) +} + +async function isColorToken(ctx: PandaContext, value: string): Promise { + return !!ctx.utility.tokens.values.get('colors')?.get(value) +} + +async function isColorAttribute(ctx: PandaContext, _attr: string): Promise { + const longhand = await resolveLongHand(ctx, _attr) + const attr = longhand || _attr + const attrConfig = ctx.utility.config[attr] + return attrConfig?.values === 'colors' +} + +async function isValidFile(ctx: PandaContext, fileName: string): Promise { + return ctx.getFiles().includes(fileName) +} + +async function resolveShorthand(ctx: PandaContext, name: string): Promise { + return ctx.utility.resolveShorthand(name) +} + +async function resolveLongHand(ctx: PandaContext, name: string): Promise { + const reverseShorthandsMap = new Map() + + for (const [key, values] of ctx.utility.getPropShorthandsMap()) { + for (const value of values) { + reverseShorthandsMap.set(value, key) + } + } + + return reverseShorthandsMap.get(name) +} + +async function isValidProperty(ctx: PandaContext, name: string) { + return ctx.isValidProperty(name) +} + +async function matchFile(ctx: PandaContext, name: string, imports: ImportResult[]) { + const file = ctx.imports.file(imports) + + return file.match(name) +} + +type MatchImportResult = { + name: string + alias: string + mod: string +} +async function matchImports(ctx: PandaContext, result: MatchImportResult) { + return ctx.imports.match(result, (mod) => { + const { tsOptions } = ctx.parserOptions + if (!tsOptions?.pathMappings) return + return resolveTsPathPattern(tsOptions.pathMappings, mod) + }) +} + +type Opts = { + currentFile: string + configPath?: string +} + +export function runAsync(action: 'filterInvalidTokenz', opts: Opts, paths: string[]): Promise +export function runAsync(action: 'isColorToken', opts: Opts, value: string): Promise +export function runAsync(action: 'isColorAttribute', opts: Opts, attr: string): Promise +export function runAsync(action: 'isValidFile', opts: Opts, fileName: string): Promise +export function runAsync(action: 'resolveShorthand', opts: Opts, name: string): Promise +export function runAsync(action: 'resolveLongHand', opts: Opts, name: string): Promise +export function runAsync(action: 'isValidProperty', opts: Opts, name: string): Promise +export function runAsync(action: 'matchFile', opts: Opts, name: string, imports: ImportResult[]): Promise +export function runAsync(action: 'matchImports', opts: Opts, result: MatchImportResult): Promise +export async function runAsync(action: string, opts: Opts, ...args: any): Promise { + const ctx = await getContext(opts) + + switch (action) { + case 'matchImports': + // @ts-expect-error cast + return matchImports(ctx, ...args) + case 'matchFile': + // @ts-expect-error cast + return matchFile(ctx, ...args) + case 'isValidProperty': + // @ts-expect-error cast + return isValidProperty(ctx, ...args) + case 'resolveLongHand': + // @ts-expect-error cast + return resolveLongHand(ctx, ...args) + case 'resolveShorthand': + // @ts-expect-error cast + return resolveShorthand(ctx, ...args) + case 'isValidFile': + return isValidFile(ctx, opts.currentFile) + case 'isColorAttribute': + // @ts-expect-error cast + return isColorAttribute(ctx, ...args) + case 'isColorToken': + // @ts-expect-error cast + return isColorToken(ctx, ...args) + case 'filterInvalidTokenz': + // @ts-expect-error cast + return filterInvalidTokenz(ctx, ...args) + } +} + +export function run(action: 'filterInvalidTokenz', opts: Opts, paths: string[]): string[] +export function run(action: 'isColorToken', opts: Opts, value: string): boolean +export function run(action: 'isColorAttribute', opts: Opts, attr: string): boolean +export function run(action: 'isValidFile', opts: Opts): boolean +export function run(action: 'resolveShorthand', opts: Opts, name: string): string +export function run(action: 'resolveLongHand', opts: Opts, name: string): string +export function run(action: 'isValidProperty', opts: Opts, name: string): boolean +export function run(action: 'matchFile', opts: Opts, name: string, imports: ImportResult[]): boolean +export function run(action: 'matchImports', opts: Opts, result: MatchImportResult): boolean +export function run(action: string, opts: Opts, ...args: any[]): any { + // @ts-expect-error cast + return runAsync(action, opts, ...args) +} + +runAsWorker(run as any) diff --git a/packages/eslint-plugin/test-utils.ts b/packages/eslint-plugin/test-utils.ts new file mode 100644 index 0000000000..8e583feb5d --- /dev/null +++ b/packages/eslint-plugin/test-utils.ts @@ -0,0 +1,12 @@ +import { RuleTester, type RuleTesterConfig } from '@typescript-eslint/rule-tester' + +const baseTesterConfig: RuleTesterConfig = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, +} + +export const tester = new RuleTester(baseTesterConfig) diff --git a/packages/eslint-plugin/tsconfig.json b/packages/eslint-plugin/tsconfig.json new file mode 100644 index 0000000000..36d00ae1dd --- /dev/null +++ b/packages/eslint-plugin/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "index.ts", "test-utils.ts"], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@pandacss/fixture": ["../fixture/src/index.ts"] + } + } +} diff --git a/packages/eslint-plugin/tsup.config.ts b/packages/eslint-plugin/tsup.config.ts new file mode 100644 index 0000000000..3f37529cf8 --- /dev/null +++ b/packages/eslint-plugin/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts', 'src/utils/worker.ts'], + format: ['esm', 'cjs'], + shims: true, +}) diff --git a/packages/shared/__tests__/arbitrary-value.test.ts b/packages/shared/__tests__/arbitrary-value.test.ts index 2a156aaf1e..9d9a928a09 100644 --- a/packages/shared/__tests__/arbitrary-value.test.ts +++ b/packages/shared/__tests__/arbitrary-value.test.ts @@ -23,8 +23,7 @@ describe('arbitrary className', () => { [full-end] `), ).toMatchInlineSnapshot(` - " - [full-start] + "[full-start] minmax(16px, 1fr) [breakout-start] minmax(0, 16px) @@ -34,8 +33,37 @@ describe('arbitrary className', () => { minmax(0, 16px) [breakout-end] minmax(16px, 1fr) - [full-end] - " + [full-end]" + `) + + expect( + getArbitraryValue(` + [ + [full-start] + minmax(16px, 1fr) + [breakout-start] + minmax(0, 16px) + [content-start] + minmax(min-content, 1024px) + [content-end] + minmax(0, 16px) + [breakout-end] + minmax(16px, 1fr) + [full-end] + ] + `), + ).toMatchInlineSnapshot(` + "[full-start] + minmax(16px, 1fr) + [breakout-start] + minmax(0, 16px) + [content-start] + minmax(min-content, 1024px) + [content-end] + minmax(0, 16px) + [breakout-end] + minmax(16px, 1fr) + [full-end]" `) }) }) diff --git a/packages/shared/src/arbitrary-value.ts b/packages/shared/src/arbitrary-value.ts index 9dad8d46aa..a5425f482e 100644 --- a/packages/shared/src/arbitrary-value.ts +++ b/packages/shared/src/arbitrary-value.ts @@ -1,5 +1,6 @@ -export const getArbitraryValue = (value: string) => { - if (!value) return value +export const getArbitraryValue = (_value: string) => { + if (!_value || typeof _value !== 'string') return _value + const value = _value.trim() if (value[0] === '[' && value[value.length - 1] === ']') { const innerValue = value.slice(1, -1) @@ -19,7 +20,7 @@ export const getArbitraryValue = (value: string) => { if (bracketCount === 0) { // All brackets are balanced - return innerValue + return innerValue.trim() } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d917d52cf..1baa6a7a77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@typescript-eslint/parser': specifier: 6.2.1 version: 6.2.1(eslint@8.54.0)(typescript@5.3.3) + '@typescript-eslint/rule-tester': + specifier: ^6.19.1 + version: 6.19.1(@eslint/eslintrc@2.1.4)(eslint@8.54.0)(typescript@5.3.3) eslint: specifier: ^8.54.0 version: 8.54.0 @@ -326,6 +329,30 @@ importers: packages/error: {} + packages/eslint-plugin: + dependencies: + '@pandacss/config': + specifier: workspace:* + version: link:../config + '@pandacss/core': + specifier: workspace:* + version: link:../core + '@pandacss/node': + specifier: workspace:* + version: link:../node + '@pandacss/shared': + specifier: workspace:* + version: link:../shared + '@pandacss/types': + specifier: workspace:* + version: link:../types + eslint: + specifier: '*' + version: 8.54.0 + synckit: + specifier: ^0.9.0 + version: 0.9.0 + packages/extractor: dependencies: ts-evaluator: @@ -1333,6 +1360,52 @@ importers: specifier: ^4.4.8 version: 4.4.8(@types/node@20.4.5) + sandbox/vite-eslint: + dependencies: + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + vite-plugin-eslint: + specifier: ^1.8.1 + version: 1.8.1(eslint@8.56.0)(vite@5.0.7) + devDependencies: + '@pandacss/dev': + specifier: workspace:* + version: link:../../packages/cli + '@pandacss/eslint-plugin': + specifier: workspace:* + version: link:../../packages/eslint-plugin + '@pandacss/studio': + specifier: workspace:* + version: link:../../packages/studio + '@types/react': + specifier: 18.2.42 + version: 18.2.42 + '@types/react-dom': + specifier: 18.2.17 + version: 18.2.17 + '@vitejs/plugin-react': + specifier: 4.2.1 + version: 4.2.1(vite@5.0.7) + postcss: + specifier: ^8.4.31 + version: 8.4.31 + source-map-explorer: + specifier: ^2.5.3 + version: 2.5.3 + typescript: + specifier: 5.3.3 + version: 5.3.3 + vite: + specifier: 5.0.7 + version: 5.0.7(@types/node@20.4.5) + vite-bundle-visualizer: + specifier: 0.11.0 + version: 0.11.0 + sandbox/vite-ts: dependencies: react: @@ -8097,6 +8170,11 @@ packages: requiresBuild: true optional: true + /@pkgr/core@0.1.1: + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + dev: false + /@pkgr/utils@2.4.1: resolution: {integrity: sha512-JOqwkgFEyi+OROIyq7l4Jy28h/WwhDnG/cPkXG2Z1iFbubB6jsHW1NDvmyOzTBxHr3yg68YGirmh1JUgMqa+9w==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -9031,7 +9109,6 @@ packages: dependencies: estree-walker: 2.0.2 picomatch: 2.3.1 - dev: true /@rollup/pluginutils@5.0.2(rollup@3.28.0): resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} @@ -11570,6 +11647,25 @@ packages: transitivePeerDependencies: - supports-color + /@typescript-eslint/rule-tester@6.19.1(@eslint/eslintrc@2.1.4)(eslint@8.54.0)(typescript@5.3.3): + resolution: {integrity: sha512-1qvOSO9kjtjP66UimQ06tnZC/XVhb2s5hVi2Cn33efnzM3m+j8rwcGJJ9xwKacUWe7U50iHrY9xrakmF7SPWbg==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@eslint/eslintrc': '>=2' + eslint: '>=8' + dependencies: + '@eslint/eslintrc': 2.1.4 + '@typescript-eslint/typescript-estree': 6.19.1(typescript@5.3.3) + '@typescript-eslint/utils': 6.19.1(eslint@8.54.0)(typescript@5.3.3) + ajv: 6.12.6 + eslint: 8.54.0 + lodash.merge: 4.6.2 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/scope-manager@5.61.0: resolution: {integrity: sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11578,6 +11674,14 @@ packages: '@typescript-eslint/visitor-keys': 5.61.0 dev: false + /@typescript-eslint/scope-manager@6.19.1: + resolution: {integrity: sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 6.19.1 + '@typescript-eslint/visitor-keys': 6.19.1 + dev: true + /@typescript-eslint/scope-manager@6.2.1: resolution: {integrity: sha512-UCqBF9WFqv64xNsIEPfBtenbfodPXsJ3nPAr55mGPkQIkiQvgoWNo+astj9ZUfJfVKiYgAZDMnM6dIpsxUMp3Q==} engines: {node: ^16.0.0 || >=18.0.0} @@ -11658,6 +11762,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: false + /@typescript-eslint/types@6.19.1: + resolution: {integrity: sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + /@typescript-eslint/types@6.2.1: resolution: {integrity: sha512-528bGcoelrpw+sETlyM91k51Arl2ajbNT9L4JwoXE2dvRe1yd8Q64E4OL7vHYw31mlnVsf+BeeLyAZUEQtqahQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -11688,6 +11797,28 @@ packages: - supports-color dev: false + /@typescript-eslint/typescript-estree@6.19.1(typescript@5.3.3): + resolution: {integrity: sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 6.19.1 + '@typescript-eslint/visitor-keys': 6.19.1 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.5.4 + ts-api-utils: 1.0.1(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: true + /@typescript-eslint/typescript-estree@6.2.1(typescript@5.3.3): resolution: {integrity: sha512-G+UJeQx9AKBHRQBpmvr8T/3K5bJa485eu+4tQBxFq0KoT22+jJyzo1B50JDT9QdC1DEmWQfdKsa8ybiNWYsi0Q==} engines: {node: ^16.0.0 || >=18.0.0} @@ -11749,6 +11880,25 @@ packages: - typescript dev: false + /@typescript-eslint/utils@6.19.1(eslint@8.54.0)(typescript@5.3.3): + resolution: {integrity: sha512-JvjfEZuP5WoMqwh9SPAPDSHSg9FBHHGhjPugSRxu5jMfjvBpq5/sGTD+9M9aQ5sh6iJ8AY/Kk/oUYVEMAPwi7w==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) + '@types/json-schema': 7.0.12 + '@types/semver': 7.5.0 + '@typescript-eslint/scope-manager': 6.19.1 + '@typescript-eslint/types': 6.19.1 + '@typescript-eslint/typescript-estree': 6.19.1(typescript@5.3.3) + eslint: 8.54.0 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/utils@6.2.1(eslint@8.54.0)(typescript@5.3.3): resolution: {integrity: sha512-eBIXQeupYmxVB6S7x+B9SdBeB6qIdXKjgQBge2J+Ouv8h9Cxm5dHf/gfAZA6dkMaag+03HdbVInuXMmqFB/lKQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -11795,6 +11945,14 @@ packages: eslint-visitor-keys: 3.4.3 dev: false + /@typescript-eslint/visitor-keys@6.19.1: + resolution: {integrity: sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 6.19.1 + eslint-visitor-keys: 3.4.3 + dev: true + /@typescript-eslint/visitor-keys@6.2.1: resolution: {integrity: sha512-iTN6w3k2JEZ7cyVdZJTVJx2Lv7t6zFA8DCrJEHD2mwfc16AEvvBWVhbFh34XyG2NORCd0viIgQY1+u7kPI0WpA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -11961,6 +12119,22 @@ packages: - supports-color dev: true + /@vitejs/plugin-react@4.2.1(vite@5.0.7): + resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 + dependencies: + '@babel/core': 7.23.5 + '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.5) + '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.5) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.0 + vite: 5.0.7(@types/node@20.4.5) + transitivePeerDependencies: + - supports-color + dev: true + /@vitejs/plugin-vue-jsx@3.0.2(vite@4.3.9)(vue@3.3.4): resolution: {integrity: sha512-obF26P2Z4Ogy3cPp07B4VaW6rpiu0ue4OT2Y15UxT5BZZ76haUY9guOsZV3uWh/I6xc+VeiW+ZVabRE82FyzWw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -23462,6 +23636,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -27279,6 +27460,14 @@ packages: yargs: 17.7.2 dev: true + /rollup@2.79.1: + resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: false + /rollup@3.28.0: resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -28672,6 +28861,14 @@ packages: '@pkgr/utils': 2.4.1 tslib: 2.6.2 + /synckit@0.9.0: + resolution: {integrity: sha512-7RnqIMq572L8PeEzKeBINYEJDDxpcH8JEgLwUqBd3TkofhFRbkq4QLR0u+36avGAhCRbk2nnmjcW9SE531hPDg==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.6.2 + dev: false + /tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} dev: false @@ -30318,6 +30515,19 @@ packages: vscode-uri: 3.0.7 dev: true + /vite-plugin-eslint@1.8.1(eslint@8.56.0)(vite@5.0.7): + resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==} + peerDependencies: + eslint: '>=7' + vite: '>=2' + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.44.1 + eslint: 8.56.0 + rollup: 2.79.1 + vite: 5.0.7(@types/node@20.4.5) + dev: false + /vite-plugin-solid@2.7.0(solid-js@1.7.12)(vite@5.0.12): resolution: {integrity: sha512-avp/Jl5zOp/Itfo67xtDB2O61U7idviaIp4mLsjhCa13PjKNasz+IID0jYTyqUp9SFx6/PmBr6v4KgDppqompg==} peerDependencies: @@ -30545,7 +30755,6 @@ packages: rollup: 4.7.0 optionalDependencies: fsevents: 2.3.3 - dev: true /vitefu@0.2.4(vite@5.0.12): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} diff --git a/sandbox/vite-eslint/.eslintrc.json b/sandbox/vite-eslint/.eslintrc.json new file mode 100644 index 0000000000..e37bc98282 --- /dev/null +++ b/sandbox/vite-eslint/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "plugin:@pandacss/all" + ] +} \ No newline at end of file diff --git a/sandbox/vite-eslint/.gitignore b/sandbox/vite-eslint/.gitignore new file mode 100644 index 0000000000..dafadc4b90 --- /dev/null +++ b/sandbox/vite-eslint/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +## Panda +styled-system +styled-system-studio diff --git a/sandbox/vite-eslint/index.html b/sandbox/vite-eslint/index.html new file mode 100644 index 0000000000..e0d1c84080 --- /dev/null +++ b/sandbox/vite-eslint/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/sandbox/vite-eslint/package.json b/sandbox/vite-eslint/package.json new file mode 100644 index 0000000000..601e0eaa1c --- /dev/null +++ b/sandbox/vite-eslint/package.json @@ -0,0 +1,38 @@ +{ + "name": "sandbox-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "prepare": "panda", + "dev": "vite", + "lint": "eslint --ext .ts,.tsx src", + "lint:fix": "eslint --ext .ts,.tsx --fix src", + "build": "tsc && vite build", + "preview": "vite preview", + "css": "PANDA_DEBUG=ast:* panda", + "css:gen": "PANDA_DEBUG=* panda codegen", + "css:dev": "PANDA_DEBUG=file:* panda --watch", + "explore": "source-map-explorer 'dist/assets/*.js' --no-border-checks", + "analyze": "pnpm panda codegen --clean && ANALYZE=true vite build && pnpm explore", + "visualize": "pnpm panda codegen --clean && vite-bundle-visualizer" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite-plugin-eslint": "^1.8.1" + }, + "devDependencies": { + "@pandacss/dev": "workspace:*", + "@pandacss/eslint-plugin": "workspace:*", + "@pandacss/studio": "workspace:*", + "@types/react": "18.2.42", + "@types/react-dom": "18.2.17", + "@vitejs/plugin-react": "4.2.1", + "postcss": "^8.4.31", + "source-map-explorer": "^2.5.3", + "typescript": "5.3.3", + "vite": "5.0.7", + "vite-bundle-visualizer": "0.11.0" + } +} \ No newline at end of file diff --git a/sandbox/vite-eslint/panda.config.ts b/sandbox/vite-eslint/panda.config.ts new file mode 100644 index 0000000000..b0653c4da7 --- /dev/null +++ b/sandbox/vite-eslint/panda.config.ts @@ -0,0 +1,108 @@ +import { defineConfig, defineRecipe } from '@pandacss/dev' + +const someRecipe = defineRecipe({ + className: 'some-recipe', + base: { color: 'green', fontSize: '16px' }, + variants: { + size: { small: { fontSize: '14px' } }, + }, + compoundVariants: [{ size: 'small', css: { color: 'blue' } }], +}) + +export default defineConfig({ + preflight: true, + include: ['./src/**/*.{tsx,jsx}', './pages/**/*.{jsx,tsx}'], + exclude: [], + outdir: 'styled-system', + jsxFactory: 'panda', + jsxFramework: 'react', + theme: { + semanticTokens: { + colors: { + text: { value: { base: '{colors.gray.600}', _osDark: '{colors.gray.400}' } }, + }, + }, + recipes: { + someRecipe, + button: { + className: 'button', + jsx: ['Button', 'ListedButton', /WithRegex$/, 'PrimaryButtonLike'], + description: 'A button styles', + base: { + fontSize: 'lg', + }, + variants: { + size: { + sm: { + padding: '2', + borderRadius: 'sm', + }, + md: { + padding: '4', + borderRadius: 'md', + }, + }, + variant: { + primary: { + color: 'white', + backgroundColor: 'blue.500', + }, + danger: { + color: 'white', + backgroundColor: 'red.500', + }, + secondary: { + color: 'pink.300', + backgroundColor: 'green.500', + }, + purple: { + color: 'amber.300', + backgroundColor: 'purple.500', + }, + }, + state: { + focused: { + color: 'green', + }, + hovered: { + color: 'pink.400', + }, + }, + rounded: { + true: { + borderRadius: 'md', + }, + }, + }, + compoundVariants: [ + { + size: 'sm', + variant: 'primary', + css: { + fontSize: '12px', + }, + }, + { + variant: ['primary', 'danger'], + state: 'focused', + css: { + padding: 4, + fontWeight: 'bold', + fontSize: '24px', + }, + }, + ], + }, + }, + }, + globalCss: { + '*': { + fontFamily: 'Inter', + margin: '0', + }, + a: { + color: 'inherit', + textDecoration: 'none', + }, + }, +}) diff --git a/sandbox/vite-eslint/postcss.config.cjs b/sandbox/vite-eslint/postcss.config.cjs new file mode 100644 index 0000000000..3457feedc7 --- /dev/null +++ b/sandbox/vite-eslint/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + '@pandacss/dev/postcss': {}, + autoprefixer: {}, + }, +} diff --git a/sandbox/vite-eslint/public/vite.svg b/sandbox/vite-eslint/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/sandbox/vite-eslint/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sandbox/vite-eslint/src/App.tsx b/sandbox/vite-eslint/src/App.tsx new file mode 100644 index 0000000000..15c1b3451a --- /dev/null +++ b/sandbox/vite-eslint/src/App.tsx @@ -0,0 +1,53 @@ +import { defineKeyframes } from '@pandacss/dev' +import { css } from '../styled-system/css' +import { HStack, panda } from '../styled-system/jsx' +import { stack } from '../styled-system/patterns' +import { token } from '../styled-system/tokens' + +const keyframes = defineKeyframes({ + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, +}) + +console.log('keyframes', keyframes) + +const LocalFactoryComp = panda('button') + +function App() { + const className = css({ + bg: 'red.100', + debug: true, + color: '{colors.red.400}', + fontSize: 'token(fontSizes.2xl, 4px)', + marginInline: '{spacings.4} token(spacing.600)', + paddingTop: token('sizes.4'), + }) + const color = 'red' + + return ( +
+ +
Element 1
+ + Element 2 + +
+ {/* Not considered for now */} + +
+ ) +} + +export default App diff --git a/sandbox/vite-eslint/src/index.css b/sandbox/vite-eslint/src/index.css new file mode 100644 index 0000000000..e27a23b774 --- /dev/null +++ b/sandbox/vite-eslint/src/index.css @@ -0,0 +1 @@ +@layer reset, base, tokens, recipes, utilities; diff --git a/sandbox/vite-eslint/src/main.tsx b/sandbox/vite-eslint/src/main.tsx new file mode 100644 index 0000000000..964aeb4c7e --- /dev/null +++ b/sandbox/vite-eslint/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/sandbox/vite-eslint/tsconfig.json b/sandbox/vite-eslint/tsconfig.json new file mode 100644 index 0000000000..94fa4aa842 --- /dev/null +++ b/sandbox/vite-eslint/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "customConditions": ["source"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/sandbox/vite-eslint/tsconfig.node.json b/sandbox/vite-eslint/tsconfig.node.json new file mode 100644 index 0000000000..9d31e2aed9 --- /dev/null +++ b/sandbox/vite-eslint/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/sandbox/vite-eslint/vite.config.ts b/sandbox/vite-eslint/vite.config.ts new file mode 100644 index 0000000000..fc6362f18c --- /dev/null +++ b/sandbox/vite-eslint/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +import eslint from 'vite-plugin-eslint' +const ANALYZE = !!process.env.ANALYZE + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), eslint()], + build: { + sourcemap: ANALYZE, + }, + resolve: { + conditions: ['source'], + }, +}) diff --git a/tests-setup.ts b/tests-setup.ts index 66c4f41f5c..659949031d 100644 --- a/tests-setup.ts +++ b/tests-setup.ts @@ -1,5 +1,8 @@ import { Node } from 'ts-morph' -import { expect } from 'vitest' +import { expect, afterAll } from 'vitest' +import { RuleTester } from '@typescript-eslint/rule-tester' + +RuleTester.afterAll = afterAll expect.addSnapshotSerializer({ serialize(value) { diff --git a/vitest.config.ts b/vitest.config.ts index 2b6b1de243..dbe64307ca 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ root: process.cwd(), plugins: [tsconfigPaths()], test: { + globals: true, setupFiles: ['tests-setup.ts'], hideSkippedTests: true, environment: 'happy-dom', diff --git a/website/pages/docs/references/_meta.json b/website/pages/docs/references/_meta.json index f2339fa864..a53a08258f 100644 --- a/website/pages/docs/references/_meta.json +++ b/website/pages/docs/references/_meta.json @@ -1,4 +1,5 @@ { "cli": "CLI", - "config": "Config" -} + "config": "Config", + "eslint": "ESLint" +} \ No newline at end of file diff --git a/website/pages/docs/references/eslint.mdx b/website/pages/docs/references/eslint.mdx new file mode 100644 index 0000000000..c66ee123a1 --- /dev/null +++ b/website/pages/docs/references/eslint.mdx @@ -0,0 +1,311 @@ +--- +title: ESLint Plugin +description: ESLint plugin for Panda CSS +--- + +# ESLint Plugin + +ESLint plugin for Panda CSS: `@pandacss/eslint-plugin`. + +## Installation + + + + ```bash + pnpm add -D @pandacss/eslint-plugin + ``` + + + ```bash + npm install -D @pandacss/eslint-plugin + ``` + + + ```bash + yarn add -D @pandacss/eslint-plugin + ``` + + + ```bash + bun add -D @pandacss/eslint-plugin + ``` + + + +## Usage + +Add it to your `.eslintrc.json` file: + +```json +{ + "plugins": ["@pandacss"], +} +``` + +Then configure the rules you want to use under the rules section: + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-shorthand-prop": "warn", + } +} +``` + +You can also enable the `recommended` rules in extends: + +```diff +{ +- "plugins": ["@pandacss"] ++ "extends": ["plugin:@pandacss/recommended"] +} +``` + +## Rules + +### file-not-included + +Disallow the use of panda css in files that are not included in the specified panda `include` config. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/file-not-included": "warn", // or error + } +} +``` + + +### no-config-function-in-source + +Prohibit the use of config functions outside the Panda config. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-config-function-in-source": "warn", // or error + } +} +``` + +### no-debug + +Disallow the inclusion of the debug attribute when shipping code to the production environment. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-debug": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ debug: true }) + +// ✅ good +const styles = css({ ... }) +``` + + +### no-dynamic-styling + +Ensure user doesn't use dynamic styling at any point. Prefer to use static styles, leverage css variables or recipes for known dynamic styles. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-dynamic-styling": "warn", // or error + } +} +``` + +```js +// ❌ bad +const color = 'red' +const styles = css({ background: color }) + +// ✅ good +const styles = css({ background: 'red' }) +``` + +### no-escape-hatch + +Prohibit the use of escape hatch syntax in the code. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-escape-hatch": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ background: '[#111]' }) + +// ✅ good +const styles = css({ background: 'gray.900' }) +``` + +### no-hardcoded-color + +Enforce the exclusive use of design tokens as values for colors within the codebase. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-hardcoded-color": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ background: '#111' }) + +// ✅ good +const styles = css({ background: 'gray.100' }) +``` + +### no-invalid-token-paths + +Disallow the use of invalid token paths within token function syntax. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-invalid-token-paths": "warn", // or error + } +} +``` + +```js +// ❌ bad ^ +const styles = css({ border: 'solid 1px {colorss.gray.50}' }) + +// ✅ good +const styles = css({ border: 'solid 1px {colors.gray.50}' }) +``` + +### no-shorthand-prop + +Discourage the use of shorthand properties and promote the preference for longhand CSS properties in the codebase. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-shorthand-prop": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ bgColor: 'gray.100' }) + +// ✅ good +const styles = css({ backgroundColor: 'gray.100' }) +``` + +### no-unsafe-token-fn-usage + +Prevent users from using the token function in situations where they could simply use the raw design token. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/no-unsafe-token-fn-usage": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ backgroundColor: '{colors.gray.100}' }) + +// ✅ good +const styles = css({ border: 'solid 1px {colors.gray.50}' }) +``` + +### prefer-atomic-properties + +Encourage the use of atomic properties instead of composite shorthand properties in the codebase. + +```json +{ + "plugins": ["@pandacss"], + "rules":{ + "@pandacss/prefer-atomic-properties": "warn", // or error + } +} +``` + +```js +// ❌ bad +const styles = css({ gap: '4' }) + +// ✅ good +const styles = css({ columngGap: '4', rowGap: '4' }) +``` + + + +## Configs + +### all + +This configuration activates all rules with the corresponding severity level. + +```json +{ + "extends": ["plugin:@pandacss/all"] +} +``` + +See [configs/all.ts](https://github.com/chakra-ui/css-panda/blob/eslint-plugin/packages/eslint-plugin/src/configs/all.ts) for the exact contents of this config. + +### recommended + +Suggested rules for ensuring code correctness, readily applicable without extra configuration. These rules primarily highlight instances of bad practices and/or potential bugs in most cases. + +```json +{ + "extends": ["plugin:@pandacss/recommended"] +} +``` + +See [configs/recommended.ts](https://github.com/chakra-ui/css-panda/blob/eslint-plugin/packages/eslint-plugin/src/configs/recommended.ts) for the exact contents of this config. + +## Settings + +### `configPath` + +You can tell `eslint` to use a custom panda config file by setting the `configPath` option in your `.eslintrc.js` file. + +By default we find the nearest panda config to the linted file. + +```js filename=".eslintrc.(c)js" +const path = require("path"); + +module.exports = { + plugins: [ + "@pandacss" + ], + settings: { + "@pandacss/configPath": path.join("PATH-TO/panda.config.js") + } +}; +``` \ No newline at end of file