isolate_assets 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b42e51e51aa619c08d02e6d2e7c02aca58aab0cbab94b7d629449940bb2d32df
4
+ data.tar.gz: a5872849a66dd7b213a3341e6e905af3403b1d66bf2d00c911e137b8b9411fd5
5
+ SHA512:
6
+ metadata.gz: 03bce00b9fb6117c4c0e2b202e38a99dd37b89dd60d4f767f94d3a7ca253d4b48dbd5da62d83852f054ded4466151cdc6c4aa65e20ad96564e65e806f7414dea
7
+ data.tar.gz: af6aae32f9f05cac171c7e9f761f877ae07afa7419656bf6996424e2c474ab082cb431794e7b339b7e43d9c9744e6341e2183d36b0654638e134088586746113
@@ -0,0 +1,24 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
9
+ gemfile: [rails_7_2, rails_8_0, rails_8_1]
10
+
11
+ env:
12
+ BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Ruby
18
+ uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby }}
21
+ bundler-cache: true
22
+
23
+ - name: Run tests
24
+ run: bundle exec rake
data/Appraisals ADDED
@@ -0,0 +1,11 @@
1
+ appraise "rails-7-2" do
2
+ gem "rails", "~> 7.2.0"
3
+ end
4
+
5
+ appraise "rails-8-0" do
6
+ gem "rails", "~> 8.0.0"
7
+ end
8
+
9
+ appraise "rails-8-1" do
10
+ gem "rails", "~> 8.1.0"
11
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Micah Geisel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # IsolateAssets
2
+
3
+ Self-contained asset serving for Rails engines. Serve JavaScript, CSS, and other assets from your engine without depending on Sprockets, Propshaft, or the host application's asset pipeline.
4
+
5
+ ## Why?
6
+
7
+ Rails engines that include UI components need to serve assets, but integrating with the host app's asset pipeline is problematic:
8
+
9
+ - **Sprockets/Propshaft conflicts** - Different versions, configurations, or the host might not use them at all
10
+ - **Webpacker/esbuild/Vite** - Modern setups don't expect engine assets
11
+ - **Configuration burden** - Users must manually configure asset paths
12
+ - **Version compatibility** - Asset pipeline APIs change between Rails versions
13
+
14
+ IsolateAssets solves this by letting your engine serve its own assets through a simple controller, with fingerprinting and caching handled automatically.
15
+
16
+ ## Installation
17
+
18
+ Add to your engine's gemspec:
19
+
20
+ ```ruby
21
+ spec.add_dependency "isolate_assets"
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### 1. Set up your engine
27
+
28
+ In your engine file, add `isolate_assets` alongside `isolate_namespace`:
29
+
30
+ ```ruby
31
+ # lib/my_engine/engine.rb
32
+ require "isolate_assets"
33
+
34
+ module MyEngine
35
+ class Engine < ::Rails::Engine
36
+ isolate_namespace MyEngine
37
+ isolate_assets
38
+ end
39
+ end
40
+ ```
41
+
42
+ ### 2. Add your assets
43
+
44
+ Place assets in `app/engine_assets/`:
45
+
46
+ ```
47
+ my_engine/
48
+ app/
49
+ engine_assets/
50
+ javascripts/
51
+ application.js
52
+ components/
53
+ widget.js
54
+ stylesheets/
55
+ application.css
56
+ theme.css
57
+ ```
58
+
59
+ ### 3. Include the helper
60
+
61
+ In your engine's application helper:
62
+
63
+ ```ruby
64
+ # app/helpers/my_engine/application_helper.rb
65
+ module MyEngine
66
+ module ApplicationHelper
67
+ include MyEngine.isolated_assets_helper
68
+ end
69
+ end
70
+ ```
71
+
72
+ ### 4. Use in your views
73
+
74
+ ```erb
75
+ <%# Basic stylesheet %>
76
+ <%= engine_stylesheet_link_tag "application" %>
77
+
78
+ <%# Basic script tag %>
79
+ <%= engine_javascript_include_tag "application" %>
80
+
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
+ ```
86
+
87
+ Example output:
88
+
89
+ ```html
90
+ <script type="importmap">
91
+ {
92
+ "imports": {
93
+ "jquery": "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm",
94
+ "my_engine/application": "/my_engine/assets/application.js?v=a1b2c3d4",
95
+ "my_engine/components/widget": "/my_engine/assets/components/widget.js?v=e5f6g7h8"
96
+ }
97
+ }
98
+ </script>
99
+ <script type="module">
100
+ import "my_engine/application"
101
+ </script>
102
+ ```
103
+
104
+ ## Requirements
105
+
106
+ - Ruby 3.2+
107
+ - Rails 7.2+
108
+
109
+ ## License
110
+
111
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "cucumber/rake/task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ Cucumber::Rake::Task.new(:cucumber)
9
+
10
+ task default: [:spec, :cucumber]
data/cucumber.yml ADDED
@@ -0,0 +1 @@
1
+ default: --publish-quiet
@@ -0,0 +1,28 @@
1
+ Feature: Asset Serving
2
+ As a Rails engine developer
3
+ I want to serve JavaScript and CSS assets from my engine
4
+ So that my engine's UI works without depending on the host app's asset pipeline
5
+
6
+ Scenario: Serving a JavaScript file
7
+ When I request "/dummy/assets/application.js"
8
+ Then I should receive a successful response
9
+ And the content type should be "application/javascript"
10
+ And the response should contain "Dummy engine loaded"
11
+
12
+ Scenario: Serving a CSS file
13
+ When I request "/dummy/assets/application.css"
14
+ Then I should receive a successful response
15
+ And the content type should be "text/css"
16
+ And the response should contain "font-family: sans-serif"
17
+
18
+ Scenario: Asset fingerprinting
19
+ When I request "/dummy/assets/application.js"
20
+ Then the response should have caching headers
21
+
22
+ Scenario: Missing asset returns 404
23
+ When I request "/dummy/assets/nonexistent.js"
24
+ Then I should receive a not found response
25
+
26
+ Scenario: Path traversal is blocked
27
+ When I request "/dummy/assets/../../lib/dummy.rb"
28
+ Then I should receive a not found response
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ When("I request {string}") do |path|
4
+ visit path
5
+ end
6
+
7
+ Then("I should receive a successful response") do
8
+ expect(page.status_code).to eq(200)
9
+ end
10
+
11
+ Then("I should receive a not found response") do
12
+ expect(page.status_code).to eq(404)
13
+ end
14
+
15
+ Then("the content type should be {string}") do |content_type|
16
+ expect(page.response_headers["Content-Type"]).to include(content_type)
17
+ end
18
+
19
+ Then("the response should contain {string}") do |text|
20
+ expect(page.body).to include(text)
21
+ end
22
+
23
+ Then("the response should have caching headers") do
24
+ expect(page.response_headers["Cache-Control"]).to match(/max-age=\d+/)
25
+ expect(page.response_headers["Cache-Control"]).to include("public")
26
+ expect(page.response_headers["ETag"]).to be_present
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ When("I visit the dummy engine root") do
4
+ visit "/dummy"
5
+ end
6
+
7
+ Then("the page should have a stylesheet link to {string}") do |path|
8
+ expect(page).to have_css("link[rel='stylesheet'][href^='#{path}']", visible: false)
9
+ end
10
+
11
+ Then("the stylesheet link should include a fingerprint parameter") do
12
+ link = page.find("link[rel='stylesheet']", visible: false)
13
+ expect(link[:href]).to match(/\?v=[a-f0-9]{8}/)
14
+ end
15
+
16
+ Then("the page should have an import map") do
17
+ expect(page).to have_css("script[type='importmap']", visible: false)
18
+ end
19
+
20
+ Then("the import map should include {string}") do |key|
21
+ script = page.find("script[type='importmap']", visible: false)
22
+ import_map = JSON.parse(script.text(:all))
23
+ expect(import_map["imports"]).to have_key(key)
24
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
4
+
5
+ ENV["RAILS_ENV"] = "test"
6
+ require_relative "../../spec/dummy_host/config/environment"
7
+
8
+ require "capybara/cucumber"
9
+
10
+ Capybara.app = Rails.application
@@ -0,0 +1,14 @@
1
+ Feature: View Helpers
2
+ As a Rails engine developer
3
+ I want view helpers to generate asset tags
4
+ So that I can easily include my engine's assets in views
5
+
6
+ Scenario: Stylesheet link tag includes fingerprint
7
+ When I visit the dummy engine root
8
+ Then the page should have a stylesheet link to "/dummy/assets/application.css"
9
+ And the stylesheet link should include a fingerprint parameter
10
+
11
+ Scenario: Import map includes engine JavaScript files
12
+ When I visit the dummy engine root
13
+ Then the page should have an import map
14
+ And the import map should include "dummy/application"
@@ -0,0 +1,15 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "appraisal"
7
+ gem "rails", "~> 7.2.0"
8
+
9
+ group :test do
10
+ gem "rspec", "~> 3.0"
11
+ gem "cucumber", "~> 9.0"
12
+ gem "capybara", "~> 3.0"
13
+ end
14
+
15
+ gemspec path: "../", name: "isolate_assets"
@@ -0,0 +1,15 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "appraisal"
7
+ gem "rails", "~> 8.0.0"
8
+
9
+ group :test do
10
+ gem "rspec", "~> 3.0"
11
+ gem "cucumber", "~> 9.0"
12
+ gem "capybara", "~> 3.0"
13
+ end
14
+
15
+ gemspec path: "../", name: "isolate_assets"
@@ -0,0 +1,15 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "appraisal"
7
+ gem "rails", "~> 8.1.0"
8
+
9
+ group :test do
10
+ gem "rspec", "~> 3.0"
11
+ gem "cucumber", "~> 9.0"
12
+ gem "capybara", "~> 3.0"
13
+ end
14
+
15
+ gemspec path: "../", name: "isolate_assets"
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IsolateAssets
4
+ class Assets
5
+ attr_reader :engine, :assets_subdir
6
+
7
+ def initialize(engine:, assets_subdir: "engine_assets")
8
+ @engine = engine
9
+ @assets_subdir = assets_subdir
10
+ @fingerprints = {}
11
+ end
12
+
13
+ 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
20
+ end
21
+
22
+ def asset_url(source, type)
23
+ fingerprint_value = fingerprint(source, type)
24
+ engine.routes.url_helpers.isolated_asset_path("#{source}.#{normalize_type(type)}", v: fingerprint_value)
25
+ end
26
+
27
+ def fingerprint(source, type)
28
+ cache_key = "#{source}.#{type}"
29
+
30
+ if ::Rails.env.production?
31
+ @fingerprints[cache_key] ||= calculate_fingerprint(source, type)
32
+ else
33
+ calculate_fingerprint(source, type)
34
+ end
35
+ end
36
+
37
+ 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
46
+ end
47
+
48
+ def javascript_files
49
+ engine.root.glob("app/#{assets_subdir}/javascripts/**/*.js")
50
+ end
51
+
52
+ private
53
+
54
+ def normalize_type(type)
55
+ case type.to_s
56
+ when "javascript" then "js"
57
+ when "stylesheet" then "css"
58
+ else type.to_s
59
+ end
60
+ end
61
+
62
+ def calculate_fingerprint(source, type)
63
+ file_path = asset_path(source, type)
64
+
65
+ if file_path && File.exist?(file_path)
66
+ Digest::SHA256.file(file_path).hexdigest[0...8]
67
+ else
68
+ "missing"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IsolateAssets
4
+ class Controller < ActionController::API
5
+ include ActionController::MimeResponds
6
+
7
+ class_attribute :isolated_assets
8
+
9
+ def show
10
+ file_path = safe_file_path
11
+
12
+ if file_path && File.exist?(file_path)
13
+ expires_in 1.year, public: true
14
+ fresh_when(etag: File.mtime(file_path), public: true)
15
+
16
+ send_file file_path,
17
+ type: content_type,
18
+ disposition: "inline"
19
+ else
20
+ head :not_found
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def safe_file_path
27
+ requested = params[:file].gsub("..", "")
28
+ format = params[:format] || request.format.symbol.to_s
29
+ isolated_assets.asset_path(requested, format)
30
+ end
31
+
32
+ def content_type
33
+ format = params[:format] || request.format.symbol.to_s
34
+ isolated_assets.content_type(format)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IsolateAssets
4
+ module EngineExtension
5
+ def isolate_assets(assets_subdir: "engine_assets")
6
+ engine_class = self
7
+
8
+ # Create a unique controller class for this engine
9
+ controller_class = Class.new(IsolateAssets::Controller)
10
+
11
+ # Register an initializer to set up assets when Rails boots
12
+ initializer "#{engine_name}.isolate_assets", before: :set_routes_reloader do
13
+ assets = IsolateAssets::Assets.new(engine: engine_class, assets_subdir: assets_subdir)
14
+
15
+ # Create a unique helper module for this engine with the assets baked in
16
+ helper_module = Module.new do
17
+ define_method(:isolated_assets) { assets }
18
+ include IsolateAssets::Helper
19
+ end
20
+
21
+ # Wire up the controller
22
+ controller_class.isolated_assets = assets
23
+
24
+ # Store on the engine's namespace module
25
+ 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 }
28
+ end
29
+
30
+ # Draw routes
31
+ engine_class.routes.prepend do
32
+ get "/assets/*file", to: controller_class.action(:show), as: :isolated_asset
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IsolateAssets
4
+ module Helper
5
+ # Note: isolated_assets method is defined by the including module,
6
+ # created dynamically in EngineExtension#isolate_assets
7
+
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)
19
+ tag.link(
20
+ rel: "stylesheet",
21
+ href: engine_asset_url(source, "css"),
22
+ **options
23
+ )
24
+ end
25
+
26
+ def engine_javascript_include_tag(source, **options)
27
+ tag.script(
28
+ src: engine_asset_url(source, "js"),
29
+ **options
30
+ )
31
+ end
32
+
33
+ def engine_javascript_importmap_tags(entry_point = "application", imports = {})
34
+ assets_root = isolated_assets.engine.root.join("app/#{isolated_assets.assets_subdir}/javascripts")
35
+ engine_imports = isolated_assets.javascript_files.each_with_object({}) do |path, hash|
36
+ relative_path = path.relative_path_from(assets_root).to_s
37
+ key = "#{isolated_assets.engine.engine_name}/#{relative_path.sub(/\.js\z/, "")}"
38
+ hash[key] = engine_asset_url(relative_path.sub(/\.js\z/, ""), "js")
39
+ end
40
+ [
41
+ tag.script(type: "importmap") do
42
+ JSON.pretty_generate({"imports" => imports.merge(engine_imports)}).html_safe
43
+ end,
44
+ tag.script(<<~JS.html_safe, type: "module")
45
+ import "#{isolated_assets.engine.engine_name}/#{entry_point}"
46
+ JS
47
+ ].join("\n").html_safe
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IsolateAssets
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "active_support/core_ext/class/attribute"
5
+ require_relative "isolate_assets/version"
6
+
7
+ module IsolateAssets
8
+ autoload :Assets, "isolate_assets/assets"
9
+ autoload :Controller, "isolate_assets/controller"
10
+ autoload :Helper, "isolate_assets/helper"
11
+ autoload :EngineExtension, "isolate_assets/engine_extension"
12
+ end
13
+
14
+ # Extend Rails::Engine with isolate_assets
15
+ Rails::Engine.extend(IsolateAssets::EngineExtension)
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: isolate_assets
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Micah Geisel
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.2'
27
+ description: Serve JavaScript, CSS, and other assets from your Rails engine without
28
+ depending on Sprockets, Propshaft, or the host application's asset pipeline.
29
+ email:
30
+ - micah@botandrose.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".github/workflows/ci.yml"
36
+ - Appraisals
37
+ - LICENSE
38
+ - README.md
39
+ - Rakefile
40
+ - cucumber.yml
41
+ - features/asset_serving.feature
42
+ - features/step_definitions/asset_steps.rb
43
+ - features/step_definitions/view_helper_steps.rb
44
+ - features/support/env.rb
45
+ - features/view_helpers.feature
46
+ - gemfiles/rails_7_2.gemfile
47
+ - gemfiles/rails_8_0.gemfile
48
+ - gemfiles/rails_8_1.gemfile
49
+ - lib/isolate_assets.rb
50
+ - lib/isolate_assets/assets.rb
51
+ - lib/isolate_assets/controller.rb
52
+ - lib/isolate_assets/engine_extension.rb
53
+ - lib/isolate_assets/helper.rb
54
+ - lib/isolate_assets/version.rb
55
+ homepage: https://github.com/botandrose/isolate_assets
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/botandrose/isolate_assets
60
+ source_code_uri: https://github.com/botandrose/isolate_assets
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.4.19
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Self-contained asset serving for Rails engines
80
+ test_files: []