isolate_assets 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ac311515e556ca2199cbfdb72559a19bffed148c18184c1ac364e742b31d2d6
4
- data.tar.gz: ff9aeb49469e913c4b48c20ee1b82790778410f553606b62f10702a37373623a
3
+ metadata.gz: b7a0b5e25e5067341e5e3c33c79159bbe1b9fe946efd2683a9c7a1dad13f8d9a
4
+ data.tar.gz: 9374de1fe6e919a09f742e8d0b483720f9f115e05423487ef88abd3680e1a02c
5
5
  SHA512:
6
- metadata.gz: 0f25291c4ff1393aa72c764d52492e9baaba70f61880ce31403292b15796120a3f1e751cc53db01a8a9fe2e3eb4524ef20767a60bd739cbe3dc2dd96e0b915e5
7
- data.tar.gz: c4aac80bac4d4fc9cf59367afa765024e6f2f3574da76637159875c617a3ddae5b0bbbfd175cd4afad1eed32aae5bb0fa316c53a753045ea738383dcb4401b1c
6
+ metadata.gz: 98df37ce2970ad2849dced942d34ba434f8e22f4a0590be3fb3b9e693fd557638793f4d88d201b963b2d976d2cefbe7c96c712b67f307c848d7fb89e0c92f325
7
+ data.tar.gz: '08b88c5fd5862e0ac159c714fef265168554a1d25fbafa60f1aef12cf6e28c34f9b13bd67e74a38f71bdc01458f960a64f904f5a4da802e57b0e1390c946f563'
@@ -4,6 +4,7 @@ jobs:
4
4
  test:
5
5
  runs-on: ubuntu-latest
6
6
  strategy:
7
+ fail-fast: false
7
8
  matrix:
8
9
  ruby: ["3.2", "3.3", "3.4", "4.0"]
9
10
  gemfile: [rails_7_2, rails_8_0, rails_8_1]
data/README.md CHANGED
@@ -54,13 +54,44 @@ my_engine/
54
54
  stylesheets/
55
55
  application.css
56
56
  theme.css
57
+ images/
58
+ logo.png
59
+ icons/
60
+ menu.svg
61
+ fonts/
62
+ custom.woff2
57
63
  ```
58
64
 
59
65
  IsolateAssets automatically excludes your engine's `app/assets/` directory from the host app's asset pipeline (Sprockets/Propshaft), so your assets won't conflict with or be processed by the host application.
60
66
 
61
- ### 3. Include the helper
67
+ ### 3. Use in your views
62
68
 
63
- In your engine's application helper:
69
+ Call helper methods directly on your engine's namespace:
70
+
71
+ ```erb
72
+ <%# Stylesheets %>
73
+ <%= MyEngine.stylesheet_link_tag "application" %>
74
+
75
+ <%# JavaScript %>
76
+ <%= MyEngine.javascript_include_tag "application" %>
77
+
78
+ <%# Images %>
79
+ <%= MyEngine.image_tag "logo.png", alt: "Logo" %>
80
+ <%= MyEngine.image_path "icon.svg" %>
81
+
82
+ <%# Other assets %>
83
+ <%= MyEngine.font_path "custom.woff2" %>
84
+ <%= MyEngine.asset_path "data.json" %>
85
+
86
+ <%# ES6 import maps with CDN dependencies %>
87
+ <%= MyEngine.javascript_importmap_tags "application", {
88
+ "jquery" => "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm",
89
+ } %>
90
+ ```
91
+
92
+ ### Alternative: Include helper for unprefixed access
93
+
94
+ If you prefer `stylesheet_link_tag` over `MyEngine.stylesheet_link_tag`, include the helper in your engine's ApplicationHelper:
64
95
 
65
96
  ```ruby
66
97
  # app/helpers/my_engine/application_helper.rb
@@ -71,22 +102,32 @@ module MyEngine
71
102
  end
72
103
  ```
73
104
 
74
- ### 4. Use in your views
105
+ Then in views:
75
106
 
76
107
  ```erb
77
- <%# Basic stylesheet %>
78
- <%= engine_stylesheet_link_tag "application" %>
108
+ <%= stylesheet_link_tag "application" %>
109
+ <%= image_tag "logo.png", alt: "Logo" %>
110
+ ```
79
111
 
80
- <%# Basic script tag %>
81
- <%= engine_javascript_include_tag "application" %>
112
+ Note: This shadows Rails' built-in asset helpers within your engine's views.
82
113
 
83
- <%# ES6 import maps with CDN dependencies %>
84
- <%= engine_javascript_importmap_tags "application", {
85
- "jquery" => "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm",
86
- } %>
87
- ```
114
+ ### Available helpers
88
115
 
89
- Example output:
116
+ | Helper | Description |
117
+ |--------|-------------|
118
+ | `stylesheet_link_tag(source, **options)` | `<link>` tag for CSS |
119
+ | `javascript_include_tag(source, **options)` | `<script>` tag for JS |
120
+ | `javascript_importmap_tags(entry_point, imports)` | ES6 import map |
121
+ | `image_tag(source, **options)` | `<img>` tag |
122
+ | `image_path(source)` | URL path for images |
123
+ | `asset_path(source)` | URL path for any asset (infers type from extension) |
124
+ | `font_path(source)` | URL path for fonts |
125
+ | `audio_tag(source, **options)` | `<audio>` tag |
126
+ | `audio_path(source)` | URL path for audio |
127
+ | `video_tag(source, **options)` | `<video>` tag |
128
+ | `video_path(source)` | URL path for video |
129
+
130
+ ### Example output
90
131
 
91
132
  ```html
92
133
  <script type="importmap">
@@ -113,6 +154,27 @@ This exclusion relies on filtering `config.assets.paths` after engines register
113
154
  isolate_assets assets_subdir: "isolated_assets" # uses app/isolated_assets/
114
155
  ```
115
156
 
157
+ ## Non-isolated engines
158
+
159
+ `isolate_assets` assumes `isolate_namespace` and a mounted route set. For an engine that isn't isolated — one that draws its routes directly into the application router — use `IsolateAssets.register` instead, then draw the asset route yourself with whatever path and name you want:
160
+
161
+ ```ruby
162
+ # lib/my_engine.rb
163
+ module MyEngine
164
+ class Engine < ::Rails::Engine; end
165
+ Assets = IsolateAssets.register(namespace: self, engine: Engine, route_name: :my_engine_asset)
166
+ end
167
+ ```
168
+
169
+ ```ruby
170
+ # config/routes.rb (drawn into the application router)
171
+ Rails.application.routes.draw do
172
+ MyEngine::Assets.draw(self, "/my_engine/assets")
173
+ end
174
+ ```
175
+
176
+ This gives you the same namespaced helpers as the isolated path — `MyEngine.stylesheet_link_tag "app"`, `MyEngine.javascript_include_tag "app"`, etc. — fingerprinted against your chosen route.
177
+
116
178
  ## Requirements
117
179
 
118
180
  - Ruby 3.2+
@@ -15,6 +15,11 @@ Feature: Asset Serving
15
15
  And the content type should be "text/css"
16
16
  And the response should contain "font-family: sans-serif"
17
17
 
18
+ Scenario: Serving an image file
19
+ When I request "/dummy/assets/logo.png"
20
+ Then I should receive a successful response
21
+ And the content type should be "image/png"
22
+
18
23
  Scenario: Asset fingerprinting
19
24
  When I request "/dummy/assets/application.js"
20
25
  Then the response should have caching headers
@@ -22,3 +22,12 @@ Then("the import map should include {string}") do |key|
22
22
  import_map = JSON.parse(script.text(:all))
23
23
  expect(import_map["imports"]).to have_key(key)
24
24
  end
25
+
26
+ Then("the page should have an image tag with src starting with {string}") do |path|
27
+ expect(page).to have_css("img[src^='#{path}']", visible: false)
28
+ end
29
+
30
+ Then("the image tag should include a fingerprint parameter") do
31
+ img = page.find("img", visible: false)
32
+ expect(img[:src]).to match(/\?v=[a-f0-9]{8}/)
33
+ end
@@ -12,3 +12,8 @@ Feature: View Helpers
12
12
  When I visit the dummy engine root
13
13
  Then the page should have an import map
14
14
  And the import map should include "dummy/application"
15
+
16
+ Scenario: Image tag includes fingerprint
17
+ When I visit the dummy engine root
18
+ Then the page should have an image tag with src starting with "/dummy/assets/logo.png"
19
+ And the image tag should include a fingerprint parameter
@@ -10,6 +10,7 @@ group :test do
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "cucumber", "~> 9.0"
12
12
  gem "capybara", "~> 3.0"
13
+ gem "sprockets-rails"
13
14
  end
14
15
 
15
16
  gemspec path: "../", name: "isolate_assets"
@@ -10,6 +10,7 @@ group :test do
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "cucumber", "~> 9.0"
12
12
  gem "capybara", "~> 3.0"
13
+ gem "sprockets-rails"
13
14
  end
14
15
 
15
16
  gemspec path: "../", name: "isolate_assets"
@@ -10,6 +10,7 @@ group :test do
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "cucumber", "~> 9.0"
12
12
  gem "capybara", "~> 3.0"
13
+ gem "sprockets-rails"
13
14
  end
14
15
 
15
16
  gemspec path: "../", name: "isolate_assets"
@@ -2,26 +2,88 @@
2
2
 
3
3
  module IsolateAssets
4
4
  class Assets
5
- attr_reader :engine, :assets_subdir
5
+ attr_reader :engine, :assets_subdir, :route_name
6
+ attr_accessor :controller
6
7
 
7
- def initialize(engine:, assets_subdir: "assets")
8
+ ASSET_DIRECTORIES = {
9
+ "js" => "javascripts",
10
+ "javascript" => "javascripts",
11
+ "css" => "stylesheets",
12
+ "stylesheet" => "stylesheets",
13
+ "png" => "images",
14
+ "jpg" => "images",
15
+ "jpeg" => "images",
16
+ "gif" => "images",
17
+ "svg" => "images",
18
+ "webp" => "images",
19
+ "ico" => "images",
20
+ "woff" => "fonts",
21
+ "woff2" => "fonts",
22
+ "ttf" => "fonts",
23
+ "otf" => "fonts",
24
+ "eot" => "fonts",
25
+ "mp3" => "audio",
26
+ "ogg" => "audio",
27
+ "wav" => "audio",
28
+ "mp4" => "video",
29
+ "webm" => "video",
30
+ "ogv" => "video"
31
+ }.freeze
32
+
33
+ CONTENT_TYPES = {
34
+ "js" => "application/javascript",
35
+ "javascript" => "application/javascript",
36
+ "css" => "text/css",
37
+ "stylesheet" => "text/css",
38
+ "png" => "image/png",
39
+ "jpg" => "image/jpeg",
40
+ "jpeg" => "image/jpeg",
41
+ "gif" => "image/gif",
42
+ "svg" => "image/svg+xml",
43
+ "webp" => "image/webp",
44
+ "ico" => "image/x-icon",
45
+ "woff" => "font/woff",
46
+ "woff2" => "font/woff2",
47
+ "ttf" => "font/ttf",
48
+ "otf" => "font/otf",
49
+ "eot" => "application/vnd.ms-fontobject",
50
+ "mp3" => "audio/mpeg",
51
+ "ogg" => "audio/ogg",
52
+ "wav" => "audio/wav",
53
+ "mp4" => "video/mp4",
54
+ "webm" => "video/webm",
55
+ "ogv" => "video/ogg"
56
+ }.freeze
57
+
58
+ def initialize(engine:, assets_subdir: "assets", route_name: :isolated_asset, url_helpers: nil)
8
59
  @engine = engine
9
60
  @assets_subdir = assets_subdir
61
+ @route_name = route_name
62
+ @url_helpers = url_helpers || -> { engine.routes.url_helpers }
10
63
  @fingerprints = {}
11
64
  end
12
65
 
66
+ # Draws the catch-all asset route into the given router (the application
67
+ # router for non-isolated engines, the engine's own for isolated ones).
68
+ def draw(mapper, path)
69
+ mapper.get "#{path}/*file", to: controller.action(:show), as: route_name
70
+ end
71
+
13
72
  def asset_path(source, type)
14
- case type.to_s
15
- when "js", "javascript"
16
- engine.root.join("app/#{assets_subdir}/javascripts", "#{source}.js")
17
- when "css", "stylesheet"
18
- engine.root.join("app/#{assets_subdir}/stylesheets", "#{source}.css")
19
- end
73
+ directory = ASSET_DIRECTORIES[type.to_s]
74
+ return nil unless directory
75
+
76
+ normalized = normalize_type(type)
77
+ engine.root.join("app/#{assets_subdir}/#{directory}", "#{source}.#{normalized}")
20
78
  end
21
79
 
22
80
  def asset_url(source, type)
23
81
  fingerprint_value = fingerprint(source, type)
24
- engine.routes.url_helpers.isolated_asset_path("#{source}.#{normalize_type(type)}", v: fingerprint_value)
82
+ @url_helpers.call.public_send(
83
+ "#{route_name}_path",
84
+ "#{source}.#{normalize_type(type)}",
85
+ v: fingerprint_value,
86
+ )
25
87
  end
26
88
 
27
89
  def fingerprint(source, type)
@@ -35,14 +97,7 @@ module IsolateAssets
35
97
  end
36
98
 
37
99
  def content_type(type)
38
- case type.to_s
39
- when "js", "javascript"
40
- "application/javascript"
41
- when "css", "stylesheet"
42
- "text/css"
43
- else
44
- "application/octet-stream"
45
- end
100
+ CONTENT_TYPES[type.to_s] || "application/octet-stream"
46
101
  end
47
102
 
48
103
  def javascript_files
@@ -5,8 +5,10 @@ module IsolateAssets
5
5
  def isolate_assets(assets_subdir: "assets")
6
6
  engine_class = self
7
7
 
8
- # Exclude engine assets from host's asset pipeline
8
+ # Exclude engine assets from host's asset pipeline (if one exists)
9
9
  initializer "#{engine_name}.isolate_assets.exclude_from_pipeline", before: :load_config_initializers do |app|
10
+ next unless app.config.respond_to?(:assets) && app.config.assets
11
+
10
12
  asset_base = engine_class.root.join("app", assets_subdir)
11
13
  app.config.assets.excluded_paths ||= []
12
14
  if asset_base.exist?
@@ -18,29 +20,21 @@ module IsolateAssets
18
20
 
19
21
  # Sprockets doesn't respect excluded_paths, so filter manually
20
22
  initializer "#{engine_name}.isolate_assets.filter_asset_paths", after: :load_config_initializers do |app|
21
- if app.config.assets.excluded_paths.present?
22
- excluded = app.config.assets.excluded_paths.map(&:to_s)
23
- app.config.assets.paths = app.config.assets.paths.reject do |path|
24
- excluded.include?(path.to_s)
25
- end
23
+ next unless app.config.respond_to?(:assets) && app.config.assets
24
+ next unless app.config.assets.excluded_paths.present?
25
+
26
+ excluded = app.config.assets.excluded_paths.map(&:to_s)
27
+ app.config.assets.paths = app.config.assets.paths.reject do |path|
28
+ excluded.include?(path.to_s)
26
29
  end
27
30
  end
28
31
 
29
32
  initializer "#{engine_name}.isolate_assets", before: :set_routes_reloader do
30
33
  assets = IsolateAssets::Assets.new(engine: engine_class, assets_subdir: assets_subdir)
34
+ controller_class = IsolateAssets.build_controller(assets)
31
35
 
32
- # Subclass the controller so that multiple engines using this gem get their own controller
33
- controller_class = Class.new(IsolateAssets::Controller)
34
- controller_class.isolated_assets = assets
35
-
36
- # Hack in the helpers. There's gotta be a better way than this...
37
- helper_module = Module.new do
38
- define_method(:isolated_assets) { assets }
39
- include IsolateAssets::Helper
40
- end
41
36
  if engine_class.respond_to?(:railtie_namespace) && engine_class.railtie_namespace
42
- engine_class.railtie_namespace.singleton_class.define_method(:isolated_assets) { assets }
43
- engine_class.railtie_namespace.singleton_class.define_method(:isolated_assets_helper) { helper_module }
37
+ IsolateAssets.expose_helpers(engine_class.railtie_namespace, assets)
44
38
  end
45
39
 
46
40
  engine_class.routes.prepend do
@@ -2,40 +2,33 @@
2
2
 
3
3
  module IsolateAssets
4
4
  module Helper
5
+ include ActionView::Helpers::TagHelper
6
+ include ActionView::Helpers::AssetTagHelper
7
+
5
8
  # Note: isolated_assets method is defined by the including module,
6
9
  # created dynamically in EngineExtension#isolate_assets
7
10
 
8
- def engine_asset_url(source, type)
9
- fingerprint_value = isolated_assets.fingerprint(source, type)
10
- normalized_type = case type.to_s
11
- when "javascript" then "js"
12
- when "stylesheet" then "css"
13
- else type.to_s
14
- end
15
- isolated_asset_path("#{source}.#{normalized_type}", v: fingerprint_value)
16
- end
17
-
18
- def engine_stylesheet_link_tag(source, **options)
11
+ def stylesheet_link_tag(source, **options)
19
12
  tag.link(
20
13
  rel: "stylesheet",
21
- href: engine_asset_url(source, "css"),
14
+ href: isolated_assets.asset_url(source, "css"),
22
15
  **options
23
16
  )
24
17
  end
25
18
 
26
- def engine_javascript_include_tag(source, **options)
19
+ def javascript_include_tag(source, **options)
27
20
  tag.script(
28
- src: engine_asset_url(source, "js"),
21
+ src: isolated_assets.asset_url(source, "js"),
29
22
  **options
30
23
  )
31
24
  end
32
25
 
33
- def engine_javascript_importmap_tags(entry_point = "application", imports = {})
26
+ def javascript_importmap_tags(entry_point = "application", imports = {})
34
27
  assets_root = isolated_assets.engine.root.join("app/#{isolated_assets.assets_subdir}/javascripts")
35
28
  engine_imports = isolated_assets.javascript_files.each_with_object({}) do |path, hash|
36
29
  relative_path = path.relative_path_from(assets_root).to_s
37
30
  key = "#{isolated_assets.engine.engine_name}/#{relative_path.sub(/\.js\z/, "")}"
38
- hash[key] = engine_asset_url(relative_path.sub(/\.js\z/, ""), "js")
31
+ hash[key] = isolated_assets.asset_url(relative_path.sub(/\.js\z/, ""), "js")
39
32
  end
40
33
  [
41
34
  tag.script(type: "importmap") do
@@ -46,5 +39,28 @@ module IsolateAssets
46
39
  JS
47
40
  ].join("\n").html_safe
48
41
  end
42
+
43
+ def asset_path(source)
44
+ ext = File.extname(source).delete_prefix(".")
45
+ source_without_ext = source.sub(/\.#{Regexp.escape(ext)}\z/, "")
46
+ isolated_assets.asset_url(source_without_ext, ext)
47
+ end
48
+
49
+ alias_method :image_path, :asset_path
50
+ alias_method :font_path, :asset_path
51
+ alias_method :audio_path, :asset_path
52
+ alias_method :video_path, :asset_path
53
+
54
+ def image_tag(source, **options)
55
+ tag.img(src: image_path(source), **options)
56
+ end
57
+
58
+ def audio_tag(source, **options)
59
+ tag.audio(src: audio_path(source), **options)
60
+ end
61
+
62
+ def video_tag(source, **options)
63
+ tag.video(src: video_path(source), **options)
64
+ end
49
65
  end
50
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IsolateAssets
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,6 +9,55 @@ module IsolateAssets
9
9
  autoload :Controller, "isolate_assets/controller"
10
10
  autoload :Helper, "isolate_assets/helper"
11
11
  autoload :EngineExtension, "isolate_assets/engine_extension"
12
+
13
+ HELPER_METHODS = %i[
14
+ stylesheet_link_tag javascript_include_tag javascript_importmap_tags
15
+ asset_path image_path image_tag font_path audio_path audio_tag video_path video_tag
16
+ ].freeze
17
+
18
+ # Wires up an engine that draws its asset route into the application router
19
+ # rather than mounting an isolated route set. Returns the Assets handle; draw
20
+ # the route from the engine's routes file with `assets.draw(self, path)`.
21
+ def self.register(namespace:, engine:, route_name:, assets_subdir: "assets")
22
+ assets = Assets.new(
23
+ engine: engine,
24
+ assets_subdir: assets_subdir,
25
+ route_name: route_name,
26
+ url_helpers: -> { Rails.application.routes.url_helpers },
27
+ )
28
+ build_controller(assets)
29
+ expose_helpers(namespace, assets)
30
+ assets
31
+ end
32
+
33
+ def self.build_controller(assets)
34
+ controller = Class.new(Controller)
35
+ controller.isolated_assets = assets
36
+ assets.controller = controller
37
+ controller
38
+ end
39
+
40
+ # Defines isolated_assets + the asset tag helpers (stylesheet_link_tag, etc.)
41
+ # as singleton methods on the given module, e.g. Dummy.stylesheet_link_tag.
42
+ def self.expose_helpers(namespace, assets)
43
+ helper_module = Module.new do
44
+ define_method(:isolated_assets) { assets }
45
+ include Helper
46
+ end
47
+ namespace.singleton_class.define_method(:isolated_assets) { assets }
48
+ namespace.singleton_class.define_method(:isolated_assets_helper) { helper_module }
49
+
50
+ helper_context = Class.new do
51
+ include Helper
52
+ define_method(:isolated_assets) { assets }
53
+ end.new
54
+ HELPER_METHODS.each do |method_name|
55
+ namespace.singleton_class.define_method(method_name) do |*args, **kwargs, &block|
56
+ helper_context.send(method_name, *args, **kwargs, &block)
57
+ end
58
+ end
59
+ helper_module
60
+ end
12
61
  end
13
62
 
14
63
  # Extend Rails::Engine with isolate_assets
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isolate_assets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-10 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties