UNPKG

@pandabox/prettier-plugin

Version:

Prettier plugin for Panda css

526 lines (429 loc) 18.2 kB
import { resolveTsPathPattern } from '@pandacss/config/ts-path' import { type ImportResult } from '@pandacss/core' import type { PandaContext } from '@pandacss/node' import { getPropertyPriority } from '@pandacss/shared' import { TSESTree } from '@typescript-eslint/types' import { simpleTraverse } from '@typescript-eslint/typescript-estree' import type { ParserOptions } from 'prettier' import { getPropPriority, defaultGroupNames, type PriorityGroup, type PriorityGroupName } from './get-priority-index' import type { PluginOptions } from './options' const NodeType = TSESTree.AST_NODE_TYPES const recipeFnNames = ['cva', 'sva', 'defineRecipe', 'defineSlotRecipe'] const cvaOrder = [ 'className', 'description', 'slots', 'base', 'variants', 'defaultVariants', 'compoundVariants', 'staticCss', ] const pandaConfigFns = ['defineStyles', 'defineRecipe', 'defineSlotRecipe'] export class PrettyPanda { priorityGroups: PriorityGroup[] = [] options: PluginOptions constructor( public ast: TSESTree.Program, public context: PandaContext, public prettierOptions?: ParserOptions & Partial<PluginOptions>, ) { this.options = { pandaFirstProps: prettierOptions?.pandaFirstProps?.length ? prettierOptions?.pandaFirstProps : ['as', 'asChild', 'ref', 'className', 'layerStyle', 'textStyle'], pandaLastProps: prettierOptions?.pandaLastProps ?? [], pandaOnlyComponents: prettierOptions?.pandaOnlyComponents ?? false, pandaOnlyIncluded: prettierOptions?.pandaOnlyIncluded ?? false, pandaStylePropsFirst: prettierOptions?.pandaStylePropsFirst ?? false, pandaSortOtherProps: prettierOptions?.pandaSortOtherProps ?? false, pandaGroupOrder: prettierOptions?.pandaGroupOrder?.length ? (prettierOptions?.pandaGroupOrder as any) : defaultGroupNames, pandaFunctions: prettierOptions?.pandaFunctions ?? [], pandaIgnoreComponents: prettierOptions?.pandaIgnoreComponents ?? [], // componentSpecificProps: undefined, // not supported yet } this.priorityGroups = this.generatePriorityGroups(context) } generatePriorityGroups = (context: PandaContext) => { const groups = new Map<PriorityGroupName, Set<string>>([ ['System', new Set(['base', 'colorPalette'])], ['Other', new Set()], ['Conditions', new Set()], ['Arbitrary conditions', new Set()], ['Css', new Set(['css'])], ]) const otherStyleProps = groups.get('Other')! Object.entries(context.utility.config).map(([key, value]) => { if (!value?.group) { otherStyleProps.add(key) return } if (!groups.has(value.group)) { groups.set(value.group, new Set()) } const set = groups.get(value.group)! set.add(key) }) const groupNames = this.options?.pandaGroupOrder const groupPriorities = groupNames.reduce( (acc, key, index) => { acc[key as PriorityGroupName] = index + 1 return acc }, {} as Record<PriorityGroupName, number>, ) const priorityGroups = [] as PriorityGroup[] groups.forEach((keys, _name) => { const name = _name as PriorityGroupName const priorityGroup: PriorityGroup = { name, keys: Array.from(keys).sort((a, b) => { const aPriority = getPropertyPriority(a) const bPriority = getPropertyPriority(b) return aPriority - bPriority }), priority: groupPriorities[name], } priorityGroups.push(priorityGroup) }) const shorthandsMap = context.utility.getPropShorthandsMap() // Prepend shorthands right before their longhand priorityGroups.forEach((group) => { const keys = [] as string[] group.keys.forEach((key) => { const shorthands = shorthandsMap.get(key) if (shorthands?.length) { keys.push(...shorthands) } keys.push(key) }) group.keys = uniq(keys) }) const conditionGroup = priorityGroups.find((g) => g.name === 'Conditions')! // Sort conditions in the same order as in the generated CSS const sortedConditionKeys = context.conditions.getSortedKeys() sortedConditionKeys.forEach((condName) => { conditionGroup.keys.push(condName) }) return priorityGroups } getPriority = (key: string, identifier?: string) => { if (identifier) { const pattern = this.context.patterns.details.find((p) => p.baseName === identifier || p.match.test(identifier)) if (pattern) { const prop = pattern.config.properties?.[key] if (prop && prop.type === 'property' && typeof prop.value === 'string') { return getPropPriority({ key: prop.value, config: this.options, priorityGroups: this.priorityGroups }) } } } return getPropPriority({ key, config: this.options, priorityGroups: this.priorityGroups }) } format = (_text: string) => { const ignoredLines = this.ast.comments ?.filter((comment) => comment.value.startsWith(' prettier-ignore')) .map((comment) => comment.loc.end.line) ?? [] // Only keep imports from panda const importDeclarations = this.getImports().filter((result) => { if (result.mod === '@pandacss/dev' && pandaConfigFns.includes(result.name)) return true return this.context.imports.match(result, (mod) => { const { tsOptions } = this.context.parserOptions if (!tsOptions?.pathMappings) return return resolveTsPathPattern(tsOptions.pathMappings, mod) }) }) const file = this.context.imports.file(importDeclarations) const { jsx } = this.context if (file.isEmpty() && !jsx.isEnabled) { return this.ast } simpleTraverse(this.ast, { enter: (node) => { // sort `<Box ... />` style props if (node.type === NodeType.JSXElement) { // <Box ... /> -> Box let tagName = node.openingElement.name.type === NodeType.JSXIdentifier ? node.openingElement.name.name : '' // <Box ... /> -> Box // <styled.div /> -> styled let tagIdentifier = tagName // <styled.div /> if ( node.openingElement.name.type === NodeType.JSXMemberExpression && node.openingElement.name.object.type === NodeType.JSXIdentifier ) { tagIdentifier = node.openingElement.name.object.name tagName = node.openingElement.name.object.name + '.' + node.openingElement.name.property.name } // <> ... </> if (!tagName) return if (this.options.pandaIgnoreComponents.includes(tagName)) return if (this.options.pandaOnlyComponents) { const isPandaComponent = file.isPandaComponent(tagName) && file.find(tagIdentifier) if (!isPandaComponent) return } else if (!file.matchTag(tagName)) { return } if (ignoredLines.includes(node.loc.start.line - 1)) { return } // sort style props node.openingElement.attributes.sort((a, b) => { if (a.type !== NodeType.JSXAttribute || b.type !== NodeType.JSXAttribute) return 0 if (a.name.type !== NodeType.JSXIdentifier || b.name.type !== NodeType.JSXIdentifier) return 0 return this.compareIdent(a.name, b.name, tagName) }) // sort style props inside css={{ ... }} prop const cssProp = node.openingElement.attributes.find( (attr) => attr.type === NodeType.JSXAttribute && attr.name.name.toString() === 'css', ) if ( cssProp && cssProp.type === NodeType.JSXAttribute && cssProp.value?.type === NodeType.JSXExpressionContainer && cssProp.value.expression.type === NodeType.ObjectExpression ) { cssProp.value.expression.properties.sort((a, b) => { if (a.type !== NodeType.Property || b.type !== NodeType.Property) return 0 if (a.key.type !== NodeType.Identifier || b.key.type !== NodeType.Identifier) return 0 return this.compareIdent(a.key, b.key) }) } } // sort `css({ ... })` call expression arguments if (node.type === NodeType.CallExpression) { if (node.callee.type !== NodeType.Identifier && node.callee.type !== NodeType.MemberExpression) return // css({ ... }) const names = this.getFnName(node) if (!names) return const fnName = names.fnName // also sort patterns (e.g. `stack.raw({ direction: "row", mt: "4" })`) const foundImport = file.find(fnName) const isRuntimeFn = file.matchFn(fnName) && foundImport // and also sort config functions `defineStyles`, `defineRecipe`, `defineSlotRecipe` // or custom functions from the `pandaFunctions` option const isPandaFn = isRuntimeFn || (foundImport && foundImport.mod === '@pandacss/dev') || pandaConfigFns.includes(fnName) || this.options.pandaFunctions.includes(fnName) || this.options.pandaFunctions.includes(fnName + '.' + names.fnIdentifier) if (!isPandaFn) return if (ignoredLines.includes(node.loc.start.line - 1)) { return } this.sortFunction(node, fnName) } }, }) return this.ast } sortObjectProperties = (node: TSESTree.Node, identifier?: string) => { if (node.type !== NodeType.ObjectExpression) return node.properties = this.sortProps(node.properties, identifier) } sortFunction = (node: TSESTree.CallExpression, fnName: string) => { const kind = this.guessFnKind(node, fnName) if (!kind) return return kind === 'atomic' ? this.sortCssFn(node, fnName) : this.sortCvaConfig(node, kind) } getFnName = (node: TSESTree.CallExpression) => { if (node.callee.type !== NodeType.Identifier && node.callee.type !== NodeType.MemberExpression) return let fnName = '' let fnIdentifier = '' if (node.callee.type === NodeType.Identifier) { fnName = node.callee.name } else if (node.callee.object.type === NodeType.Identifier) { fnName = node.callee.object.name if (node.callee.property.type === NodeType.Identifier) { fnIdentifier = node.callee.property.name } else if (node.callee.property.type === NodeType.Literal && typeof node.callee.property.value === 'string') { fnIdentifier = node.callee.property.value } } return { fnName, fnIdentifier } } guessFnKind = (node: TSESTree.CallExpression, fnName: string) => { if (node.callee.type !== NodeType.Identifier && node.callee.type !== NodeType.MemberExpression) return if (fnName === 'css') { return 'atomic' } if (recipeFnNames.includes(fnName)) { return recipeFnNamesToType[fnName as keyof typeof recipeFnNamesToType] } const firstArgument = node.arguments[0] if (firstArgument) { if (firstArgument.type === NodeType.ObjectExpression) { const propNames = firstArgument.properties.map((prop) => { if (prop.type !== NodeType.Property) return if (prop.key.type !== NodeType.Identifier) return return prop.key.name }) const isSva = propNames.includes('slots') const isCva = cvaOrder.some((key) => propNames.includes(key)) if (isSva) return 'slot-recipe' if (isCva) return 'recipe' } } return 'atomic' } sortCssFn = (node: TSESTree.CallExpression, fnName: string) => { // css / css.raw / {pattern(?.raw)} node.arguments.forEach((arg) => { this.sortObjectProperties(arg, fnName) }) } sortCvaConfig = (node: TSESTree.CallExpression, kind: FnKind) => { node.arguments.forEach((arg) => { if (arg.type !== NodeType.ObjectExpression) return // Sort cva root keys in predefined order arg.properties.sort((a, b) => { if (a.type !== NodeType.Property || b.type !== NodeType.Property) return 0 if (a.key.type !== NodeType.Identifier || b.key.type !== NodeType.Identifier) return 0 return cvaOrder.indexOf(a.key.name) - cvaOrder.indexOf(b.key.name) }) // Sort each variants styles object arg.properties.forEach((prop) => { if (prop.type !== NodeType.Property) return if (prop.key.type !== NodeType.Identifier) return if (prop.key.name === 'base') { if (kind === 'slot-recipe') { const base = prop.value if (base.type !== NodeType.ObjectExpression) return base.properties.forEach((slot) => { if (slot.type !== NodeType.Property) return if (slot.key.type !== NodeType.Identifier) return this.sortObjectProperties(slot.value) }) } else { this.sortObjectProperties(prop.value) } } else if (prop.key.name === 'variants') { const variants = prop.value if (variants.type !== NodeType.ObjectExpression) return variants.properties.forEach((variant) => { if (variant.type !== NodeType.Property) return const variantObj = variant.value if (variantObj.type !== NodeType.ObjectExpression) return variantObj.properties.forEach((variantProp) => { if (variantProp.type !== NodeType.Property) return if (variantProp.key.type !== NodeType.Identifier) return const styles = variantProp.value if (styles.type !== NodeType.ObjectExpression) return if (kind === 'slot-recipe') { styles.properties.forEach((slotObj) => { if (slotObj.type !== NodeType.Property) return if (slotObj.key.type !== NodeType.Identifier) return const slotStyles = slotObj.value if (slotStyles.type !== NodeType.ObjectExpression) return this.sortObjectProperties(slotStyles) }) } else { this.sortObjectProperties(styles) } }) }) } }) }) } sortProps = (unsorted: (TSESTree.Property | TSESTree.SpreadElement)[], identifier?: string) => { const noSpread = isOnlyProperties(unsorted) if (noSpread) { const sorted = [...unsorted].sort((a, b) => this.compareProp(a, b, identifier)) return this.sortNestedProps(sorted, identifier) } // contains SpreadElement // Sort sections which has only Properties. let start = 0 let end = 0 let sorted: (TSESTree.Property | TSESTree.SpreadElement)[] = [] for (let i = 0; i < unsorted.length; i++) { if (unsorted[i].type === NodeType.SpreadElement) { end = i if (start < end) { // Sort sections which don't have SpreadElement. const sectionToSort = unsorted.slice(start, end) as TSESTree.Property[] const sectionSorted = sectionToSort.sort((a, b) => this.compareProp(a, b, identifier)) sorted = sorted.concat(sectionSorted) } // SpreadElement will be pushed as is. sorted.push(unsorted[i]) start = i + 1 } else if (i === unsorted.length - 1) { // This is last property and not spread one. end = i + 1 if (start < end) { const sectionToSort = unsorted.slice(start, end) as TSESTree.Property[] const sectionSorted = sectionToSort.sort((a, b) => this.compareProp(a, b, identifier)) sorted = sorted.concat(sectionSorted) } } } return this.sortNestedProps(sorted, identifier) } sortNestedProps = (props: (TSESTree.Property | TSESTree.SpreadElement)[], identifier?: string) => { return props.map((prop) => { if (prop.type === NodeType.Property && prop.value.type === NodeType.ObjectExpression) { prop.value.properties = this.sortProps(prop.value.properties, identifier) } return prop }) } compareProp = (a: TSESTree.Property, b: TSESTree.Property, identifier?: string) => { if (a.type !== NodeType.Property || b.type !== NodeType.Property) return 0 // Sort arbitrary conditions last if (a.key.type === NodeType.Literal) return 1 if (b.key.type === NodeType.Literal) return -1 if (a.key.type !== NodeType.Identifier || b.key.type !== NodeType.Identifier) return 0 return this.compareIdent(a.key, b.key, identifier) } compareIdent = ( a: TSESTree.Identifier | TSESTree.JSXIdentifier, b: TSESTree.Identifier | TSESTree.JSXIdentifier, identifier?: string, ) => { const aPriority = this.getPriority(a.name.toString(), identifier) const bPriority = this.getPriority(b.name.toString(), identifier) if (aPriority !== bPriority) { return aPriority - bPriority } // Sort other props alphabetically if (this.options.pandaSortOtherProps) { return a.name < b.name ? -1 : 1 } return 0 } getImports = () => { const imports: ImportResult[] = [] this.ast.body.forEach((node) => { if (node.type !== NodeType.ImportDeclaration) return const mod = node.source.value if (!mod) return node.specifiers.forEach((specifier) => { if (specifier.type !== NodeType.ImportSpecifier) return const name = specifier.imported.name const alias = specifier.local.name const result = { name, alias, mod } imports.push(result) }) }) return imports } } const uniq = <T>(...items: T[][]): T[] => items.filter(Boolean).reduce<T[]>((acc, item) => Array.from(new Set([...acc, ...item])), []) const isOnlyProperties = ( properties: (TSESTree.Property | TSESTree.SpreadElement)[], ): properties is TSESTree.Property[] => { return properties.every((property) => property.type === NodeType.Property) } const recipeFnNamesToType = { cva: 'recipe', sva: 'slot-recipe', defineRecipe: 'recipe', defineSlotRecipe: 'slot-recipe', } as const type FnKind = 'atomic' | 'recipe' | 'slot-recipe'