actionpack-minimizer 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: 6097ac817e3819beabfa9fc7952395e2d796429505b56654beb9120140535b0a
4
+ data.tar.gz: d2c992a82c0691212eb47b262dd016baa46f44cdf1a38b64aaea69c08586fa0b
5
+ SHA512:
6
+ metadata.gz: 443211ad950bbdc1fadc55098abd58c7359e99aed1854dfa6b0949cf1147f7a70fb0816721679036dd2d0ab3a98b4d621e37c25369c80df86d4116939f7b37d2
7
+ data.tar.gz: a0ad267400fd14780acbc991eed72ccc1cf70f1aabe4c0ddf75e6f25b9007eba5b2e1415a8d9b04fc605833d8793ed8d6d670b3389f180cb8a1c9752f9992397
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-18)
4
+
5
+ First release.
6
+
7
+ - Named route helpers as interpolation over cached URL parts, with Rails' own escaping.
8
+ - Templates: `render` of partials and records, and `link_to` (with literal or computed options, and block form), compiled to direct calls.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sampo Kuokkanen
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,129 @@
1
+ # actionpack-minimizer
2
+
3
+ Precompiles named route helpers and the common view helpers. Part of
4
+ [rails-minify](../rails-minify); controller callbacks are in
5
+ activesupport-minimizer.
6
+
7
+ ```ruby
8
+ # config/environments/production.rb
9
+ config.ap_minimizer.route_helpers = true
10
+ config.ap_minimizer.views = true
11
+ ```
12
+
13
+ ## Route helpers
14
+
15
+ Rails builds every URL from scratch. Its "optimized" path still merges
16
+ hashes and runs the Journey formatter; with `default_url_options` set (most
17
+ apps with a locale in their URLs), every helper takes the full `url_for`
18
+ path with recall, defaults and route matching. Here the original helper
19
+ runs once with placeholder arguments, and what surrounds the placeholders is
20
+ reused:
21
+
22
+ ```ruby
23
+ trip_url("Zq9ArmSentinel0Zq9") # => "https://example.com/ja/trips/Zq9ArmSentinel0Zq9"
24
+ # later calls:
25
+ "https://example.com/ja/trips/#{escape_segment(trip.to_param)}"
26
+ ```
27
+
28
+ The cache key is everything else that goes into the URL: url_options except
29
+ the request's path parameters (host, script name, default_url_options such
30
+ as the locale), whether Rails would optimize, the helper and argument count
31
+ (which decide the segment each argument fills), and the request's path
32
+ parameters only for route parts nothing else provides (usually none; Rails
33
+ fills those from the request). Arguments are escaped with Rails' own
34
+ `escape_segment` (skipped for ids and slugs with nothing to escape).
35
+
36
+ Options hashes, nil or empty `to_param`, more than four arguments, glob
37
+ routes, constrained segments filled by arguments, and arguments that would
38
+ land in the query string or anchor use the original helper.
39
+
40
+ ## Views
41
+
42
+ Each template's compiled Ruby is rewritten when ActionView compiles it:
43
+
44
+ ```ruby
45
+ render "comments/comment", comment: c # -> __ap_render_partial(...)
46
+ render partial: "comments/comment", locals: { comment: c }
47
+ link_to post.title, post_path(post) # -> __ap_link_to(...)
48
+ link_to "Edit", edit_path, class: "btn" # -> __ap_link_to_attrs(..., " class=\"btn\"")
49
+ link_to trip_path(trip), class: "card" do # -> __ap_link_to_block(..., " class=\"card\"", 3) do
50
+ render @comment # -> __ap_render_object(@comment)
51
+ ```
52
+
53
+ `render @comment` (and `render @comment, extra: 1`) works out the partial
54
+ path and local name with Rails' own ObjectRenderer once per
55
+ `to_partial_path` and controller prefix (so namespaced controllers still get
56
+ `admin/comments/_comment`), then does what ObjectRenderer does after that.
57
+ Collections (`render @comments`) and components (`render_in`) are told
58
+ apart at runtime and keep Rails' render: the collection renderer is already
59
+ the faster one, instrumenting once per collection.
60
+
61
+ For `link_to` with html options, Rails' `tag_options` renders each pair on
62
+ its own and concatenates them, so literal pairs (strings, symbols, numbers,
63
+ booleans, nil, arrays and `data:`/`aria:` hashes of those) are rendered once,
64
+ by `tag_options`, when the template compiles, and computed values per call
65
+ (plain Strings and Symbols inline with Rails' escaping, anything else through
66
+ `tag_options` for that one pair); `href` goes after them, as `link_to` puts
67
+ it. Option values are evaluated once, in order. Computed keys, `**splats`,
68
+ `method:`, `remote:` and `href:` keep `link_to`. Block-form `link_to` keeps its block
69
+ (and the rewrites inside it) and renders it with `capture`, as `content_tag`
70
+ does. Name and url are evaluated once each, in order; a url that isn't a
71
+ String goes to `link_to` with the original options.
72
+
73
+ A template whose rewrite doesn't parse is left as written; the census lists it.
74
+
75
+ `__ap_render_partial` caches the template per path, local names and lookup
76
+ details, then does what PartialRenderer does after its lookup (the
77
+ `render_partial.action_view` event with the same payload, `template.render`),
78
+ without the renderer object and option hashes. `__ap_link_to` builds the `<a>`
79
+ with link_to's escaping when the URL is a String, and calls `link_to`
80
+ otherwise. Partials named without a directory are looked up under the
81
+ controller's prefixes, which are part of the cache key. Collections, `as:`/`object:`, blocks and views overriding
82
+ `render`/`link_to`/`url_for`/`content_tag` keep the stock path.
83
+
84
+ ## Numbers
85
+
86
+ Ruby 4.0, Rails 8.1.
87
+
88
+ Without `default_url_options` (Rails' optimized path), YJIT:
89
+
90
+ | route helper | stock | compiled |
91
+ |-------------------------|--------:|---------:|
92
+ | `root_path` | 767 ns | 167 ns |
93
+ | `post_path(post)` | 1532 ns | 494 ns |
94
+ | `post_url(post)` | 2971 ns | 495 ns |
95
+
96
+ With `default_url_options` returning the locale and a `scope "(:locale)"`
97
+ (Rails' full path), YJIT:
98
+
99
+ | route helper | stock | compiled |
100
+ |-------------------------|--------:|---------:|
101
+ | `trip_path(1)` | 7979 ns | 582 ns |
102
+ | `trips_path` | 5547 ns | 167 ns |
103
+
104
+ Each helper is a small body calling one shared method. A version with
105
+ specialized branches per helper was 40% faster per call, but with golf-buddy's
106
+ ~1,100 helpers it cost 19 MB and made whole pages 3% slower under YJIT
107
+ (more code to compile and keep in cache), so it's gone.
108
+
109
+ In golf-buddy's controller and integration tests, 34,646 of 34,873 helper
110
+ calls (99.3%) took the compiled path.
111
+
112
+ A page rendering 50 partials with two `link_to` each (`bench/views.rb`):
113
+ 830 → 385 µs per page, 423 → 218 µs with YJIT.
114
+
115
+ `link_to` with literal html options (`bench/links.rb`, 50 per page): 7.6 → 2.0 µs
116
+ per link, 4.4 → 1.1 µs with YJIT. With a computed class: 7.3 → 2.5 µs,
117
+ 4.3 → 1.5 µs. Block form with options: 9.3 → 3.4 µs, 5.4 → 1.8 µs with YJIT.
118
+
119
+ Single-record `render @comment`: 4.9 → 2.6 µs, 10.3 → 5.0 µs without YJIT.
120
+
121
+ golf-buddy: 999 of 1,080 `render`/`link_to` call sites compile (233 partial
122
+ renders, 149 record renders, of which collections and components take Rails'
123
+ path at runtime, 375 `link_to`, 242 block-form `link_to`: all of them).
124
+
125
+ golf-buddy's test suite (4317 runs) gives the same results with both on.
126
+
127
+ Note: with eager loading, Rails frees each route's AST once routes are
128
+ finalized, so `Route#glob?` raises afterwards; glob status is recorded when
129
+ helpers are defined.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ap_minimizer"
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApMinimizer
4
+ # What the route helper and view compilers cover in an app, and why the
5
+ # rest takes Rails' path.
6
+ module Census
7
+ Result = Data.define(:routes, :views) do
8
+ def lines(verbose: false)
9
+ out = []
10
+ compiled, skipped = routes.partition { |_, why| why.nil? }
11
+ out << "route helpers: #{compiled.size}/#{routes.size} #{Routes.enabled? ? "compiled" : "compilable (config.ap_minimizer.route_helpers is off)"}"
12
+ skipped.group_by(&:last).sort_by { -it[1].size }.each do |why, list|
13
+ out << " #{list.size} through Rails: #{why}"
14
+ list.first(verbose ? list.size : 3).each { |name, _| out << " #{name}" }
15
+ end
16
+ compiled = ["render partial", "render record", "link_to", "link_to with a block"]
17
+ done = views.select { |k, _| compiled.include?(k) }.sum(&:last)
18
+ out << "view call sites: #{done}/#{views.values.sum} compiled (#{views["render partial"]} render, #{views["render record"].to_i} render record (collections and components keep Rails' render at runtime), " \
19
+ "#{views["link_to"]} link_to, #{views["link_to with a block"].to_i} link_to with a block)"
20
+ views.reject { |k, _| compiled.include?(k) }.sort_by { -it[1] }.each { |k, n| out << " #{n} #{k}" }
21
+ out
22
+ end
23
+ end
24
+
25
+ class << self
26
+ def report(route_set, view_paths)
27
+ Result.new(routes(route_set), views(view_paths))
28
+ end
29
+
30
+ # [[helper name, nil or why not compiled], ...]
31
+ def routes(route_set)
32
+ route_set.named_routes.names.sort.map do |name|
33
+ route = route_set.named_routes.get(name)
34
+ # Positional arguments fill the required segments; constraints there
35
+ # (id: /\d+/) keep Rails' helper. Constraints on optional segments
36
+ # filled from url_options, like (:locale), don't.
37
+ constrained = route.required_parts & route.path.requirements.keys
38
+ why =
39
+ if Routes.glob?(route) then "a glob segment (or unknown: AST already freed)"
40
+ elsif constrained.any? then "constraints on segments arguments fill (#{constrained.join(", ")})"
41
+ end
42
+ ["#{name}_path", why]
43
+ end
44
+ end
45
+
46
+ # Counts over every ERB template under the paths, rewritten or not.
47
+ def views(view_paths)
48
+ stats = Hash.new(0)
49
+ Array(view_paths).flat_map { Dir.glob(File.join(it.to_s, "**", "*.erb")) }.each do |file|
50
+ ruby = ActionView::Template::Handlers::ERB::Erubi.new(File.read(file)).src
51
+ Views.rewrite_with_stats(ruby).last.each { |k, n| stats[k] += n }
52
+ rescue StandardError
53
+ stats["templates that didn't compile for the census"] += 1
54
+ end
55
+ stats["render partial"] += 0
56
+ stats["link_to"] += 0
57
+ stats.to_h
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApMinimizer
4
+ class Railtie < Rails::Railtie
5
+ config.ap_minimizer = ActiveSupport::OrderedOptions.new
6
+
7
+ # In config/environments/production.rb:
8
+ #
9
+ # config.ap_minimizer.route_helpers = true # post_path(post) as string interpolation
10
+ # config.ap_minimizer.views = true # literal partial renders and tag helpers precompiled
11
+ #
12
+ # Before routes are drawn, so every helper gets defined through the hook.
13
+ initializer "ap_minimizer", before: :set_routes_reloader_hook do |app|
14
+ options = app.config.ap_minimizer
15
+ ApMinimizer::Routes.install! if options.route_helpers
16
+ ActiveSupport.on_load(:action_view) { ApMinimizer::Views.install! } if options.views
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApMinimizer
4
+ # Named route helpers (post_path, post_url, ...) as string interpolation.
5
+ #
6
+ # Rails builds every URL from scratch: the "optimized" path merges hashes
7
+ # and runs the Journey formatter (~1.5 µs); with default_url_options set,
8
+ # as in most apps with a locale in their URLs, every helper takes the full
9
+ # url_for path with recall, defaults and route matching (~6-8 µs).
10
+ #
11
+ # Here the original helper runs once with placeholder arguments,
12
+ #
13
+ # trip_path("Zq9ArmSentinel0Zq9") # => "/ja/trips/Zq9ArmSentinel0Zq9"
14
+ #
15
+ # and what surrounds the placeholders is reused:
16
+ #
17
+ # "/ja/trips/#{escape_segment(trip.to_param)}"
18
+ #
19
+ # That holds for as long as everything else that goes into the URL is the
20
+ # same, so the result is cached by:
21
+ #
22
+ # - url_options, except the current request's path parameters (_recall):
23
+ # host, protocol, script name, default_url_options (the locale), ...
24
+ # - whether Rails would take its optimized path here;
25
+ # - the helper and the number of arguments, which decide the segment
26
+ # each argument fills;
27
+ # - the values of the request's path parameters for the route parts that
28
+ # neither the arguments nor url_options provide (usually none), since
29
+ # Rails fills those from the request.
30
+ #
31
+ # The original helper runs instead for an options hash, a nil or empty
32
+ # to_param, more than four arguments, glob routes, arguments filling a
33
+ # constrained segment, and when an argument would land in the query string
34
+ # or anchor, where escaping differs.
35
+ module Routes
36
+ SENTINEL = "Zq9ArmSentinel%dZq9"
37
+ SENTINEL_RE = /Zq9ArmSentinel(\d+)Zq9/
38
+ MAX_ARGS = 4
39
+ MAX_ENTRIES = 4096
40
+
41
+ # Unreserved characters are never escaped: most ids and slugs skip the encoder.
42
+ UNRESERVED = /\A[a-zA-Z0-9\-._~]*\z/
43
+ ESCAPE = ->(value) { UNRESERVED.match?(value) ? value : ActionDispatch::Journey::Router::Utils.escape_segment(value) }
44
+
45
+ HELPERS = {} # [helpers module, helper name] => UrlHelper
46
+ # Journey route => glob?, recorded when helpers are defined: with eager
47
+ # loading Rails frees each route's AST once routes are finalized, and
48
+ # Route#glob? reads it.
49
+ GLOBS = {}.compare_by_identity
50
+ BASES = {} # [routes object_id, optimized?, url_options without _recall] => Integer
51
+ META = {} # [base, helper name, argument count] => recall keys, or false
52
+ PARTS = {} # [base, helper name, argument count, recall values] => frozen literal parts, or false
53
+ MUTEX = Mutex.new
54
+
55
+ class << self
56
+ def install!
57
+ @enabled = true
58
+ @next_base = 0
59
+ collection = ActionDispatch::Routing::RouteSet::NamedRouteCollection
60
+ collection.prepend(Collection) unless collection < Collection
61
+ end
62
+
63
+ def enabled? = @enabled == true
64
+
65
+ # The url_options part of the cache key, as a small Integer. Computed
66
+ # once per url_options object (Rails memoizes it per request).
67
+ def base_for(context, options)
68
+ key = [context._routes.object_id, context.send(:optimize_routes_generation?), options.except(:_recall)]
69
+ BASES[key] || MUTEX.synchronize do
70
+ reset! if BASES.size >= MAX_ENTRIES
71
+ BASES[key] ||= (@next_base += 1)
72
+ end
73
+ end
74
+
75
+ # Literal parts around each argument, or false to always use the original helper.
76
+ def parts_for(context, name, count, base, options)
77
+ meta_key = [base, name, count]
78
+ recall_keys = META.fetch(meta_key) { store(META, meta_key, recall_keys_for(context, name, count, options)) }
79
+ return false unless recall_keys
80
+
81
+ recall = options[:_recall] || {}
82
+ key = [base, name, count, recall_keys.map { recall[it] }]
83
+ PARTS.fetch(key) { store(PARTS, key, literal_parts(context, name, count)) }
84
+ end
85
+
86
+ # The URL for args, or nil to let the original helper decide (it raises
87
+ # or builds something else for nil, empty or non-String to_param values).
88
+ def fill(parts, args)
89
+ return parts[0].dup if args.empty?
90
+
91
+ out = +parts[0]
92
+ args.each_with_index do |arg, i|
93
+ value = arg.to_param
94
+ return nil unless ::String === value && !value.empty?
95
+
96
+ out << ESCAPE.(value) << parts[i + 1]
97
+ end
98
+ out
99
+ end
100
+
101
+ # True when unknown (the AST is already gone): such routes keep Rails' helper.
102
+ def glob?(route)
103
+ GLOBS.fetch(route) { route.path.ast ? route.glob? : true }
104
+ end
105
+
106
+ def define(mod, name, helper)
107
+ HELPERS[[mod, name]] = helper
108
+ route = helper.instance_variable_get(:@route)
109
+ GLOBS[route] = route.path.ast ? route.glob? : true
110
+ mod.alias_method(:"__ap_original_#{name}", name)
111
+ mod.send(:private, :"__ap_original_#{name}")
112
+ mod.module_eval(<<~RUBY, "route helper: #{name}", 1)
113
+ def #{name}(*args)
114
+ ::ApMinimizer::Routes.url_for_args(self, :#{name}, args) || __ap_original_#{name}(*args)
115
+ end
116
+ RUBY
117
+ define_parts_lookup(mod)
118
+ end
119
+
120
+ # Shared by every helper: the URL, or nil for the original helper.
121
+ #
122
+ # One small body per helper and the work here, rather than a
123
+ # specialized body per helper: those were 40% faster per call but, with
124
+ # ~1,100 helpers, cost 19 MB and made whole pages slower under YJIT.
125
+ def url_for_args(context, name, args)
126
+ last = args[-1]
127
+ return if args.size > MAX_ARGS || ::Hash === last || ::ActionController::Parameters === last
128
+
129
+ parts = context.__send__(:__ap_route_parts, name, args.size) or return
130
+ fill(parts, args)
131
+ end
132
+
133
+ private
134
+
135
+ def store(table, key, value)
136
+ MUTEX.synchronize do
137
+ reset! if table.size >= MAX_ENTRIES
138
+ table[key] = value
139
+ end
140
+ end
141
+
142
+ def reset!
143
+ [BASES, META, PARTS].each(&:clear)
144
+ end
145
+
146
+ # Route parts Rails would fill from the current request: those neither
147
+ # the arguments nor url_options and the route's defaults provide. false
148
+ # when the helper can't be compiled for this argument count.
149
+ def recall_keys_for(context, name, count, options)
150
+ owner = context.class.instance_method(:"__ap_original_#{name}").owner
151
+ helper = HELPERS[[owner, name]] or return false
152
+ route = helper.instance_variable_get(:@route)
153
+ return false if glob?(route)
154
+
155
+ optimized = helper.is_a?(ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper::OptimizedUrlHelper) &&
156
+ count == helper.arg_size && context.send(:optimize_routes_generation?)
157
+ return [].freeze if optimized # Rails' optimized path uses neither recall nor defaults
158
+
159
+ sentinels = Array.new(count) { format(SENTINEL, it) }
160
+ defaults = options.merge(helper.instance_variable_get(:@options))
161
+ mapped = helper.handle_positional_args(options, {}, sentinels, defaults, route.segment_keys.uniq)
162
+ filled = mapped.select { |_, v| ::String === v && SENTINEL_RE.match?(v) }.keys
163
+ return false unless filled.size == count # an argument with no segment to go to
164
+ return false if filled.any? { route.path.requirements.key?(it) }
165
+
166
+ (route.parts - mapped.keys).freeze
167
+ rescue StandardError
168
+ false
169
+ end
170
+
171
+ def literal_parts(context, name, count)
172
+ sentinels = Array.new(count) { format(SENTINEL, it) }
173
+ url = context.__send__(:"__ap_original_#{name}", *sentinels)
174
+ return false unless url.scan(SENTINEL_RE).flatten == Array.new(count) { it.to_s }
175
+
176
+ # Query strings and anchors escape differently from path segments.
177
+ cut = url.index(/[?#]/)
178
+ return false if cut && count.positive? && url.index(SENTINEL_RE, cut)
179
+
180
+ url.split(SENTINEL_RE, -1).values_at(*(0..count).map { it * 2 }).map(&:freeze).freeze
181
+ rescue StandardError
182
+ false
183
+ end
184
+
185
+ # In each helper context (controller, view, mailer): the parts for each
186
+ # helper and argument count, for the current url_options object.
187
+ def define_parts_lookup(mod)
188
+ return if mod.private_method_defined?(:__ap_route_parts)
189
+
190
+ mod.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
191
+ private def __ap_route_parts(name, count)
192
+ options = url_options
193
+ memo = @__ap_route_parts
194
+ unless memo && memo[0].equal?(options)
195
+ memo = @__ap_route_parts = [options, ::ApMinimizer::Routes.base_for(self, options), {}]
196
+ end
197
+ by_count = (memo[2][name] ||= [])
198
+ parts = by_count[count]
199
+ parts.nil? ? (by_count[count] = ::ApMinimizer::Routes.parts_for(self, name, count, memo[1], options)) : parts
200
+ rescue ::FrozenError
201
+ nil
202
+ end
203
+ RUBY
204
+ end
205
+ end
206
+
207
+ module Collection
208
+ private
209
+
210
+ def define_url_helper(mod, name, helper, url_strategy)
211
+ super
212
+ Routes.define(mod, name, helper) if Routes.enabled?
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApMinimizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "bigdecimal"
5
+ require "json"
6
+
7
+ module ApMinimizer
8
+ # Rewrites each template's compiled Ruby as ActionView compiles it:
9
+ #
10
+ # render "comments/comment", comment: c -> __ap_render_partial("comments/comment", { comment: c })
11
+ # render partial: "comments/comment", locals: { comment: c } (same)
12
+ # link_to post.title, post_path(post) -> __ap_link_to(post.title, post_path(post))
13
+ # link_to "Edit", edit_path, class: "btn" -> the attributes string, built once by Rails'
14
+ # tag_options when the template compiles
15
+ #
16
+ # __ap_render_partial looks the partial up once per path, local names and
17
+ # lookup details (formats, locale, variants, handlers), then does what
18
+ # PartialRenderer does after its lookup: the render_partial.action_view
19
+ # event with the same payload, and template.render. It skips building the
20
+ # renderer and the options hashes. Partials named without a directory
21
+ # are looked up under the controller's prefixes, which are part of the cache key.
22
+ #
23
+ # __ap_link_to builds `<a href="...">...</a>` directly when the URL is a
24
+ # String, with link_to's escaping; anything else calls link_to.
25
+ #
26
+ # Both call the stock helper instead in a view class that overrides render,
27
+ # link_to, url_for or content_tag.
28
+ module Views
29
+ STOCK_OWNERS = {
30
+ render: ["ActionView::Helpers::RenderingHelper"],
31
+ link_to: ["ActionView::Helpers::UrlHelper"],
32
+ url_for: ["ActionView::Helpers::UrlHelper", "ActionView::RoutingUrlFor"],
33
+ content_tag: ["ActionView::Helpers::TagHelper"],
34
+ }.freeze
35
+
36
+ NO_PREFIXES = [].freeze
37
+
38
+ # Literal html options of block-form link_to sites, for the fallback.
39
+ LINK_OPTIONS = []
40
+ LINK_OPTIONS_MUTEX = Mutex.new
41
+
42
+ class << self
43
+ def register_link_options(options)
44
+ LINK_OPTIONS_MUTEX.synchronize { (LINK_OPTIONS << options.freeze).size - 1 }
45
+ end
46
+
47
+ def install!
48
+ @enabled = true
49
+ @stock = {}
50
+ @templates = {}
51
+ @objects = {}
52
+ @mutex = Mutex.new
53
+ ActionView::Template.prepend(TemplateHook) unless ActionView::Template < TemplateHook
54
+ ActionView::Base.include(Helpers) unless ActionView::Base < Helpers
55
+ end
56
+
57
+ def disable! = @enabled = false
58
+
59
+ def enabled? = @enabled == true
60
+
61
+ # True when render/link_to/url_for/content_tag are ActionView's own in this view class.
62
+ def stock?(klass)
63
+ @stock.fetch(klass) do
64
+ @stock[klass] = STOCK_OWNERS.all? { |name, owners| owners.include?(klass.instance_method(name).owner.name) }
65
+ end
66
+ end
67
+
68
+ # As PartialRenderer#find_template: a name without a directory is
69
+ # looked up under the controller's prefixes, so they're in the key.
70
+ def template_for(view, path, locals, keys = locals.keys)
71
+ lookup = view.lookup_context
72
+ prefixes = path.include?("/") ? NO_PREFIXES : lookup.prefixes
73
+ key = [lookup.details_key, path, keys, prefixes]
74
+ @templates.fetch(key) do
75
+ template = lookup.find_template(path, prefixes, true, keys, {})
76
+ @mutex.synchronize { @templates[key.dup.tap { it[3] = prefixes.dup.freeze }] = template }
77
+ end
78
+ end
79
+
80
+ # For render(record): [partial path, local name, [local name]], worked
81
+ # out by Rails' ObjectRenderer once per to_partial_path and controller
82
+ # prefix; false when Rails would raise or look in the controller's
83
+ # directory (no "/" in the path), so the stock render runs.
84
+ def object_partial(view, object, partial_path)
85
+ lookup = view.lookup_context
86
+ prefixed = view.prefix_partial_path_with_controller_namespace
87
+ prefix = lookup.prefixes.first
88
+ key = [prefixed, prefix, partial_path]
89
+ @objects.fetch(key) do
90
+ info =
91
+ begin
92
+ renderer = ActionView::ObjectRenderer.new(lookup, {})
93
+ path = renderer.send(:partial_path, object, view)
94
+ local = renderer.send(:local_variable, path)
95
+ path.include?("/") ? [path.freeze, local, [local].freeze].freeze : false
96
+ rescue StandardError
97
+ false
98
+ end
99
+ @mutex.synchronize { @objects[key] = info }
100
+ end
101
+ end
102
+
103
+ def rewrite(source) = Rewriter.new(source).rewrite
104
+
105
+ # What link_to's html options become in the tag, from Rails itself.
106
+ def static_attributes(options)
107
+ @tag_builder ||= ActionView::Helpers::TagHelper::TagBuilder.new(ActionView::Base.empty)
108
+ @tag_builder.tag_options(options.transform_keys(&:to_s)) || ""
109
+ end
110
+
111
+ # [rewritten source, stats] for the census.
112
+ def rewrite_with_stats(source)
113
+ rewriter = Rewriter.new(source)
114
+ [rewriter.rewrite, rewriter.stats]
115
+ end
116
+ end
117
+
118
+ module TemplateHook
119
+ private
120
+
121
+ def compiled_source
122
+ source = super
123
+ Views.enabled? ? Views.rewrite(source) : source
124
+ end
125
+ end
126
+
127
+ module Helpers
128
+ private
129
+
130
+ def __ap_render_partial(path, locals = {})
131
+ return render(path, locals) unless Views.enabled? && ::Hash === locals && Views.stock?(self.class)
132
+
133
+ template = Views.template_for(self, path, locals)
134
+ ActiveSupport::Notifications.instrument("render_partial.action_view",
135
+ identifier: template.identifier, layout: nil, locals: locals) do |payload|
136
+ content = template.render(self, locals, add_to_stack: true) { |*name| _layout_for(*name) }
137
+ payload[:cache_hit] = view_renderer.cache_hits[template.virtual_path]
138
+ content
139
+ end
140
+ end
141
+
142
+ def __ap_fast_links? = Views.enabled? && Views.stock?(self.class)
143
+
144
+ # link_to(name, url, literal options): attrs is Rails' rendering of the
145
+ # options; link_to appends href after them.
146
+ def __ap_link_to_attrs(name, url, attrs)
147
+ href = ERB::Util.unwrapped_html_escape(url)
148
+ href = href.gsub('"', "&quot;") if href.include?('"')
149
+ content = name || url
150
+ content = ERB::Util.unwrapped_html_escape(content) if content.present?
151
+ "<a#{attrs} href=\"#{href}\">#{content}</a>".html_safe
152
+ end
153
+
154
+ # link_to(url[, literal options]) do ... end. Content as content_tag
155
+ # makes it: capture(&block).
156
+ def __ap_link_to_block(url, attrs, options_index, &block)
157
+ unless Views.enabled? && ::String === url && Views.stock?(self.class)
158
+ return link_to(url, &block) unless options_index
159
+
160
+ return link_to(url, LINK_OPTIONS[options_index].dup, &block)
161
+ end
162
+
163
+ href = ERB::Util.unwrapped_html_escape(url)
164
+ href = href.gsub('"', "&quot;") if href.include?('"')
165
+ content = capture(&block)
166
+ content = ERB::Util.unwrapped_html_escape(content) if content.present?
167
+ "<a#{attrs} href=\"#{href}\">#{content}</a>".html_safe
168
+ end
169
+
170
+ # render(record) / render(record, locals): what ObjectRenderer does
171
+ # after working out the partial: the object goes into the locals under
172
+ # the partial's name, then the same event and template.render.
173
+ def __ap_render_object(object, locals = nil)
174
+ unless Views.enabled? && Views.stock?(self.class) && (locals.nil? || ::Hash === locals) &&
175
+ !(::String === object) && !(::Hash === object) && !object.respond_to?(:render_in) && !object.respond_to?(:to_ary)
176
+ return locals.nil? ? render(object) : render(object, locals)
177
+ end
178
+
179
+ model = object.respond_to?(:to_model) ? object.to_model : object
180
+ info = model.respond_to?(:to_partial_path) && Views.object_partial(self, object, model.to_partial_path)
181
+ return (locals.nil? ? render(object) : render(object, locals)) unless info
182
+
183
+ path, local, keys = info
184
+ if locals
185
+ keys = locals.keys << local
186
+ else
187
+ locals = {}
188
+ end
189
+ template = Views.template_for(self, path, locals, keys)
190
+ locals[local] = object
191
+ ActiveSupport::Notifications.instrument("render_partial.action_view",
192
+ identifier: template.identifier, layout: nil, locals: locals) do |payload|
193
+ content = template.render(self, locals, add_to_stack: true) { |*name| _layout_for(*name) }
194
+ payload[:cache_hit] = view_renderer.cache_hits[template.virtual_path]
195
+ content
196
+ end
197
+ end
198
+
199
+ # One html option with a value known only at runtime, as Rails'
200
+ # tag_options renders it (it renders each pair on its own). Plain
201
+ # Strings and Symbols under an ordinary key take the short way.
202
+ def __ap_attr(key, escaped_key, plain, value)
203
+ if plain && (::String === value || ::Symbol === value)
204
+ v = ERB::Util.unwrapped_html_escape(value)
205
+ v = v.gsub('"', "&quot;") if v.include?('"')
206
+ " #{escaped_key}=\"#{v}\""
207
+ else
208
+ tag_builder.tag_options({ key => value }) || ""
209
+ end
210
+ end
211
+
212
+ # Block-form link_to with options not all literal: the fallback gets them as a Hash.
213
+ def __ap_link_to_block_dyn(url, attrs, options, &block)
214
+ return link_to(url, options, &block) unless Views.enabled? && ::String === url && Views.stock?(self.class)
215
+
216
+ href = ERB::Util.unwrapped_html_escape(url)
217
+ href = href.gsub('"', "&quot;") if href.include?('"')
218
+ content = capture(&block)
219
+ content = ERB::Util.unwrapped_html_escape(content) if content.present?
220
+ "<a#{attrs} href=\"#{href}\">#{content}</a>".html_safe
221
+ end
222
+
223
+ def __ap_link_to(name, url)
224
+ return link_to(name, url) unless Views.enabled? && ::String === url && Views.stock?(self.class)
225
+
226
+ href = ERB::Util.unwrapped_html_escape(url)
227
+ href = href.gsub('"', "&quot;") if href.include?('"')
228
+ content = name || url
229
+ content = ERB::Util.unwrapped_html_escape(content) if content.present?
230
+ "<a href=\"#{href}\">#{content}</a>".html_safe
231
+ end
232
+ end
233
+
234
+ class Rewriter
235
+ # { "render partial" => n, "link_to" => n, "render: <why not>" => n, ... }
236
+ attr_reader :stats
237
+
238
+ NotLiteral = Class.new(StandardError)
239
+ SPECIAL_LINK_OPTIONS = %w[method remote href].freeze
240
+
241
+ def initialize(source)
242
+ @source = source
243
+ @edits = []
244
+ @stats = Hash.new(0)
245
+ @temps = 0
246
+ end
247
+
248
+ def rewrite
249
+ result = Prism.parse(@source)
250
+ return @source unless result.success?
251
+
252
+ visit(result.value)
253
+ return @source if @edits.empty?
254
+
255
+ out = @source.b
256
+ @edits.sort_by { -it[0] }.each { |start, stop, text| out[start...stop] = text.b }
257
+ out.force_encoding(@source.encoding)
258
+ return out if Prism.parse(out).success?
259
+
260
+ # A bug here must not break the template: keep it as written, and say so in the census.
261
+ @stats = Hash.new(0).merge("templates left as written (their rewrite didn't parse)" => 1)
262
+ @source
263
+ end
264
+
265
+ private
266
+
267
+ def visit(node)
268
+ return unless node
269
+
270
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :link_to && node.block.is_a?(Prism::BlockNode) &&
271
+ (head = link_to_block_head(node))
272
+ @stats["link_to with a block"] += 1
273
+ @edits << [node.location.start_offset, node.block.location.start_offset, head]
274
+ visit(node.block) # its body keeps its own rewrites
275
+ return
276
+ end
277
+
278
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && %i[render link_to].include?(node.name)
279
+ if node.block
280
+ @stats["#{node.name}: with a block#{" (#{@reason})" if @reason && node.name == :link_to}"] += 1
281
+ elsif (text = replacement(node))
282
+ @stats[node.name == :render ? "render partial" : "link_to"] += 1 unless @counted
283
+ @counted = false
284
+ @edits << [node.location.start_offset, node.location.end_offset, text]
285
+ return # arguments keep their source; nested calls in them stay as written
286
+ else
287
+ @stats["#{node.name}: #{@reason || "other arguments"}"] += 1
288
+ end
289
+ @reason = nil
290
+ end
291
+ node.compact_child_nodes.each { visit(it) }
292
+ end
293
+
294
+ def replacement(node)
295
+ args = node.arguments&.arguments || []
296
+ case node.name
297
+ when :render then render_replacement(args)
298
+ when :link_to then link_to_replacement(args)
299
+ end
300
+ end
301
+
302
+ # render "dir/name" | render "dir/name", locals | render "dir/name", k: v
303
+ # render partial: "dir/name" [, locals: {...}]
304
+ def render_replacement(args)
305
+ if (text = render_object_replacement(args))
306
+ @stats["render record"] += 1
307
+ @counted = true
308
+ return text
309
+ end
310
+ @reason = render_reason(args)
311
+ if args.size.between?(1, 2) && (path = partial_path(args[0]))
312
+ return "__ap_render_partial(#{path.inspect})" if args.size == 1
313
+
314
+ locals = args[1]
315
+ return if locals.is_a?(Prism::BlockArgumentNode) || locals.is_a?(Prism::SplatNode)
316
+
317
+ text = locals.is_a?(Prism::KeywordHashNode) ? "{ #{slice(locals)} }" : "(#{slice(locals)})"
318
+ return "__ap_render_partial(#{path.inspect}, #{text})"
319
+ end
320
+ hash = args[0] if args.size == 1
321
+ return unless hash.is_a?(Prism::KeywordHashNode) || hash.is_a?(Prism::HashNode)
322
+
323
+ pairs = hash.elements.to_h do |el|
324
+ return nil unless el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode)
325
+
326
+ [el.key.unescaped.to_sym, el.value]
327
+ end
328
+ return unless pairs.keys.include?(:partial) && (pairs.keys - %i[partial locals]).empty?
329
+ return unless (path = partial_path(pairs[:partial]))
330
+
331
+ pairs[:locals] ? "__ap_render_partial(#{path.inspect}, (#{slice(pairs[:locals])}))" : "__ap_render_partial(#{path.inspect})"
332
+ end
333
+
334
+ # render @comment, render comment, render(comment, extra: 1): anything
335
+ # that isn't a partial name or an options hash. Collections and
336
+ # components are told apart at runtime and keep the stock render.
337
+ NOT_OBJECTS = [Prism::StringNode, Prism::InterpolatedStringNode, Prism::HashNode, Prism::KeywordHashNode,
338
+ Prism::SymbolNode, Prism::NilNode, Prism::SplatNode, Prism::BlockArgumentNode, Prism::ArrayNode].freeze
339
+
340
+ def render_object_replacement(args)
341
+ object, locals = args
342
+ return unless args.size.between?(1, 2) && NOT_OBJECTS.none? { object.is_a?(it) }
343
+ return "__ap_render_object((#{slice(object)}))" if args.size == 1
344
+ return unless locals.is_a?(Prism::KeywordHashNode) || locals.is_a?(Prism::HashNode)
345
+
346
+ text = locals.is_a?(Prism::KeywordHashNode) ? "{ #{slice(locals)} }" : slice(locals)
347
+ "__ap_render_object((#{slice(object)}), #{text})"
348
+ end
349
+
350
+ def render_reason(args)
351
+ first = args[0]
352
+ case first
353
+ when Prism::StringNode
354
+ return "a partial name with unusual characters"
355
+ when Prism::KeywordHashNode, Prism::HashNode
356
+ keys = first.elements.filter_map { it.key.unescaped.to_sym if it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) }
357
+ return "collection:/object:/as:/layout: options" if (keys - %i[partial locals]).any?
358
+ when Prism::InterpolatedStringNode then return "an interpolated partial name"
359
+ when nil then return "no arguments"
360
+ else return "a record, collection or component (render @x)"
361
+ end
362
+ "other arguments"
363
+ end
364
+
365
+ def partial_path(node)
366
+ return unless node.is_a?(Prism::StringNode) && !node.opening_loc&.slice&.start_with?("<<")
367
+
368
+ path = node.unescaped
369
+ path if path.match?(%r{\A[\w/\-.]+\z})
370
+ end
371
+
372
+ def link_to_replacement(args)
373
+ return link_to_with_options(args) if args.size == 3
374
+ @reason = args.size == 2 ? "an options hash or splat" : "#{args.size} arguments (name only, or url options)"
375
+ return unless args.size == 2
376
+ return if args.any? { it.is_a?(Prism::KeywordHashNode) || it.is_a?(Prism::HashNode) || it.is_a?(Prism::BlockArgumentNode) || it.is_a?(Prism::SplatNode) }
377
+
378
+ "__ap_link_to((#{slice(args[0])}), (#{slice(args[1])}))"
379
+ end
380
+
381
+ OPTIONS_REASON = "html options with computed keys, **splats, or method:/remote:/href:"
382
+
383
+ # link_to(name, url, class: "btn", data: { ... }). Name, url and option
384
+ # values are evaluated once, in order; a url that isn't a String at
385
+ # runtime goes to link_to with the same options.
386
+ def link_to_with_options(args)
387
+ name, url, options = args
388
+ @reason = OPTIONS_REASON
389
+ return unless options.is_a?(Prism::KeywordHashNode) || options.is_a?(Prism::HashNode)
390
+ return if [name, url].any? { it.is_a?(Prism::KeywordHashNode) || it.is_a?(Prism::HashNode) || it.is_a?(Prism::SplatNode) || it.is_a?(Prism::BlockArgumentNode) }
391
+ return unless (pairs = option_pairs(options))
392
+
393
+ n = "__apn#{@temps += 1}"
394
+ u = "__apu#{@temps}"
395
+ assigns = pairs.filter_map { |pair| "#{pair[:temp]} = (#{slice(pair[:node])}); " if pair[:temp] }.join
396
+ "(#{n} = (#{slice(name)}); #{u} = (#{slice(url)}); #{assigns}__ap_fast_links? && ::String === #{u} ? " \
397
+ "__ap_link_to_attrs(#{n}, #{u}, #{attributes_expression(pairs)}) : link_to(#{n}, #{u}, #{rebuilt_options(pairs)}))"
398
+ end
399
+
400
+ # link_to(url[, options]) do ... end: the call up to the block, as a
401
+ # call to __ap_link_to_block (literal options) or __ap_link_to_block_dyn.
402
+ def link_to_block_head(node)
403
+ @reason = nil
404
+ args = node.arguments&.arguments || []
405
+ url, options = args
406
+ @reason =
407
+ if node.block.parameters then "block parameters"
408
+ elsif !args.size.between?(1, 2) then "#{args.size} arguments"
409
+ elsif [Prism::KeywordHashNode, Prism::HashNode, Prism::SplatNode, Prism::BlockArgumentNode].any? { url.is_a?(it) }
410
+ "a hash or splat as the url"
411
+ elsif options && !(options.is_a?(Prism::KeywordHashNode) || options.is_a?(Prism::HashNode))
412
+ OPTIONS_REASON
413
+ end
414
+ return if @reason
415
+ return "__ap_link_to_block((#{slice(url)}), \"\", nil) " unless options
416
+ return unless (pairs = option_pairs(options))
417
+
418
+ if pairs.none? { it[:temp] }
419
+ hash = pairs.to_h { [it[:key], it[:value]] }
420
+ return "__ap_link_to_block((#{slice(url)}), #{Views.static_attributes(hash).inspect}, #{Views.register_link_options(hash)}) "
421
+ end
422
+
423
+ assigns = pairs.filter_map { |pair| "#{pair[:temp]} = (#{slice(pair[:node])}); " if pair[:temp] }.join
424
+ "__ap_link_to_block_dyn((#{slice(url)}), (#{assigns}#{attributes_expression(pairs)}), #{rebuilt_options(pairs)}) "
425
+ end
426
+
427
+ # [{ key:, value: } for literal values, { key:, node:, temp: } for the rest],
428
+ # or nil (and @reason) for keys link_to treats specially or that aren't literal.
429
+ def option_pairs(options)
430
+ options.elements.map do |el|
431
+ unless el.is_a?(Prism::AssocNode) && (el.key.is_a?(Prism::SymbolNode) || el.key.is_a?(Prism::StringNode))
432
+ @reason = OPTIONS_REASON
433
+ return nil
434
+ end
435
+ key = literal(el.key)
436
+ if SPECIAL_LINK_OPTIONS.include?(key.to_s) || key.to_s.empty?
437
+ @reason = OPTIONS_REASON
438
+ return nil
439
+ end
440
+ begin
441
+ { key: key, value: literal(el.value), value_node: el.value }
442
+ rescue NotLiteral
443
+ { key: key, node: el.value, temp: "__apa#{@temps += 1}" }
444
+ end
445
+ end
446
+ rescue NotLiteral
447
+ @reason = OPTIONS_REASON
448
+ nil
449
+ end
450
+
451
+ # Ruby for the attributes string: Rails' rendering of each literal pair,
452
+ # __ap_attr for each computed one, in the options' order.
453
+ def attributes_expression(pairs)
454
+ pieces = pairs.map do |pair|
455
+ key = pair[:key].to_s
456
+ if pair[:temp]
457
+ plain = ActionView::Helpers::TagHelper::TAG_TYPES[key].nil?
458
+ "__ap_attr(#{key.inspect}, #{ERB::Util.xml_name_escape(key).inspect}, #{plain}, #{pair[:temp]})"
459
+ else
460
+ Views.static_attributes({ key => pair[:value] }).inspect
461
+ end
462
+ end
463
+ return pieces.first if pieces.size == 1 && !pairs.first[:temp]
464
+
465
+ %("#{pieces.map { "\#{#{it}}" }.join}")
466
+ end
467
+
468
+ # The options as link_to gets them in the fallback: computed values from their temps.
469
+ def rebuilt_options(pairs)
470
+ "{ #{pairs.map { |pair| "#{pair[:key].inspect} => #{pair[:temp] || slice(pair[:value_node])}" }.join(", ")} }"
471
+ end
472
+
473
+ def literal(node)
474
+ case node
475
+ when Prism::StringNode
476
+ raise NotLiteral if node.opening_loc&.slice&.start_with?("<<")
477
+
478
+ node.unescaped
479
+ when Prism::SymbolNode then node.unescaped.to_sym
480
+ when Prism::IntegerNode, Prism::FloatNode then node.value
481
+ when Prism::TrueNode then true
482
+ when Prism::FalseNode then false
483
+ when Prism::NilNode then nil
484
+ when Prism::ArrayNode then node.elements.map { literal(it) }
485
+ when Prism::HashNode, Prism::KeywordHashNode
486
+ node.elements.to_h do |el|
487
+ raise NotLiteral unless el.is_a?(Prism::AssocNode) && (el.key.is_a?(Prism::SymbolNode) || el.key.is_a?(Prism::StringNode))
488
+
489
+ [literal(el.key), literal(el.value)]
490
+ end
491
+ else raise NotLiteral
492
+ end
493
+ end
494
+
495
+ def slice(node) = @source.byteslice(node.location.start_offset, node.location.length)
496
+ end
497
+ end
498
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+ require "ap_minimizer/version"
5
+
6
+ # Precompiles parts of Action Pack / Action View: named route helpers
7
+ # (ApMinimizer::Routes) and template rendering (ApMinimizer::Views).
8
+ # Controller callbacks are unrolled by activesupport-minimizer.
9
+ module ApMinimizer
10
+ autoload :Routes, "ap_minimizer/routes"
11
+ autoload :Views, "ap_minimizer/views"
12
+ autoload :Census, "ap_minimizer/census"
13
+ end
14
+
15
+ require "ap_minimizer/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actionpack-minimizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sampo Kuokkanen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '8.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.3'
32
+ description: Turns named route helpers into string interpolation over cached URL parts,
33
+ and compiles render and link_to calls in templates to direct calls without their
34
+ option-hash machinery.
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - CHANGELOG.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - lib/actionpack-minimizer.rb
43
+ - lib/ap_minimizer.rb
44
+ - lib/ap_minimizer/census.rb
45
+ - lib/ap_minimizer/railtie.rb
46
+ - lib/ap_minimizer/routes.rb
47
+ - lib/ap_minimizer/version.rb
48
+ - lib/ap_minimizer/views.rb
49
+ homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/actionpack-minimizer
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ source_code_uri: https://github.com/sampokuokkanen/rails-minify/tree/main/actionpack-minimizer
54
+ changelog_uri: https://github.com/sampokuokkanen/rails-minify/blob/main/actionpack-minimizer/CHANGELOG.md
55
+ bug_tracker_uri: https://github.com/sampokuokkanen/rails-minify/issues
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.4'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.20
72
+ specification_version: 4
73
+ summary: Precompile named route helpers and template rendering
74
+ test_files: []