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 +4 -4
- data/.github/workflows/ci.yml +1 -0
- data/README.md +68 -15
- data/features/asset_serving.feature +5 -0
- data/features/step_definitions/view_helper_steps.rb +9 -0
- data/features/view_helpers.feature +5 -0
- data/gemfiles/rails_7_2.gemfile +1 -0
- data/gemfiles/rails_8_0.gemfile +1 -0
- data/gemfiles/rails_8_1.gemfile +1 -0
- data/lib/isolate_assets/assets.rb +57 -15
- data/lib/isolate_assets/engine_extension.rb +51 -12
- data/lib/isolate_assets/helper.rb +32 -16
- data/lib/isolate_assets/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc92b30bb9f88b0d8f12eb3ed349f6a6f1f9decbe87a675b7200e70c8c9bd611
|
|
4
|
+
data.tar.gz: 5aa6ea46f8f8b348f913b1656a6187c3e0875536edb066929f453d997abf7186
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bb014e542b5067a9b5543f0ae6c6a1c14e8578b1b6f8dbe2d717c304f4798ff11e7293baab555bcdcddba0162067ee172b6ccd6ddf5b92d25dfeb0bc18c2c94
|
|
7
|
+
data.tar.gz: 96bedf91563d0e5cbea9ac08075e3b5611bdd61a8275fa366058420a7dacbeed2c04845f7c7cce7b424453a2e0638ba0879f403f168cdce2309d38ba67228e59
|
data/.github/workflows/ci.yml
CHANGED
data/README.md
CHANGED
|
@@ -41,12 +41,12 @@ end
|
|
|
41
41
|
|
|
42
42
|
### 2. Add your assets
|
|
43
43
|
|
|
44
|
-
Place assets in `app/
|
|
44
|
+
Place assets in the standard `app/assets/` directory:
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
my_engine/
|
|
48
48
|
app/
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
Then in views:
|
|
73
106
|
|
|
74
107
|
```erb
|
|
75
|
-
|
|
76
|
-
<%=
|
|
108
|
+
<%= stylesheet_link_tag "application" %>
|
|
109
|
+
<%= image_tag "logo.png", alt: "Logo" %>
|
|
110
|
+
```
|
|
77
111
|
|
|
78
|
-
|
|
79
|
-
<%= engine_javascript_include_tag "application" %>
|
|
112
|
+
Note: This shadows Rails' built-in asset helpers within your engine's views.
|
|
80
113
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
data/gemfiles/rails_7_2.gemfile
CHANGED
data/gemfiles/rails_8_0.gemfile
CHANGED
data/gemfiles/rails_8_1.gemfile
CHANGED
|
@@ -4,19 +4,68 @@ module IsolateAssets
|
|
|
4
4
|
class Assets
|
|
5
5
|
attr_reader :engine, :assets_subdir
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
9
|
-
|
|
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
|
-
#
|
|
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
|
|
27
|
-
|
|
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
|
|
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:
|
|
14
|
+
href: isolated_assets.asset_url(source, "css"),
|
|
22
15
|
**options
|
|
23
16
|
)
|
|
24
17
|
end
|
|
25
18
|
|
|
26
|
-
def
|
|
19
|
+
def javascript_include_tag(source, **options)
|
|
27
20
|
tag.script(
|
|
28
|
-
src:
|
|
21
|
+
src: isolated_assets.asset_url(source, "js"),
|
|
29
22
|
**options
|
|
30
23
|
)
|
|
31
24
|
end
|
|
32
25
|
|
|
33
|
-
def
|
|
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] =
|
|
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
|