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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +129 -0
- data/lib/actionpack-minimizer.rb +3 -0
- data/lib/ap_minimizer/census.rb +61 -0
- data/lib/ap_minimizer/railtie.rb +19 -0
- data/lib/ap_minimizer/routes.rb +216 -0
- data/lib/ap_minimizer/version.rb +5 -0
- data/lib/ap_minimizer/views.rb +498 -0
- data/lib/ap_minimizer.rb +15 -0
- metadata +74 -0
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,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,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('"', """) 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('"', """) 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('"', """) 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('"', """) 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('"', """) 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
|
data/lib/ap_minimizer.rb
ADDED
|
@@ -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: []
|