isolate_assets 0.1.0 → 0.3.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: b42e51e51aa619c08d02e6d2e7c02aca58aab0cbab94b7d629449940bb2d32df
4
- data.tar.gz: a5872849a66dd7b213a3341e6e905af3403b1d66bf2d00c911e137b8b9411fd5
3
+ metadata.gz: dc92b30bb9f88b0d8f12eb3ed349f6a6f1f9decbe87a675b7200e70c8c9bd611
4
+ data.tar.gz: 5aa6ea46f8f8b348f913b1656a6187c3e0875536edb066929f453d997abf7186
5
5
  SHA512:
6
- metadata.gz: 03bce00b9fb6117c4c0e2b202e38a99dd37b89dd60d4f767f94d3a7ca253d4b48dbd5da62d83852f054ded4466151cdc6c4aa65e20ad96564e65e806f7414dea
7
- data.tar.gz: af6aae32f9f05cac171c7e9f761f877ae07afa7419656bf6996424e2c474ab082cb431794e7b339b7e43d9c9744e6341e2183d36b0654638e134088586746113
6
+ metadata.gz: 3bb014e542b5067a9b5543f0ae6c6a1c14e8578b1b6f8dbe2d717c304f4798ff11e7293baab555bcdcddba0162067ee172b6ccd6ddf5b92d25dfeb0bc18c2c94
7
+ data.tar.gz: 96bedf91563d0e5cbea9ac08075e3b5611bdd61a8275fa366058420a7dacbeed2c04845f7c7cce7b424453a2e0638ba0879f403f168cdce2309d38ba67228e59
@@ -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
@@ -41,12 +41,12 @@ end
41
41
 
42
42
  ### 2. Add your assets
43
43
 
44
- Place assets in `app/engine_assets/`:
44
+ Place assets in the standard `app/assets/` directory:
45
45
 
46
46
  ```
47
47
  my_engine/
48
48
  app/
49
- engine_assets/
49
+ assets/
50
50
  javascripts/
51
51
  application.js
52
52
  components/
@@ -54,11 +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
- ### 3. Include the helper
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
- In your engine's application helper:
67
+ ### 3. Use in your views
68
+
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:
62
95
 
63
96
  ```ruby
64
97
  # app/helpers/my_engine/application_helper.rb
@@ -69,22 +102,32 @@ module MyEngine
69
102
  end
70
103
  ```
71
104
 
72
- ### 4. Use in your views
105
+ Then in views:
73
106
 
74
107
  ```erb
75
- <%# Basic stylesheet %>
76
- <%= engine_stylesheet_link_tag "application" %>
108
+ <%= stylesheet_link_tag "application" %>
109
+ <%= image_tag "logo.png", alt: "Logo" %>
110
+ ```
77
111
 
78
- <%# Basic script tag %>
79
- <%= engine_javascript_include_tag "application" %>
112
+ Note: This shadows Rails' built-in asset helpers within your engine's views.
80
113
 
81
- <%# ES6 import maps with CDN dependencies %>
82
- <%= engine_javascript_importmap_tags "application", {
83
- "jquery" => "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm",
84
- } %>
85
- ```
114
+ ### Available helpers
115
+
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 |
86
129
 
87
- Example output:
130
+ ### Example output
88
131
 
89
132
  ```html
90
133
  <script type="importmap">
@@ -101,6 +144,16 @@ Example output:
101
144
  </script>
102
145
  ```
103
146
 
147
+ ## How it works
148
+
149
+ IsolateAssets automatically excludes your engine's asset directories from `config.assets.paths`, so Sprockets, Propshaft, and dartsass-rails won't process them. Your assets are served exclusively through the isolate_assets controller with their own fingerprinting and caching.
150
+
151
+ This exclusion relies on filtering `config.assets.paths` after engines register their directories. While this works with current Rails asset tools, future versions could change path discovery. For guaranteed isolation, use a non-standard directory:
152
+
153
+ ```ruby
154
+ isolate_assets assets_subdir: "isolated_assets" # uses app/isolated_assets/
155
+ ```
156
+
104
157
  ## Requirements
105
158
 
106
159
  - 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"
@@ -4,19 +4,68 @@ module IsolateAssets
4
4
  class Assets
5
5
  attr_reader :engine, :assets_subdir
6
6
 
7
- def initialize(engine:, assets_subdir: "engine_assets")
7
+ ASSET_DIRECTORIES = {
8
+ "js" => "javascripts",
9
+ "javascript" => "javascripts",
10
+ "css" => "stylesheets",
11
+ "stylesheet" => "stylesheets",
12
+ "png" => "images",
13
+ "jpg" => "images",
14
+ "jpeg" => "images",
15
+ "gif" => "images",
16
+ "svg" => "images",
17
+ "webp" => "images",
18
+ "ico" => "images",
19
+ "woff" => "fonts",
20
+ "woff2" => "fonts",
21
+ "ttf" => "fonts",
22
+ "otf" => "fonts",
23
+ "eot" => "fonts",
24
+ "mp3" => "audio",
25
+ "ogg" => "audio",
26
+ "wav" => "audio",
27
+ "mp4" => "video",
28
+ "webm" => "video",
29
+ "ogv" => "video"
30
+ }.freeze
31
+
32
+ CONTENT_TYPES = {
33
+ "js" => "application/javascript",
34
+ "javascript" => "application/javascript",
35
+ "css" => "text/css",
36
+ "stylesheet" => "text/css",
37
+ "png" => "image/png",
38
+ "jpg" => "image/jpeg",
39
+ "jpeg" => "image/jpeg",
40
+ "gif" => "image/gif",
41
+ "svg" => "image/svg+xml",
42
+ "webp" => "image/webp",
43
+ "ico" => "image/x-icon",
44
+ "woff" => "font/woff",
45
+ "woff2" => "font/woff2",
46
+ "ttf" => "font/ttf",
47
+ "otf" => "font/otf",
48
+ "eot" => "application/vnd.ms-fontobject",
49
+ "mp3" => "audio/mpeg",
50
+ "ogg" => "audio/ogg",
51
+ "wav" => "audio/wav",
52
+ "mp4" => "video/mp4",
53
+ "webm" => "video/webm",
54
+ "ogv" => "video/ogg"
55
+ }.freeze
56
+
57
+ def initialize(engine:, assets_subdir: "assets")
8
58
  @engine = engine
9
59
  @assets_subdir = assets_subdir
10
60
  @fingerprints = {}
11
61
  end
12
62
 
13
63
  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
64
+ directory = ASSET_DIRECTORIES[type.to_s]
65
+ return nil unless directory
66
+
67
+ normalized = normalize_type(type)
68
+ engine.root.join("app/#{assets_subdir}/#{directory}", "#{source}.#{normalized}")
20
69
  end
21
70
 
22
71
  def asset_url(source, type)
@@ -35,14 +84,7 @@ module IsolateAssets
35
84
  end
36
85
 
37
86
  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
87
+ CONTENT_TYPES[type.to_s] || "application/octet-stream"
46
88
  end
47
89
 
48
90
  def javascript_files
@@ -2,32 +2,71 @@
2
2
 
3
3
  module IsolateAssets
4
4
  module EngineExtension
5
- def isolate_assets(assets_subdir: "engine_assets")
5
+ HELPER_METHODS = %i[
6
+ stylesheet_link_tag javascript_include_tag javascript_importmap_tags
7
+ asset_path image_path image_tag font_path audio_path audio_tag video_path video_tag
8
+ ].freeze
9
+
10
+ def isolate_assets(assets_subdir: "assets")
6
11
  engine_class = self
7
12
 
8
- # Create a unique controller class for this engine
9
- controller_class = Class.new(IsolateAssets::Controller)
13
+ # Exclude engine assets from host's asset pipeline (if one exists)
14
+ initializer "#{engine_name}.isolate_assets.exclude_from_pipeline", before: :load_config_initializers do |app|
15
+ next unless app.config.respond_to?(:assets) && app.config.assets
16
+
17
+ asset_base = engine_class.root.join("app", assets_subdir)
18
+ app.config.assets.excluded_paths ||= []
19
+ if asset_base.exist?
20
+ asset_base.children.select(&:directory?).each do |subdir|
21
+ app.config.assets.excluded_paths << subdir.to_s
22
+ end
23
+ end
24
+ end
25
+
26
+ # Sprockets doesn't respect excluded_paths, so filter manually
27
+ initializer "#{engine_name}.isolate_assets.filter_asset_paths", after: :load_config_initializers do |app|
28
+ next unless app.config.respond_to?(:assets) && app.config.assets
29
+ next unless app.config.assets.excluded_paths.present?
30
+
31
+ excluded = app.config.assets.excluded_paths.map(&:to_s)
32
+ app.config.assets.paths = app.config.assets.paths.reject do |path|
33
+ excluded.include?(path.to_s)
34
+ end
35
+ end
10
36
 
11
- # Register an initializer to set up assets when Rails boots
12
37
  initializer "#{engine_name}.isolate_assets", before: :set_routes_reloader do
13
38
  assets = IsolateAssets::Assets.new(engine: engine_class, assets_subdir: assets_subdir)
14
39
 
15
- # Create a unique helper module for this engine with the assets baked in
40
+ # Subclass the controller so that multiple engines using this gem get their own controller
41
+ controller_class = Class.new(IsolateAssets::Controller)
42
+ controller_class.isolated_assets = assets
43
+
44
+ # Create helper module for inclusion in engine's ApplicationHelper
16
45
  helper_module = Module.new do
17
46
  define_method(:isolated_assets) { assets }
18
47
  include IsolateAssets::Helper
19
48
  end
20
49
 
21
- # Wire up the controller
22
- controller_class.isolated_assets = assets
23
-
24
- # Store on the engine's namespace module
25
50
  if engine_class.respond_to?(:railtie_namespace) && engine_class.railtie_namespace
26
- engine_class.railtie_namespace.singleton_class.define_method(:isolated_assets) { assets }
27
- engine_class.railtie_namespace.singleton_class.define_method(:isolated_assets_helper) { helper_module }
51
+ namespace = engine_class.railtie_namespace
52
+
53
+ # Expose isolated_assets and helper module
54
+ namespace.singleton_class.define_method(:isolated_assets) { assets }
55
+ namespace.singleton_class.define_method(:isolated_assets_helper) { helper_module }
56
+
57
+ # Define helper methods directly on namespace (e.g., Dummy.stylesheet_link_tag)
58
+ helper_context = Class.new do
59
+ include IsolateAssets::Helper
60
+ define_method(:isolated_assets) { assets }
61
+ end.new
62
+
63
+ HELPER_METHODS.each do |method_name|
64
+ namespace.singleton_class.define_method(method_name) do |*args, **kwargs, &block|
65
+ helper_context.send(method_name, *args, **kwargs, &block)
66
+ end
67
+ end
28
68
  end
29
69
 
30
- # Draw routes
31
70
  engine_class.routes.prepend do
32
71
  get "/assets/*file", to: controller_class.action(:show), as: :isolated_asset
33
72
  end
@@ -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.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isolate_assets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel