@pandabox/unplugin
Version:
Panda CSS as a Vite/Rollup/Webpack/Esbuild plugin
355 lines (312 loc) • 11.5 kB
text/typescript
import { loadConfig } from '@pandacss/config'
import type { LoadConfigResult, ParserResultBeforeHookArgs, RequiredBy } from '@pandacss/types'
import { createFilter } from '@rollup/pluginutils'
import { type TransformResult, type UnpluginFactory } from 'unplugin'
import type { ModuleNode, Plugin, ViteDevServer } from 'vite'
import { writeFile } from 'node:fs/promises'
import { codegen, PandaContext } from '@pandacss/node'
import { createContext, type PandaPluginContext } from '../plugin/create-context'
import { ensureAbsolute } from './ensure-absolute'
import { tranformPanda, type TransformOptions } from './transform'
import path from 'node:path'
import { addCompoundVariantCss, inlineCva } from './cva-fns'
import type { SourceFile } from 'ts-morph'
import type { OutputAsset } from 'rollup'
import { throttle } from 'es-toolkit'
import { existsSync } from 'node:fs'
const createVirtualModuleId = (id: string) => {
const base = `virtual:panda${id}`
return {
id: base,
resolved: '\0' + base,
}
}
const ids = {
css: createVirtualModuleId('.css'),
inlineCva: createVirtualModuleId('-inline-cva'),
compoundVariants: createVirtualModuleId('-compound-variants'),
}
const pandaPreamble = '/*! PANDA_CSS */'
const throttleWaitMs = 1000
export interface PandaPluginOptions extends Partial<PandaPluginHooks>, Pick<TransformOptions, 'optimizeJs'> {
/** @see https://panda-css.com/docs/references/config#cwd */
cwd?: string
/** @see https://panda-css.com/docs/references/cli#--config--c-1 */
configPath?: string | undefined
/**
* If set, output the generated CSS to the filesystem instead of the virtual module (`virtual:panda.css`).
* @see https://panda-css.com/docs/references/cli#--outfile
*/
outfile?: string | undefined
/**
* @see https://www.npmjs.com/package/@rollup/pluginutils#include-and-exclude
* @default `[/\.[cm]?[jt]sx?$/]`
*/
include?: string | RegExp | (string | RegExp)[]
/**
* @see https://www.npmjs.com/package/@rollup/pluginutils#include-and-exclude
* @default [/node_modules/]
*/
exclude?: string | RegExp | (string | RegExp)[]
/**
* Will remove unused CSS variables and keyframes from the generated CSS
*/
optimizeCss?: boolean
/**
* Perform CSS minification
*
* @default false
*/
minifyCss?: boolean
/**
* Generate a styled-system folder on server start.
*
* @default true
* @deprecated Use 'codegen' instead
*/
codeGen?: boolean
/**
* Generate a styled-system folder on server start.
*
* @default true
*/
codegen?: boolean
}
interface SourceFileHookArgs {
sourceFile: SourceFile
context: PandaContext
}
type MaybePromise<T> = T | Promise<T>
export interface PandaPluginHooks {
contextCreated: (args: { context: PandaContext }) => MaybePromise<void>
/**
* A transform callback similar to the `transform` hook of `vite` that allows you to modify the source code before it's parsed.
* Called before the source file is parsed by ts-morph and Panda.
*/
transform: (
args: Omit<ParserResultBeforeHookArgs, 'configure'> & Pick<SourceFileHookArgs, 'context'>,
) => MaybePromise<TransformResult | void>
/**
* A callback that allows you to modify or use the ts-morph sourceFile before it's parsed by Panda.
* Called after ts-morph has parsed the source file, but before it was parsed by Panda.
*/
onSourceFile: (args: SourceFileHookArgs) => MaybePromise<void>
}
export const unpluginFactory: UnpluginFactory<PandaPluginOptions | undefined> = (rawOptions) => {
const options = resolveOptions(rawOptions ?? {})
const filter = createFilter(options.include, options.exclude)
let outfile = options.outfile ? ensureAbsolute(options.outfile, options.cwd) : ids.css.resolved
let _ctx: PandaPluginContext
let initPromise: Promise<PandaPluginContext> | undefined
const getCtx = async () => {
await init()
if (!_ctx) throw new Error('@pandabox/unplugin context not initialized')
return _ctx as PandaPluginContext
}
const init = () => {
if (initPromise) return initPromise
// console.log('loadConfig', options)
// @ts-expect-error
initPromise = loadConfig({ cwd: options.cwd, file: options.configPath }).then(async (conf: LoadConfigResult) => {
conf.config.cwd = options.cwd
_ctx = createContext({ root: options.cwd, conf, codegen: options.codegen })
if (options.contextCreated) {
await options.contextCreated({ context: _ctx.panda })
}
})
return initPromise
}
let server: ViteDevServer
let lastCss: string | undefined
let updateCssOnTransform = true
/**
* Throttle HMR updates to vite server
*/
const updateCss = async () => {
// console.log('invalidate', { from: file })
const ctx = await getCtx()
const css = await ctx.toCss(ctx.panda.createSheet(), options)
const isCssUpdated = lastCss !== css
lastCss = css
if (!isCssUpdated) return
if (outfile !== ids.css.resolved) {
await writeFile(outfile, css)
} else {
if (!server) return
const mod = server.moduleGraph.getModuleById(outfile.replaceAll('\\', '/'))
if (!mod) return
await server.reloadModule(mod)
}
}
const requestUpdateCss = throttle(updateCss, throttleWaitMs, { edges: ['leading', 'trailing'] })
return {
name: 'unplugin-panda',
enforce: 'pre',
resolveId(id) {
if (id === ids.css.id) {
return ids.css.resolved
}
if (id === ids.inlineCva.id) {
return ids.inlineCva.resolved
}
if (id === ids.compoundVariants.id) {
return ids.compoundVariants.resolved
}
},
async load(id) {
if (id === ids.inlineCva.resolved) {
return `export ${inlineCva.toString()}`
}
if (id === ids.compoundVariants.resolved) {
return `export ${addCompoundVariantCss.toString()}`
}
if (id !== outfile) return
if (!server) return pandaPreamble
const ctx = await getCtx()
const sheet = ctx.panda.createSheet()
const css = await ctx.toCss(sheet, options)
// console.log('load', { id, outfile, resolved: ids.css.resolved })
return css
},
transformInclude(id) {
return filter(id)
},
async transform(code, id) {
// console.log('transform', { id })
const ctx = await getCtx()
const { panda } = ctx
let transformResult: TransformResult = { code, map: undefined }
if (options.transform) {
const result = (await options.transform({ filePath: id, content: code, context: ctx.panda })) || code
if (typeof result === 'string') {
transformResult.code = result
} else if (result) {
transformResult = result
}
}
const sourceFile = panda.project.addSourceFile(id, transformResult.code)
if (options.onSourceFile) {
await options.onSourceFile({ sourceFile, context: ctx.panda })
}
const parserResult = panda.project.parseSourceFile(id)
if (!parserResult) return null
if (!parserResult.isEmpty()) {
ctx.files.set(id, code)
if (updateCssOnTransform) requestUpdateCss()
}
if (!options.optimizeJs) {
return transformResult.code !== code ? transformResult : null
}
// console.log(parserResult.toJSON().css.at(0)?.data)
const result = tranformPanda(ctx, {
code: transformResult.code,
id,
sourceFile,
parserResult,
optimizeJs: options.optimizeJs,
})
return result
},
vite: {
name: 'unplugin-panda',
configResolved(config) {
if (!options.cwd) {
options.cwd = config.configFile ? path.dirname(config.configFile) : config.root
outfile = options.outfile ? ensureAbsolute(options.outfile, options.cwd) : ids.css.resolved
}
updateCssOnTransform = config.command !== 'build'
// console.log('configResolved')
},
async configureServer(_server) {
server = _server
const ctx = await getCtx()
// console.log('configureServer', { outfile, resolved: ids.css.resolved })
if (outfile !== ids.css.resolved) {
if (!existsSync(outfile)) await writeFile(outfile, '')
let prevState = updateCssOnTransform
updateCssOnTransform = false
try {
for (const file of ctx.panda.getFiles()) {
if (path.basename(file) === 'panda.buildinfo.json') {
ctx.panda.project.parseSourceFile(file)
} else {
await server.transformRequest(file)
}
}
} finally {
updateCssOnTransform = prevState
}
await updateCss()
}
// (re) generate the `styled-system` (outdir) on server (re)start
if (options.codegen) {
const { msg } = await codegen(ctx.panda)
}
// console.log(options)
// console.log(ctx.panda.paths.root, ctx.panda.config.cwd)
// console.log('codegen done', msg)
const sources = new Set(
[ctx.panda.conf.path, ...(ctx.panda.conf.dependencies ?? []), ...(ctx.panda.config.dependencies ?? [])].map(
(f) => ensureAbsolute(f, ctx.root),
),
)
sources.forEach((file) => server.watcher.add(file))
server.watcher.on('change', async (file) => {
const filePath = ensureAbsolute(file, ctx.root)
if (!sources.has(filePath)) return
await ctx.reloadContext()
const timestamp = Date.now()
const invalidate = (file: string) => {
const mod = server.moduleGraph.getModuleById(file)
if (mod) {
server.moduleGraph.invalidateModule(mod, new Set(), timestamp, true)
}
}
// Invalidate CSS
invalidate(outfile)
})
},
async generateBundle(_, bundles) {
const cssBundle = Object.values(bundles).find(
(bundle) =>
bundle.type === 'asset' &&
bundle.name?.endsWith('.css') &&
typeof bundle.source === 'string' &&
bundle.source.includes(pandaPreamble),
) as OutputAsset | undefined
if (cssBundle) {
const source = cssBundle.source
const ctx = await getCtx()
const sheet = ctx.panda.createSheet()
const css = await ctx.toCss(sheet, options)
cssBundle.source = (source as string).replace(pandaPreamble, css)
}
},
} as Plugin,
}
}
const resolveOptions = (options: PandaPluginOptions): RequiredBy<PandaPluginOptions, 'cwd' | 'codegen'> => {
let optimizeJs = options.optimizeJs ?? 'auto'
if (typeof optimizeJs === 'object') {
optimizeJs = {
css: optimizeJs.css ?? 'auto',
cva: optimizeJs.cva ?? 'auto',
pattern: optimizeJs.cva ?? 'auto',
recipe: optimizeJs.cva ?? 'auto',
'jsx-factory': optimizeJs.cva ?? 'auto',
'jsx-pattern': optimizeJs.cva ?? 'auto',
...optimizeJs,
}
}
return {
...options,
cwd: options.cwd || '',
configPath: options.configPath,
include: options.include || [/\.[cm]?[jt]sx?$/],
exclude: options.exclude || [/node_modules/, /styled-system/],
optimizeCss: options.optimizeCss ?? true,
minifyCss: options.minifyCss ?? false,
optimizeJs: options.optimizeJs ?? 'macro',
codegen: options.codegen ?? options.codeGen ?? true,
}
}