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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ server
/shard.lock

.DS_Store

10 changes: 10 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"printWidth": 80,
"tabWidth": 2,
"singleQuote": true,
"semi": false,
"bracketSpacing": false,
"quoteProps": "consistent",
"trailingComma": "none",
"arrowParens": "avoid"
}
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
root = "./spec/bun"
6 changes: 6 additions & 0 deletions public/bun-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 3 additions & 0 deletions script/test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
271 changes: 271 additions & 0 deletions spec/bun/lucky.test.js
Original file line number Diff line number Diff line change
@@ -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'), '<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('$/')
})
})
8 changes: 6 additions & 2 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/bun/bake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import LuckyBun from './lucky.js'

LuckyBun.flags({
dev: process.argv.includes('--dev'),
prod: process.argv.includes('--prod')
})

await LuckyBun.bake()
56 changes: 56 additions & 0 deletions src/bun/config.cr
Original file line number Diff line number Diff line change
@@ -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
Loading