Skip to content

Commit 8aa3c64

Browse files
committed
feat(cssgen): splitting
1 parent fd13554 commit 8aa3c64

File tree

11 files changed

+288
-33
lines changed

11 files changed

+288
-33
lines changed

.changeset/css-splitting-mode.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@pandacss/node': minor
3+
'@pandacss/generator': minor
4+
'@pandacss/cli': minor
5+
---
6+
7+
Add `--splitting` flag to `cssgen` command for per-layer CSS output.
8+
9+
When enabled, CSS is emitted as separate files instead of a single `styles.css`:
10+
11+
```
12+
styled-system/
13+
├── styles.css # @layer declaration + @imports
14+
└── styles/
15+
├── reset.css # Preflight/reset CSS
16+
├── global.css # Global CSS
17+
├── tokens.css # Design tokens
18+
├── utilities.css # Utility classes
19+
├── recipes/
20+
│ ├── index.css # @imports all recipe files
21+
│ └── {recipe}.css # Individual recipe styles
22+
└── themes/
23+
└── {theme}.css # Theme tokens (not auto-imported)
24+
```
25+
26+
Usage:
27+
28+
```bash
29+
panda cssgen --splitting
30+
```

packages/cli/src/cli-main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,12 @@ export async function main() {
185185
.option('--polyfill', 'Polyfill CSS @layers at-rules for older browsers.')
186186
.option('-p, --poll', 'Use polling instead of filesystem events when watching')
187187
.option('-o, --outfile [file]', "Output file for extracted css, default to './styled-system/styles.css'")
188+
.option('--splitting', 'Emit CSS as separate files per layer (reset, global, tokens, utilities) and per recipe')
188189
.option('--cwd <cwd>', 'Current working directory', { default: cwd })
189190
.option('--cpu-prof', 'Generates a `.cpuprofile` to help debug performance issues')
190191
.option('--logfile <file>', 'Outputs logs to a file')
191192
.action(async (maybeGlob?: string, flags: CssGenCommandFlags = {}) => {
192-
const { silent, config: configPath, outfile, watch, poll, minimal, ...rest } = flags
193+
const { silent, config: configPath, outfile, watch, poll, minimal, splitting, ...rest } = flags
193194

194195
const cwd = resolve(flags.cwd ?? '')
195196
const stream = setLogStream({ cwd, logfile: flags.logfile })
@@ -225,6 +226,7 @@ export async function main() {
225226
outfile,
226227
type: cssArtifact,
227228
minimal,
229+
splitting,
228230
}
229231

230232
await cssgen(ctx, options)

packages/cli/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface CssGenCommandFlags {
2525
polyfill?: boolean
2626
cpuProf?: boolean
2727
logfile?: string
28+
splitting?: boolean
2829
}
2930

3031
export interface StudioCommandFlags extends Pick<Config, 'cwd'> {

packages/config/src/config-deps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ const artifactConfigDeps: Record<ArtifactId, ConfigPath[]> = {
7373
themes: ['themes'].concat(tokens),
7474
// staticCss depends on tokens (for wildcards) and recipes (for recipe rules)
7575
'static-css': ['staticCss', 'patterns', 'theme.recipes', 'theme.slotRecipes'].concat(tokens),
76+
// Split CSS artifacts (generated via cssgen --splitting)
77+
styles: [],
78+
'styles.css': [],
7679
}
7780

7881
// Prepare a list of regex that resolves to an artifact id from a list of config paths

packages/core/src/stylesheet.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ export class Stylesheet {
9494
})
9595
}
9696

97+
/**
98+
* Process only the styles for a specific recipe from the decoder
99+
*/
100+
processDecoderForRecipe = (decoder: StyleDecoder, recipeName: string) => {
101+
const recipeSet = decoder.recipes.get(recipeName)
102+
if (recipeSet) {
103+
recipeSet.forEach((recipe) => {
104+
this.processCss(recipe.result, recipe.entry.slot ? 'recipes_slots' : 'recipes')
105+
})
106+
}
107+
108+
// Process recipe base styles (may include slot-specific keys like "button__root")
109+
decoder.recipes_base.forEach((baseSet, recipeKey) => {
110+
// recipeKey could be "button" or "button__root" for slot recipes
111+
const [baseRecipeName] = recipeKey.split('__')
112+
if (baseRecipeName === recipeName) {
113+
baseSet.forEach((recipe) => {
114+
this.processCss(recipe.result, recipe.slot ? 'recipes_slots_base' : 'recipes_base')
115+
})
116+
}
117+
})
118+
}
119+
97120
getLayerCss = (...layers: CascadeLayer[]) => {
98121
const breakpoints = this.context.conditions.breakpoints
99122
return optimizeCss(

packages/generator/src/artifacts/js/themes.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,42 @@ import { stringifyVars } from '../css/token-css'
66

77
const getThemeId = (themeName: string) => 'panda-theme-' + themeName
88

9-
export function generateThemes(ctx: Context) {
10-
const { themes } = ctx.config
11-
if (!themes) return
12-
9+
/**
10+
* Get CSS for a specific theme
11+
*/
12+
export function getThemeCss(ctx: Context, themeName: string): string {
1313
const { tokens, conditions } = ctx
14-
15-
return Object.entries(themes).map(([name, _themeVariant]) => {
16-
const results = [] as string[]
17-
const condName = ctx.conditions.getThemeName(name)
18-
19-
for (const [key, values] of tokens.view.vars.entries()) {
20-
if (key.startsWith(condName)) {
21-
const css = stringifyVars({ values, conditionKey: key, root: '', conditions })
22-
if (css) {
23-
results.push(css)
24-
}
14+
const results: string[] = []
15+
const condName = conditions.getThemeName(themeName)
16+
17+
for (const [key, values] of tokens.view.vars.entries()) {
18+
if (key.startsWith(condName)) {
19+
const css = stringifyVars({ values, conditionKey: key, root: '', conditions })
20+
if (css) {
21+
results.push(css)
2522
}
2623
}
24+
}
2725

28-
return {
29-
name: `theme-${name}`,
30-
json: JSON.stringify(
31-
compact({
32-
name,
33-
id: getThemeId(name),
34-
css: results.join('\n\n'),
35-
}),
36-
null,
37-
2,
38-
),
39-
}
40-
})
26+
return results.join('\n\n')
27+
}
28+
29+
export function generateThemes(ctx: Context) {
30+
const { themes } = ctx.config
31+
if (!themes) return
32+
33+
return Object.entries(themes).map(([name, _themeVariant]) => ({
34+
name: `theme-${name}`,
35+
json: JSON.stringify(
36+
compact({
37+
name,
38+
id: getThemeId(name),
39+
css: getThemeCss(ctx, name),
40+
}),
41+
null,
42+
2,
43+
),
44+
}))
4145
}
4246

4347
export function generateThemesIndex(ctx: Context, files: ReturnType<typeof generateThemes>) {

packages/generator/src/generator.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Context, type StyleDecoder, type Stylesheet } from '@pandacss/core'
2-
import { PandaError } from '@pandacss/shared'
2+
import { dashCase, PandaError } from '@pandacss/shared'
33
import type { ArtifactId, CssArtifactType, LoadConfigResult } from '@pandacss/types'
44
import { match } from 'ts-pattern'
55
import { generateArtifacts } from './artifacts'
@@ -9,6 +9,29 @@ import { generateParserCss } from './artifacts/css/parser-css'
99
import { generateResetCss } from './artifacts/css/reset-css'
1010
import { generateStaticCss } from './artifacts/css/static-css'
1111
import { generateTokenCss } from './artifacts/css/token-css'
12+
import { getThemeCss } from './artifacts/js/themes'
13+
14+
export interface SplitCssArtifact {
15+
type: 'layer' | 'recipe' | 'theme'
16+
name: string
17+
file: string
18+
code: string
19+
/** Directory relative to styles/ */
20+
dir?: string
21+
}
22+
23+
export interface SplitCssResult {
24+
/** Layer CSS files (reset, global, tokens, utilities) */
25+
layers: SplitCssArtifact[]
26+
/** Recipe CSS files */
27+
recipes: SplitCssArtifact[]
28+
/** Theme CSS files (not auto-imported) */
29+
themes: SplitCssArtifact[]
30+
/** Content for recipes/index.css */
31+
recipesIndex: string
32+
/** Content for main styles.css */
33+
index: string
34+
}
1235

1336
export class Generator extends Context {
1437
constructor(conf: LoadConfigResult) {
@@ -65,4 +88,111 @@ export class Generator extends Context {
6588

6689
return css
6790
}
91+
92+
/**
93+
* Get CSS for a specific layer from the stylesheet
94+
*/
95+
getLayerCss = (sheet: Stylesheet, layer: 'reset' | 'base' | 'tokens' | 'recipes' | 'utilities') => {
96+
return sheet.getLayerCss(layer)
97+
}
98+
99+
/**
100+
* Get CSS for a specific recipe
101+
*/
102+
getRecipeCss = (recipeName: string) => {
103+
const sheet = this.createSheet()
104+
const decoder = this.decoder.collect(this.encoder)
105+
sheet.processDecoderForRecipe(decoder, recipeName)
106+
return sheet.getLayerCss('recipes')
107+
}
108+
109+
/**
110+
* Get all recipe names from the decoder
111+
*/
112+
getRecipeNames = () => {
113+
const decoder = this.decoder.collect(this.encoder)
114+
return Array.from(decoder.recipes.keys())
115+
}
116+
117+
/**
118+
* Get all split CSS artifacts for the stylesheet
119+
* Used when --splitting flag is enabled
120+
*/
121+
getSplitCssArtifacts = (sheet: Stylesheet): SplitCssResult => {
122+
const layerNames = this.config.layers as Record<string, string>
123+
const decoder = this.decoder.collect(this.encoder)
124+
125+
// Layer artifacts
126+
const layerDefs = [
127+
{ name: 'reset', file: 'reset.css', css: sheet.getLayerCss('reset') },
128+
{ name: 'global', file: 'global.css', css: sheet.getLayerCss('base') },
129+
{ name: 'tokens', file: 'tokens.css', css: sheet.getLayerCss('tokens') },
130+
{ name: 'utilities', file: 'utilities.css', css: sheet.getLayerCss('utilities') },
131+
]
132+
133+
const layers: SplitCssArtifact[] = layerDefs
134+
.filter((l) => l.css.trim())
135+
.map((l) => ({
136+
type: 'layer' as const,
137+
name: l.name,
138+
file: l.file,
139+
code: l.css,
140+
}))
141+
142+
// Recipe artifacts
143+
const recipes: SplitCssArtifact[] = []
144+
for (const recipeName of decoder.recipes.keys()) {
145+
const recipeSheet = this.createSheet()
146+
recipeSheet.processDecoderForRecipe(decoder, recipeName)
147+
const code = recipeSheet.getLayerCss('recipes')
148+
if (code.trim()) {
149+
recipes.push({
150+
type: 'recipe',
151+
name: recipeName,
152+
file: `${dashCase(recipeName)}.css`,
153+
code,
154+
dir: 'recipes',
155+
})
156+
}
157+
}
158+
159+
// Theme artifacts (not auto-imported in styles.css)
160+
const themes: SplitCssArtifact[] = []
161+
if (this.config.themes) {
162+
for (const themeName of Object.keys(this.config.themes)) {
163+
const css = getThemeCss(this, themeName)
164+
if (css.trim()) {
165+
themes.push({
166+
type: 'theme',
167+
name: themeName,
168+
file: `${dashCase(themeName)}.css`,
169+
code: `@layer ${layerNames.tokens} {\n${css}\n}`,
170+
dir: 'themes',
171+
})
172+
}
173+
}
174+
}
175+
176+
// Build recipes/index.css content
177+
const recipesIndex = recipes.map((r) => `@import './${r.file}';`).join('\n')
178+
179+
// Build main styles.css content
180+
const layerOrder = [layerNames.reset, layerNames.base, layerNames.tokens, layerNames.recipes, layerNames.utilities]
181+
const imports = [`@layer ${layerOrder.join(', ')};`, '']
182+
183+
for (const layer of layers) {
184+
imports.push(`@import './styles/${layer.file}';`)
185+
}
186+
if (recipes.length) {
187+
imports.push(`@import './styles/recipes/index.css';`)
188+
}
189+
190+
return {
191+
layers,
192+
recipes,
193+
themes,
194+
recipesIndex,
195+
index: imports.join('\n'),
196+
}
197+
}
68198
}

packages/generator/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './generator'
2+
export { getThemeCss } from './artifacts/js/themes'

packages/node/src/create-context.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,68 @@ export class PandaContext extends Generator {
110110
writeCss = (sheet?: Stylesheet) => {
111111
logger.info('css', this.runtime.path.join(...this.paths.root, 'styles.css'))
112112
return this.output.write({
113-
id: 'styles.css' as any,
113+
id: 'styles.css',
114114
dir: this.paths.root,
115115
files: [{ file: 'styles.css', code: this.getCss(sheet) }],
116116
})
117117
}
118118

119+
writeSplitCss = async (sheet: Stylesheet) => {
120+
const { path: pathUtil, fs } = this.runtime
121+
const rootDir = this.paths.root
122+
const stylesDir = [...rootDir, 'styles']
123+
124+
// Get all artifacts from the generator
125+
const artifacts = this.getSplitCssArtifacts(sheet)
126+
127+
// Derive and create directories from artifacts
128+
const subDirs = new Set([...artifacts.recipes, ...artifacts.themes].map((a) => a.dir).filter(Boolean))
129+
fs.ensureDirSync(pathUtil.join(...stylesDir))
130+
subDirs.forEach((dir) => fs.ensureDirSync(pathUtil.join(...stylesDir, dir!)))
131+
132+
// Collect all files for batched write
133+
const styleFiles: Array<{ file: string; code: string }> = []
134+
135+
// Layer files
136+
for (const layer of artifacts.layers) {
137+
styleFiles.push({ file: layer.file, code: layer.code })
138+
logger.info('css', pathUtil.join(...stylesDir, layer.file))
139+
}
140+
141+
// Recipe files
142+
for (const recipe of artifacts.recipes) {
143+
styleFiles.push({ file: `${recipe.dir}/${recipe.file}`, code: recipe.code })
144+
logger.info('css', pathUtil.join(...stylesDir, recipe.dir!, recipe.file))
145+
}
146+
147+
// Recipes index
148+
if (artifacts.recipes.length) {
149+
styleFiles.push({ file: `${artifacts.recipes[0].dir}/index.css`, code: artifacts.recipesIndex })
150+
logger.info('css', pathUtil.join(...stylesDir, artifacts.recipes[0].dir!, 'index.css'))
151+
}
152+
153+
// Theme files
154+
for (const theme of artifacts.themes) {
155+
styleFiles.push({ file: `${theme.dir}/${theme.file}`, code: theme.code })
156+
logger.info('css', pathUtil.join(...stylesDir, theme.dir!, theme.file))
157+
}
158+
159+
// Write all split files to styles/ directory
160+
await this.output.write({
161+
id: 'styles',
162+
dir: stylesDir,
163+
files: styleFiles,
164+
})
165+
166+
// Write main styles.css
167+
logger.info('css', pathUtil.join(...rootDir, 'styles.css'))
168+
await this.output.write({
169+
id: 'styles.css',
170+
dir: rootDir,
171+
files: [{ file: 'styles.css', code: artifacts.index }],
172+
})
173+
}
174+
119175
watchConfig = (cb: (file: string) => void | Promise<void>, opts?: Omit<WatchOptions, 'include'>) => {
120176
const { cwd, poll, exclude } = opts ?? {}
121177
logger.info('ctx:watch', this.messages.configWatch())

0 commit comments

Comments
 (0)