hanami-sprockets 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.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/view/html"
4
+
5
+ module Hanami
6
+ class Assets
7
+ # Asset helpers for use in templates
8
+ #
9
+ # @api public
10
+ # @since 0.1.0
11
+ module Helpers
12
+ # Generate a stylesheet link tag
13
+ #
14
+ # @param sources [Array<String>] one or more stylesheet sources
15
+ # @param options [Hash] HTML attributes
16
+ #
17
+ # @return [String] HTML link tags
18
+ #
19
+ # @api public
20
+ # @since 0.1.0
21
+ def stylesheet_tag(*sources, **options)
22
+ sources.map do |source|
23
+ if external_source?(source)
24
+ stylesheet_link_tag(source, **options)
25
+ else
26
+ begin
27
+ asset = hanami_assets[source + ".css"]
28
+ attrs = build_stylesheet_attributes(asset, **options)
29
+ stylesheet_link_tag(asset.url, **attrs)
30
+ rescue AssetMissingError
31
+ stylesheet_link_tag("#{hanami_assets.config.path_prefix}/#{source}.css", **options)
32
+ end
33
+ end
34
+ end.join("\n").html_safe
35
+ end
36
+
37
+ # Generate a javascript script tag
38
+ #
39
+ # @param sources [Array<String>] one or more javascript sources
40
+ # @param options [Hash] HTML attributes
41
+ #
42
+ # @return [String] HTML script tags
43
+ #
44
+ # @api public
45
+ # @since 0.1.0
46
+ def javascript_tag(*sources, **options)
47
+ sources.map do |source|
48
+ if external_source?(source)
49
+ javascript_include_tag(source, **options)
50
+ else
51
+ begin
52
+ asset = hanami_assets[source + ".js"]
53
+ attrs = build_javascript_attributes(asset, **options)
54
+ javascript_include_tag(asset.url, **attrs)
55
+ rescue AssetMissingError
56
+ javascript_include_tag("#{hanami_assets.config.path_prefix}/#{source}.js", **options)
57
+ end
58
+ end
59
+ end.join("\n").html_safe
60
+ end
61
+
62
+ # Generate an image tag
63
+ #
64
+ # @param source [String] image source
65
+ # @param options [Hash] HTML attributes
66
+ #
67
+ # @return [String] HTML img tag
68
+ #
69
+ # @api public
70
+ # @since 0.1.0
71
+ def image_tag(source, **options)
72
+ if external_source?(source)
73
+ build_image_tag(source, **options).html_safe
74
+ else
75
+ begin
76
+ # Try common image extensions
77
+ %w[.png .jpg .jpeg .gif .svg].each do |ext|
78
+ begin
79
+ asset = hanami_assets[source + ext]
80
+ return build_image_tag(asset.url, **options).html_safe
81
+ rescue AssetMissingError
82
+ next
83
+ end
84
+ end
85
+
86
+ # Fallback to direct path
87
+ build_image_tag("#{hanami_assets.config.path_prefix}/#{source}", **options).html_safe
88
+ rescue AssetMissingError
89
+ build_image_tag("#{hanami_assets.config.path_prefix}/#{source}", **options).html_safe
90
+ end
91
+ end
92
+ end
93
+
94
+ # Get the URL for an asset
95
+ #
96
+ # @param source [String] asset source
97
+ #
98
+ # @return [String] asset URL
99
+ #
100
+ # @api public
101
+ # @since 0.1.0
102
+ def asset_url(source)
103
+ if external_source?(source)
104
+ source
105
+ else
106
+ begin
107
+ hanami_assets[source].url
108
+ rescue AssetMissingError
109
+ "#{hanami_assets.config.path_prefix}/#{source}"
110
+ end
111
+ end
112
+ end
113
+
114
+ # Get the path for an asset (without base URL)
115
+ #
116
+ # @param source [String] asset source
117
+ #
118
+ # @return [String] asset path
119
+ #
120
+ # @api public
121
+ # @since 0.1.0
122
+ def asset_path(source)
123
+ if external_source?(source)
124
+ source
125
+ else
126
+ begin
127
+ hanami_assets[source].path
128
+ rescue AssetMissingError
129
+ "#{hanami_assets.config.path_prefix}/#{source}"
130
+ end
131
+ end
132
+ end
133
+
134
+
135
+ private
136
+
137
+ def hanami_assets
138
+ # This should be set by the framework integration
139
+ @hanami_assets or raise "Hanami::Assets instance not configured"
140
+ end
141
+
142
+ def external_source?(source)
143
+ source.start_with?('http://') || source.start_with?('https://') || source.start_with?('//')
144
+ end
145
+
146
+ def stylesheet_link_tag(href, **options)
147
+ attrs = { href: href, type: "text/css", rel: "stylesheet" }.merge(options)
148
+ "<link#{build_html_attributes(attrs)}>"
149
+ end
150
+
151
+ def javascript_include_tag(src, **options)
152
+ attrs = { src: src, type: "text/javascript" }.merge(options)
153
+ "<script#{build_html_attributes(attrs)}></script>"
154
+ end
155
+
156
+ def build_image_tag(src, **options)
157
+ attrs = { src: src }.merge(options)
158
+ "<img#{build_html_attributes(attrs)}>"
159
+ end
160
+
161
+
162
+ def build_stylesheet_attributes(asset, **options)
163
+ attrs = options.dup
164
+
165
+ if hanami_assets.subresource_integrity? && asset.sri && hanami_assets.crossorigin?(asset.url)
166
+ attrs[:integrity] = asset.sri
167
+ attrs[:crossorigin] = "anonymous" unless attrs.key?(:crossorigin)
168
+ end
169
+
170
+ attrs
171
+ end
172
+
173
+ def build_javascript_attributes(asset, **options)
174
+ attrs = options.dup
175
+
176
+ if hanami_assets.subresource_integrity? && asset.sri && hanami_assets.crossorigin?(asset.url)
177
+ attrs[:integrity] = asset.sri
178
+ attrs[:crossorigin] = "anonymous" unless attrs.key?(:crossorigin)
179
+ end
180
+
181
+ attrs
182
+ end
183
+
184
+
185
+ def build_html_attributes(attrs)
186
+ attrs.map do |key, value|
187
+ if value == true
188
+ " #{key}"
189
+ elsif value
190
+ " #{key}=\"#{escape_html(value)}\""
191
+ end
192
+ end.join
193
+ end
194
+
195
+ def escape_html(string)
196
+ string.to_s.gsub(/[&<>"]/, {
197
+ "&" => "&amp;",
198
+ "<" => "&lt;",
199
+ ">" => "&gt;",
200
+ '"' => "&quot;"
201
+ })
202
+ end
203
+
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Assets
5
+ # Rack middleware for serving assets in development
6
+ #
7
+ # @api public
8
+ # @since 0.1.0
9
+ class Middleware
10
+ # @api private
11
+ # @since 0.1.0
12
+ def initialize(app, assets)
13
+ @app = app
14
+ @assets = assets
15
+ end
16
+
17
+ # @api private
18
+ # @since 0.1.0
19
+ def call(env)
20
+ request = Rack::Request.new(env)
21
+
22
+ # Check if this is an asset request
23
+ if asset_request?(request.path)
24
+ serve_asset(request.path)
25
+ else
26
+ @app.call(env)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def asset_request?(path)
33
+ path.start_with?(@assets.config.path_prefix)
34
+ end
35
+
36
+ def serve_asset(path)
37
+ # Remove the path prefix to get the logical path
38
+ logical_path = path.sub(@assets.config.path_prefix + "/", "")
39
+
40
+ begin
41
+ # Try to find the asset first
42
+ sprockets_asset = @assets.environment.find_asset(logical_path)
43
+
44
+ # If not found and looks like a fingerprinted asset, try stripping the fingerprint
45
+ if !sprockets_asset && logical_path.match(/-[a-f0-9]+(\.[^.]+)$/)
46
+ base_name = logical_path.sub(/-[a-f0-9]+(\.[^.]+)$/, '\1')
47
+ sprockets_asset = @assets.environment.find_asset(base_name)
48
+ logical_path = base_name if sprockets_asset
49
+ end
50
+ if sprockets_asset
51
+ headers = {
52
+ 'Content-Type' => sprockets_asset.content_type,
53
+ 'Content-Length' => sprockets_asset.bytesize.to_s,
54
+ 'ETag' => %("#{sprockets_asset.etag}"),
55
+ 'Cache-Control' => 'public, max-age=31536000'
56
+ }
57
+
58
+ [200, headers, [sprockets_asset.source]]
59
+ else
60
+ [404, { 'Content-Type' => 'text/plain' }, ['Asset not found']]
61
+ end
62
+ rescue => e
63
+ [500, { 'Content-Type' => 'text/plain' }, ["Asset error: #{e.message}"]]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Assets
5
+ # @api private
6
+ # @since 0.1.0
7
+ VERSION = "0.1.0"
8
+ end
9
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "zeitwerk"
6
+ require "sprockets"
7
+ require "base64"
8
+ require "digest"
9
+
10
+ module Hanami
11
+ # Assets management for Ruby web applications using Sprockets
12
+ #
13
+ # @since 0.1.0
14
+ class Assets
15
+ # @since 0.1.0
16
+ # @api private
17
+ def self.gem_loader
18
+ @gem_loader ||= Zeitwerk::Loader.new.tap do |loader|
19
+ root = File.expand_path("..", __dir__)
20
+ loader.tag = "hanami-sprockets"
21
+ loader.push_dir(root)
22
+ loader.ignore(
23
+ "#{root}/hanami-sprockets.rb",
24
+ "#{root}/hanami/sprockets/version.rb",
25
+ "#{root}/hanami/sprockets/errors.rb"
26
+ )
27
+ loader.enable_reloading if loader.respond_to?(:enable_reloading)
28
+ loader.inflector = Zeitwerk::GemInflector.new("#{root}/hanami-sprockets.rb")
29
+ end
30
+ end
31
+
32
+ gem_loader.setup
33
+ require_relative "sprockets/version"
34
+ require_relative "sprockets/errors"
35
+ require_relative "sprockets/config"
36
+ require_relative "sprockets/base_url"
37
+ require_relative "sprockets/asset"
38
+ require_relative "sprockets/helpers"
39
+ require_relative "sprockets/middleware"
40
+
41
+ # Returns the directory (under `public/assets/`) to be used for storing a slice's compiled
42
+ # assets.
43
+ #
44
+ # This is shared logic used by both Hanami (for the assets provider) and Hanami::CLI (for the
45
+ # assets commands).
46
+ #
47
+ # @since 0.1.0
48
+ # @api private
49
+ def self.public_assets_dir(slice)
50
+ return nil if slice.app.eql?(slice)
51
+
52
+ slice.slice_name.to_s.split("/").map { |name| "_#{name}" }.join("/")
53
+ end
54
+
55
+ # @api private
56
+ # @since 0.1.0
57
+ attr_reader :config
58
+
59
+ # @api private
60
+ # @since 0.1.0
61
+ attr_reader :root
62
+
63
+ # @api private
64
+ # @since 0.1.0
65
+ attr_reader :environment
66
+
67
+ # @api public
68
+ # @since 0.1.0
69
+ def initialize(config:, root:)
70
+ @config = config
71
+ @root = Pathname(root)
72
+ @environment = setup_sprockets_environment
73
+ end
74
+
75
+ # Returns the asset at the given path.
76
+ #
77
+ # @return [Hanami::Sprockets::Asset] the asset
78
+ #
79
+ # @raise AssetMissingError if no asset can be found at the path
80
+ #
81
+ # @api public
82
+ # @since 0.1.0
83
+ def [](path)
84
+ # Find the asset using Sprockets
85
+ sprockets_asset = environment.find_asset(path)
86
+
87
+ raise AssetMissingError.new(path) unless sprockets_asset
88
+
89
+ # Generate the asset path - use digest_path for fingerprinting in production
90
+ asset_path = if config.digest
91
+ "#{config.path_prefix}/#{sprockets_asset.digest_path}"
92
+ else
93
+ "#{config.path_prefix}/#{sprockets_asset.logical_path}"
94
+ end
95
+
96
+ # Create our Asset wrapper
97
+ Asset.new(
98
+ path: asset_path,
99
+ base_url: config.base_url,
100
+ sri: calculate_sri(sprockets_asset),
101
+ logical_path: sprockets_asset.logical_path,
102
+ digest_path: sprockets_asset.digest_path,
103
+ content_type: sprockets_asset.content_type,
104
+ source: sprockets_asset.source
105
+ )
106
+ end
107
+
108
+ # Returns true if subresource integrity is configured.
109
+ #
110
+ # @return [Boolean]
111
+ #
112
+ # @api public
113
+ # @since 0.1.0
114
+ def subresource_integrity?
115
+ config.subresource_integrity.any?
116
+ end
117
+
118
+ # Returns true if the given source path is a cross-origin request.
119
+ #
120
+ # @return [Boolean]
121
+ #
122
+ # @api public
123
+ # @since 0.1.0
124
+ def crossorigin?(source_path)
125
+ config.crossorigin?(source_path)
126
+ end
127
+
128
+ # Precompile assets (for production)
129
+ #
130
+ # @api public
131
+ # @since 0.1.0
132
+ def precompile(target_dir = nil, &block)
133
+ target_dir ||= root.join("public", "assets")
134
+ target_dir = Pathname(target_dir)
135
+ target_dir.mkpath
136
+
137
+ manifest = ::Sprockets::Manifest.new(environment, target_dir, "manifest.json")
138
+
139
+ # Precompile configured assets - compile by explicit names first
140
+ ['app.css', 'app.js'].each do |asset|
141
+ begin
142
+ manifest.compile(asset)
143
+ block&.call(asset) if block
144
+ rescue Sprockets::FileNotFound
145
+ # Asset doesn't exist, skip it
146
+ end
147
+ end
148
+
149
+ # Then process configured precompile patterns
150
+ config.precompile.each do |asset|
151
+ begin
152
+ manifest.compile(asset)
153
+ block&.call(asset) if block
154
+ rescue Sprockets::FileNotFound
155
+ # Asset doesn't exist, skip it
156
+ end
157
+ end
158
+
159
+ # Write the manifest file
160
+ File.write(File.join(target_dir, "manifest.json"), JSON.pretty_generate(manifest.assets))
161
+
162
+ manifest
163
+ end
164
+
165
+ # Get all logical paths (useful for debugging)
166
+ #
167
+ # @api public
168
+ # @since 0.1.0
169
+ def logical_paths
170
+ paths = []
171
+ # Walk through all load paths and find assets
172
+ environment.paths.each do |load_path|
173
+ next unless Dir.exist?(load_path)
174
+
175
+ Dir.glob("**/*", base: load_path).each do |file|
176
+ full_path = File.join(load_path, file)
177
+ next unless File.file?(full_path)
178
+ next if File.basename(file).start_with?(".")
179
+
180
+ # Try to find it as an asset to see if Sprockets can handle it
181
+ begin
182
+ if environment.find_asset(file)
183
+ paths << file
184
+ end
185
+ rescue
186
+ # Skip files that cause errors
187
+ end
188
+ end
189
+ end
190
+
191
+ paths.uniq.sort
192
+ end
193
+ # Clear the cache (useful in development)
194
+ #
195
+ # @api public
196
+ # @since 0.1.0
197
+ def clear_cache!
198
+ @environment = setup_sprockets_environment
199
+ end
200
+
201
+ private
202
+
203
+ def setup_sprockets_environment
204
+ env = ::Sprockets::Environment.new(root.to_s)
205
+
206
+ # Set up context class for helpers
207
+ assets_config = config
208
+ env.context_class.class_eval do
209
+ define_method :asset_path do |path, options = {}|
210
+ # Find the asset and return its path
211
+ asset = environment.find_asset(path)
212
+ if asset
213
+ if assets_config.digest
214
+ "#{assets_config.path_prefix}/#{asset.digest_path}"
215
+ else
216
+ "#{assets_config.path_prefix}/#{asset.logical_path}"
217
+ end
218
+ else
219
+ "#{assets_config.path_prefix}/#{path}"
220
+ end
221
+ end
222
+
223
+ define_method :asset_url do |path, options = {}|
224
+ asset_path(path, options)
225
+ end
226
+ end
227
+
228
+ # Add common Rails-like asset paths
229
+ potential_paths = [
230
+ "app/assets/stylesheets",
231
+ "app/assets/javascripts",
232
+ "app/assets/images",
233
+ "app/assets/fonts",
234
+ "lib/assets/stylesheets",
235
+ "lib/assets/javascripts",
236
+ "lib/assets/images",
237
+ "vendor/assets/stylesheets",
238
+ "vendor/assets/javascripts",
239
+ "vendor/assets/images"
240
+ ]
241
+
242
+ potential_paths.each do |path_str|
243
+ full_path = root.join(path_str)
244
+ env.append_path(full_path.to_s) if full_path.exist?
245
+ end
246
+
247
+ # Add any additional paths from config
248
+ config.asset_paths.each { |path| env.append_path(path) }
249
+
250
+ # Automatically discover and add gem asset paths
251
+ discover_gem_asset_paths.each { |path| env.append_path(path) }
252
+
253
+ # Configure processors based on what gems are available
254
+ configure_processors(env)
255
+
256
+ env
257
+ end
258
+
259
+ def configure_processors(env)
260
+ # Sprockets will auto-detect most processors if the gems are available
261
+ # But we can explicitly configure them here if needed
262
+
263
+ # SCSS/Sass support (if sassc is available)
264
+ begin
265
+ require 'sassc'
266
+ # SassC will automatically be used for .scss and .sass files
267
+ # Sprockets will handle this automatically
268
+ rescue LoadError
269
+ # Sass not available, that's fine
270
+ end
271
+
272
+ # CoffeeScript support (if coffee-rails is available)
273
+ begin
274
+ require 'coffee_script'
275
+ # Sprockets will automatically use CoffeeScript if available
276
+ rescue LoadError
277
+ # CoffeeScript not available, that's fine
278
+ end
279
+
280
+ # ES6+ support via Babel (if babel-transpiler is available)
281
+ begin
282
+ require 'babel/transpiler'
283
+ env.register_transformer 'application/javascript', 'application/javascript', Sprockets::BabelProcessor
284
+ rescue LoadError
285
+ # Babel not available, that's fine
286
+ end
287
+ end
288
+
289
+ def discover_gem_asset_paths
290
+ paths = []
291
+
292
+ # Common asset directory patterns that gems use
293
+ asset_patterns = [
294
+ 'assets/stylesheets',
295
+ 'assets/javascripts',
296
+ 'assets/images',
297
+ 'assets/fonts',
298
+ 'app/assets/stylesheets',
299
+ 'app/assets/javascripts',
300
+ 'app/assets/images',
301
+ 'vendor/assets/stylesheets',
302
+ 'vendor/assets/javascripts'
303
+ ]
304
+
305
+ # Iterate through all loaded gems
306
+ Gem.loaded_specs.each do |name, spec|
307
+ asset_patterns.each do |pattern|
308
+ potential_path = File.join(spec.gem_dir, pattern)
309
+ if File.directory?(potential_path)
310
+ paths << potential_path
311
+ end
312
+ end
313
+ end
314
+
315
+ paths
316
+ end
317
+
318
+ def calculate_sri(sprockets_asset)
319
+ return nil unless subresource_integrity?
320
+
321
+ algorithms = config.subresource_integrity
322
+ sri_values = []
323
+
324
+ algorithms.each do |algorithm|
325
+ case algorithm
326
+ when :sha256
327
+ sri_values << "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(sprockets_asset.source))}"
328
+ when :sha384
329
+ sri_values << "sha384-#{Base64.strict_encode64(Digest::SHA384.digest(sprockets_asset.source))}"
330
+ when :sha512
331
+ sri_values << "sha512-#{Base64.strict_encode64(Digest::SHA512.digest(sprockets_asset.source))}"
332
+ end
333
+ end
334
+
335
+ sri_values.join(" ")
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hanami/sprockets"