diff --git a/.gitignore b/.gitignore index 111ddb8b2..118fb2382 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ server /shard.lock .DS_Store + 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/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 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/public/bun-manifest.json b/public/bun-manifest.json new file mode 100644 index 000000000..1a4ca694d --- /dev/null +++ b/public/bun-manifest.json @@ -0,0 +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/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 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('$/') + }) +}) diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index e5955135b..e9f16b5ec 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -7,8 +7,12 @@ include RoutesHelper Pulsar.enable_test_mode! -Lucky::AssetHelpers.load_manifest -Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", use_vite: true) +# Load default Bun manifest +Lucky::AssetHelpers.load_manifest(from: :bun) +# Load legacy Laravel Mix manifest +Lucky::AssetHelpers.load_manifest(from: :mix) +# Load alternative Vite manifest +Lucky::AssetHelpers.load_manifest("./public/vite-manifest.json", from: :vite) Spec.before_each do ARGV.clear 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/config.cr b/src/bun/config.cr new file mode 100644 index 000000000..2e6124d1d --- /dev/null +++ b/src/bun/config.cr @@ -0,0 +1,56 @@ +require "json" + +module LuckyBun + struct Config + include JSON::Serializable + + CONFIG_PATH = "./config/bun.json" + + @[JSON::Field(key: "manifestPath")] + getter manifest_path : String = "public/bun-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 diff --git a/src/bun/lucky.js b/src/bun/lucky.js new file mode 100644 index 000000000..92485ddbd --- /dev/null +++ b/src/bun/lucky.js @@ -0,0 +1,294 @@ +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', + manifestPath: 'public/bun-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)$/, '') + + if (!existsSync(entryPath)) { + console.warn(` ▸ Missing entry point ${entry}, continuing...`) + continue + } + + 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.manifestPath), + 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() + } +} 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/lucky/asset_helpers.cr b/src/lucky/asset_helpers.cr index cfa41bb63..293309b87 100644 --- a/src/lucky/asset_helpers.cr +++ b/src/lucky/asset_helpers.cr @@ -4,38 +4,38 @@ # 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 %} - {% else %} - {{ run "../run_macros/generate_asset_helpers" }} - {% end %} + # Loads the asset manifest at compile time. + # + # Call this once in src/app.cr: + # + # ``` + # # Bun (default): + # Lucky::AssetHelpers.load_manifest + # + # # Laravel Mix: + # Lucky::AssetHelpers.load_manifest(from: :mix) + # + # # Vite: + # Lucky::AssetHelpers.load_manifest(from: :vite) + # + # # 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: @@ -52,7 +52,8 @@ 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'" %} @@ -91,26 +92,34 @@ 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 # # 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 + if fingerprinted_path = Lucky::AssetHelpers::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. + # + # 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 end 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 diff --git a/src/run_macros/asset_manifest_builder.cr b/src/run_macros/asset_manifest_builder.cr new file mode 100644 index 000000000..8d8fde378 --- /dev/null +++ b/src/run_macros/asset_manifest_builder.cr @@ -0,0 +1,185 @@ +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 + + # 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) + + case @source + in .bun? then build_bun_manifest + in .mix? then build_mix_manifest + in .vite? then build_vite_manifest + 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 + + self.retries += 1 + sleep @retry_after + build_with_retry + end + + # 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| + path = File.join(config.public_path, value.as_s) + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{key}"] = "#{path}" %}) + end + end + + # 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\//, "") + puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST["#{clean_key}"] = "#{value.as_s}" %}) + end + end + + # 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" } }` + # 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 + + # 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| + 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 + + # 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?("_") + + 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 + + # 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? + file.blank? ? "./public/mix-manifest.json" : file + in .vite? + 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? + <<-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/generate_asset_helpers.cr b/src/run_macros/generate_asset_helpers.cr deleted file mode 100644 index 934295fce..000000000 --- a/src/run_macros/generate_asset_helpers.cr +++ /dev/null @@ -1,111 +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