Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_bun_plugin_build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/bun-plugin/package.json": "Patch"
},
"note": "Bun plugin works with Bun.build: `DevupUI()` (from `@devup-ui/bun-plugin/register`) serves the generated stylesheet to the bundler's CSS loader once every other module is loaded, so the build emits a CSS output with the styles of the whole bundle instead of an empty module; class names are short there and readable under the runtime (`debug` option). Files importing the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), `@stylexjs/stylex`, or a subpath or re-export of these or `@devup-ui/react`, are compiled instead of skipped",
"date": "2026-09-30T00:00:00.000Z"
}
26 changes: 23 additions & 3 deletions packages/bun-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,29 @@ Add the zero-config entry to Bun's preload list:
preload = ["@devup-ui/bun-plugin"]
```

## Custom Shorthands

To configure custom shorthands, preload a local module instead of the
## Bundling with `Bun.build`

```ts
import { DevupUI } from '@devup-ui/bun-plugin/register'

await Bun.build({
entrypoints: ['./src/index.tsx'],
outdir: './dist',
plugins: [DevupUI()],
})
```

The build emits the stylesheet as a CSS output holding the styles of every
module in the bundle. Imports of the packages Devup UI takes the place of
(`@emotion/react`, `@emotion/styled`, `styled-components`,
`@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too.

Class names are short in `Bun.build` and readable under the runtime; pass
`debug` to choose.

## Custom Shorthands

To configure custom shorthands, preload a local module instead of the
zero-config entry:

```toml
Expand Down
91 changes: 91 additions & 0 deletions packages/bun-plugin/__regression__/css-emit.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,97 @@ await import(cssPath)
},
)

const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs')

function run(cwd: string, script: string) {
writeFileSync(join(cwd, 'bunfig.toml'), '')
writeFileSync(join(cwd, 'check.ts'), script)
const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], {
cwd,
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' },
})
expect(
result.exitCode,
result.stdout.toString() + result.stderr.toString(),
).toBe(0)
}

it('bundles the styles of every module into the Bun.build stylesheet', () => {
const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
try {
const widths = Array.from({ length: 8 }, (_, i) => 301 + i)
for (const width of widths) {
writeFileSync(
join(cwd, `fixture-${width}.ts`),
`import { css } from '@devup-ui/react'
export const cls = css({ width: '${width}px' })
`,
)
}
writeFileSync(
join(cwd, 'entry.ts'),
widths
.map((width) => `export { cls as c${width} } from './fixture-${width}'`)
.join('\n'),
)
run(
cwd,
`import { expect } from 'bun:test'
import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))}

const result = await Bun.build({
entrypoints: ['./entry.ts'],
outdir: './out',
plugins: [DevupUI()],
})
expect(result.success).toBe(true)
const stylesheets = result.outputs.filter((output) => output.path.endsWith('.css'))
expect(stylesheets).toHaveLength(1)
const css = (await stylesheets[0].text()).replace(/\\s+/g, '')
for (const width of ${JSON.stringify(widths)}) expect(css).toContain('width:' + width + 'px')
`,
)
} finally {
rmSync(cwd, { recursive: true, force: true })
}
})

it('compiles the packages Devup UI takes the place of, and StyleX', () => {
const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
try {
writeFileSync(
join(cwd, 'emotion.ts'),
`import { css } from '@emotion/react'
export const cls = css({ width: '201px' })
`,
)
writeFileSync(
join(cwd, 'stylex.ts'),
`import * as stylex from '@stylexjs/stylex'
export const styles = stylex.create({ box: { width: '203px' } })
`,
)
run(
cwd,
`import { readFileSync } from 'node:fs'
import { expect } from 'bun:test'

await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))})
const emotion = await import('./emotion.ts')
const stylex = await import('./stylex.ts')
expect(emotion.cls).toBeTruthy()
expect(stylex.styles).toBeTruthy()
const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')
for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px')
`,
)
} finally {
rmSync(cwd, { recursive: true, force: true })
}
})

it('emits vanilla-extract styles through the WASM engine', () => {
const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
try {
Expand Down
109 changes: 78 additions & 31 deletions packages/bun-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ import {
codeExtract,
getCss,
getThemeInterface,
hasDevupUI,
registerShorthands,
registerTheme,
setDebug,
setModuleResolver,
} from '@devup-ui/wasm'
import { plugin } from 'bun'
import { type BunPlugin, plugin, type PluginBuilder } from 'bun'

import { cssDirName, cssNamespace, resolveCssId } from './css-id'

Expand All @@ -30,11 +29,25 @@ const distDir = 'df'
const cssDir = resolve(distDir, cssDirName)
const singleCss = true
const importAliases = mergeImportAliases()
// The packages whose imports the extractor compiles: Devup UI, the packages it
// takes the place of, and StyleX
const compiledPackages = [
libPackage,
'@stylexjs/stylex',
...Object.keys(importAliases),
]

export interface DevupUIBunPluginOptions {
shorthands?: CustomShorthands
/**
* Readable class names. Defaults to `true` under the Bun runtime (tests) and
* `false` in `Bun.build`.
*/
debug?: boolean
}

type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js'

async function writeDataFiles() {
let theme = {}
try {
Expand Down Expand Up @@ -71,16 +84,31 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) {
await writeDataFiles()
}

// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a
// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the
// script/data loaders). The injected import exists so bundlers pick the
// stylesheet up, so under the Bun runtime it resolves to an empty module.
function loadCssModule() {
return { contents: '', loader: 'js' as const }
const scanners = new Map<SourceLoader, Bun.Transpiler>()

/** Whether `contents` imports a package the extractor compiles */
function importsCompiledPackage(contents: string, loader: SourceLoader) {
let scanner = scanners.get(loader)
if (!scanner) {
scanner = new Bun.Transpiler({ loader })
scanners.set(loader, scanner)
}
try {
return scanner
.scanImports(contents)
.some(({ path }) =>
compiledPackages.some(
(name) => path === name || path.startsWith(`${name}/`),
),
)
} catch {
// Bun reports the syntax error when it loads the untouched source
return false
}
}

async function loadSourceFile(filePath: string) {
const loader: 'tsx' | 'ts' | 'jsx' | 'js' = filePath.endsWith('.tsx')
async function loadSourceFile(filePath: string, bundling: boolean) {
const loader: SourceLoader = filePath.endsWith('.tsx')
? 'tsx'
: filePath.endsWith('.ts')
? 'ts'
Expand All @@ -89,7 +117,7 @@ async function loadSourceFile(filePath: string) {
: 'js'
const contents = await Bun.file(filePath).text()

if (hasDevupUI(filePath, contents, libPackage)) {
if (importsCompiledPackage(contents, loader)) {
const code = codeExtract(
filePath,
contents,
Expand All @@ -100,29 +128,29 @@ async function loadSourceFile(filePath: string) {
false,
importAliases,
)
// singleCss stores every extracted style in the base sheet. Finish the
// write before returning the injected import; synchronous writes also keep
// Under the runtime the stylesheet is read from disk. singleCss stores
// every extracted style in the base sheet; synchronous writes keep
// concurrent source loads from overwriting a newer sheet with an older one.
writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8')
if (!bundling)
writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8')
return { contents: code.code, loader }
}
return { contents, loader }
}

// Registers the Bun plugin. Returns the promise produced by `plugin()` (its
// `setup` is async), so callers MUST `await` it. Bun's preload mechanism waits
// for an awaited module evaluation to settle; awaiting this guarantees the
// `onLoad` hook is installed before any source file is loaded. Without the
// await, preload-driven `bun test` users race the async setup and load sources
// against the @devup-ui/react runtime stubs (throwing "Cannot run on the
// runtime").
function register(options: DevupUIBunPluginOptions = {}) {
return plugin({
/**
* The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the
* Bun runtime ({@link register}).
*/
function DevupUI(options: DevupUIBunPluginOptions = {}) {
return {
name: 'devup-ui',

async setup(build) {
async setup(build: PluginBuilder) {
// `Bun.build` hands its config to plugins; the runtime has none
const bundling = build.config !== undefined
await initialize(options)
setDebug(true)
setDebug(options.debug ?? !bundling)

// Resolve devup-ui CSS files onto a path-free virtual id, so nothing
// derived from this checkout's cwd can be baked into Bun's shared,
Expand All @@ -132,20 +160,39 @@ function register(options: DevupUIBunPluginOptions = {}) {
({ path, importer }) => resolveCssId(path, importer, distDir),
)

// Serve the virtual stylesheet resolved above
build.onLoad({ filter: /.*/, namespace: cssNamespace }, () =>
loadCssModule(),
// The bundler takes the stylesheet once every other module is loaded,
// so it holds the styles of all of them. The Bun runtime has no CSS
// loader (`onLoad` only accepts the script/data loaders), so there the
// injected import resolves to an empty module.
build.onLoad(
{ filter: /.*/, namespace: cssNamespace },
async ({ defer }) => {
if (!bundling) return { contents: '', loader: 'js' }
await defer()
return { contents: getCss(null, false), loader: 'css' }
},
)

// Load source files from packages directory (file namespace)
build.onLoad(
{
filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/,
},
({ path }) => loadSourceFile(path),
({ path }) => loadSourceFile(path, bundling),
)
},
})
} satisfies BunPlugin
}

// Registers the Bun runtime plugin. Returns the promise produced by `plugin()`
// (its `setup` is async), so callers MUST `await` it. Bun's preload mechanism
// waits for an awaited module evaluation to settle; awaiting this guarantees
// the `onLoad` hook is installed before any source file is loaded. Without the
// await, preload-driven `bun test` users race the async setup and load sources
// against the @devup-ui/react runtime stubs (throwing "Cannot run on the
// runtime").
function register(options: DevupUIBunPluginOptions = {}) {
return plugin(DevupUI(options))
}

export { plugin, register }
export { DevupUI, plugin, register }
2 changes: 1 addition & 1 deletion packages/bun-plugin/src/register.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export type { DevupUIBunPluginOptions } from './plugin'
export { register } from './plugin'
export { DevupUI, register } from './plugin'
Loading