assiette 0.2.0 → 0.3.1

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: 0a187177bddf32e79959d651085f938025b48fc19fdaaacadd45d40b123752b1
4
- data.tar.gz: 011cd619392df23a7a4f83d65b020615686390037cb3254dc026b5ba11f6918f
3
+ metadata.gz: 1267863146e1fa5c134f47f1cf797123609d8466690d3a39d9603fe9c35787ef
4
+ data.tar.gz: fcfbe591ac7de77fddb85bfe0d3b2cee0838ef30ac596aef612712b37294511a
5
5
  SHA512:
6
- metadata.gz: c4c1634cd357063c7d38dcf3a9c12f74527a5c7b4fad59069c67c0fea8f14291c41e0c8e4a4a39f55bd399dc4e5d5c5af834768d2544296cf52c6ff4bd7565a6
7
- data.tar.gz: 127a615aac3b7edb7e5338dd433cccb1f1a3a6994d3d0406cb8955d210ab0a427513310d37f7d88b905cab69a57b1730ce07564641e0e206835ac8299c572619
6
+ metadata.gz: d525c3fb1891574eac36d5b42ac82d5426b7a60e870a8001f4115edc15f32935e5559623a08ad849a6e580917788c25e16d39d64274deec47aa4291c3b399d8a
7
+ data.tar.gz: 3b35f41fec398a1ed00d3b18f2bec5167d14cd0af451d8cc838fa4f5957c21bf8d349e8fcc0a15d54ceeb788a9c78c0bb30e754baf44350c51961cf4e0836d53
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "digest/sha2"
5
+ require "base64"
6
+ require "pathname"
7
+ require_relative "version_tag"
8
+
9
+ module Assiette
10
+ class AssetHandler
11
+ CONTENT_TYPES = {
12
+ ".js" => "application/javascript",
13
+ ".mjs" => "application/javascript",
14
+ ".css" => "text/css",
15
+ ".svg" => "image/svg+xml",
16
+ ".png" => "image/png",
17
+ ".ico" => "image/x-icon"
18
+ }.freeze
19
+
20
+ JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
21
+
22
+ def initialize(root:, additional_directory_mappings: {})
23
+ @mappings = build_mappings(root, additional_directory_mappings)
24
+ @integrity_cache = {}
25
+ @integrity_mutex = Mutex.new
26
+ @modules_cache = nil
27
+ @modules_mutex = Mutex.new
28
+ @modules_version = nil
29
+ end
30
+
31
+ def resolve_file(path)
32
+ clean = path.sub(%r{\A/}, "")
33
+ @mappings.each do |prefix, root|
34
+ if prefix.empty?
35
+ relative = clean
36
+ elsif clean.start_with?(prefix + "/")
37
+ relative = clean[(prefix.length + 1)..]
38
+ elsif clean == prefix
39
+ next
40
+ else
41
+ next
42
+ end
43
+
44
+ abs = root.join(relative).cleanpath
45
+ next unless abs.to_s.start_with?(root.to_s + "/")
46
+ return abs if abs.exist? && abs.file?
47
+ end
48
+ nil
49
+ end
50
+
51
+ def absolute_asset_url_path(path, script_name = "")
52
+ clean = path.sub(%r{\A/}, "")
53
+ return nil unless resolve_file(clean)
54
+ "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
55
+ end
56
+
57
+ def asset_integrity(path)
58
+ version_tag = Assiette.version_tag
59
+ @integrity_mutex.synchronize do
60
+ if @integrity_version != version_tag
61
+ @integrity_cache = {}
62
+ @integrity_version = version_tag
63
+ end
64
+ return @integrity_cache[path] if @integrity_cache.key?(path)
65
+
66
+ clean = path.sub(%r{\A/}, "")
67
+ @integrity_cache[path] = compute_integrity(clean, version_tag)
68
+ end
69
+ end
70
+
71
+ def js_modules
72
+ version_tag = Assiette.version_tag
73
+ @modules_mutex.synchronize do
74
+ return @modules_cache if @modules_version == version_tag
75
+
76
+ @modules_cache = @mappings.flat_map { |prefix, root|
77
+ Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
78
+ next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
79
+ relative = Pathname.new(abs).relative_path_from(root).to_s
80
+ mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
81
+ {path: mod_path, integrity: asset_integrity(mod_path)}
82
+ }
83
+ }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
84
+
85
+ @modules_version = version_tag
86
+ @modules_cache
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def build_mappings(root, additional_directory_mappings)
93
+ mappings = [["", Pathname.new(root).expand_path]]
94
+ additional_directory_mappings.each do |prefix, path|
95
+ clean_prefix = prefix.to_s.sub(%r{\A/}, "").chomp("/")
96
+ mappings << [clean_prefix, Pathname.new(path).expand_path]
97
+ end
98
+ mappings
99
+ end
100
+
101
+ def compute_integrity(clean, version_tag)
102
+ file_path = resolve_file(clean)
103
+ return nil unless file_path
104
+
105
+ raw = File.read(file_path)
106
+ ext = File.extname(clean)
107
+ served = case ext
108
+ when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
109
+ when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
110
+ else raw
111
+ end
112
+ "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
113
+ end
114
+ end
115
+ end
@@ -7,7 +7,7 @@ module Assiette
7
7
  def assiette_asset_path(path)
8
8
  entry = request.env["assiette.stack"]&.last
9
9
  raise "No Assiette::Server in middleware stack" unless entry
10
- entry[:server].absolute_asset_url_path(path, entry[:script_name])
10
+ entry[:handler].absolute_asset_url_path(path, entry[:script_name])
11
11
  end
12
12
 
13
13
  # Returns the SRI integrity hash for an asset, computed over the served
@@ -15,7 +15,7 @@ module Assiette
15
15
  def assiette_asset_integrity(path)
16
16
  entry = request.env["assiette.stack"]&.last
17
17
  raise "No Assiette::Server in middleware stack" unless entry
18
- entry[:server].asset_integrity(path)
18
+ entry[:handler].asset_integrity(path)
19
19
  end
20
20
 
21
21
  # Generates a <link rel="stylesheet"> tag with SRI integrity.
@@ -30,7 +30,7 @@ module Assiette
30
30
  def assiette_modulepreload_tags
31
31
  entry = request.env["assiette.stack"]&.last
32
32
  raise "No Assiette::Server in middleware stack" unless entry
33
- modules = entry[:server].js_modules
33
+ modules = entry[:handler].js_modules
34
34
  safe_join(modules.map { |mod|
35
35
  tag.link(rel: "modulepreload", href: assiette_asset_path(mod[:path]),
36
36
  integrity: mod[:integrity], crossorigin: "anonymous")
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assiette
4
+ # Include this module into ActionView::Base to make standard Rails asset
5
+ # helpers (image_tag, stylesheet_link_tag, etc.) resolve paths through
6
+ # an Assiette::AssetHandler assigned to Rails.application.assets.
7
+ #
8
+ # ActiveSupport.on_load(:action_view) do
9
+ # include Assiette::RailsAssetUrlHelper
10
+ # end
11
+ module RailsAssetUrlHelper
12
+ def compute_asset_path(source, options = {})
13
+ resolver = Rails.application.assets
14
+ if resolver.is_a?(Assiette::AssetHandler)
15
+ resolved = resolver.absolute_asset_url_path("/#{source}")
16
+ return resolved if resolved
17
+ end
18
+ super
19
+ end
20
+ end
21
+ end
@@ -7,5 +7,11 @@ module Assiette
7
7
  generators do
8
8
  require_relative "../generators/assiette/install/install_generator"
9
9
  end
10
+
11
+ initializer "assiette.rails_asset_url_helper" do
12
+ ActiveSupport.on_load(:action_view) do
13
+ include Assiette::RailsAssetUrlHelper
14
+ end
15
+ end
10
16
  end
11
17
  end
@@ -1,40 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest/sha1"
4
- require "digest/sha2"
5
- require "base64"
6
- require "pathname"
7
- require_relative "rewriter"
8
- require_relative "version_tag"
9
-
10
3
  module Assiette
11
4
  class Server
12
- CONTENT_TYPES = {
13
- ".js" => "application/javascript",
14
- ".mjs" => "application/javascript",
15
- ".css" => "text/css",
16
- ".svg" => "image/svg+xml",
17
- ".png" => "image/png",
18
- ".ico" => "image/x-icon"
19
- }.freeze
20
-
21
- JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
22
-
23
5
  CACHE_CONTROL = "public, max-age=432000, must-revalidate"
24
6
 
25
- def initialize(app, root:, additional_directory_mappings: {})
7
+ # Accepts either a pre-built handler or keyword args:
8
+ # Server.new(app, handler)
9
+ # Server.new(app, root: "...", additional_directory_mappings: {})
10
+ def initialize(app, handler = nil, root: nil, additional_directory_mappings: {})
26
11
  @app = app
27
- @mappings = build_mappings(root, additional_directory_mappings)
28
- @integrity_cache = {}
29
- @integrity_mutex = Mutex.new
30
- @modules_cache = nil
31
- @modules_mutex = Mutex.new
32
- @modules_version = nil
12
+ @handler = handler || AssetHandler.new(root: root, additional_directory_mappings: additional_directory_mappings)
33
13
  end
34
14
 
35
15
  def call(env)
36
16
  stack = (env["assiette.stack"] ||= [])
37
- stack << {server: self, script_name: env["SCRIPT_NAME"].to_s}
17
+ stack << {handler: @handler, script_name: env["SCRIPT_NAME"].to_s}
38
18
 
39
19
  result = serve(env)
40
20
  return result if result
@@ -42,57 +22,8 @@ module Assiette
42
22
  @app.call(env)
43
23
  end
44
24
 
45
- # Public API for helpers
46
- def absolute_asset_url_path(path, script_name = "")
47
- clean = path.sub(%r{\A/}, "")
48
- return nil unless resolve_file(clean)
49
- "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
50
- end
51
-
52
- def asset_integrity(path)
53
- version_tag = Assiette.version_tag
54
- @integrity_mutex.synchronize do
55
- if @integrity_version != version_tag
56
- @integrity_cache = {}
57
- @integrity_version = version_tag
58
- end
59
- return @integrity_cache[path] if @integrity_cache.key?(path)
60
-
61
- clean = path.sub(%r{\A/}, "")
62
- @integrity_cache[path] = compute_integrity(clean, version_tag)
63
- end
64
- end
65
-
66
- def js_modules
67
- version_tag = Assiette.version_tag
68
- @modules_mutex.synchronize do
69
- return @modules_cache if @modules_version == version_tag
70
-
71
- @modules_cache = @mappings.flat_map { |prefix, root|
72
- Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
73
- next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
74
- relative = Pathname.new(abs).relative_path_from(root).to_s
75
- mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
76
- {path: mod_path, integrity: asset_integrity(mod_path)}
77
- }
78
- }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
79
-
80
- @modules_version = version_tag
81
- @modules_cache
82
- end
83
- end
84
-
85
25
  private
86
26
 
87
- def build_mappings(root, additional_directory_mappings)
88
- mappings = [["", Pathname.new(root).expand_path]]
89
- additional_directory_mappings.each do |prefix, path|
90
- clean_prefix = prefix.to_s.sub(%r{\A/}, "").chomp("/")
91
- mappings << [clean_prefix, Pathname.new(path).expand_path]
92
- end
93
- mappings
94
- end
95
-
96
27
  def serve(env)
97
28
  return unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
98
29
 
@@ -100,10 +31,10 @@ module Assiette
100
31
  path_info = path_info.sub(%r{\A/}, "")
101
32
 
102
33
  extension = File.extname(path_info)
103
- content_type = CONTENT_TYPES[extension]
34
+ content_type = AssetHandler::CONTENT_TYPES[extension]
104
35
  return unless content_type
105
36
 
106
- file_path = resolve_file(path_info)
37
+ file_path = @handler.resolve_file(path_info)
107
38
  return unless file_path
108
39
 
109
40
  raw_bytes = File.binread(file_path)
@@ -117,7 +48,7 @@ module Assiette
117
48
  query = Rack::Utils.parse_query(env["QUERY_STRING"])
118
49
  tag = query["v"].to_s.empty? ? Assiette.version_tag : query["v"]
119
50
 
120
- body = if JS_EXTENSIONS.include?(extension)
51
+ body = if AssetHandler::JS_EXTENSIONS.include?(extension)
121
52
  Rewriter.rewrite_js_imports(raw_bytes, tag)
122
53
  elsif extension == ".css"
123
54
  Rewriter.rewrite_css_urls(raw_bytes, tag)
@@ -134,39 +65,5 @@ module Assiette
134
65
 
135
66
  [200, headers, [body]]
136
67
  end
137
-
138
- def resolve_file(path)
139
- clean = path.sub(%r{\A/}, "")
140
- @mappings.each do |prefix, root|
141
- if prefix.empty?
142
- relative = clean
143
- elsif clean.start_with?(prefix + "/")
144
- relative = clean[(prefix.length + 1)..]
145
- elsif clean == prefix
146
- next
147
- else
148
- next
149
- end
150
-
151
- abs = root.join(relative).cleanpath
152
- next unless abs.to_s.start_with?(root.to_s + "/")
153
- return abs if abs.exist? && abs.file?
154
- end
155
- nil
156
- end
157
-
158
- def compute_integrity(clean, version_tag)
159
- file_path = resolve_file(clean)
160
- return nil unless file_path
161
-
162
- raw = File.read(file_path)
163
- ext = File.extname(clean)
164
- served = case ext
165
- when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
166
- when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
167
- else raw
168
- end
169
- "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
170
- end
171
68
  end
172
69
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assiette
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/assiette.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "assiette/version"
4
- require_relative "assiette/version_tag"
5
- require_relative "assiette/rewriter"
6
- require_relative "assiette/server"
7
- require_relative "assiette/helpers"
8
- require_relative "assiette/railtie" if defined?(Rails::Railtie)
9
4
 
10
5
  module Assiette
6
+ autoload :Rewriter, File.expand_path("assiette/rewriter", __dir__)
7
+ autoload :AssetHandler, File.expand_path("assiette/asset_handler", __dir__)
8
+ autoload :Server, File.expand_path("assiette/server", __dir__)
9
+ autoload :Helpers, File.expand_path("assiette/helpers", __dir__)
10
+ autoload :RailsAssetUrlHelper, File.expand_path("assiette/rails_asset_url_helper", __dir__)
11
11
  end
12
+
13
+ require_relative "assiette/railtie" if defined?(Rails::Railtie)
@@ -6,20 +6,28 @@
6
6
  # This file runs during :load_config_initializers, which is before the
7
7
  # middleware stack is frozen (:build_middleware_stack) in Rails 8.1+.
8
8
 
9
- # Serve files from app/assets (JS modules, CSS, SVGs, etc.)
10
- Rails.application.config.middleware.use(
11
- Assiette::Server,
12
- root: Rails.root.join("app/assets")
9
+ # Build a single handler that knows about app/assets and public/, and share
10
+ # it between the Rack middleware (which serves the files) and the Rails
11
+ # asset helpers (which generate URLs).
12
+ handler = Assiette::AssetHandler.new(
13
+ root: Rails.root.join("app/assets"),
14
+ additional_directory_mappings: {
15
+ "/" => Rails.root.join("public")
16
+ }
13
17
  )
14
18
 
15
- # Serve files from public/ (favicons, static images, etc.)
16
- Rails.application.config.middleware.use(
17
- Assiette::Server,
18
- root: Rails.root.join("public")
19
- )
19
+ # Serve files through the shared handler.
20
+ Rails.application.config.middleware.use Assiette::Server, handler
21
+
22
+ # Make the standard Rails asset helpers (asset_path, image_tag,
23
+ # stylesheet_link_tag, javascript_include_tag, ...) resolve paths through
24
+ # Assiette and pick up its ?v= cache-busting tag. Unknown assets fall back
25
+ # to Rails' default behavior. Remove this line if you'd rather use only
26
+ # the assiette_* helpers below.
27
+ Rails.application.assets = handler
20
28
 
21
- # Make assiette_asset_path, assiette_stylesheet_tag, and
22
- # assiette_modulepreload_tags available in all views.
29
+ # Also expose assiette_asset_path, assiette_stylesheet_tag, and
30
+ # assiette_modulepreload_tags for templates that want them directly.
23
31
  ActiveSupport.on_load(:action_controller_base) do
24
32
  helper Assiette::Helpers
25
33
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assiette
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-20 00:00:00.000000000 Z
10
+ date: 2026-05-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -34,7 +34,9 @@ files:
34
34
  - LICENSE.txt
35
35
  - Rakefile
36
36
  - lib/assiette.rb
37
+ - lib/assiette/asset_handler.rb
37
38
  - lib/assiette/helpers.rb
39
+ - lib/assiette/rails_asset_url_helper.rb
38
40
  - lib/assiette/railtie.rb
39
41
  - lib/assiette/rewriter.rb
40
42
  - lib/assiette/server.rb