rails-wayback 0.1.0 → 0.2.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -7
  3. data/Rakefile +17 -1
  4. data/app/controllers/rails_wayback/assets_controller.rb +78 -0
  5. data/app/controllers/rails_wayback/pages_controller.rb +50 -9
  6. data/config/routes.rb +5 -0
  7. data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
  8. data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
  9. data/lib/rails_wayback/asset_provenance.rb +41 -0
  10. data/lib/rails_wayback/bar_middleware.rb +235 -67
  11. data/lib/rails_wayback/bar_renderer.css +76 -0
  12. data/lib/rails_wayback/bar_renderer.html.erb +43 -0
  13. data/lib/rails_wayback/bar_renderer.js +361 -0
  14. data/lib/rails_wayback/bar_renderer.rb +70 -218
  15. data/lib/rails_wayback/bar_search.js +73 -0
  16. data/lib/rails_wayback/cache_inventory.rb +199 -0
  17. data/lib/rails_wayback/cli.rb +70 -3
  18. data/lib/rails_wayback/configuration.rb +13 -2
  19. data/lib/rails_wayback/controller_extensions.rb +71 -20
  20. data/lib/rails_wayback/doctor.rb +123 -0
  21. data/lib/rails_wayback/engine.rb +27 -19
  22. data/lib/rails_wayback/engine_mount.rb +15 -0
  23. data/lib/rails_wayback/git.rb +92 -8
  24. data/lib/rails_wayback/historical_asset_body.rb +41 -0
  25. data/lib/rails_wayback/historical_asset_helper.rb +68 -0
  26. data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
  27. data/lib/rails_wayback/ref_policy.rb +64 -0
  28. data/lib/rails_wayback/render_context.rb +24 -0
  29. data/lib/rails_wayback/render_provenance.rb +84 -0
  30. data/lib/rails_wayback/render_tracker.rb +34 -0
  31. data/lib/rails_wayback/toggle.rb +10 -2
  32. data/lib/rails_wayback/version.rb +1 -1
  33. data/lib/rails_wayback/view_source.rb +171 -24
  34. data/lib/rails_wayback.rb +42 -6
  35. data/lib/tasks/rails_wayback.rake +17 -0
  36. data/rails-wayback.gemspec +2 -5
  37. metadata +21 -33
@@ -0,0 +1,361 @@
1
+ (function (root, factory) {
2
+ const Toolbar = factory();
3
+
4
+ if (typeof module !== "undefined" && module.exports) module.exports = Toolbar;
5
+ if (root) root.RailsWaybackToolbar = Toolbar;
6
+
7
+ if (root && root.document) {
8
+ const bar = root.document.getElementById("rails-wayback-bar");
9
+ if (bar && !bar.railsWaybackToolbar) {
10
+ bar.railsWaybackToolbar = new Toolbar(bar);
11
+ bar.railsWaybackToolbar.connect();
12
+ }
13
+ }
14
+ })(typeof globalThis !== "undefined" ? globalThis : this, function () {
15
+ class RailsWaybackToolbar {
16
+ constructor(element, dependencies) {
17
+ const options = dependencies || {};
18
+ const runtimeWindow = options.window || window;
19
+
20
+ this.element = element;
21
+ this.window = runtimeWindow;
22
+ this.document = options.document || runtimeWindow.document;
23
+ this.fetch = options.fetch || runtimeWindow.fetch.bind(runtimeWindow);
24
+ this.search = options.search || runtimeWindow.RailsWaybackSearch;
25
+ this.AbortController = options.AbortController || runtimeWindow.AbortController;
26
+
27
+ this.handleBranchSearch = this.handleBranchSearch.bind(this);
28
+ this.handleCommitSearch = this.handleCommitSearch.bind(this);
29
+ this.handleBranchChange = this.handleBranchChange.bind(this);
30
+ this.handleCommitChange = this.handleCommitChange.bind(this);
31
+ this.travel = this.travel.bind(this);
32
+ this.reset = this.reset.bind(this);
33
+ }
34
+
35
+ connect() {
36
+ if (this.lifecycleController && !this.lifecycleController.signal.aborted) {
37
+ return this.initialLoad;
38
+ }
39
+
40
+ this.readState();
41
+ this.findElements();
42
+ this.lifecycleController = new this.AbortController();
43
+ this.bindEvents(this.lifecycleController.signal);
44
+ this.setState();
45
+ this.initialLoad = this.fetchBranches();
46
+ return this.initialLoad;
47
+ }
48
+
49
+ disconnect() {
50
+ if (this.lifecycleController) this.lifecycleController.abort();
51
+ if (this.branchRequest) this.branchRequest.abort();
52
+ if (this.commitRequest) this.commitRequest.abort();
53
+ }
54
+
55
+ readState() {
56
+ this.mount = this.element.dataset.mount;
57
+ this.headBranch = this.element.dataset.branch;
58
+ this.headCommit = this.element.dataset.commit;
59
+ this.activeRef = this.element.dataset.activeRef || "";
60
+ this.activeBranch = this.element.dataset.activeBranch || "";
61
+ this.refError = this.element.dataset.refError || "";
62
+ this.references = [];
63
+ this.commits = [];
64
+ this.selectedBranchValue = this.activeBranch || this.headBranch;
65
+ this.selectedCommitValue = this.activeRef || this.headCommit;
66
+ }
67
+
68
+ findElements() {
69
+ this.branchSel = this.element.querySelector("[data-rw-branch]");
70
+ this.commitSel = this.element.querySelector("[data-rw-commit]");
71
+ this.branchSearch = this.element.querySelector("[data-rw-branch-search]");
72
+ this.commitSearch = this.element.querySelector("[data-rw-commit-search]");
73
+ this.branchResults = this.element.querySelector("[data-rw-branch-results]");
74
+ this.commitResults = this.element.querySelector("[data-rw-commit-results]");
75
+ this.stateEl = this.element.querySelector("[data-rw-state]");
76
+ this.travelBtn = this.element.querySelector("[data-rw-travel]");
77
+ this.resetBtn = this.element.querySelector("[data-rw-reset]");
78
+ }
79
+
80
+ bindEvents(signal) {
81
+ const options = { signal: signal };
82
+ this.branchSearch.addEventListener("input", this.handleBranchSearch, options);
83
+ this.commitSearch.addEventListener("input", this.handleCommitSearch, options);
84
+ this.branchSel.addEventListener("change", this.handleBranchChange, options);
85
+ this.commitSel.addEventListener("change", this.handleCommitChange, options);
86
+ this.travelBtn.addEventListener("click", this.travel, options);
87
+ this.resetBtn.addEventListener("click", this.reset, options);
88
+ }
89
+
90
+ handleBranchSearch(event) {
91
+ this.renderReferences(event.target.value);
92
+ }
93
+
94
+ handleCommitSearch(event) {
95
+ this.renderCommits(event.target.value);
96
+ }
97
+
98
+ handleBranchChange(event) {
99
+ this.selectedBranchValue = event.target.value;
100
+ this.selectedCommitValue = "";
101
+ this.branchSearch.value = "";
102
+ this.commitSearch.value = "";
103
+ this.renderReferences("");
104
+ this.fetchCommits(this.selectedBranchValue);
105
+ }
106
+
107
+ handleCommitChange(event) {
108
+ this.selectedCommitValue = event.target.value;
109
+ }
110
+
111
+ setState() {
112
+ if (this.refError) {
113
+ this.stateEl.textContent = "Travel rejected";
114
+ this.stateEl.classList.add("rw-active");
115
+ this.resetBtn.hidden = false;
116
+ } else if (this.activeRef) {
117
+ const label = this.activeBranch
118
+ ? this.referenceLabel(this.activeBranch) + "@" + this.activeRef.slice(0, 7)
119
+ : this.activeRef.slice(0, 7);
120
+ this.stateEl.textContent = "Traveling: " + label;
121
+ this.stateEl.classList.add("rw-active");
122
+ this.resetBtn.hidden = false;
123
+ } else {
124
+ this.stateEl.textContent = "Live";
125
+ this.stateEl.classList.remove("rw-active");
126
+ this.resetBtn.hidden = true;
127
+ }
128
+ }
129
+
130
+ showError(message) {
131
+ this.stateEl.textContent = message;
132
+ this.stateEl.classList.add("rw-active");
133
+ }
134
+
135
+ referenceLabel(reference) {
136
+ if (reference.indexOf("refs/heads/") === 0) return reference.slice(11);
137
+ if (reference.indexOf("refs/remotes/") === 0) return reference.slice(13);
138
+ if (reference.indexOf("refs/tags/") === 0) return "tag:" + reference.slice(10);
139
+ return reference;
140
+ }
141
+
142
+ referenceMatchesSelection(reference, selection) {
143
+ return reference.full_name === selection ||
144
+ reference.name === selection ||
145
+ reference.label === selection;
146
+ }
147
+
148
+ resultMessage(count, noun, query, selectionRetained) {
149
+ if (!query.trim()) return count + " " + noun + (count === 1 ? "" : "s") + " loaded";
150
+ if (count > 0) return count + " matching " + noun + (count === 1 ? "" : "s");
151
+ return "No matching " + noun + "s" +
152
+ (selectionRetained ? "; current selection retained" : "");
153
+ }
154
+
155
+ renderReferences(query) {
156
+ const result = this.search.searchReferences(
157
+ this.references,
158
+ query,
159
+ this.selectedBranchValue
160
+ );
161
+ this.branchSel.innerHTML = "";
162
+ result.visible.forEach((reference) => {
163
+ const option = this.document.createElement("option");
164
+ option.value = reference.full_name;
165
+ option.textContent = reference.label;
166
+ option.selected = reference.full_name === this.selectedBranchValue;
167
+ this.branchSel.appendChild(option);
168
+ });
169
+
170
+ if (!this.branchSel.value && result.visible.length > 0) {
171
+ this.selectedBranchValue = result.visible[0].full_name;
172
+ this.branchSel.value = this.selectedBranchValue;
173
+ }
174
+ const retained = result.visible.length > result.matches.length;
175
+ this.branchResults.textContent = this.resultMessage(
176
+ result.matches.length,
177
+ "ref",
178
+ query,
179
+ retained
180
+ );
181
+ this.branchSearch.setAttribute(
182
+ "aria-invalid",
183
+ result.matches.length === 0 && query.trim() ? "true" : "false"
184
+ );
185
+ }
186
+
187
+ renderCommits(query) {
188
+ const result = this.search.searchCommits(this.commits, query, this.selectedCommitValue);
189
+ this.commitSel.innerHTML = "";
190
+ result.visible.forEach((commit) => {
191
+ const option = this.document.createElement("option");
192
+ option.value = commit.sha;
193
+ option.textContent = commit.short_sha + " — " + (commit.subject || "").slice(0, 80);
194
+ option.selected = commit.sha === this.selectedCommitValue;
195
+ this.commitSel.appendChild(option);
196
+ });
197
+
198
+ if (!this.commitSel.value && result.visible.length > 0) {
199
+ this.selectedCommitValue = result.visible[0].sha;
200
+ this.commitSel.value = this.selectedCommitValue;
201
+ }
202
+ const retained = result.visible.length > result.matches.length;
203
+ this.commitResults.textContent = this.resultMessage(
204
+ result.matches.length,
205
+ "commit",
206
+ query,
207
+ retained
208
+ );
209
+ this.commitSearch.setAttribute(
210
+ "aria-invalid",
211
+ result.matches.length === 0 && query.trim() ? "true" : "false"
212
+ );
213
+ this.travelBtn.disabled = !this.commitSel.value;
214
+ }
215
+
216
+ async fetchBranches() {
217
+ const request = this.startRequest("branchRequest");
218
+ this.branchSearch.disabled = true;
219
+ this.branchSel.disabled = true;
220
+ this.branchResults.textContent = "Loading refs";
221
+
222
+ try {
223
+ const response = await this.fetch(this.mount + "/branches", {
224
+ headers: { Accept: "application/json" },
225
+ signal: request.signal
226
+ });
227
+ if (!response.ok) throw new Error("HTTP " + response.status + " on /branches");
228
+
229
+ const data = await response.json();
230
+ if (!this.requestIsCurrent("branchRequest", request)) return;
231
+
232
+ this.references = data.refs || (data.branches || []).map((branch) => ({
233
+ full_name: branch,
234
+ name: branch,
235
+ label: branch,
236
+ type: "branch"
237
+ }));
238
+ const initial = this.references.find((reference) => (
239
+ this.referenceMatchesSelection(reference, this.selectedBranchValue)
240
+ ));
241
+ this.selectedBranchValue = initial
242
+ ? initial.full_name
243
+ : this.references[0] && this.references[0].full_name;
244
+ this.renderReferences(this.branchSearch.value);
245
+ this.branchSearch.disabled = this.references.length === 0;
246
+ this.branchSel.disabled = this.references.length === 0;
247
+ if ((data.errors || []).length > 0) this.showError(data.errors.join(" | "));
248
+ if (this.branchSel.value) await this.fetchCommits(this.branchSel.value);
249
+ } catch (error) {
250
+ if (this.requestWasCancelled("branchRequest", request, error)) return;
251
+
252
+ this.branchResults.textContent = "Refs unavailable";
253
+ this.showError("branches: " + error.message);
254
+ }
255
+ }
256
+
257
+ async fetchCommits(branch) {
258
+ const request = this.startRequest("commitRequest");
259
+ this.travelBtn.disabled = true;
260
+ this.commitSearch.disabled = true;
261
+ this.commitSel.disabled = true;
262
+ this.commitResults.textContent = "Loading commits";
263
+
264
+ try {
265
+ const response = await this.fetch(this.mount + "/commits/" + encodeURI(branch), {
266
+ headers: { Accept: "application/json" },
267
+ signal: request.signal
268
+ });
269
+ if (!response.ok) {
270
+ throw new Error("HTTP " + response.status + " on /commits/" + branch);
271
+ }
272
+
273
+ const data = await response.json();
274
+ if (!this.requestIsCurrent("commitRequest", request)) return;
275
+
276
+ this.commits = data.commits || [];
277
+ if (!this.commits.some((commit) => commit.sha === this.selectedCommitValue)) {
278
+ this.selectedCommitValue = this.commits[0] && this.commits[0].sha;
279
+ }
280
+ this.renderCommits(this.commitSearch.value);
281
+ this.commitSearch.disabled = this.commits.length === 0;
282
+ this.commitSel.disabled = this.commits.length === 0;
283
+ if (data.error) {
284
+ this.showError("commits: " + data.error);
285
+ } else if (!this.activeRef) {
286
+ this.setState();
287
+ }
288
+ } catch (error) {
289
+ if (this.requestWasCancelled("commitRequest", request, error)) return;
290
+
291
+ this.commitResults.textContent = "Commits unavailable";
292
+ this.showError("commits: " + error.message);
293
+ }
294
+ }
295
+
296
+ startRequest(property) {
297
+ if (this[property]) this[property].abort();
298
+ const request = new this.AbortController();
299
+ this[property] = request;
300
+ return request;
301
+ }
302
+
303
+ requestIsCurrent(property, request) {
304
+ return this[property] === request && !request.signal.aborted;
305
+ }
306
+
307
+ requestWasCancelled(property, request, error) {
308
+ return error.name === "AbortError" || !this.requestIsCurrent(property, request);
309
+ }
310
+
311
+ setCookie(name, value) {
312
+ this.document.cookie = name + "=" + encodeURIComponent(value) + "; Path=/; SameSite=Lax";
313
+ }
314
+
315
+ travel() {
316
+ const sha = this.commitSel.value;
317
+ if (!sha || !this.trustWarningAccepted()) return;
318
+
319
+ this.setCookie("rails_wayback_ref", sha);
320
+ if (this.branchSel.value) this.setCookie("rails_wayback_branch", this.branchSel.value);
321
+ const url = new this.window.URL(this.window.location.href);
322
+ url.searchParams.set("_wayback_ref", sha);
323
+ if (this.branchSel.value) url.searchParams.set("_wayback_branch", this.branchSel.value);
324
+ this.window.location.assign(url.toString());
325
+ }
326
+
327
+ trustWarningAccepted() {
328
+ const key = "rails-wayback.trust-warning-accepted";
329
+ try {
330
+ if (this.window.sessionStorage.getItem(key) === "true") return true;
331
+ } catch (_error) {
332
+ // Storage may be unavailable; confirmation still protects this action.
333
+ }
334
+
335
+ const accepted = this.window.confirm(
336
+ "Historical templates execute Ruby inside this Rails process. " +
337
+ "Continue only if you trust this commit."
338
+ );
339
+ if (accepted) {
340
+ try {
341
+ this.window.sessionStorage.setItem(key, "true");
342
+ } catch (_error) {
343
+ // The confirmation remains effective for this travel action.
344
+ }
345
+ }
346
+ return accepted;
347
+ }
348
+
349
+ reset() {
350
+ const url = new this.window.URL(this.window.location.href);
351
+ url.searchParams.delete("_wayback_ref");
352
+ url.searchParams.delete("_wayback_branch");
353
+
354
+ const resetUrl = new this.window.URL(this.mount + "/reset", this.window.location.origin);
355
+ resetUrl.searchParams.set("return_to", url.pathname + url.search + url.hash);
356
+ this.window.location.assign(resetUrl.toString());
357
+ }
358
+ }
359
+
360
+ return RailsWaybackToolbar;
361
+ });