rails-wayback 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+
6
+ module RailsWayback
7
+ # Builds the HTML/CSS/JS payload that the middleware injects into
8
+ # every HTML response so the developer can travel from any page of
9
+ # their app without visiting a dedicated route.
10
+ class BarRenderer
11
+ def initialize(current_branch:, current_commit:, active_ref: nil, active_branch: nil,
12
+ engine_mount: "/rails-wayback", diff_info: nil)
13
+ @current_branch = current_branch
14
+ @current_commit = current_commit
15
+ @active_ref = active_ref
16
+ @active_branch = active_branch
17
+ @engine_mount = engine_mount
18
+ @diff_info = diff_info
19
+ end
20
+
21
+ def render
22
+ <<~HTML
23
+ <style data-rails-wayback>#{styles}</style>
24
+ <div id="rails-wayback-bar" data-mount="#{escape(@engine_mount)}"
25
+ data-branch="#{escape(@current_branch)}"
26
+ data-commit="#{escape(@current_commit)}"
27
+ data-active-ref="#{escape(@active_ref.to_s)}"
28
+ data-active-branch="#{escape(@active_branch.to_s)}">
29
+ <div class="rw-bar-inner">
30
+ <div class="rw-field">
31
+ <label>#{escape(branch_label)}</label>
32
+ <select data-rw-branch></select>
33
+ </div>
34
+ <div class="rw-field rw-flex">
35
+ <label>#{escape(commit_label)}</label>
36
+ <select data-rw-commit></select>
37
+ </div>
38
+ <div class="rw-state" data-rw-state></div>
39
+ <button type="button" data-rw-travel class="rw-btn rw-btn-primary">Travel</button>
40
+ <button type="button" data-rw-reset class="rw-btn rw-btn-ghost" hidden>Return to HEAD</button>
41
+ </div>
42
+ #{diff_summary_html}
43
+ </div>
44
+ <script data-rails-wayback>#{script}</script>
45
+ HTML
46
+ end
47
+
48
+ private
49
+
50
+ def branch_label
51
+ @active_ref ? "Rendering branch" : "Current branch"
52
+ end
53
+
54
+ def commit_label
55
+ @active_ref ? "Rendering commit" : "Current commit"
56
+ end
57
+
58
+ def escape(value)
59
+ CGI.escapeHTML(value.to_s)
60
+ end
61
+
62
+ # Second row of the bar shown only while traveling. Tells the
63
+ # developer whether the current page actually contains any changed
64
+ # templates for the active ref, so they don't waste time refreshing
65
+ # a page that has no diffs vs the ref they picked.
66
+ def diff_summary_html
67
+ return "" unless @diff_info
68
+
69
+ changed = Array(@diff_info[:changed_files])
70
+ matched = Array(@diff_info[:matched])
71
+ total = changed.size
72
+
73
+ if total.zero?
74
+ message = %(No view/asset diffs between HEAD and this ref.)
75
+ css_class = "rw-diff rw-diff-neutral"
76
+ list_html = ""
77
+ elsif matched.any?
78
+ message = %(#{matched.size} changed view#{"s" if matched.size != 1} rendered on this page.)
79
+ css_class = "rw-diff rw-diff-match"
80
+ list_html = diff_details("Matched on this page (#{matched.size})", matched) +
81
+ diff_details("All changed files (#{total})", changed)
82
+ else
83
+ message = %(No changed views on this page. #{total} file#{"s" if total != 1} differ elsewhere.)
84
+ css_class = "rw-diff rw-diff-miss"
85
+ list_html = diff_details("Changed files (#{total})", changed)
86
+ end
87
+
88
+ <<~HTML.strip
89
+ <div class="#{css_class}">
90
+ <span class="rw-diff-msg">#{escape(message)}</span>
91
+ #{list_html}
92
+ </div>
93
+ HTML
94
+ end
95
+
96
+ def diff_details(summary, files)
97
+ items = files.map { |f| %(<li>#{escape(f)}</li>) }.join
98
+ <<~HTML.strip
99
+ <details class="rw-diff-details">
100
+ <summary>#{escape(summary)}</summary>
101
+ <ul>#{items}</ul>
102
+ </details>
103
+ HTML
104
+ end
105
+
106
+ def styles
107
+ <<~CSS
108
+ #rails-wayback-bar {
109
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000;
110
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
111
+ font-size: 12px; color: #e6e9ef;
112
+ background: rgba(15, 17, 21, 0.96);
113
+ border-top: 1px solid #2a2f3a;
114
+ box-shadow: 0 -8px 20px rgba(0,0,0,.35);
115
+ }
116
+ #rails-wayback-bar * { box-sizing: border-box; }
117
+ #rails-wayback-bar .rw-bar-inner {
118
+ display: flex; align-items: end; gap: 12px; padding: 8px 12px;
119
+ }
120
+ #rails-wayback-bar .rw-field { display: flex; flex-direction: column; gap: 3px; min-width: 180px; }
121
+ #rails-wayback-bar .rw-flex { flex: 1; }
122
+ #rails-wayback-bar label {
123
+ color: #8a93a6; text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
124
+ }
125
+ #rails-wayback-bar select {
126
+ background: #0b0d12; color: #e6e9ef;
127
+ border: 1px solid #2a2f3a; border-radius: 4px;
128
+ padding: 5px 8px; font-family: inherit; font-size: 12px; width: 100%;
129
+ }
130
+ #rails-wayback-bar .rw-state { color: #8a93a6; font-size: 11px; margin: 0 4px; }
131
+ #rails-wayback-bar .rw-state.rw-active { color: #ffd479; }
132
+ #rails-wayback-bar .rw-btn {
133
+ border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer;
134
+ font-family: inherit; font-size: 12px; font-weight: 600;
135
+ }
136
+ #rails-wayback-bar .rw-btn-primary { background: #6ea8fe; color: #0b0d12; }
137
+ #rails-wayback-bar .rw-btn-ghost { background: transparent; color: #8a93a6; border: 1px solid #2a2f3a; }
138
+ #rails-wayback-bar .rw-btn:disabled { opacity: .5; cursor: not-allowed; }
139
+ #rails-wayback-bar .rw-diff {
140
+ border-top: 1px solid #2a2f3a;
141
+ padding: 6px 12px; font-size: 11px; color: #8a93a6;
142
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
143
+ }
144
+ #rails-wayback-bar .rw-diff-match .rw-diff-msg { color: #7ee787; }
145
+ #rails-wayback-bar .rw-diff-miss .rw-diff-msg { color: #ffd479; }
146
+ #rails-wayback-bar .rw-diff-neutral .rw-diff-msg { color: #8a93a6; }
147
+ #rails-wayback-bar .rw-diff-details { color: #8a93a6; }
148
+ #rails-wayback-bar .rw-diff-details summary {
149
+ cursor: pointer; user-select: none;
150
+ }
151
+ #rails-wayback-bar .rw-diff-details[open] {
152
+ flex-basis: 100%;
153
+ }
154
+ #rails-wayback-bar .rw-diff-details ul {
155
+ margin: 6px 0 0; padding: 6px 10px; list-style: none;
156
+ background: #0b0d12; border: 1px solid #2a2f3a; border-radius: 4px;
157
+ max-height: 200px; overflow: auto;
158
+ font-family: inherit; font-size: 11px;
159
+ }
160
+ #rails-wayback-bar .rw-diff-details li { padding: 2px 0; color: #c8cfdc; }
161
+ body { padding-bottom: 56px !important; }
162
+ body:has(#rails-wayback-bar .rw-diff) { padding-bottom: 88px !important; }
163
+ CSS
164
+ end
165
+
166
+ def script
167
+ <<~JS
168
+ (function () {
169
+ var bar = document.getElementById("rails-wayback-bar");
170
+ if (!bar) return;
171
+
172
+ var mount = bar.dataset.mount;
173
+ var headBranch = bar.dataset.branch;
174
+ var headCommit = bar.dataset.commit;
175
+ var activeRef = bar.dataset.activeRef || "";
176
+ var activeBranch = bar.dataset.activeBranch || "";
177
+ // While traveling, reflect the ref's branch/commit in the
178
+ // dropdowns instead of the developer's local HEAD, so the bar
179
+ // matches what is actually being rendered.
180
+ var selectedBranch = activeBranch || headBranch;
181
+ var selectedCommit = activeRef || headCommit;
182
+
183
+ var branchSel = bar.querySelector("[data-rw-branch]");
184
+ var commitSel = bar.querySelector("[data-rw-commit]");
185
+ var stateEl = bar.querySelector("[data-rw-state]");
186
+ var travelBtn = bar.querySelector("[data-rw-travel]");
187
+ var resetBtn = bar.querySelector("[data-rw-reset]");
188
+
189
+ function setState() {
190
+ if (activeRef) {
191
+ var label = activeBranch
192
+ ? activeBranch + "@" + activeRef.slice(0, 7)
193
+ : activeRef.slice(0, 7);
194
+ stateEl.textContent = "Traveling: " + label;
195
+ stateEl.classList.add("rw-active");
196
+ resetBtn.hidden = false;
197
+ } else {
198
+ stateEl.textContent = "Live";
199
+ stateEl.classList.remove("rw-active");
200
+ resetBtn.hidden = true;
201
+ }
202
+ }
203
+
204
+ function showError(msg) {
205
+ stateEl.textContent = msg;
206
+ stateEl.classList.add("rw-active");
207
+ }
208
+
209
+ function loadBranches() {
210
+ fetch(mount + "/branches", { headers: { Accept: "application/json" } })
211
+ .then(function (r) {
212
+ if (!r.ok) throw new Error("HTTP " + r.status + " on /branches");
213
+ return r.json();
214
+ })
215
+ .then(function (data) {
216
+ branchSel.innerHTML = "";
217
+ (data.branches || []).forEach(function (b) {
218
+ var opt = document.createElement("option");
219
+ opt.value = b; opt.textContent = b;
220
+ if (b === selectedBranch) opt.selected = true;
221
+ branchSel.appendChild(opt);
222
+ });
223
+ if ((data.errors || []).length > 0) showError(data.errors.join(" | "));
224
+ if (branchSel.value) loadCommits(branchSel.value);
225
+ })
226
+ .catch(function (err) {
227
+ showError("branches: " + err.message);
228
+ });
229
+ }
230
+
231
+ function loadCommits(branch) {
232
+ travelBtn.disabled = true;
233
+ fetch(mount + "/commits/" + encodeURI(branch), { headers: { Accept: "application/json" } })
234
+ .then(function (r) {
235
+ if (!r.ok) throw new Error("HTTP " + r.status + " on /commits/" + branch);
236
+ return r.json();
237
+ })
238
+ .then(function (data) {
239
+ commitSel.innerHTML = "";
240
+ (data.commits || []).forEach(function (c) {
241
+ var opt = document.createElement("option");
242
+ opt.value = c.sha;
243
+ opt.textContent = c.short_sha + " — " + (c.subject || "").slice(0, 80);
244
+ if (c.sha === selectedCommit) opt.selected = true;
245
+ commitSel.appendChild(opt);
246
+ });
247
+ if (data.error) {
248
+ showError("commits: " + data.error);
249
+ } else if (!activeRef) {
250
+ setState();
251
+ }
252
+ travelBtn.disabled = !commitSel.value;
253
+ })
254
+ .catch(function (err) {
255
+ showError("commits: " + err.message);
256
+ });
257
+ }
258
+
259
+ function setCookie(name, value) {
260
+ document.cookie = name + "=" + encodeURIComponent(value) + "; Path=/; SameSite=Lax";
261
+ }
262
+
263
+ function clearCookie(name) {
264
+ document.cookie = name + "=; Path=/; Max-Age=0; SameSite=Lax";
265
+ }
266
+
267
+ // Travel is a *session*, not a one-off request: the ref lives
268
+ // in a cookie so it survives every subsequent link click. The
269
+ // URL params are added just so a first-time landing (or a
270
+ // shared link) still works even before the cookie is set.
271
+ function travel() {
272
+ var sha = commitSel.value;
273
+ if (!sha) return;
274
+ setCookie("rails_wayback_ref", sha);
275
+ if (branchSel.value) setCookie("rails_wayback_branch", branchSel.value);
276
+ var url = new URL(window.location.href);
277
+ url.searchParams.set("_wayback_ref", sha);
278
+ if (branchSel.value) url.searchParams.set("_wayback_branch", branchSel.value);
279
+ window.location.assign(url.toString());
280
+ }
281
+
282
+ function reset() {
283
+ clearCookie("rails_wayback_ref");
284
+ clearCookie("rails_wayback_branch");
285
+ var url = new URL(window.location.href);
286
+ url.searchParams.delete("_wayback_ref");
287
+ url.searchParams.delete("_wayback_branch");
288
+ window.location.assign(url.toString());
289
+ }
290
+
291
+ branchSel.addEventListener("change", function (e) { loadCommits(e.target.value); });
292
+ travelBtn.addEventListener("click", travel);
293
+ resetBtn.addEventListener("click", reset);
294
+
295
+ setState();
296
+ loadBranches();
297
+ })();
298
+ JS
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_wayback"
4
+
5
+ module RailsWayback
6
+ # Small CLI powering the `rails-wayback` executable.
7
+ #
8
+ # rails-wayback on # enable the engine in the host app
9
+ # rails-wayback off # disable it
10
+ # rails-wayback status # print current state
11
+ # rails-wayback clean # remove the ref cache under tmp/
12
+ class CLI
13
+ COMMANDS = %w[on off status clean help].freeze
14
+
15
+ def self.start(argv, stdout: $stdout, stderr: $stderr)
16
+ new(stdout: stdout, stderr: stderr).run(argv)
17
+ end
18
+
19
+ def initialize(stdout: $stdout, stderr: $stderr)
20
+ @stdout = stdout
21
+ @stderr = stderr
22
+ end
23
+
24
+ def run(argv)
25
+ command = (argv.first || "help").to_s
26
+ case command
27
+ when "on" then cmd_on
28
+ when "off" then cmd_off
29
+ when "status" then cmd_status
30
+ when "clean" then cmd_clean
31
+ when "help", "--help", "-h" then cmd_help
32
+ else
33
+ @stderr.puts "Unknown command: #{command}"
34
+ cmd_help
35
+ 1
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def cmd_on
42
+ RailsWayback.enable!
43
+ @stdout.puts "rails-wayback: enabled (mounted at /rails-wayback on the next request)"
44
+ 0
45
+ end
46
+
47
+ def cmd_off
48
+ RailsWayback.disable!
49
+ @stdout.puts "rails-wayback: disabled"
50
+ 0
51
+ end
52
+
53
+ def cmd_status
54
+ @stdout.puts "rails-wayback: #{RailsWayback.toggle.status}"
55
+ 0
56
+ end
57
+
58
+ def cmd_clean
59
+ RailsWayback::ViewSource.new.cleanup!
60
+ @stdout.puts "rails-wayback: ref cache removed"
61
+ 0
62
+ end
63
+
64
+ def cmd_help
65
+ @stdout.puts <<~HELP
66
+ Usage: rails-wayback <command>
67
+
68
+ Commands:
69
+ on Enable the wayback UI in this Rails app
70
+ off Disable the wayback UI
71
+ status Print whether the UI is currently enabled
72
+ clean Remove the tmp/rails_wayback ref cache
73
+ help Show this help
74
+ HELP
75
+ 0
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RailsWayback
6
+ class Configuration
7
+ # Includes the common places where Rails apps put render targets.
8
+ # `app/components` is picked up by the ViewComponent gem;
9
+ # `app/views` is stock Rails.
10
+ DEFAULT_VIEW_PATHS = ["app/views", "app/components"].freeze
11
+ DEFAULT_ASSET_PATHS = ["app/assets", "public"].freeze
12
+ DEFAULT_MAX_COMMITS = 50
13
+
14
+ attr_accessor :view_paths, :asset_paths, :max_commits,
15
+ :app_root, :cache_root, :toggle_file
16
+
17
+ def initialize
18
+ @view_paths = DEFAULT_VIEW_PATHS.dup
19
+ @asset_paths = DEFAULT_ASSET_PATHS.dup
20
+ @max_commits = DEFAULT_MAX_COMMITS
21
+ @app_root = nil
22
+ @cache_root = nil
23
+ @toggle_file = nil
24
+ end
25
+
26
+ def app_root_path
27
+ Pathname.new(@app_root || RailsWayback.root)
28
+ end
29
+
30
+ def cache_root_path
31
+ Pathname.new(@cache_root || app_root_path.join("tmp", "rails_wayback"))
32
+ end
33
+
34
+ def toggle_file_path
35
+ Pathname.new(@toggle_file || cache_root_path.join("enabled"))
36
+ end
37
+
38
+ def refs_cache_path
39
+ cache_root_path.join("refs")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ # Auto-included in every `ActionController::Base` subclass through the
5
+ # engine's initializer. Does nothing on ordinary requests. When a
6
+ # request carries `?_wayback_ref=<sha>` and the gem is enabled, it
7
+ # materialises the ref and prepends the sandbox to the view paths so
8
+ # the current controller renders historical templates while keeping
9
+ # its own instance variables intact.
10
+ module ControllerExtensions
11
+ extend ActiveSupport::Concern
12
+
13
+ WAYBACK_PARAM = "_wayback_ref"
14
+ WAYBACK_COOKIE = "rails_wayback_ref"
15
+ RESPONSE_HEADER = "X-RailsWayback-Ref"
16
+
17
+ included do
18
+ before_action :__rails_wayback_prepend_views
19
+ helper_method :rails_wayback_active_ref
20
+ end
21
+
22
+ def rails_wayback_active_ref
23
+ @__rails_wayback_active_ref
24
+ end
25
+
26
+ private
27
+
28
+ def __rails_wayback_prepend_views
29
+ return unless RailsWayback.enabled?
30
+
31
+ ref = __rails_wayback_ref_from_request
32
+ return unless ref
33
+
34
+ dirs = RailsWayback::ViewSource.new.view_root_for(ref)
35
+
36
+ if dirs.empty?
37
+ log_warning("no view directories materialised for ref #{ref}")
38
+ set_header("empty:#{ref}")
39
+ return
40
+ end
41
+
42
+ # `prepend_view_path` is the supported cross-version API. It puts
43
+ # the sandbox in front of every other resolver for this request's
44
+ # lookup context.
45
+ dirs.each { |dir| prepend_view_path(dir.to_s) }
46
+
47
+ @__rails_wayback_active_ref = ref
48
+ set_header(ref)
49
+
50
+ log_info("prepended #{dirs.size} view path(s) from ref #{ref}")
51
+ log_info("resolver order: #{resolver_summary}")
52
+ rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
53
+ log_warning("#{e.class}: #{e.message}")
54
+ set_header("error:#{e.class}")
55
+ end
56
+
57
+ # Params take precedence for one-off overrides (e.g. a shared URL);
58
+ # cookies keep the travel session alive across normal internal
59
+ # navigations so clicking a link on a traveled page keeps rendering
60
+ # from the historical ref.
61
+ def __rails_wayback_ref_from_request
62
+ from_params = params[WAYBACK_PARAM].to_s
63
+ return from_params unless from_params.empty?
64
+
65
+ cookies[WAYBACK_COOKIE].to_s.then { |v| v.empty? ? nil : v }
66
+ end
67
+
68
+ def resolver_summary
69
+ lookup_context.view_paths.to_a.map { |r| resolver_path(r) }.join(" | ")
70
+ rescue StandardError
71
+ "(unavailable)"
72
+ end
73
+
74
+ def resolver_path(resolver)
75
+ return resolver.to_path if resolver.respond_to?(:to_path)
76
+ return resolver.path if resolver.respond_to?(:path)
77
+ resolver.class.name
78
+ end
79
+
80
+ def set_header(value)
81
+ response.set_header(RESPONSE_HEADER, value) if response
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ def log_info(msg)
87
+ Rails.logger.info("[rails-wayback] #{msg}") if defined?(Rails) && Rails.respond_to?(:logger)
88
+ end
89
+
90
+ def log_warning(msg)
91
+ Rails.logger.warn("[rails-wayback] #{msg}") if defined?(Rails) && Rails.respond_to?(:logger)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "rails_wayback/controller_extensions"
5
+ require "rails_wayback/bar_renderer"
6
+ require "rails_wayback/bar_middleware"
7
+
8
+ module RailsWayback
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace RailsWayback
11
+
12
+ initializer "rails_wayback.controller_extensions" do
13
+ ActiveSupport.on_load(:action_controller_base) do
14
+ include RailsWayback::ControllerExtensions
15
+ end
16
+ end
17
+
18
+ initializer "rails_wayback.middleware" do |app|
19
+ app.middleware.use RailsWayback::BarMiddleware
20
+ end
21
+
22
+ initializer "rails_wayback.render_instrumentation" do
23
+ ActiveSupport::Notifications.subscribe("render_template.action_view") do |*args|
24
+ event = ActiveSupport::Notifications::Event.new(*args)
25
+ identifier = event.payload[:identifier].to_s
26
+ next if identifier.empty?
27
+
28
+ origin = if identifier.include?("/tmp/rails_wayback/refs/")
29
+ "SANDBOX"
30
+ elsif identifier.start_with?("/")
31
+ "CURRENT"
32
+ else
33
+ "OTHER"
34
+ end
35
+ Rails.logger.info("[rails-wayback] render #{origin}: #{identifier}") if Rails.respond_to?(:logger)
36
+
37
+ # Per-request tracker so the middleware can report, in the bar,
38
+ # which templates the current page actually pulled from the
39
+ # sandbox. Middleware clears this at the start of every request.
40
+ tracker = Thread.current[RailsWayback::RENDER_TRACKER_KEY] ||= []
41
+ tracker << identifier
42
+ end
43
+ end
44
+
45
+ rake_tasks do
46
+ load File.expand_path("../tasks/rails_wayback.rake", __dir__)
47
+ end
48
+ end
49
+ end