@pandabox/prettier-plugin
Version:
Prettier plugin for Panda css
526 lines (429 loc) • 18.2 kB
text/typescript
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'