From 3908c0ac7b25ac5bfec39dfc7491c05b513093b4 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 12:38:06 +0100 Subject: [PATCH 01/19] Add shared config between Lucky and Bun The central config file ensures both Bun and Lucky use the same output dir, manifest file, source dirs, etc. --- src/bun/config.cr | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/bun/config.cr diff --git a/src/bun/config.cr b/src/bun/config.cr new file mode 100644 index 000000000..59fd01b28 --- /dev/null +++ b/src/bun/config.cr @@ -0,0 +1,56 @@ +require "json" + +module Bun + struct Config + include JSON::Serializable + + CONFIG_PATH = "./config/bun.json" + + @[JSON::Field(key: "manifestName")] + getter manifest_name : String = "manifest.json" + + @[JSON::Field(key: "outDir")] + getter out_dir : String = "public/assets" + + @[JSON::Field(key: "publicPath")] + getter public_path : String = "/assets" + + @[JSON::Field(key: "staticDirs")] + getter static_dirs : Array(String) = %w[src/images src/fonts] + + @[JSON::Field(key: "entryPoints")] + getter entry_points : EntryPoints = EntryPoints.from_json("{}") + + @[JSON::Field(key: "devServer")] + getter dev_server : DevServer = DevServer.from_json("{}") + + struct EntryPoints + include JSON::Serializable + + getter js : Array(String) = %w[src/js/app.js] + getter css : Array(String) = %w[src/css/app.css] + end + + struct DevServer + include JSON::Serializable + + getter host : String = "127.0.0.1" + getter port : Int32 = 3002 + getter? secure : Bool = false + + def ws_protocol : String + secure? ? "wss" : "ws" + end + + def ws_url : String + "#{ws_protocol}://#{host}:#{port}" + end + end + + def self.load : Config + Config.from_json(File.read(File.expand_path(CONFIG_PATH))) + rescue File::NotFoundError + Config.from_json("{}") + end + end +end From 642a9dc37c4b66b1c7357e89882b4ca0df8e494f Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 12:42:27 +0100 Subject: [PATCH 02/19] Add bake script for Bun The bake script handles all front-end asset needs. It builds and moves assets and generates a manifest. In development, it runs a watcher, starts a WebSocket-server, and creates unminified builds. In production it minifies and fingerprints all assets. --- .prettierrc | 10 ++ src/bun/bake.js | 8 ++ src/bun/lucky.js | 289 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 .prettierrc create mode 100644 src/bun/bake.js create mode 100644 src/bun/lucky.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..45718f102 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "singleQuote": true, + "semi": false, + "bracketSpacing": false, + "quoteProps": "consistent", + "trailingComma": "none", + "arrowParens": "avoid" +} diff --git a/src/bun/bake.js b/src/bun/bake.js new file mode 100644 index 000000000..65f27fb8d --- /dev/null +++ b/src/bun/bake.js @@ -0,0 +1,8 @@ +import LuckyBun from './lucky.js' + +LuckyBun.flags({ + dev: process.argv.includes('--dev'), + prod: process.argv.includes('--prod') +}) + +await LuckyBun.bake() diff --git a/src/bun/lucky.js b/src/bun/lucky.js new file mode 100644 index 000000000..ecc494eff --- /dev/null +++ b/src/bun/lucky.js @@ -0,0 +1,289 @@ +import {mkdirSync, readFileSync, existsSync, rmSync, watch} from 'fs' +import {join, dirname, basename, extname} from 'path' +import {Glob} from 'bun' + +export default { + CONFIG_PATH: 'config/bun.json', + IGNORE_PATTERNS: [ + /^\d+$/, + /^\.#/, + /~$/, + /\.swp$/, + /\.swo$/, + /\.tmp$/, + /^#.*#$/, + /\.DS_Store$/ + ], + + root: process.cwd(), + config: null, + manifest: {}, + dev: false, + prod: false, + wsClients: new Set(), + + // Plugin for Bun to allow using a `$` root alias to reference assets in CSS. + cssAliasPlugin(root) { + return { + name: 'css-alias', + setup(build) { + build.onLoad({filter: /\.css$/}, async args => { + let content = await Bun.file(args.path).text() + const srcDir = join(root, 'src') + content = content.replace(/url\(['"]?\$\//g, `url('${srcDir}/`) + return {contents: content, loader: 'css'} + }) + } + } + }, + + // Sets environment flags. + flags({dev, prod}) { + if (dev != null) this.dev = dev + if (prod != null) this.prod = prod + }, + + // Deeply merges two objects. + deepMerge(target, source) { + const result = {...target} + for (const k of Object.keys(source)) + result[k] = + source[k] && typeof source[k] === 'object' && !Array.isArray(source[k]) + ? this.deepMerge(target[k] || {}, source[k]) + : source[k] + return result + }, + + // Safely loads config file with a fallback to defaults. + loadConfig() { + const defaults = { + entryPoints: {js: ['src/js/app.js'], css: ['src/css/app.css']}, + staticDirs: ['src/images', 'src/fonts'], + outDir: 'public/assets', + publicPath: '/assets', + manifestName: 'manifest.json', + devServer: {host: '127.0.0.1', port: 3002, secure: false} + } + + try { + const json = readFileSync(join(this.root, this.CONFIG_PATH), 'utf-8') + this.config = this.deepMerge(defaults, JSON.parse(json)) + } catch { + this.config = defaults + } + }, + + // Returns the output directory. + get outDir() { + if (this.config == null) throw new Error(' ✖ Config is not loaded') + + return join(this.root, this.config.outDir) + }, + + // Fingerprints a file name, but only in production. + fingerprint(name, ext, content) { + if (!this.prod) return `${name}${ext}` + + const hash = Bun.hash(content).toString(16).slice(0, 8) + return `${name}-${hash}${ext}` + }, + + // Builds assets for a given file type (e.g. css or js/jsx/ts/tsx). + async buildAssets(type, options = {}) { + const outDir = join(this.outDir, type) + mkdirSync(outDir, {recursive: true}) + + const entries = this.config.entryPoints[type] + const ext = `.${type}` + + for (const entry of entries) { + const entryPath = join(this.root, entry) + const entryName = basename(entry).replace(/\.(ts|js|tsx|jsx|css)$/, '') + + const result = await Bun.build({ + entrypoints: [entryPath], + minify: this.prod, + plugins: [this.cssAliasPlugin(this.root)], + ...options + }) + + if (!result.success) { + console.error(` ▸ Failed to build ${entry}`) + result.logs.forEach(log => console.error(log)) + continue + } + + const output = result.outputs.find(o => o.path.endsWith(ext)) + if (!output) { + console.error(` ▸ No ${type.toUpperCase()} output for ${entry}`) + continue + } + + const content = await output.text() + const fileName = this.fingerprint(entryName, ext, content) + await Bun.write(join(outDir, fileName), content) + + this.manifest[`${type}/${entryName}${ext}`] = `${type}/${fileName}` + } + }, + + // Builds JS assets. + async buildJS() { + await this.buildAssets('js', { + target: 'browser', + format: 'iife', + sourcemap: this.dev ? 'inline' : 'none' + }) + }, + + // Builds CSS assets. + async buildCSS() { + await this.buildAssets('css') + }, + + // Copies static assets to the output directory. + async copyStaticAssets() { + const glob = new Glob('**/*.*') + + for (const dir of this.config.staticDirs) { + const fullDir = join(this.root, dir) + if (!existsSync(fullDir)) continue + + const assetType = basename(dir) + const destDir = join(this.outDir, assetType) + + for await (const file of glob.scan({cwd: fullDir, onlyFiles: true})) { + const srcPath = join(fullDir, file) + const content = await Bun.file(srcPath).arrayBuffer() + + const ext = extname(file) + const name = file.slice(0, -ext.length) || file + const fileName = this.fingerprint(name, ext, new Uint8Array(content)) + const destPath = join(destDir, fileName) + + mkdirSync(dirname(destPath), {recursive: true}) + await Bun.write(destPath, content) + + this.manifest[`${assetType}/${file}`] = `${assetType}/${fileName}` + } + } + }, + + // Clears out the output directory. + cleanOutDir() { + rmSync(this.outDir, {recursive: true, force: true}) + }, + + // Writes the asset manifest. + async writeManifest() { + mkdirSync(this.outDir, {recursive: true}) + await Bun.write( + join(this.outDir, this.config.manifestName), + JSON.stringify(this.manifest, null, 2) + ) + }, + + // Performs a full new build based on the current environment. + async build() { + const env = this.prod ? 'production' : 'development' + console.log(`Building manifest for ${env}...`) + const start = performance.now() + this.loadConfig() + this.cleanOutDir() + await this.copyStaticAssets() + await this.buildJS() + await this.buildCSS() + await this.writeManifest() + const ms = Math.round(performance.now() - start) + console.log(`DONE Built successfully in ${ms} ms`, this.prettyManifest()) + }, + + // Returns a printable version of the manifest. + prettyManifest() { + const lines = Object.entries(this.manifest) + .map(([key, value]) => ` ${key} → ${value}`) + .join('\n') + return `\n${lines}\n\n` + }, + + // Sends a hot or cold reload command over WebSockets. + reload(type = 'full') { + setTimeout(() => { + const message = JSON.stringify({type}) + for (const client of this.wsClients) { + try { + client.send(message) + } catch { + this.wsClients.delete(client) + } + } + }, 50) + }, + + // Watches for file changes to rebuild the appropriate files. + async watch() { + const srcDir = join(this.root, 'src') + + watch(srcDir, {recursive: true}, async (event, filename) => { + if (!filename) return + + const normalizedFilename = filename.replace(/\\/g, '/') + const base = basename(normalizedFilename) + const ext = extname(base).slice(1) + + if (this.IGNORE_PATTERNS.some(pattern => pattern.test(base))) return + + console.log(` ▸ ${normalizedFilename} changed`) + + try { + if (ext === 'css') await this.buildCSS() + else if (['js', 'ts', 'jsx', 'tsx'].includes(ext)) await this.buildJS() + else if (base.includes('.')) await this.copyStaticAssets() + + await this.writeManifest() + this.reload(ext === 'css' ? 'css' : 'full') + } catch (err) { + console.error(' ✖ Build error:', err.message) + } + }) + + console.log('Beginning to watch your project') + }, + + // Starts the development server. + async serve() { + await this.build() + await this.watch() + + const {host, port, secure} = this.config.devServer + const wsClients = this.wsClients + + Bun.serve({ + hostname: secure ? '0.0.0.0' : host, + port, + fetch(req, server) { + if (server.upgrade(req)) return + return new Response('LuckyBun WebSocket Server', {status: 200}) + }, + websocket: { + open(ws) { + wsClients.add(ws) + console.log(` ▸ Client connected (${wsClients.size})\n\n`) + }, + close(ws) { + wsClients.delete(ws) + console.log(` ▸ Client disconnected (${wsClients.size})\n\n`) + }, + message() {} + } + }) + + const protocol = secure ? 'wss' : 'ws' + console.log(`\n\n 🔌 Live reload at ${protocol}://${host}:${port}\n\n`) + }, + + // Main entry point to bake your Lucky Buns based on the current environment. + async bake() { + this.dev ? await this.serve() : await this.build() + } +} From 27c4078068636e49e086209f0fd05f2e76bf29cc Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 12:44:36 +0100 Subject: [PATCH 03/19] Add a test suite for the bake script This test suite uses Bun's built in test helpers. --- bunfig.toml | 2 + spec/bun/lucky.test.js | 271 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 bunfig.toml create mode 100644 spec/bun/lucky.test.js diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..0f797f34c --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "./spec/bun" diff --git a/spec/bun/lucky.test.js b/spec/bun/lucky.test.js new file mode 100644 index 000000000..08a6a3835 --- /dev/null +++ b/spec/bun/lucky.test.js @@ -0,0 +1,271 @@ +import {describe, test, expect, beforeEach, afterAll} from 'bun:test' +import {mkdirSync, writeFileSync, rmSync, existsSync, readFileSync} from 'fs' +import {join} from 'path' +import LuckyBun from '../../src/bun/lucky.js' + +const TEST_DIR = join(process.cwd(), '.test-tmp') + +beforeEach(() => { + rmSync(TEST_DIR, {recursive: true, force: true}) + mkdirSync(TEST_DIR, {recursive: true}) + LuckyBun.manifest = {} + LuckyBun.config = null + LuckyBun.prod = false + LuckyBun.dev = false +}) + +afterAll(() => { + rmSync(TEST_DIR, {recursive: true, force: true}) +}) + +describe('flags', () => { + test('sets dev flag', () => { + LuckyBun.flags({dev: true}) + expect(LuckyBun.dev).toBe(true) + }) + + test('sets prod flag', () => { + LuckyBun.flags({prod: true}) + expect(LuckyBun.prod).toBe(true) + }) + + test('ignores undefined values', () => { + LuckyBun.dev = true + LuckyBun.flags({prod: false}) + expect(LuckyBun.dev).toBe(true) + expect(LuckyBun.prod).toBe(false) + }) +}) + +describe('deepMerge', () => { + test('merges flat objects', () => { + const result = LuckyBun.deepMerge({a: 1, b: 2}, {b: 3, c: 4}) + expect(result).toEqual({a: 1, b: 3, c: 4}) + }) + + test('merges nested objects', () => { + const result = LuckyBun.deepMerge( + {outer: {a: 1, b: 2}}, + {outer: {b: 3, c: 4}} + ) + expect(result).toEqual({outer: {a: 1, b: 3, c: 4}}) + }) + + test('replaces arrays instead of merging', () => { + const result = LuckyBun.deepMerge({arr: [1, 2]}, {arr: [3, 4, 5]}) + expect(result).toEqual({arr: [3, 4, 5]}) + }) + + test('handles null values', () => { + const result = LuckyBun.deepMerge({a: {nested: 1}}, {a: null}) + expect(result).toEqual({a: null}) + }) +}) + +describe('loadConfig', () => { + test('uses defaults without a config file', () => { + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + expect(LuckyBun.config.outDir).toBe('public/assets') + expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js']) + expect(LuckyBun.config.devServer.port).toBe(3002) + }) + + test('merges user config with defaults', () => { + mkdirSync(join(TEST_DIR, 'config'), {recursive: true}) + writeFileSync( + join(TEST_DIR, 'config/bun.json'), + JSON.stringify({outDir: 'dist', devServer: {port: 4000}}) + ) + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + + expect(LuckyBun.config.outDir).toBe('dist') + expect(LuckyBun.config.devServer.port).toBe(4000) + expect(LuckyBun.config.devServer.host).toBe('127.0.0.1') + expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js']) + }) +}) + +describe('fingerprint', () => { + test('returns plain filename in dev mode', () => { + LuckyBun.prod = false + expect(LuckyBun.fingerprint('app', '.js', 'content')).toBe('app.js') + }) + + test('returns hashed filename in prod mode', () => { + LuckyBun.prod = true + expect(LuckyBun.fingerprint('app', '.js', 'content')).toMatch( + /^app-[a-f0-9]{8}\.js$/ + ) + }) + + test('produces consistent hashes', () => { + LuckyBun.prod = true + const hash1 = LuckyBun.fingerprint('app', '.js', 'same') + const hash2 = LuckyBun.fingerprint('app', '.js', 'same') + expect(hash1).toBe(hash2) + }) + + test('produces different hashes for different content', () => { + LuckyBun.prod = true + const hash1 = LuckyBun.fingerprint('app', '.js', 'a') + const hash2 = LuckyBun.fingerprint('app', '.js', 'b') + expect(hash1).not.toBe(hash2) + }) +}) + +describe('IGNORE_PATTERNS', () => { + const ignores = f => LuckyBun.IGNORE_PATTERNS.some(p => p.test(f)) + + test('ignores editor artifacts', () => { + expect(ignores('.#file.js')).toBe(true) + expect(ignores('file.js~')).toBe(true) + expect(ignores('file.swp')).toBe(true) + expect(ignores('file.swo')).toBe(true) + expect(ignores('file.tmp')).toBe(true) + expect(ignores('#file.js#')).toBe(true) + }) + + test('ignores system files', () => { + expect(ignores('.DS_Store')).toBe(true) + expect(ignores('12345')).toBe(true) + }) + + test('allows normal files', () => { + expect(ignores('app.js')).toBe(false) + expect(ignores('styles.css')).toBe(false) + expect(ignores('image.png')).toBe(false) + }) +}) + +describe('buildAssets', () => { + test('builds JS files', async () => { + mkdirSync(join(TEST_DIR, 'src/js'), {recursive: true}) + mkdirSync(join(TEST_DIR, 'public/assets'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/js/app.js'), 'console.log("test")') + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + await LuckyBun.buildJS() + + expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js') + expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true) + }) + + test('builds CSS files', async () => { + mkdirSync(join(TEST_DIR, 'src/css'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/css/app.css'), 'body { color: pink }') + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + await LuckyBun.buildCSS() + + expect(LuckyBun.manifest['css/app.css']).toBe('css/app.css') + expect(existsSync(join(TEST_DIR, 'public/assets/css/app.css'))).toBe(true) + }) + + test('fingerprints in prod mode', async () => { + mkdirSync(join(TEST_DIR, 'src/js'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/js/app.js'), 'console.log("prod")') + + LuckyBun.root = TEST_DIR + LuckyBun.prod = true + LuckyBun.loadConfig() + await LuckyBun.buildJS() + + expect(LuckyBun.manifest['js/app.js']).toMatch(/^js\/app-[a-f0-9]{8}\.js$/) + }) +}) + +describe('copyStaticAssets', () => { + test('copies images', async () => { + mkdirSync(join(TEST_DIR, 'src/images'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/images/logo.png'), 'fake-image-data') + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + await LuckyBun.copyStaticAssets() + + expect(LuckyBun.manifest['images/logo.png']).toBe('images/logo.png') + expect(existsSync(join(TEST_DIR, 'public/assets/images/logo.png'))).toBe( + true + ) + }) + + test('preserves nested directory structure', async () => { + mkdirSync(join(TEST_DIR, 'src/images/icons'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/images/icons/arrow.svg'), '') + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + await LuckyBun.copyStaticAssets() + + expect(LuckyBun.manifest['images/icons/arrow.svg']).toBeDefined() + }) +}) + +describe('cleanOutDir', () => { + test('removes output directory', () => { + const outDir = join(TEST_DIR, 'public/assets') + mkdirSync(join(outDir, 'js'), {recursive: true}) + writeFileSync(join(outDir, 'js/old.js'), 'old') + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + LuckyBun.cleanOutDir() + + expect(existsSync(outDir)).toBe(false) + }) +}) + +describe('writeManifest', () => { + test('writes manifest JSON', async () => { + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + LuckyBun.manifest = {'js/app.js': 'js/app-abc123.js'} + + await LuckyBun.writeManifest() + + const content = readFileSync( + join(TEST_DIR, 'public/assets/manifest.json'), + 'utf-8' + ) + expect(JSON.parse(content)).toEqual({'js/app.js': 'js/app-abc123.js'}) + }) +}) + +describe('outDir', () => { + test('throws if config not loaded', () => { + LuckyBun.config = null + expect(() => LuckyBun.outDir).toThrow('Config is not loaded') + }) + + test('returns full path when config loaded', () => { + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + expect(LuckyBun.outDir).toBe(join(TEST_DIR, 'public/assets')) + }) +}) + +describe('cssAliasPlugin', () => { + test('replaces $/ with src path', async () => { + mkdirSync(join(TEST_DIR, 'src/css'), {recursive: true}) + mkdirSync(join(TEST_DIR, 'src/images'), {recursive: true}) + writeFileSync(join(TEST_DIR, 'src/images/bg.png'), 'fake') + writeFileSync( + join(TEST_DIR, 'src/css/app.css'), + "body { background: url('$/images/bg.png'); }" + ) + + LuckyBun.root = TEST_DIR + LuckyBun.loadConfig() + await LuckyBun.buildCSS() + + const cssPath = join(TEST_DIR, 'public/assets/css/app.css') + const content = readFileSync(cssPath, 'utf-8') + expect(content).toContain('/src/') + expect(content).not.toContain('$/') + }) +}) From 388470e43eadd8f22d9e3713aca00f9beaf4dfc3 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 12:46:46 +0100 Subject: [PATCH 04/19] Add asset manifest builder for Bun Also renames the original generate_asset_helper to match Bun's variant. --- .../asset_manifest_builder_for_bun.cr | 72 +++++++++++++++++++ ...s.cr => asset_manifest_builder_for_mix.cr} | 0 2 files changed, 72 insertions(+) create mode 100644 src/run_macros/asset_manifest_builder_for_bun.cr rename src/run_macros/{generate_asset_helpers.cr => asset_manifest_builder_for_mix.cr} (100%) diff --git a/src/run_macros/asset_manifest_builder_for_bun.cr b/src/run_macros/asset_manifest_builder_for_bun.cr new file mode 100644 index 000000000..9b1d17f6e --- /dev/null +++ b/src/run_macros/asset_manifest_builder_for_bun.cr @@ -0,0 +1,72 @@ +require "json" +require "colorize" +require "../bun/config" + +struct AssetManifestBuilder + property retries = 0 + @manifest_path : String + @config : Bun::Config + @max_retries : Int32 + @retry_after : Float64 + + def initialize + @config = Bun::Config.load + @manifest_path = resolve_manifest_path + + # These values can be configured at compile time via environment variables: + # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20) + # - LUCKY_ASSET_MANIFEST_RETRY_DELAY: Delay between retries in seconds (default: 0.25) + @max_retries = ENV["LUCKY_ASSET_MANIFEST_RETRY_COUNT"]?.try(&.to_i) || 20 + @retry_after = ENV["LUCKY_ASSET_MANIFEST_RETRY_DELAY"]?.try(&.to_f) || 0.25 + end + + def build_with_retry + retry_or_raise_error unless File.exists?(@manifest_path) + build_manifest + end + + private def resolve_manifest_path + File.expand_path(File.join(@config.out_dir, @config.manifest_name)) + end + + private def retry_or_raise_error + raise_missing_manifest_error unless retries < @max_retries + + self.retries += 1 + sleep @retry_after + build_with_retry + end + + private def build_manifest + JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| + path = expand_asset_path(value.as_s) + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{key}"] = "#{path}" %}) + end + end + + private def expand_asset_path(file : String) : String + File.join(@config.public_path, file) + end + + private def raise_missing_manifest_error + message = <<-ERROR + #{"Manifest not found:".colorize(:red)} #{@manifest_path} + + #{"Make sure you have compiled your assets:".colorize(:yellow)} + bun run dev # start development server with watcher + bun run build # normal build + bun run prod # minified and fingerprinted build + + ERROR + + puts message + raise "Asset manifest not found" + end +end + +begin + AssetManifestBuilder.new.build_with_retry +rescue e + puts e.message.try(&.colorize(:red)) + raise e +end diff --git a/src/run_macros/generate_asset_helpers.cr b/src/run_macros/asset_manifest_builder_for_mix.cr similarity index 100% rename from src/run_macros/generate_asset_helpers.cr rename to src/run_macros/asset_manifest_builder_for_mix.cr From 8bede928fa9be239d39e210ad0e979e1a1f73bc7 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 14:03:57 +0100 Subject: [PATCH 05/19] Rename Bun namespace to LuckyBun --- src/bun/config.cr | 2 +- src/lucky.cr | 1 + src/run_macros/asset_manifest_builder_for_bun.cr | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bun/config.cr b/src/bun/config.cr index 59fd01b28..4a2d5011b 100644 --- a/src/bun/config.cr +++ b/src/bun/config.cr @@ -1,6 +1,6 @@ require "json" -module Bun +module LuckyBun struct Config include JSON::Serializable diff --git a/src/lucky.cr b/src/lucky.cr index aadc3c554..a587e51fc 100644 --- a/src/lucky.cr +++ b/src/lucky.cr @@ -10,6 +10,7 @@ require "./lucky/quick_def" require "./charms/*" require "http/server" require "lucky_router" +require "./bun/*" require "./lucky/events/*" require "./lucky/support/*" require "./lucky/renderable_error" diff --git a/src/run_macros/asset_manifest_builder_for_bun.cr b/src/run_macros/asset_manifest_builder_for_bun.cr index 9b1d17f6e..53cef0dd8 100644 --- a/src/run_macros/asset_manifest_builder_for_bun.cr +++ b/src/run_macros/asset_manifest_builder_for_bun.cr @@ -5,12 +5,12 @@ require "../bun/config" struct AssetManifestBuilder property retries = 0 @manifest_path : String - @config : Bun::Config + @config : LuckyBun::Config @max_retries : Int32 @retry_after : Float64 def initialize - @config = Bun::Config.load + @config = LuckyBun::Config.load @manifest_path = resolve_manifest_path # These values can be configured at compile time via environment variables: From 1074dffc63574f0e358259794cc3309ebe6a8cd4 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 14:23:18 +0100 Subject: [PATCH 06/19] Make asset helper work with Bun By default it now uses Bun. with the `legacy` flag, the old Mix manifest can be generated. The `css_entry_points` class method is used for the HMR script to dynamically reload CSS files without a full page reload. This commit also removes duplication in the dynamic_asset helpers. --- src/lucky/asset_helpers.cr | 67 +++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index cfa41bb63..5e4e77d4b 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -4,33 +4,34 @@ # and allow for setting a CDN. # # For an in-depth guide check: https://luckyframework.org/guides/frontend/asset-handling +# module Lucky::AssetHelpers ASSET_MANIFEST = {} of String => String CONFIG = {has_loaded_manifest: false} - macro load_manifest(manifest_file = "") - {{ run "../run_macros/generate_asset_helpers", manifest_file }} - {% CONFIG[:has_loaded_manifest] = true %} - end - - # EXPERIMENTAL: This feature is experimental. Use this to test - # vite integration with Lucky - macro load_manifest(manifest_file, use_vite) - {{ run "../run_macros/generate_asset_helpers", manifest_file, use_vite }} - {% CONFIG[:has_loaded_manifest] = true %} - end - - # Load manifest using the configured asset build system - # This is the new recommended way to load asset manifests - macro load_manifest_from_build_system - {% if @type.has_constant?("ASSET_BUILD_SYSTEM_TYPE") %} - {% if @type.constant("ASSET_BUILD_SYSTEM_TYPE") == "vite" %} - {{ run "../run_macros/generate_asset_helpers", "./public/.vite/manifest.json", "true" }} - {% else %} - {{ run "../run_macros/generate_asset_helpers", "./public/mix-manifest.json", "false" }} - {% end %} + # Loads the asset manifest at compile time. + # + # Call this once in src/app.cr: + # + # ``` + # # For Bun (default): + # Lucky::AssetHelpers.load_manifest + # + # # For Laravel Mix (legacy): + # Lucky::AssetHelpers.load_manifest(legacy: true) + # + # # For Vite: + # Lucky::AssetHelpers.load_manifest(use_vite: true) + # + # # Laravel Mix with custom manifest path: + # Lucky::AssetHelpers.load_manifest("public/custom-manifest.json", legacy: true) + # ``` + # + macro load_manifest(manifest_file = "", legacy = false, use_vite = false) + {% if legacy || use_vite %} + {{ run "../run_macros/asset_manifest_builder_for_mix", manifest_file, use_vite }} {% else %} - {{ run "../run_macros/generate_asset_helpers" }} + {{ run "../run_macros/asset_manifest_builder_for_bun" }} {% end %} {% CONFIG[:has_loaded_manifest] = true %} end @@ -96,21 +97,35 @@ module Lucky::AssetHelpers # ``` # # In a page or component # # Will find the asset in `public/assets/images/logo.png` - # img src: asset("images/logo.png") + # img src: dynamic_asset("images/logo.png") # # # Can also be used elsewhere by prepending Lucky::AssetHelpers - # Lucky::AssetHelpers.asset("images/logo.png") + # Lucky::AssetHelpers.dynamic_asset("images/logo.png") # ``` # # NOTE: This method does *not* check assets at compile time. The asset path # is found at runtime so it is possible the asset does not exist. Be sure to # manually test that the asset is returned as expected. def dynamic_asset(path : String) : String - fingerprinted_path = Lucky::AssetHelpers::ASSET_MANIFEST[path]? - if fingerprinted_path + Lucky::AssetHelpers.dynamic_asset(path) + end + + # Class method variant for use outside pages/components. + # + # ``` + # Lucky::AssetHelpers.dynamic_asset("images/logo.png") + # ``` + # + def self.dynamic_asset(path : String) : String + if fingerprinted_path = ASSET_MANIFEST[path]? Lucky::Server.settings.asset_host + fingerprinted_path else raise "Missing asset: #{path}" end end + + # Returns all the CSS entrypoints from the manifest. + def self.css_entry_points : Array(String) + ASSET_MANIFEST.keys.select(&.ends_with?(".css")) + end end From 20a3e99b5bb77b7b3065dd8ca1d8bab2992ed87a Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 15:13:59 +0100 Subject: [PATCH 07/19] Add bun reload tag This adds a development tag helper rendering a reload script that connects with Bun's WebSocket server. It performs hot reloads for changes to CSS files and full page reloads for any other assets. --- src/lucky/html_builder.cr | 1 + src/lucky/tags/bun_reload_tag.cr | 53 ++++++++++++++++++++++++++++++++ src/lucky/welcome_page.cr | 1 + 3 files changed, 55 insertions(+) create mode 100644 src/lucky/tags/bun_reload_tag.cr diff --git a/src/lucky/html_builder.cr b/src/lucky/html_builder.cr index 3ae6b5ede..0a84466c8 100644 --- a/src/lucky/html_builder.cr +++ b/src/lucky/html_builder.cr @@ -21,6 +21,7 @@ module Lucky::HTMLBuilder include Lucky::RenderIfDefined include Lucky::TagDefaults include Lucky::LiveReloadTag + include Lucky::BunReloadTag include Lucky::SvgInliner abstract def view : IO diff --git a/src/lucky/tags/bun_reload_tag.cr b/src/lucky/tags/bun_reload_tag.cr new file mode 100644 index 000000000..5b2785261 --- /dev/null +++ b/src/lucky/tags/bun_reload_tag.cr @@ -0,0 +1,53 @@ +module Lucky::BunReloadTag + # Renders a live reload tag which connects to Bun's WebSocket server. + # + # NOTE: This tag only generates output in development, so there is no need to + # render it conditionally. + # + def bun_reload_connect_tag + return unless LuckyEnv.development? + + config = LuckyBun::Config.load + tag "script" do + raw <<-JS + (() => { + const cssPaths = #{bun_reload_connect_css_files(config).to_json}; + const ws = new WebSocket('#{config.dev_server.ws_url}') + + ws.onmessage = (event) => { + const data = JSON.parse(event.data) + + if (data.type === 'css') { + document.querySelectorAll('link[rel="stylesheet"]').forEach(link => { + const linkPath = new URL(link.href).pathname.split('?')[0] + if (cssPaths.some(p => linkPath.startsWith(p))) { + const url = new URL(link.href) + url.searchParams.set('r', Date.now()) + link.href = url.toString() + } + }) + console.log('▸ CSS reloaded') + } else if (data.type === 'error') { + console.error('✖ Build error:', data.message) + } else { + console.log('▸ Reloading...') + location.reload() + } + } + + ws.onopen = () => console.log('▸ Live reload connected') + ws.onclose = () => setTimeout(() => location.reload(), 2000) + })() + JS + end + end + + # Collects all CSS entrypoints at their public paths. + private def bun_reload_connect_css_files( + config : LuckyBun::Config, + ) : Array(String) + Lucky::AssetHelpers.css_entry_points.map do |key| + File.join(config.public_path, key) + end + end +end diff --git a/src/lucky/welcome_page.cr b/src/lucky/welcome_page.cr index 150151dc2..38e888f9d 100644 --- a/src/lucky/welcome_page.cr +++ b/src/lucky/welcome_page.cr @@ -2,6 +2,7 @@ class Lucky::WelcomePage include Lucky::HTMLPage include Lucky::LiveReloadTag + include Lucky::BunReloadTag SIGN_UP_ACTION = SignUps::New From 9eb7dfc104efb6f7e019fddd38c1d7878324be66 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 15:53:11 +0100 Subject: [PATCH 08/19] Install Bun in Dockerfile for tests --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index f399fdba3..9c1284db7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM crystallang/crystal:latest WORKDIR /data +COPY --from=oven/bun:latest /usr/local/bin/bun /usr/local/bin/bun + RUN apt-get update && \ apt-get install -y curl libreadline-dev && \ # Cleanup leftovers From f48105279017486085ef8563afb2617d46446d91 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 15:55:48 +0100 Subject: [PATCH 09/19] Run Bun test suite alongside Lucky's --- script/test | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/test b/script/test index cc371f3c0..443f26190 100755 --- a/script/test +++ b/script/test @@ -5,6 +5,9 @@ COMPOSE="docker compose run --rm app" printf "\nrunning specs with 'crystal spec'\n\n" $COMPOSE crystal spec "$@" +printf "\nrunning bun tests with 'bun test'\n\n" +$COMPOSE bun test + if [ $# = 0 ]; then printf "\nChecking that tasks build correctly\n\n" $COMPOSE shards build From 562f8437f267602c35fd8d2769b1dad097de65a7 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 15:56:50 +0100 Subject: [PATCH 10/19] Remove unnecessary global dynamic asset helper --- src/lucky/asset_helpers.cr | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index 5e4e77d4b..24545ce0b 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -107,16 +107,6 @@ module Lucky::AssetHelpers # is found at runtime so it is possible the asset does not exist. Be sure to # manually test that the asset is returned as expected. def dynamic_asset(path : String) : String - Lucky::AssetHelpers.dynamic_asset(path) - end - - # Class method variant for use outside pages/components. - # - # ``` - # Lucky::AssetHelpers.dynamic_asset("images/logo.png") - # ``` - # - def self.dynamic_asset(path : String) : String if fingerprinted_path = ASSET_MANIFEST[path]? Lucky::Server.settings.asset_host + fingerprinted_path else From 8baf6882ec06ac8b3406dbb1d80a7e79eb3cfd8f Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 16:26:39 +0100 Subject: [PATCH 11/19] Add example Bun manifest --- .gitignore | 3 +++ public/assets/manifest.json | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 public/assets/manifest.json diff --git a/.gitignore b/.gitignore index 111ddb8b2..3fcb3ad22 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ server /shard.lock .DS_Store + +!/public/assets/manifest.json + diff --git a/public/assets/manifest.json b/public/assets/manifest.json new file mode 100644 index 000000000..5419f6e0e --- /dev/null +++ b/public/assets/manifest.json @@ -0,0 +1,6 @@ +{ + "images/lucky_logo.png": "images/lucky_logo-8dc912a1.png", + "js/app.js": "js/app-eb1157b7.js", + "css/app.css": "css/app-4b2f41d7.css" +} + From 8e758103e94e475c6af0d3705690a2858fb2af9f Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 16:28:07 +0100 Subject: [PATCH 12/19] Make sure build does not fwil with missing source Check if a source file exists and avoid processing if it does not, and log a warning reference. --- src/bun/lucky.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bun/lucky.js b/src/bun/lucky.js index ecc494eff..7aa36ae5c 100644 --- a/src/bun/lucky.js +++ b/src/bun/lucky.js @@ -100,6 +100,11 @@ export default { const entryPath = join(this.root, entry) const entryName = basename(entry).replace(/\.(ts|js|tsx|jsx|css)$/, '') + if (!existsSync(entryPath)) { + console.warn(` ▸ Missing entry point ${entry}, continuing...`) + continue + } + const result = await Bun.build({ entrypoints: [entryPath], minify: this.prod, From 39800b7c6834b3ac760ae9d5cfa0ba6aaf88b06e Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 16:35:35 +0100 Subject: [PATCH 13/19] Use legacy flag to load manifest --- spec/spec_helper.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index e5955135b..f18c917a2 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -7,7 +7,7 @@ include RoutesHelper Pulsar.enable_test_mode! -Lucky::AssetHelpers.load_manifest +Lucky::AssetHelpers.load_manifest(legacy: true) Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", use_vite: true) Spec.before_each do From 746fb48a2e02dbadfe648ac54e0d1c3d740993a9 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 17:17:31 +0100 Subject: [PATCH 14/19] Follow Lucky's convention with manifest files Rather than writing Bun's manifest file to the assets dir, write it to the public dir as bun-manifest.json. --- public/{assets/manifest.json => bun-manifest.json} | 0 src/bun/config.cr | 4 ++-- src/bun/lucky.js | 4 ++-- src/run_macros/asset_manifest_builder_for_bun.cr | 6 +----- src/run_macros/asset_manifest_builder_for_mix.cr | 5 ++++- 5 files changed, 9 insertions(+), 10 deletions(-) rename public/{assets/manifest.json => bun-manifest.json} (100%) diff --git a/public/assets/manifest.json b/public/bun-manifest.json similarity index 100% rename from public/assets/manifest.json rename to public/bun-manifest.json diff --git a/src/bun/config.cr b/src/bun/config.cr index 4a2d5011b..2e6124d1d 100644 --- a/src/bun/config.cr +++ b/src/bun/config.cr @@ -6,8 +6,8 @@ module LuckyBun CONFIG_PATH = "./config/bun.json" - @[JSON::Field(key: "manifestName")] - getter manifest_name : String = "manifest.json" + @[JSON::Field(key: "manifestPath")] + getter manifest_path : String = "public/bun-manifest.json" @[JSON::Field(key: "outDir")] getter out_dir : String = "public/assets" diff --git a/src/bun/lucky.js b/src/bun/lucky.js index 7aa36ae5c..92485ddbd 100644 --- a/src/bun/lucky.js +++ b/src/bun/lucky.js @@ -61,7 +61,7 @@ export default { staticDirs: ['src/images', 'src/fonts'], outDir: 'public/assets', publicPath: '/assets', - manifestName: 'manifest.json', + manifestPath: 'public/bun-manifest.json', devServer: {host: '127.0.0.1', port: 3002, secure: false} } @@ -183,7 +183,7 @@ export default { async writeManifest() { mkdirSync(this.outDir, {recursive: true}) await Bun.write( - join(this.outDir, this.config.manifestName), + join(this.outDir, this.config.manifestPath), JSON.stringify(this.manifest, null, 2) ) }, diff --git a/src/run_macros/asset_manifest_builder_for_bun.cr b/src/run_macros/asset_manifest_builder_for_bun.cr index 53cef0dd8..c3cdfc5db 100644 --- a/src/run_macros/asset_manifest_builder_for_bun.cr +++ b/src/run_macros/asset_manifest_builder_for_bun.cr @@ -11,7 +11,7 @@ struct AssetManifestBuilder def initialize @config = LuckyBun::Config.load - @manifest_path = resolve_manifest_path + @manifest_path = File.expand_path(@config.manifest_path) # These values can be configured at compile time via environment variables: # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20) @@ -25,10 +25,6 @@ struct AssetManifestBuilder build_manifest end - private def resolve_manifest_path - File.expand_path(File.join(@config.out_dir, @config.manifest_name)) - end - private def retry_or_raise_error raise_missing_manifest_error unless retries < @max_retries diff --git a/src/run_macros/asset_manifest_builder_for_mix.cr b/src/run_macros/asset_manifest_builder_for_mix.cr index 934295fce..21bc59568 100644 --- a/src/run_macros/asset_manifest_builder_for_mix.cr +++ b/src/run_macros/asset_manifest_builder_for_mix.cr @@ -9,7 +9,10 @@ private class AssetManifestBuilder @max_retries : Int32 @retry_after : Float64 - def initialize(@manifest_path : String = "./public/mix-manifest.json", @use_vite : Bool = false) + def initialize( + @manifest_path : String = "./public/mix-manifest.json", + @use_vite : Bool = false, + ) @manifest_path = File.expand_path(@manifest_path) # These values can be configured at compile time via environment variables: From 7c834708ff2bd2a1e75ddbc033941f8603c40619 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 15 Feb 2026 17:17:57 +0100 Subject: [PATCH 15/19] Load Bun manifest in test suite --- .gitignore | 2 -- spec/spec_helper.cr | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3fcb3ad22..118fb2382 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,3 @@ server .DS_Store -!/public/assets/manifest.json - diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index f18c917a2..f827c07d6 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -7,7 +7,11 @@ include RoutesHelper Pulsar.enable_test_mode! +# Load default Bun manifest +Lucky::AssetHelpers.load_manifest +# Load legacy Laravel Mix manifest Lucky::AssetHelpers.load_manifest(legacy: true) +# Load alternative Vite manifest Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", use_vite: true) Spec.before_each do From bd709e1f6a165ed0ab41abedde8b18d7da4e85d1 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 16 Feb 2026 08:32:03 +0100 Subject: [PATCH 16/19] Use full module name to access asset manifest --- src/lucky/asset_helpers.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index 24545ce0b..ea640b2d0 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -107,7 +107,7 @@ module Lucky::AssetHelpers # is found at runtime so it is possible the asset does not exist. Be sure to # manually test that the asset is returned as expected. def dynamic_asset(path : String) : String - if fingerprinted_path = ASSET_MANIFEST[path]? + if fingerprinted_path = Lucky::AssetHelpers::ASSET_MANIFEST[path]? Lucky::Server.settings.asset_host + fingerprinted_path else raise "Missing asset: #{path}" From 814d196708124cd9b97c243726b05be6512b56ed Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 22 Feb 2026 19:17:20 +0100 Subject: [PATCH 17/19] Combine asset anifes builders for all bundlers --- src/lucky/asset_helpers.cr | 25 ++- src/run_macros/asset_manifest_builder.cr | 169 ++++++++++++++++++ .../asset_manifest_builder_for_bun.cr | 68 ------- .../asset_manifest_builder_for_mix.cr | 114 ------------ 4 files changed, 179 insertions(+), 197 deletions(-) create mode 100644 src/run_macros/asset_manifest_builder.cr delete mode 100644 src/run_macros/asset_manifest_builder_for_bun.cr delete mode 100644 src/run_macros/asset_manifest_builder_for_mix.cr diff --git a/src/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index ea640b2d0..6b2e844de 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -14,25 +14,20 @@ module Lucky::AssetHelpers # Call this once in src/app.cr: # # ``` - # # For Bun (default): + # # Bun (default): # Lucky::AssetHelpers.load_manifest # - # # For Laravel Mix (legacy): - # Lucky::AssetHelpers.load_manifest(legacy: true) + # # Laravel Mix: + # Lucky::AssetHelpers.load_manifest(from: :mix) # - # # For Vite: - # Lucky::AssetHelpers.load_manifest(use_vite: true) + # # Vite: + # Lucky::AssetHelpers.load_manifest(from: :vite) # - # # Laravel Mix with custom manifest path: - # Lucky::AssetHelpers.load_manifest("public/custom-manifest.json", legacy: true) + # # Custom manifest path (Mix or Vite only): + # Lucky::AssetHelpers.load_manifest("public/custom-manifest.json", from: :mix) # ``` - # - macro load_manifest(manifest_file = "", legacy = false, use_vite = false) - {% if legacy || use_vite %} - {{ run "../run_macros/asset_manifest_builder_for_mix", manifest_file, use_vite }} - {% else %} - {{ run "../run_macros/asset_manifest_builder_for_bun" }} - {% end %} + macro load_manifest(manifest_file = "", from = :bun) + {{ run "../run_macros/asset_manifest_builder", from, manifest_file }} {% CONFIG[:has_loaded_manifest] = true %} end @@ -53,7 +48,7 @@ module Lucky::AssetHelpers # # NOTE: This macro requires a `StringLiteral`. That means you cannot # interpolate strings like this: `asset("images/icon-#{service_name}.png")`. - # instead use `dynamic_asset` if you need string interpolation. + # Instead use `dynamic_asset` if you need string interpolation. macro asset(path) {% unless CONFIG[:has_loaded_manifest] %} {% raise "No manifest loaded. Call 'Lucky::AssetHelpers.load_manifest'" %} diff --git a/src/run_macros/asset_manifest_builder.cr b/src/run_macros/asset_manifest_builder.cr new file mode 100644 index 000000000..2088dc4c9 --- /dev/null +++ b/src/run_macros/asset_manifest_builder.cr @@ -0,0 +1,169 @@ +require "json" +require "colorize" +require "../bun/config" + +struct AssetManifestBuilder + enum Source + Bun + Mix + Vite + end + + property retries = 0 + + @manifest_path : String + @source : Source + @max_retries : Int32 + @retry_after : Float64 + @bun_config : LuckyBun::Config? + + def initialize(@source : Source = Source::Bun, manifest_file : String = "") + @manifest_path = resolve_manifest_path(manifest_file) + + # These values can be configured at compile time via environment variables: + # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20) + # - LUCKY_ASSET_MANIFEST_RETRY_DELAY: Delay between retries in seconds (default: 0.25) + @max_retries = ENV["LUCKY_ASSET_MANIFEST_RETRY_COUNT"]?.try(&.to_i) || 20 + @retry_after = ENV["LUCKY_ASSET_MANIFEST_RETRY_DELAY"]?.try(&.to_f) || 0.25 + end + + def build_with_retry + retry_or_raise_error unless File.exists?(@manifest_path) + + case @source + in .bun? then build_bun_manifest + in .mix? then build_mix_manifest + in .vite? then build_vite_manifest + end + end + + private def retry_or_raise_error + raise_missing_manifest_error unless retries < @max_retries + + self.retries += 1 + sleep @retry_after + build_with_retry + end + + # Bun manifest format: { "js/app.js": "app-H2SH18AB.js", ... } + # Values are filenames relative to the output directory. + # We prepend the public_path from LuckyBun::Config (default: "/assets"). + private def build_bun_manifest + config = bun_config + JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| + path = File.join(config.public_path, value.as_s) + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{key}"] = "#{path}" %}) + end + end + + # Mix manifest format: { "/js/app.js": "/js/app.js?id=abc123", ... } + # Keys have leading "/" and optionally "assets/" prefix that we strip. + # Values are used as-is (they already include the leading "/"). + private def build_mix_manifest + JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| + clean_key = key.gsub(/^\//, "").gsub(/^assets\//, "") + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "#{value.as_s}" %}) + end + end + + # Vite has two manifest formats: + # + # Dev manifest (from vite-plugin-dev-manifest): + # { "url": "http://localhost:5173/", "inputs": { "src/js/app.js": "src/js/app.js" } } + # + # Production manifest: + # { "src/js/app.js": { "file": "assets/app.abc123.js", "src": "src/js/app.js" } } + private def build_vite_manifest + manifest = JSON.parse(File.read(@manifest_path)) + + if manifest.as_h.has_key?("url") && manifest.as_h.has_key?("inputs") + build_vite_dev_manifest(manifest) + else + build_vite_prod_manifest(manifest) + end + end + + private def build_vite_dev_manifest(manifest) + base_url = manifest["url"].as_s + manifest["inputs"].as_h.each do |_, value| + path = value.as_s + clean_key = path.starts_with?("src/") ? path[4..] : path + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "#{base_url}#{path}" %}) + end + end + + private def build_vite_prod_manifest(manifest) + manifest.as_h.each do |key, value| + next if key.starts_with?("_") + + if value.as_h.has_key?("src") + clean_key = key.starts_with?("src/") ? key[4..] : key + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "/#{value["file"].as_s}" %}) + end + end + end + + private def resolve_manifest_path(manifest_file : String) : String + path = case @source + in .bun? + bun_config.manifest_path + in .mix? + manifest_file.blank? ? "./public/mix-manifest.json" : manifest_file + in .vite? + manifest_file.blank? ? "./public/.vite/manifest.json" : manifest_file + end + + File.expand_path(path) + end + + private def bun_config : LuckyBun::Config + @bun_config ||= LuckyBun::Config.load + end + + private def raise_missing_manifest_error + message = case @source + in .bun? + <<-ERROR + #{"Manifest not found:".colorize(:red)} #{@manifest_path} + + #{"Make sure you have compiled your assets:".colorize(:yellow)} + bun run dev # start development server with watcher + bun run build # normal build + bun run prod # minified and fingerprinted build + + ERROR + in .mix? + <<-ERROR + #{"Manifest not found:".colorize(:red)} #{@manifest_path} + + #{"Make sure you have compiled your assets:".colorize(:yellow)} + yarn run mix # development build + yarn run mix watch # development build with watcher + yarn run mix --production # production build + + ERROR + in .vite? + <<-ERROR + #{"Manifest not found:".colorize(:red)} #{@manifest_path} + + #{"Make sure you have compiled your assets:".colorize(:yellow)} + npx vite # start development server + npx vite build # production build + + ERROR + end + + puts message + raise "Asset manifest not found" + end +end + +begin + source = AssetManifestBuilder::Source.parse(ARGV[0]? || "bun") + manifest_file = ARGV[1]? || "" + + AssetManifestBuilder.new(source, manifest_file).build_with_retry +rescue e + puts e.message.try(&.colorize(:red)) + raise e +end diff --git a/src/run_macros/asset_manifest_builder_for_bun.cr b/src/run_macros/asset_manifest_builder_for_bun.cr deleted file mode 100644 index c3cdfc5db..000000000 --- a/src/run_macros/asset_manifest_builder_for_bun.cr +++ /dev/null @@ -1,68 +0,0 @@ -require "json" -require "colorize" -require "../bun/config" - -struct AssetManifestBuilder - property retries = 0 - @manifest_path : String - @config : LuckyBun::Config - @max_retries : Int32 - @retry_after : Float64 - - def initialize - @config = LuckyBun::Config.load - @manifest_path = File.expand_path(@config.manifest_path) - - # These values can be configured at compile time via environment variables: - # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20) - # - LUCKY_ASSET_MANIFEST_RETRY_DELAY: Delay between retries in seconds (default: 0.25) - @max_retries = ENV["LUCKY_ASSET_MANIFEST_RETRY_COUNT"]?.try(&.to_i) || 20 - @retry_after = ENV["LUCKY_ASSET_MANIFEST_RETRY_DELAY"]?.try(&.to_f) || 0.25 - end - - def build_with_retry - retry_or_raise_error unless File.exists?(@manifest_path) - build_manifest - end - - private def retry_or_raise_error - raise_missing_manifest_error unless retries < @max_retries - - self.retries += 1 - sleep @retry_after - build_with_retry - end - - private def build_manifest - JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| - path = expand_asset_path(value.as_s) - puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{key}"] = "#{path}" %}) - end - end - - private def expand_asset_path(file : String) : String - File.join(@config.public_path, file) - end - - private def raise_missing_manifest_error - message = <<-ERROR - #{"Manifest not found:".colorize(:red)} #{@manifest_path} - - #{"Make sure you have compiled your assets:".colorize(:yellow)} - bun run dev # start development server with watcher - bun run build # normal build - bun run prod # minified and fingerprinted build - - ERROR - - puts message - raise "Asset manifest not found" - end -end - -begin - AssetManifestBuilder.new.build_with_retry -rescue e - puts e.message.try(&.colorize(:red)) - raise e -end diff --git a/src/run_macros/asset_manifest_builder_for_mix.cr b/src/run_macros/asset_manifest_builder_for_mix.cr deleted file mode 100644 index 21bc59568..000000000 --- a/src/run_macros/asset_manifest_builder_for_mix.cr +++ /dev/null @@ -1,114 +0,0 @@ -require "json" -require "colorize" - -private class AssetManifestBuilder - property retries - @retries : Int32 = 0 - @manifest_path : String - @use_vite : Bool = false - @max_retries : Int32 - @retry_after : Float64 - - def initialize( - @manifest_path : String = "./public/mix-manifest.json", - @use_vite : Bool = false, - ) - @manifest_path = File.expand_path(@manifest_path) - - # These values can be configured at compile time via environment variables: - # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20) - # - LUCKY_ASSET_MANIFEST_RETRY_DELAY: Delay between retries in seconds (default: 0.25) - @max_retries = ENV["LUCKY_ASSET_MANIFEST_RETRY_COUNT"]?.try(&.to_i) || 20 - @retry_after = ENV["LUCKY_ASSET_MANIFEST_RETRY_DELAY"]?.try(&.to_f) || 0.25 - end - - def build_with_retry - if manifest_exists? - if @use_vite - build_with_vite_manifest - else - build_with_mix_manifest - end - else - retry_or_raise_error - end - end - - private def retry_or_raise_error - if retries < @max_retries - self.retries += 1 - sleep(@retry_after) - build_with_retry - else - raise_missing_manifest_error - end - end - - private def build_with_mix_manifest - manifest_file = File.read(@manifest_path) - manifest = JSON.parse(manifest_file) - - manifest.as_h.each do |key, value| - # "/js/app.js" => "js/app.js", - key = key.gsub(/^\//, "").gsub(/^assets\//, "") - puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{key}"] = "#{value.as_s}" %}) - end - end - - private def build_with_vite_manifest - manifest_file = File.read(@manifest_path) - manifest = JSON.parse(manifest_file) - - # Check if this is a dev manifest (has "url" and "inputs" properties) - if manifest.as_h.has_key?("url") && manifest.as_h.has_key?("inputs") - # This is a dev manifest from vite-plugin-dev-manifest - base_url = manifest["url"].as_s - inputs = manifest["inputs"].as_h - - inputs.each do |_, value| - path = value.as_s - # Remove src/ prefix to match Lucky's convention - clean_key = path.starts_with?("src/") ? path[4..] : path - puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "#{base_url}#{path}" %}) - end - else - # This is a production manifest - manifest.as_h.each do |key, value| - # Skip chunks that start with underscore (these are shared chunks) - next if key.starts_with?("_") - - # Only process entries that have a src property - if value.as_h.has_key?("src") - # Remove the "src/" prefix from the key to match Lucky's convention - clean_key = key.starts_with?("src/") ? key[4..] : key - puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "/#{value["file"].as_s}" %}) - end - end - end - end - - private def manifest_exists? - File.exists?(@manifest_path) - end - - private def raise_missing_manifest_error - puts "Manifest at #{@manifest_path} does not exist".colorize(:red) - puts "Make sure you have compiled your assets".colorize(:red) - end -end - -begin - manifest_path = ARGV[0] - use_vite = ARGV[1]? == "true" - - builder = if manifest_path.blank? - AssetManifestBuilder.new - else - AssetManifestBuilder.new(manifest_path, use_vite) - end - - builder.build_with_retry -rescue ex - puts ex.message.colorize(:red) - raise ex -end From 02696fac489217073cdf38d5ef809f89a8c37c26 Mon Sep 17 00:00:00 2001 From: Wout Date: Sun, 22 Feb 2026 19:25:28 +0100 Subject: [PATCH 18/19] Fix manifest and bundler args in load_manifest macro --- public/bun-manifest.json | 2 +- spec/spec_helper.cr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/bun-manifest.json b/public/bun-manifest.json index 5419f6e0e..1a4ca694d 100644 --- a/public/bun-manifest.json +++ b/public/bun-manifest.json @@ -1,6 +1,6 @@ { + "images/logo.png": "images/logo-8dc912a1.png", "images/lucky_logo.png": "images/lucky_logo-8dc912a1.png", "js/app.js": "js/app-eb1157b7.js", "css/app.css": "css/app-4b2f41d7.css" } - diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index f827c07d6..e9f16b5ec 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -8,11 +8,11 @@ include RoutesHelper Pulsar.enable_test_mode! # Load default Bun manifest -Lucky::AssetHelpers.load_manifest +Lucky::AssetHelpers.load_manifest(from: :bun) # Load legacy Laravel Mix manifest -Lucky::AssetHelpers.load_manifest(legacy: true) +Lucky::AssetHelpers.load_manifest(from: :mix) # Load alternative Vite manifest -Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", use_vite: true) +Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", from: :vite) Spec.before_each do ARGV.clear From 79266bcb2324379a53381af9f82bb024b00fb304 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 23 Feb 2026 08:35:24 +0100 Subject: [PATCH 19/19] Add comments to asset manifest builder and helpers --- src/lucky/asset_helpers.cr | 15 +++++++-- src/run_macros/asset_manifest_builder.cr | 42 ++++++++++++++++-------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index 6b2e844de..293309b87 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -23,15 +23,19 @@ module Lucky::AssetHelpers # # Vite: # Lucky::AssetHelpers.load_manifest(from: :vite) # - # # Custom manifest path (Mix or Vite only): + # # Custom manifest path: # Lucky::AssetHelpers.load_manifest("public/custom-manifest.json", from: :mix) # ``` + # + # NOTE: The custom manifest path is only considered by Mix or Vite. Bun's is + # defined in the shared `config/bun.json`. + # macro load_manifest(manifest_file = "", from = :bun) {{ run "../run_macros/asset_manifest_builder", from, manifest_file }} {% CONFIG[:has_loaded_manifest] = true %} end - # Return the string path to an asset + # Returns the string path to an asset. # # ``` # # In a page or component: @@ -49,6 +53,7 @@ module Lucky::AssetHelpers # NOTE: This macro requires a `StringLiteral`. That means you cannot # interpolate strings like this: `asset("images/icon-#{service_name}.png")`. # Instead use `dynamic_asset` if you need string interpolation. + # macro asset(path) {% unless CONFIG[:has_loaded_manifest] %} {% raise "No manifest loaded. Call 'Lucky::AssetHelpers.load_manifest'" %} @@ -87,7 +92,7 @@ module Lucky::AssetHelpers {% end %} end - # Return the string path to an asset (allows string interpolation) + # Returns the string path to an asset (allows string interpolation). # # ``` # # In a page or component @@ -101,6 +106,7 @@ module Lucky::AssetHelpers # NOTE: This method does *not* check assets at compile time. The asset path # is found at runtime so it is possible the asset does not exist. Be sure to # manually test that the asset is returned as expected. + # def dynamic_asset(path : String) : String if fingerprinted_path = Lucky::AssetHelpers::ASSET_MANIFEST[path]? Lucky::Server.settings.asset_host + fingerprinted_path @@ -110,6 +116,9 @@ module Lucky::AssetHelpers end # Returns all the CSS entrypoints from the manifest. + # + # NOTE: This method is used by the CSS HMR implementation for Bun. + # def self.css_entry_points : Array(String) ASSET_MANIFEST.keys.select(&.ends_with?(".css")) end diff --git a/src/run_macros/asset_manifest_builder.cr b/src/run_macros/asset_manifest_builder.cr index 2088dc4c9..8d8fde378 100644 --- a/src/run_macros/asset_manifest_builder.cr +++ b/src/run_macros/asset_manifest_builder.cr @@ -27,6 +27,8 @@ struct AssetManifestBuilder @retry_after = ENV["LUCKY_ASSET_MANIFEST_RETRY_DELAY"]?.try(&.to_f) || 0.25 end + # Tries to build a manifest from the chosen bundler and retries several times + # when it fails. def build_with_retry retry_or_raise_error unless File.exists?(@manifest_path) @@ -37,6 +39,7 @@ struct AssetManifestBuilder end end + # Tracks retries and raises if maximum allowed retries are exceeded. private def retry_or_raise_error raise_missing_manifest_error unless retries < @max_retries @@ -45,9 +48,12 @@ struct AssetManifestBuilder build_with_retry end - # Bun manifest format: { "js/app.js": "app-H2SH18AB.js", ... } - # Values are filenames relative to the output directory. - # We prepend the public_path from LuckyBun::Config (default: "/assets"). + # Builds an internal asset manifest from Bun's generated manifest file. + # + # NOTE: Bun's manifest uses values are filenames relative to the output + # directory, so we need to prepend the public_path from `LuckyBun::Config` + # (defaults to "/assets"). + # private def build_bun_manifest config = bun_config JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| @@ -56,9 +62,9 @@ struct AssetManifestBuilder end end - # Mix manifest format: { "/js/app.js": "/js/app.js?id=abc123", ... } - # Keys have leading "/" and optionally "assets/" prefix that we strip. - # Values are used as-is (they already include the leading "/"). + # Builds an internal asset manifest from Laravel Mix's generated manifest + # file. Keys are prefixed with "/" and optionally "assets/" that we strip. + # private def build_mix_manifest JSON.parse(File.read(@manifest_path)).as_h.each do |key, value| clean_key = key.gsub(/^\//, "").gsub(/^assets\//, "") @@ -66,13 +72,14 @@ struct AssetManifestBuilder end end - # Vite has two manifest formats: + # Builds an internal asset manifest from Vite's generated manifest files. # + # NOTE: Vite has two manifest formats: # Dev manifest (from vite-plugin-dev-manifest): - # { "url": "http://localhost:5173/", "inputs": { "src/js/app.js": "src/js/app.js" } } - # + # `{ "url": "http://localhost:5173/", "inputs": { "src/js/app.js": "src/js/app.js" } }` # Production manifest: - # { "src/js/app.js": { "file": "assets/app.abc123.js", "src": "src/js/app.js" } } + # `{ "src/js/app.js": { "file": "assets/app.abc123.js", "src": "src/js/app.js" } }` + # private def build_vite_manifest manifest = JSON.parse(File.read(@manifest_path)) @@ -83,6 +90,8 @@ struct AssetManifestBuilder end end + # Builds an internal asset manifest from Vite's generated development + # manifest file. private def build_vite_dev_manifest(manifest) base_url = manifest["url"].as_s manifest["inputs"].as_h.each do |_, value| @@ -92,6 +101,8 @@ struct AssetManifestBuilder end end + # Builds an internal asset manifest from Vite's generated production manifest + # file. private def build_vite_prod_manifest(manifest) manifest.as_h.each do |key, value| next if key.starts_with?("_") @@ -103,23 +114,28 @@ struct AssetManifestBuilder end end - private def resolve_manifest_path(manifest_file : String) : String + # Resolves the full path of the asset manifest file based on the selected + # bundler, with a fallback to defaults. + private def resolve_manifest_path(file : String) : String path = case @source in .bun? bun_config.manifest_path in .mix? - manifest_file.blank? ? "./public/mix-manifest.json" : manifest_file + file.blank? ? "./public/mix-manifest.json" : file in .vite? - manifest_file.blank? ? "./public/.vite/manifest.json" : manifest_file + file.blank? ? "./public/.vite/manifest.json" : file end File.expand_path(path) end + # Loads and memoizes the shared config between Bun and Lucky. private def bun_config : LuckyBun::Config @bun_config ||= LuckyBun::Config.load end + # Renders a helpful message and raises an error if the asset manifest file + # could not be found. private def raise_missing_manifest_error message = case @source in .bun?