bun_bun_bundle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ import {join} from 'path'
2
+ import cssAliases from './cssAliases.js'
3
+ import cssGlobs from './cssGlobs.js'
4
+ import jsGlobs from './jsGlobs.js'
5
+
6
+ const builtins = {cssAliases, cssGlobs, jsGlobs}
7
+ const TYPE_REGEXES = {
8
+ css: /\.css$/,
9
+ js: /\.(js|ts|jsx|tsx)$/
10
+ }
11
+
12
+ // Combines transform functions into a single Bun plugin for a given file type.
13
+ function transformPipeline(type, transforms) {
14
+ const filter = TYPE_REGEXES[type]
15
+
16
+ return {
17
+ name: `${type}-transforms`,
18
+ setup(build) {
19
+ build.onLoad({filter}, async args => {
20
+ let content = await Bun.file(args.path).text()
21
+ for (const transform of transforms)
22
+ content = await transform(content, args)
23
+ return {contents: content, loader: type}
24
+ })
25
+ }
26
+ }
27
+ }
28
+
29
+ // Resolves a plugin name or path into a transform function.
30
+ async function loadFactory(name, root) {
31
+ if (builtins[name]) return builtins[name]
32
+
33
+ try {
34
+ const mod = await import(join(root, name))
35
+ console.log(` ▸ Loaded custom plugin: ${name}`)
36
+ return mod.default || mod
37
+ } catch (err) {
38
+ console.error(` ✖ Failed to load plugin "${name}": ${err.message}`)
39
+ }
40
+ }
41
+
42
+ // Resolves plugin names into transforms for a single type.
43
+ async function resolveType(names, context) {
44
+ const transforms = []
45
+ const plugins = []
46
+
47
+ for (const name of names) {
48
+ const factory = await loadFactory(name, context.root)
49
+ if (typeof factory !== 'function') {
50
+ if (factory != null)
51
+ console.error(` ✖ Plugin "${name}" does not export a function`)
52
+ continue
53
+ }
54
+
55
+ const result = factory(context)
56
+ if (typeof result === 'function') transforms.push(result)
57
+ else if (result?.setup) plugins.push(result)
58
+ else console.error(` ✖ Plugin "${name}" returned an invalid value`)
59
+ }
60
+
61
+ return {transforms, plugins}
62
+ }
63
+
64
+ // Resolves plugin config into Bun plugin instances.
65
+ export async function resolvePlugins(pluginConfig, context) {
66
+ const bunPlugins = []
67
+
68
+ if (!pluginConfig || typeof pluginConfig !== 'object') return bunPlugins
69
+
70
+ for (const [type, names] of Object.entries(pluginConfig)) {
71
+ if (!Array.isArray(names)) continue
72
+
73
+ if (!TYPE_REGEXES[type]) {
74
+ console.error(` ✖ Unknown plugin type "${type}"`)
75
+ continue
76
+ }
77
+
78
+ const {transforms, plugins} = await resolveType(names, context)
79
+ bunPlugins.push(...plugins)
80
+ if (transforms.length)
81
+ bunPlugins.unshift(transformPipeline(type, transforms))
82
+ }
83
+
84
+ return bunPlugins
85
+ }
@@ -0,0 +1,47 @@
1
+ import {dirname, extname} from 'path'
2
+ import {Glob} from 'bun'
3
+
4
+ const REGEX = /import\s+(\w+)\s+from\s+['"]glob:([^'"]+)['"]/g
5
+
6
+ // Compiles an object with a file path => default export mapping from a glob
7
+ // pattern in JS import statements.
8
+ // e.g. import components from 'glob:./components/**/*.js'
9
+ //
10
+ // ... will generate ...
11
+ //
12
+ // import _glob_components_theme from './components/theme.js'
13
+ // import _glob_components_shared_tooltip from './components/shared/tooltip.js'
14
+ // const components = {
15
+ // 'components/theme': _glob_components_theme,
16
+ // 'components/shared/tooltip': _glob_components_shared_tooltip
17
+ // }
18
+ export default function jsGlobs() {
19
+ return (content, args) => {
20
+ return content.replace(REGEX, (_, binding, pattern) => {
21
+ const dir = dirname(args.path)
22
+ const cleanPattern = pattern.replace(/^\.\//, '')
23
+ const glob = new Glob(cleanPattern)
24
+ const files = Array.from(glob.scanSync({cwd: dir})).sort()
25
+
26
+ if (!files.length) return `const ${binding} = {}`
27
+
28
+ const imports = []
29
+ const entries = []
30
+
31
+ for (const file of files) {
32
+ const ext = extname(file)
33
+ const key = file.slice(0, -ext.length)
34
+ const safe = `_glob_${key.replace(/[^a-zA-Z0-9]/g, '_')}`
35
+ imports.push(`import ${safe} from './${file}'`)
36
+ entries.push(` '${key}': ${safe}`)
37
+ }
38
+
39
+ return [
40
+ ...imports,
41
+ `const ${binding} = {`,
42
+ entries.join(',\n'),
43
+ '}'
44
+ ].join('\n')
45
+ })
46
+ }
47
+ }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BunBunBundle
6
+ class Config
7
+ CONFIG_PATH = 'config/bun.json'
8
+
9
+ attr_reader :manifest_path, :out_dir, :public_path, :static_dirs,
10
+ :entry_points, :dev_server
11
+
12
+ def initialize(data = {})
13
+ @manifest_path = data.fetch('manifestPath', 'public/bun-manifest.json')
14
+ @out_dir = data.fetch('outDir', 'public/assets')
15
+ @public_path = data.fetch('publicPath', '/assets')
16
+ @static_dirs = data.fetch('staticDirs', %w[app/assets/images app/assets/fonts])
17
+ @entry_points = EntryPoints.new(data.fetch('entryPoints', {}))
18
+ @dev_server = DevServer.new(data.fetch('devServer', {}))
19
+ end
20
+
21
+ def self.load(root: Dir.pwd)
22
+ path = File.join(root, CONFIG_PATH)
23
+ data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
24
+ new(data)
25
+ end
26
+
27
+ class EntryPoints
28
+ attr_reader :js, :css
29
+
30
+ def initialize(data = {})
31
+ @js = data.fetch('js', %w[app/assets/js/app.js])
32
+ @css = data.fetch('css', %w[app/assets/css/app.css])
33
+ end
34
+ end
35
+
36
+ class DevServer
37
+ attr_reader :host, :port
38
+
39
+ def initialize(data = {})
40
+ @host = data.fetch('host', '127.0.0.1')
41
+ @port = data.fetch('port', 3002)
42
+ @secure = data.fetch('secure', false)
43
+ end
44
+
45
+ def secure?
46
+ @secure
47
+ end
48
+
49
+ def ws_protocol
50
+ secure? ? 'wss' : 'ws'
51
+ end
52
+
53
+ def ws_url
54
+ "#{ws_protocol}://#{host}:#{port}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BunBunBundle
4
+ # Rack middleware that sets no-cache headers on asset requests in development.
5
+ #
6
+ # This ensures the browser always fetches fresh assets during development,
7
+ # avoiding stale-cache issues when files are rebuilt.
8
+ #
9
+ # Usage:
10
+ #
11
+ # # In Rails:
12
+ # # Automatically added by the Railtie in development.
13
+ #
14
+ # # In Hanami:
15
+ # # Automatically added by the Hanami integration in development.
16
+ #
17
+ # # Manual Rack usage:
18
+ # use BunBunBundle::DevCacheMiddleware
19
+ #
20
+ class DevCacheMiddleware
21
+ ASSET_EXTENSIONS = %w[
22
+ .js .css .map .json
23
+ .png .jpg .jpeg .gif .svg .webp
24
+ .woff .woff2 .ttf .eot
25
+ ].freeze
26
+
27
+ def initialize(app)
28
+ @app = app
29
+ end
30
+
31
+ def call(env)
32
+ status, headers, body = @app.call(env)
33
+
34
+ if asset_request?(env['PATH_INFO'])
35
+ headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
36
+ headers['Expires'] = '0'
37
+ end
38
+
39
+ [status, headers, body]
40
+ end
41
+
42
+ private
43
+
44
+ def asset_request?(path)
45
+ return false unless path
46
+
47
+ ASSET_EXTENSIONS.any? { |ext| path.end_with?(ext) }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BunBunBundle
4
+ # Hanami integration for BunBunBundle.
5
+ #
6
+ # Add to your Hanami app's config/app.rb:
7
+ #
8
+ # require "bun_bun_bundle/hanami"
9
+ #
10
+ # module MyApp
11
+ # class App < Hanami::App
12
+ # config.middleware.use BunBunBundle::DevCacheMiddleware if Hanami.env?(:development)
13
+ # end
14
+ # end
15
+ #
16
+ # Then include helpers in your views:
17
+ #
18
+ # # app/views/helpers.rb
19
+ # module MyApp
20
+ # module Views
21
+ # module Helpers
22
+ # include BunBunBundle::Helpers
23
+ # include BunBunBundle::ReloadTag
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ module HanamiIntegration
29
+ def self.setup(root: Dir.pwd)
30
+ BunBunBundle.config = Config.load(root: root)
31
+
32
+ BunBunBundle.manifest = if BunBunBundle.development?
33
+ Manifest.load(root: root)
34
+ else
35
+ Manifest.load(root: root, retries: 1, delay: 0)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Auto-setup when loaded in a Hanami app.
42
+ if defined?(Hanami)
43
+ Hanami::App.after :configure do
44
+ BunBunBundle::HanamiIntegration.setup(root: root.to_s)
45
+ end
46
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BunBunBundle
4
+ # Asset helpers for use in views/templates.
5
+ #
6
+ # Include this module in your view helpers to get access to `bun_asset` and
7
+ # tag helpers. Works with ERB, Haml, Slim, or any other templating engine.
8
+ #
9
+ # Example (ERB):
10
+ #
11
+ # <img src="<%= bun_asset("images/logo.png") %>">
12
+ # <%= bun_js_tag("js/app.js") %>
13
+ # <%= bun_css_tag("css/app.css") %>
14
+ #
15
+ module Helpers
16
+ # Returns the public path to an asset from the manifest.
17
+ #
18
+ # Prepends the configured public_path and asset_host:
19
+ #
20
+ # bun_asset("js/app.js") # => "/assets/js/app.js"
21
+ # bun_asset("images/logo.png") # => "/assets/images/logo-abc123.png" (production)
22
+ #
23
+ def bun_asset(path)
24
+ fingerprinted = BunBunBundle.manifest[path]
25
+ "#{BunBunBundle.asset_host}#{BunBunBundle.config.public_path}/#{fingerprinted}"
26
+ end
27
+
28
+ # Generates a <script> tag for a JS entry point.
29
+ #
30
+ # bun_js_tag("js/app.js")
31
+ # # => '<script src="/assets/js/app.js" type="text/javascript"></script>'
32
+ #
33
+ def bun_js_tag(source, **options)
34
+ src = bun_asset(source)
35
+ attrs = { type: 'text/javascript' }.merge(options).merge(src: src)
36
+ %(<script #{_bun_html_attrs(attrs)}></script>)
37
+ end
38
+
39
+ # Generates a <link> tag for a CSS entry point.
40
+ #
41
+ # bun_css_tag("css/app.css")
42
+ # # => '<link href="/assets/css/app.css" type="text/css" rel="stylesheet">'
43
+ #
44
+ def bun_css_tag(source, **options)
45
+ href = bun_asset(source)
46
+ attrs = { type: 'text/css', rel: 'stylesheet' }.merge(options).merge(href: href)
47
+ %(<link #{_bun_html_attrs(attrs)}>)
48
+ end
49
+
50
+ # Generates an <img> tag for an image asset.
51
+ #
52
+ # bun_img_tag("images/logo.png", alt: "Logo")
53
+ # # => '<img src="/assets/images/logo.png" alt="Logo">'
54
+ #
55
+ def bun_img_tag(source, **options)
56
+ src = bun_asset(source)
57
+ alt = options.delete(:alt) || File.basename(source, '.*').tr('-_', ' ').capitalize
58
+ attrs = { alt: alt }.merge(options).merge(src: src)
59
+ %(<img #{_bun_html_attrs(attrs)}>)
60
+ end
61
+
62
+ private
63
+
64
+ def _bun_html_attrs(hash)
65
+ hash.compact.map do |k, v|
66
+ v == true ? k.to_s : %(#{k}="#{_bun_escape_attr(v)}")
67
+ end.join(' ')
68
+ end
69
+
70
+ def _bun_escape_attr(value)
71
+ value.to_s.gsub('&', '&amp;').gsub('"', '&quot;').gsub('<', '&lt;').gsub('>', '&gt;')
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BunBunBundle
6
+ class Manifest
7
+ class MissingAssetError < StandardError; end
8
+
9
+ attr_reader :entries
10
+
11
+ def initialize(entries = {})
12
+ @entries = entries.freeze
13
+ end
14
+
15
+ # Loads the manifest from a JSON file.
16
+ #
17
+ # Retries a configurable number of times to allow for the manifest to be
18
+ # built during boot (e.g. in development).
19
+ def self.load(path: nil, root: Dir.pwd, retries: 10, delay: 0.25)
20
+ path ||= BunBunBundle.config.manifest_path
21
+ full_path = File.expand_path(path, root)
22
+
23
+ retries.times do
24
+ if File.exist?(full_path)
25
+ data = JSON.parse(File.read(full_path))
26
+ return new(data)
27
+ end
28
+ sleep(delay)
29
+ end
30
+
31
+ raise "Manifest not found at #{full_path}. Run: bun_bun_bundle build"
32
+ end
33
+
34
+ def [](key)
35
+ entries.fetch(key) do
36
+ suggestion = find_similar(key)
37
+ message = "Asset not found: #{key}"
38
+ message += ". Did you mean: #{suggestion}?" if suggestion
39
+ raise MissingAssetError, message
40
+ end
41
+ end
42
+
43
+ def key?(key)
44
+ entries.key?(key)
45
+ end
46
+
47
+ def css_entry_points
48
+ entries.keys.select { |k| k.end_with?('.css') }
49
+ end
50
+
51
+ private
52
+
53
+ def find_similar(key, tolerance: 4)
54
+ entries.keys
55
+ .map { |k| [k, levenshtein(key, k)] }
56
+ .select { |_, d| d <= tolerance }
57
+ .min_by { |_, d| d }
58
+ &.first
59
+ end
60
+
61
+ def levenshtein(str_a, str_b) # rubocop:disable Metrics/AbcSize
62
+ return str_b.length if str_a.empty?
63
+ return str_a.length if str_b.empty?
64
+
65
+ distances = Array.new(str_a.length + 1) { |i| i }
66
+
67
+ (1..str_b.length).each do |j|
68
+ prev = distances[0]
69
+ distances[0] = j
70
+ (1..str_a.length).each do |i|
71
+ cost = str_a[i - 1] == str_b[j - 1] ? 0 : 1
72
+ temp = distances[i]
73
+ distances[i] = [distances[i] + 1, distances[i - 1] + 1, prev + cost].min
74
+ prev = temp
75
+ end
76
+ end
77
+
78
+ distances[str_a.length]
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module BunBunBundle
6
+ class Railtie < Rails::Railtie
7
+ initializer 'bun_bun_bundle.helpers' do
8
+ ActiveSupport.on_load(:action_view) do
9
+ include BunBunBundle::Helpers
10
+ include BunBunBundle::ReloadTag
11
+ end
12
+ end
13
+
14
+ initializer 'bun_bun_bundle.manifest' do |app|
15
+ config.after_initialize do
16
+ BunBunBundle.config = Config.load(root: app.root.to_s)
17
+
18
+ BunBunBundle.manifest = if BunBunBundle.development?
19
+ Manifest.load(root: app.root.to_s)
20
+ else
21
+ Manifest.load(root: app.root.to_s, retries: 1, delay: 0)
22
+ end
23
+ end
24
+ end
25
+
26
+ initializer 'bun_bun_bundle.dev_cache' do |app|
27
+ app.middleware.insert_before(0, BunBunBundle::DevCacheMiddleware) if BunBunBundle.development?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BunBunBundle
4
+ # Renders a live reload script tag for development.
5
+ #
6
+ # Connects to Bun's WebSocket dev server for:
7
+ # - CSS hot-reloading (without full page refresh)
8
+ # - Full page reload on JS/image changes
9
+ #
10
+ # Only outputs content when BunBunBundle.development? is true.
11
+ #
12
+ module ReloadTag
13
+ # Returns the live reload <script> tag, or an empty string in production.
14
+ #
15
+ # Example (ERB):
16
+ #
17
+ # <%= bun_reload_tag %>
18
+ #
19
+ def bun_reload_tag # rubocop:disable Metrics/MethodLength
20
+ return '' unless BunBunBundle.development?
21
+
22
+ config = BunBunBundle.config
23
+ css_paths = BunBunBundle.manifest.css_entry_points.map do |key|
24
+ "#{config.public_path}/#{key}"
25
+ end
26
+
27
+ <<~HTML
28
+ <script>
29
+ (() => {
30
+ const cssPaths = #{css_paths.to_json};
31
+ const ws = new WebSocket('#{config.dev_server.ws_url}')
32
+
33
+ ws.onmessage = (event) => {
34
+ const data = JSON.parse(event.data)
35
+
36
+ if (data.type === 'css') {
37
+ document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
38
+ const linkPath = new URL(link.href).pathname.split('?')[0]
39
+ if (cssPaths.some(p => linkPath.startsWith(p))) {
40
+ const url = new URL(link.href)
41
+ url.searchParams.set('r', Date.now())
42
+ link.href = url.toString()
43
+ }
44
+ })
45
+ console.log('\\u25b8 CSS reloaded')
46
+ } else if (data.type === 'error') {
47
+ console.error('\\u2716 Build error:', data.message)
48
+ } else {
49
+ console.log('\\u25b8 Reloading...')
50
+ location.reload()
51
+ }
52
+ }
53
+
54
+ ws.onopen = () => console.log('\\u25b8 Live reload connected')
55
+ ws.onclose = () => setTimeout(() => location.reload(), 2000)
56
+ })()
57
+ </script>
58
+ HTML
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BunBunBundle
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bun_bun_bundle/version'
4
+ require_relative 'bun_bun_bundle/config'
5
+ require_relative 'bun_bun_bundle/manifest'
6
+ require_relative 'bun_bun_bundle/helpers'
7
+ require_relative 'bun_bun_bundle/reload_tag'
8
+ require_relative 'bun_bun_bundle/dev_cache_middleware'
9
+
10
+ module BunBunBundle
11
+ class << self
12
+ attr_writer :config, :manifest, :asset_host, :environment
13
+
14
+ def config
15
+ @config ||= Config.new
16
+ end
17
+
18
+ def manifest
19
+ @manifest ||= Manifest.new
20
+ end
21
+
22
+ def asset_host
23
+ @asset_host || ''
24
+ end
25
+
26
+ def environment
27
+ @environment || ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'
28
+ end
29
+
30
+ def development?
31
+ environment == 'development'
32
+ end
33
+
34
+ def production?
35
+ environment == 'production'
36
+ end
37
+
38
+ # Returns the path to the bundled JS files shipped with the gem.
39
+ def bun_path
40
+ File.expand_path('bun', __dir__)
41
+ end
42
+
43
+ # Resets all state. Useful for testing.
44
+ def reset!
45
+ @config = nil
46
+ @manifest = nil
47
+ @asset_host = nil
48
+ @environment = nil
49
+ end
50
+ end
51
+ end
52
+
53
+ require_relative 'bun_bun_bundle/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bun_bun_bundle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wout Fierens
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Zero-dependency asset bundler with CSS hot-reloading, fingerprinting,
27
+ live reload, and a flexible plugin system. Works with Rails, Hanami, or any Rack
28
+ app.
29
+ executables:
30
+ - bun_bun_bundle
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - exe/bun_bun_bundle
37
+ - lib/bun/bake.js
38
+ - lib/bun/bun_bundle.js
39
+ - lib/bun/plugins/cssAliases.js
40
+ - lib/bun/plugins/cssGlobs.js
41
+ - lib/bun/plugins/index.js
42
+ - lib/bun/plugins/jsGlobs.js
43
+ - lib/bun_bun_bundle.rb
44
+ - lib/bun_bun_bundle/config.rb
45
+ - lib/bun_bun_bundle/dev_cache_middleware.rb
46
+ - lib/bun_bun_bundle/hanami.rb
47
+ - lib/bun_bun_bundle/helpers.rb
48
+ - lib/bun_bun_bundle/manifest.rb
49
+ - lib/bun_bun_bundle/railtie.rb
50
+ - lib/bun_bun_bundle/reload_tag.rb
51
+ - lib/bun_bun_bundle/version.rb
52
+ homepage: https://codeberg.org/w0u7/bun_bun_bundle
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://codeberg.org/w0u7/bun_bun_bundle
57
+ source_code_uri: https://codeberg.org/w0u7/bun_bun_bundle
58
+ changelog_uri: https://codeberg.org/w0u7/bun_bun_bundle/blob/main/CHANGELOG.md
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.1.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.9
75
+ specification_version: 4
76
+ summary: A self-contained asset bundler powered by Bun
77
+ test_files: []