bun_bun_bundle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1acf2b909cdf00c5e5533943ad600811ee2cdd0fcd53885ca3e007f94b12db91
4
+ data.tar.gz: ee34bf6fd021e4470a9cb9f14ea7436f8b8eaf312229ad59724e4bb14a30afc1
5
+ SHA512:
6
+ metadata.gz: 3ee9e73c7e1de4a56d70411f8ba49d755a09b7c4148fa220eeef75a2dd460f141ea57fe194c29df26f2bc68cbd9d11339941f62f130bfeb7fbf22f8e0c80994f
7
+ data.tar.gz: 835bcd2d1b8203f6497931e3d92ca23a2d428fe84c750bc86eb508364805b4cae738b0fe32c30362be8a35e4dcdabea891f43b098f48de496f9585e8d9d06917
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wout Fierens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # BunBunBundle
2
+
3
+ A self-contained asset bundler for Ruby powered by [Bun](https://bun.sh). No
4
+ development dependencies, no complex configuration. Fast builds with CSS
5
+ hot-reloading, fingerprinting, live reload, and a flexible plugin system. Works
6
+ with Rails, Hanami, or any Rack app.
7
+
8
+ ## Why use BunBunBundle?
9
+
10
+ ### Lightning fast bundling
11
+
12
+ BunBunBundle leverages Bun's native bundler which is orders of magnitude faster
13
+ than traditional Node.js-based tools. Your assets are built in milliseconds,
14
+ not seconds.
15
+
16
+ ### CSS hot-reloading
17
+
18
+ CSS changes are hot-reloaded in the browser without a full page refresh. Your
19
+ state stays intact, your scroll position is preserved, and you see changes
20
+ instantly.
21
+
22
+ ### Asset fingerprinting
23
+
24
+ Every asset is fingerprinted with a content-based hash in production, so
25
+ browsers always fetch the right version.
26
+
27
+ ### No surprises in production
28
+
29
+ Development and production builds go through the exact same pipeline. The only
30
+ differences are fingerprinting and minification being enabled in production,
31
+ but nothing is holding you back form them in development as well.
32
+
33
+ ### Extensible plugin system
34
+
35
+ Comes with built-in plugins for CSS glob imports, root aliases, and JS glob
36
+ imports. Plugins are simple, plain JS files, so you can create your own JS/CSS
37
+ transformers, and raw Bun plugins are supported as well.
38
+
39
+ ### Just one dependency: Bun
40
+
41
+ The bundler ships with the gem. Bun is the only external requirement, so there
42
+ are zero dev dependencies.
43
+
44
+ ## Installation
45
+
46
+ 1. Add the gem to your `Gemfile`:
47
+
48
+ ```ruby
49
+ gem 'bun_bun_bundle'
50
+ ```
51
+
52
+ 2. Run `bundle install`
53
+
54
+ 3. Make sure [Bun](https://bun.sh) is installed:
55
+
56
+ ```sh
57
+ curl -fsSL https://bun.sh/install | bash
58
+ ```
59
+
60
+ ## Usage with Rails
61
+
62
+ The gem auto-configures itself through a Railtie. All helpers are available in
63
+ your views immediately:
64
+
65
+ ```erb
66
+ <!DOCTYPE html>
67
+ <html>
68
+ <head>
69
+ <%= bun_css_tag('css/app.css') %>
70
+ </head>
71
+ <body>
72
+ <%= bun_img_tag('images/logo.png', alt: 'My App') %>
73
+ <%= bun_js_tag('js/app.js', defer: true) %>
74
+ <%= bun_reload_tag %>
75
+ </body>
76
+ </html>
77
+ ```
78
+
79
+ The `DevCacheMiddleware` is automatically inserted in development to prevent
80
+ stale asset caching.
81
+
82
+ ## Usage with Hanami
83
+
84
+ 1. Require the Hanami integration:
85
+
86
+ ```ruby
87
+ # config/app.rb
88
+
89
+ require 'bun_bun_bundle/hanami'
90
+ ```
91
+
92
+ 2. Optionally add the dev cache middleware:
93
+
94
+ ```ruby
95
+ # config/app.rb
96
+
97
+ module MyApp
98
+ class App < Hanami::App
99
+ config.middleware.use BunBunBundle::DevCacheMiddleware if Hanami.env?(:development)
100
+ end
101
+ end
102
+ ```
103
+
104
+ 3. Include the helpers in your views:
105
+
106
+ ```ruby
107
+ # app/views/helpers.rb
108
+
109
+ module MyApp
110
+ module Views
111
+ module Helpers
112
+ include BunBunBundle::Helpers
113
+ include BunBunBundle::ReloadTag
114
+ end
115
+ end
116
+ end
117
+ ```
118
+
119
+ 4. Use them in your templates:
120
+
121
+ ```erb
122
+ <%= bun_css_tag('css/app.css') %>
123
+ <%= bun_js_tag('js/app.js') %>
124
+ <%= bun_reload_tag %>
125
+ ```
126
+
127
+ ## Usage with any Rack app
128
+
129
+ ```ruby
130
+ require 'bun_bun_bundle'
131
+
132
+ # Configure manually
133
+ BunBunBundle.config = BunBunBundle::Config.load(root: __dir__)
134
+ BunBunBundle.manifest = BunBunBundle::Manifest.load(root: __dir__)
135
+
136
+ # Optionally set a CDN host
137
+ BunBunBundle.asset_host = 'https://cdn.example.com'
138
+ ```
139
+
140
+ ## Helpers
141
+
142
+ All helpers are prefixed with `bun_` to avoid conflicts with framework helpers:
143
+
144
+ | Helper | Description |
145
+ | -------------------------------- | ------------------------------------------------ |
146
+ | `bun_asset('images/logo.png')` | Returns the fingerprinted asset path |
147
+ | `bun_js_tag('js/app.js')` | Generates a `<script>` tag |
148
+ | `bun_css_tag('css/app.css')` | Generates a `<link>` tag |
149
+ | `bun_img_tag('images/logo.png')` | Generates an `<img>` tag |
150
+ | `bun_reload_tag` | Live reload script (only renders in development) |
151
+
152
+ All tag helpers accept additional HTML attributes:
153
+
154
+ ```erb
155
+ <%= bun_js_tag('js/app.js', defer: true, async: true) %>
156
+ <%= bun_css_tag('css/app.css', media: 'print') %>
157
+ <%= bun_img_tag('images/logo.png', alt: 'My App', class: 'logo') %>
158
+ ```
159
+
160
+ ## CLI
161
+
162
+ Build your assets using the bundled CLI:
163
+
164
+ ```sh
165
+ # Development: builds, watches, and starts the live reload server
166
+ bun_bun_bundle dev
167
+
168
+ # Production: builds with fingerprinting and minification
169
+ bun_bun_bundle build
170
+
171
+ # Development with a production build (fingerprinting + minification)
172
+ bun_bun_bundle dev --prod
173
+ ```
174
+
175
+ ## Configuration
176
+
177
+ Place a `config/bun.json` in your project root:
178
+
179
+ ```json
180
+ {
181
+ "entryPoints": {
182
+ "js": ["app/assets/js/app.js"],
183
+ "css": ["app/assets/css/app.css"]
184
+ },
185
+ "outDir": "public/assets",
186
+ "publicPath": "/assets",
187
+ "manifestPath": "public/bun-manifest.json",
188
+ "staticDirs": ["app/assets/images", "app/assets/fonts"],
189
+ "devServer": {
190
+ "host": "127.0.0.1",
191
+ "port": 3002,
192
+ "secure": false
193
+ },
194
+ "plugins": {
195
+ "css": ["cssAliases", "cssGlobs"],
196
+ "js": ["jsGlobs"]
197
+ }
198
+ }
199
+ ```
200
+
201
+ All values shown above are defaults, you only need to specify what you want to
202
+ override.
203
+
204
+ ## Plugins
205
+
206
+ Three plugins are included out of the box:
207
+
208
+ | Plugin | Description |
209
+ | ------------ | ---------------------------------------------------------------- |
210
+ | `cssAliases` | Resolves `$/` root aliases in CSS `url()` references |
211
+ | `cssGlobs` | Expands glob patterns in `@import` statements |
212
+ | `jsGlobs` | Compiles `import x from 'glob:./path/*.js'` into object mappings |
213
+
214
+ ### Custom plugins
215
+
216
+ Create a JS file that exports a factory function:
217
+
218
+ ```javascript
219
+ // config/bun/banner.js
220
+
221
+ export default function banner({ prod }) {
222
+ return (content) => {
223
+ const stamp = prod ? "" : ` (dev build ${new Date().toISOString()})`;
224
+ return `/* My App${stamp} */\n${content}`;
225
+ };
226
+ }
227
+ ```
228
+
229
+ Then reference it in your config:
230
+
231
+ ```json
232
+ {
233
+ "plugins": {
234
+ "css": ["cssAliases", "cssGlobs", "config/bun/banner.js"]
235
+ }
236
+ }
237
+ ```
238
+
239
+ ## Project structure
240
+
241
+ ```
242
+ your-app/
243
+ ├── app/
244
+ │ └── assets/
245
+ │ ├── css/
246
+ │ │ └── app.css # CSS entry point
247
+ │ ├── js/
248
+ │ │ └── app.js # JS entry point
249
+ │ ├── images/ # Static images (copied + fingerprinted)
250
+ │ └── fonts/ # Static fonts (copied + fingerprinted)
251
+ ├── config/
252
+ │ └── bun.json # Optional bundler configuration
253
+ └── public/
254
+ ├── assets/ # Built assets (generated)
255
+ └── bun-manifest.json # Asset manifest (generated)
256
+ ```
257
+
258
+ ## Origins
259
+
260
+ BunBunBundle was originally built for [Fluck](https://fluck.site), a
261
+ self-hostable website builder using [Lucky
262
+ Framework](https://luckyframework.org/). I wanted to have a fast, comprehensive
263
+ asset bundler that would not require too much maintenance in the long term.
264
+
265
+ Bun was the natural choice because it does almost everything:
266
+
267
+ - JS bundling, tree-shaking, and minification
268
+ - CSS processing and minification (through the built-in
269
+ [LightningCSS](https://lightningcss.dev/) library)
270
+ - WebSocket server for hot and live reloading
271
+ - Content hashing for asset fingerprints
272
+ - Extendability with simple plugins
273
+
274
+ It's also fast and reliable. We use this setup heavily in two Lucky apps and it
275
+ is rock solid, and it has since been adopted by Lucky as the default builder.
276
+
277
+ I wanted to have the same setup in my Ruby apps as well, that's when this Gem
278
+ was born. I hope you enjoy it too!
279
+
280
+ ## Contributing
281
+
282
+ We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/).
283
+
284
+ 1. Fork it
285
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
286
+ 3. Commit your changes (`git commit -am 'feat: new feature'`)
287
+ 4. Push to the branch (`git push origin my-new-feature`)
288
+ 5. Create a new Pull Request
289
+
290
+ ## Contributors
291
+
292
+ - [Wout](https://codeberg.org/w0u7) - creator and maintainer
293
+
294
+ ## License
295
+
296
+ [MIT](LICENSE)
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bun_bun_bundle'
5
+
6
+ command = ARGV[0]
7
+ flags = ARGV[1..]
8
+ bun_dir = BunBunBundle.bun_path
9
+ bake = File.join(bun_dir, 'bake.js')
10
+
11
+ case command
12
+ when 'dev', 'watch'
13
+ exec('bun', 'run', bake, '--dev', *flags)
14
+ when 'build'
15
+ exec('bun', 'run', bake, '--prod', *flags)
16
+ else
17
+ puts <<~USAGE
18
+ Usage: bun_bun_bundle <command> [flags]
19
+
20
+ Commands:
21
+ dev Start development server with live reload and CSS hot-reloading
22
+ build Build assets for production (with fingerprinting)
23
+
24
+ Flags:
25
+ --prod Enable fingerprinting and minification
26
+ --dev Enable source maps and watch mode
27
+
28
+ Examples:
29
+ bun_bun_bundle dev # development with live reload
30
+ bun_bun_bundle dev --prod # development with production build
31
+ bun_bun_bundle build # production build
32
+
33
+ Configuration:
34
+ Place a config/bun.json in your project root to customize settings.
35
+ See the README for available options.
36
+ USAGE
37
+ exit(command ? 1 : 0)
38
+ end
data/lib/bun/bake.js ADDED
@@ -0,0 +1,8 @@
1
+ import BunBunBundle from "./bun_bundle.js";
2
+
3
+ BunBunBundle.flags({
4
+ dev: process.argv.includes("--dev"),
5
+ prod: process.argv.includes("--prod"),
6
+ });
7
+
8
+ await BunBunBundle.bake();
@@ -0,0 +1,276 @@
1
+ import {mkdirSync, readFileSync, existsSync, rmSync, watch} from 'fs'
2
+ import {join, dirname, basename, extname} from 'path'
3
+ import {Glob} from 'bun'
4
+ import {resolvePlugins} from './plugins/index.js'
5
+
6
+ export default {
7
+ CONFIG_PATH: 'config/bun.json',
8
+ IGNORE_PATTERNS: [
9
+ /^\d+$/,
10
+ /^\.#/,
11
+ /~$/,
12
+ /\.swp$/,
13
+ /\.swo$/,
14
+ /\.tmp$/,
15
+ /^#.*#$/,
16
+ /\.DS_Store$/
17
+ ],
18
+
19
+ root: process.cwd(),
20
+ config: null,
21
+ manifest: {},
22
+ dev: false,
23
+ prod: false,
24
+ wsClients: new Set(),
25
+ plugins: [],
26
+
27
+ flags({dev, prod}) {
28
+ if (dev != null) this.dev = dev
29
+ if (prod != null) this.prod = prod
30
+ },
31
+
32
+ deepMerge(target, source) {
33
+ const result = {...target}
34
+ for (const k of Object.keys(source))
35
+ result[k] =
36
+ source[k] && typeof source[k] === 'object' && !Array.isArray(source[k])
37
+ ? this.deepMerge(target[k] || {}, source[k])
38
+ : source[k]
39
+ return result
40
+ },
41
+
42
+ loadConfig() {
43
+ const defaults = {
44
+ entryPoints: {js: ['app/assets/js/app.js'], css: ['app/assets/css/app.css']},
45
+ plugins: {css: ['cssAliases', 'cssGlobs'], js: ['jsGlobs']},
46
+ staticDirs: ['app/assets/images', 'app/assets/fonts'],
47
+ outDir: 'public/assets',
48
+ publicPath: '/assets',
49
+ manifestPath: 'public/bun-manifest.json',
50
+ devServer: {host: '127.0.0.1', port: 3002, secure: false}
51
+ }
52
+
53
+ try {
54
+ const json = readFileSync(join(this.root, this.CONFIG_PATH), 'utf-8')
55
+ const user = JSON.parse(json)
56
+ this.config = this.deepMerge(defaults, user)
57
+ if (user.plugins != null) this.config.plugins = user.plugins
58
+ } catch {
59
+ this.config = defaults
60
+ }
61
+ },
62
+
63
+ async loadPlugins() {
64
+ this.plugins = await resolvePlugins(this.config.plugins, {
65
+ root: this.root,
66
+ config: this.config,
67
+ dev: this.dev,
68
+ prod: this.prod,
69
+ manifest: this.manifest
70
+ })
71
+ },
72
+
73
+ get outDir() {
74
+ if (this.config == null) throw new Error(' ✖ Config is not loaded')
75
+
76
+ return join(this.root, this.config.outDir)
77
+ },
78
+
79
+ fingerprint(name, ext, content) {
80
+ if (!this.prod) return `${name}${ext}`
81
+
82
+ const hash = Bun.hash(content).toString(16).slice(0, 8)
83
+ return `${name}-${hash}${ext}`
84
+ },
85
+
86
+ async buildAssets(type, options = {}) {
87
+ const outDir = join(this.outDir, type)
88
+ mkdirSync(outDir, {recursive: true})
89
+
90
+ const entries = this.config.entryPoints[type]
91
+ const ext = `.${type}`
92
+
93
+ for (const entry of entries) {
94
+ const entryPath = join(this.root, entry)
95
+ const entryName = basename(entry).replace(/\.(ts|js|tsx|jsx|css)$/, '')
96
+
97
+ if (!existsSync(entryPath)) {
98
+ console.warn(` ▸ Missing entry point ${entry}, continuing...`)
99
+ continue
100
+ }
101
+
102
+ const result = await Bun.build({
103
+ entrypoints: [entryPath],
104
+ minify: this.prod,
105
+ plugins: this.plugins,
106
+ ...options
107
+ })
108
+
109
+ if (!result.success) {
110
+ console.error(` ▸ Failed to build ${entry}`)
111
+ for (const log of result.logs) console.error(log)
112
+ continue
113
+ }
114
+
115
+ const output = result.outputs.find(o => o.path.endsWith(ext))
116
+ if (!output) {
117
+ console.error(` ▸ No ${type.toUpperCase()} output for ${entry}`)
118
+ continue
119
+ }
120
+
121
+ const content = await output.text()
122
+ const fileName = this.fingerprint(entryName, ext, content)
123
+ await Bun.write(join(outDir, fileName), content)
124
+
125
+ this.manifest[`${type}/${entryName}${ext}`] = `${type}/${fileName}`
126
+ }
127
+ },
128
+
129
+ async buildJS() {
130
+ await this.buildAssets('js', {
131
+ target: 'browser',
132
+ format: 'iife',
133
+ sourcemap: this.dev ? 'inline' : 'none'
134
+ })
135
+ },
136
+
137
+ async buildCSS() {
138
+ await this.buildAssets('css')
139
+ },
140
+
141
+ async copyStaticAssets() {
142
+ const glob = new Glob('**/*.*')
143
+
144
+ for (const dir of this.config.staticDirs) {
145
+ const fullDir = join(this.root, dir)
146
+ if (!existsSync(fullDir)) continue
147
+
148
+ const assetType = basename(dir)
149
+ const destDir = join(this.outDir, assetType)
150
+
151
+ for await (const file of glob.scan({cwd: fullDir, onlyFiles: true})) {
152
+ const srcPath = join(fullDir, file)
153
+ const content = await Bun.file(srcPath).arrayBuffer()
154
+
155
+ const ext = extname(file)
156
+ const name = file.slice(0, -ext.length) || file
157
+ const fileName = this.fingerprint(name, ext, new Uint8Array(content))
158
+ const destPath = join(destDir, fileName)
159
+
160
+ mkdirSync(dirname(destPath), {recursive: true})
161
+ await Bun.write(destPath, content)
162
+
163
+ this.manifest[`${assetType}/${file}`] = `${assetType}/${fileName}`
164
+ }
165
+ }
166
+ },
167
+
168
+ cleanOutDir() {
169
+ rmSync(this.outDir, {recursive: true, force: true})
170
+ },
171
+
172
+ async writeManifest() {
173
+ const manifestFullPath = join(this.root, this.config.manifestPath)
174
+ mkdirSync(dirname(manifestFullPath), {recursive: true})
175
+ await Bun.write(manifestFullPath, JSON.stringify(this.manifest, null, 2))
176
+ },
177
+
178
+ async build() {
179
+ const env = this.prod ? 'production' : 'development'
180
+ console.log(`Building manifest for ${env}...`)
181
+ const start = performance.now()
182
+ this.loadConfig()
183
+ await this.loadPlugins()
184
+ this.cleanOutDir()
185
+ await this.copyStaticAssets()
186
+ await this.buildJS()
187
+ await this.buildCSS()
188
+ await this.writeManifest()
189
+ const ms = Math.round(performance.now() - start)
190
+ console.log(`DONE Built successfully in ${ms} ms`, this.prettyManifest())
191
+ },
192
+
193
+ prettyManifest() {
194
+ const lines = Object.entries(this.manifest)
195
+ .map(([key, value]) => ` ${key} → ${value}`)
196
+ .join('\n')
197
+ return `\n${lines}\n\n`
198
+ },
199
+
200
+ reload(type = 'full') {
201
+ setTimeout(() => {
202
+ const message = JSON.stringify({type})
203
+ for (const client of this.wsClients) {
204
+ try {
205
+ client.send(message)
206
+ } catch {
207
+ this.wsClients.delete(client)
208
+ }
209
+ }
210
+ }, 50)
211
+ },
212
+
213
+ async watch() {
214
+ const srcDir = join(this.root, this.config.watchDir || 'app/assets')
215
+
216
+ watch(srcDir, {recursive: true}, async (event, filename) => {
217
+ if (!filename) return
218
+
219
+ const normalizedFilename = filename.replace(/\\/g, '/')
220
+ const base = basename(normalizedFilename)
221
+ const ext = extname(base).slice(1)
222
+
223
+ if (this.IGNORE_PATTERNS.some(pattern => pattern.test(base))) return
224
+
225
+ console.log(` ▸ ${normalizedFilename} changed`)
226
+
227
+ try {
228
+ if (ext === 'css') await this.buildCSS()
229
+ else if (['js', 'ts', 'jsx', 'tsx'].includes(ext)) await this.buildJS()
230
+ else if (base.includes('.')) await this.copyStaticAssets()
231
+
232
+ await this.writeManifest()
233
+ this.reload(ext === 'css' ? 'css' : 'full')
234
+ } catch (err) {
235
+ console.error(' ✖ Build error:', err.message)
236
+ }
237
+ })
238
+
239
+ console.log('Beginning to watch your project')
240
+ },
241
+
242
+ async serve() {
243
+ await this.build()
244
+ await this.watch()
245
+
246
+ const {host, port, secure} = this.config.devServer
247
+ const wsClients = this.wsClients
248
+
249
+ Bun.serve({
250
+ hostname: secure ? '0.0.0.0' : host,
251
+ port,
252
+ fetch(req, server) {
253
+ if (server.upgrade(req)) return
254
+ return new Response('BunBunBundle WebSocket Server', {status: 200})
255
+ },
256
+ websocket: {
257
+ open(ws) {
258
+ wsClients.add(ws)
259
+ console.log(` ▸ Client connected (${wsClients.size})\n\n`)
260
+ },
261
+ close(ws) {
262
+ wsClients.delete(ws)
263
+ console.log(` ▸ Client disconnected (${wsClients.size})\n\n`)
264
+ },
265
+ message() {}
266
+ }
267
+ })
268
+
269
+ const protocol = secure ? 'wss' : 'ws'
270
+ console.log(`\n\n 🔌 Live reload at ${protocol}://${host}:${port}\n\n`)
271
+ },
272
+
273
+ async bake() {
274
+ this.dev ? await this.serve() : await this.build()
275
+ }
276
+ }
@@ -0,0 +1,10 @@
1
+ import {join} from 'path'
2
+
3
+ const REGEX = /url\(\s*['"]?\$\//g
4
+
5
+ // Resolves `$` root aliases in CSS url() references.
6
+ // e.g. url('$/images/foo.png') → url('/absolute/src/images/foo.png')
7
+ export default function cssAliases({root}) {
8
+ const srcDir = join(root, 'src')
9
+ return content => content.replace(REGEX, `url('${srcDir}/`)
10
+ }
@@ -0,0 +1,47 @@
1
+ import {dirname, relative, resolve, join} from 'path'
2
+ import {Glob} from 'bun'
3
+
4
+ const REGEX = /@import\s+['"]([^'"]*\*[^'"]*)['"]\s*;/g
5
+
6
+ // Expands glob patterns in CSS @import statements.
7
+ // e.g. @import './components/**/*.css' → individual @import lines.
8
+ export default function cssGlobs() {
9
+ return async (content, args) => {
10
+ const fileDir = dirname(args.path)
11
+ const replacements = []
12
+
13
+ for (const [fullMatch, pattern] of content.matchAll(REGEX)) {
14
+ const lastSlash = pattern.lastIndexOf('/', pattern.indexOf('*'))
15
+ const basePath = lastSlash > 0 ? pattern.slice(0, lastSlash) : '.'
16
+ const baseDir = resolve(fileDir, basePath)
17
+ const glob = new Glob(pattern.slice(lastSlash + 1))
18
+ const files = []
19
+
20
+ for await (const file of glob.scan({cwd: baseDir, onlyFiles: true})) {
21
+ const absPath = join(baseDir, file)
22
+ if (absPath === args.path) continue
23
+ const relPath = relative(fileDir, absPath)
24
+ files.push(relPath.startsWith('.') ? relPath : `./${relPath}`)
25
+ }
26
+
27
+ files.sort()
28
+
29
+ if (!files.length) console.warn(` CSS glob matched no files: ${pattern}`)
30
+
31
+ replacements.push({
32
+ fullMatch,
33
+ expanded: files.map(f => `@import '${f}';`).join('\n'),
34
+ count: files.length,
35
+ pattern
36
+ })
37
+ }
38
+
39
+ for (const {fullMatch, expanded, count, pattern} of replacements) {
40
+ const s = count !== 1 ? 's' : ''
41
+ console.log(` CSS glob: ${pattern} → ${count} file${s}`)
42
+ content = content.replace(fullMatch, expanded)
43
+ }
44
+
45
+ return content
46
+ }
47
+ }