diff --git a/.changepacks/changepack_log_bun_plugin_build.json b/.changepacks/changepack_log_bun_plugin_build.json new file mode 100644 index 00000000..a461c80b --- /dev/null +++ b/.changepacks/changepack_log_bun_plugin_build.json @@ -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" +} diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index c2385521..289863c3 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -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 diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 7637396b..7a39652c 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -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 { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 52591c9e..5fc4b01c 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -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' @@ -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 { @@ -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() + +/** 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' @@ -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, @@ -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, @@ -132,9 +160,17 @@ 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) @@ -142,10 +178,21 @@ function register(options: DevupUIBunPluginOptions = {}) { { 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 } diff --git a/packages/bun-plugin/src/register.ts b/packages/bun-plugin/src/register.ts index eb32ee74..3f4240ff 100644 --- a/packages/bun-plugin/src/register.ts +++ b/packages/bun-plugin/src/register.ts @@ -1,2 +1,2 @@ export type { DevupUIBunPluginOptions } from './plugin' -export { register } from './plugin' +export { DevupUI, register } from './plugin'