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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +172 -0
- data/Rakefile +11 -0
- data/app/controllers/rails_wayback/application_controller.rb +19 -0
- data/app/controllers/rails_wayback/pages_controller.rb +53 -0
- data/config/routes.rb +10 -0
- data/exe/rails-wayback +8 -0
- data/lib/generators/rails_wayback/install/USAGE +8 -0
- data/lib/generators/rails_wayback/install/install_generator.rb +39 -0
- data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -0
- data/lib/rails-wayback.rb +3 -0
- data/lib/rails_wayback/bar_middleware.rb +199 -0
- data/lib/rails_wayback/bar_renderer.rb +301 -0
- data/lib/rails_wayback/cli.rb +78 -0
- data/lib/rails_wayback/configuration.rb +42 -0
- data/lib/rails_wayback/controller_extensions.rb +94 -0
- data/lib/rails_wayback/engine.rb +49 -0
- data/lib/rails_wayback/git.rb +140 -0
- data/lib/rails_wayback/toggle.rb +53 -0
- data/lib/rails_wayback/version.rb +5 -0
- data/lib/rails_wayback/view_source.rb +99 -0
- data/lib/rails_wayback.rb +59 -0
- data/lib/tasks/rails_wayback.rake +25 -0
- data/rails-wayback.gemspec +44 -0
- metadata +119 -0
|
@@ -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
|