gemstar 0.0.2 → 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 +4 -4
- data/CHANGELOG.md +6 -14
- data/README.md +13 -0
- data/lib/gemstar/cache.rb +47 -10
- data/lib/gemstar/cache_cli.rb +12 -0
- data/lib/gemstar/cache_warmer.rb +120 -0
- data/lib/gemstar/change_log.rb +75 -44
- data/lib/gemstar/cli.rb +12 -0
- data/lib/gemstar/commands/cache.rb +12 -0
- data/lib/gemstar/commands/diff.rb +3 -3
- data/lib/gemstar/commands/server.rb +136 -0
- data/lib/gemstar/config.rb +15 -0
- data/lib/gemstar/git_repo.rb +74 -7
- data/lib/gemstar/lock_file.rb +86 -8
- data/lib/gemstar/project.rb +245 -0
- data/lib/gemstar/request_logger.rb +31 -0
- data/lib/gemstar/ruby_gems_metadata.rb +49 -33
- data/lib/gemstar/version.rb +1 -1
- data/lib/gemstar/web/app.rb +936 -0
- data/lib/gemstar/web/templates/app.css +523 -0
- data/lib/gemstar/web/templates/app.js.erb +226 -0
- data/lib/gemstar/web/templates/page.html.erb +15 -0
- data/lib/gemstar/webrick_logger.rb +22 -0
- data/lib/gemstar.rb +6 -1
- metadata +70 -3
- data/lib/gemstar/railtie.rb +0 -6
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "erb"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "kramdown"
|
|
5
|
+
require "roda"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "kramdown-parser-gfm"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Gemstar
|
|
13
|
+
module Web
|
|
14
|
+
class App < Roda
|
|
15
|
+
class << self
|
|
16
|
+
def build(projects:, config_home:, cache_warmer: nil)
|
|
17
|
+
Class.new(self) do
|
|
18
|
+
opts[:projects] = projects
|
|
19
|
+
opts[:config_home] = config_home
|
|
20
|
+
opts[:cache_warmer] = cache_warmer
|
|
21
|
+
end.freeze.app
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
route do |r|
|
|
26
|
+
@projects = self.class.opts.fetch(:projects)
|
|
27
|
+
@config_home = self.class.opts.fetch(:config_home)
|
|
28
|
+
@cache_warmer = self.class.opts[:cache_warmer]
|
|
29
|
+
@metadata_cache = {}
|
|
30
|
+
apply_no_cache_headers!
|
|
31
|
+
|
|
32
|
+
r.root do
|
|
33
|
+
load_state(r.params)
|
|
34
|
+
prioritize_selected_gem
|
|
35
|
+
|
|
36
|
+
render_page(page_title) do
|
|
37
|
+
render_shell
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
r.get "detail" do
|
|
42
|
+
load_state(r.params)
|
|
43
|
+
prioritize_selected_gem
|
|
44
|
+
render_detail
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
r.get "gemfile" do
|
|
48
|
+
project_index = selected_project_index(r.params["project"])
|
|
49
|
+
project = @projects[project_index]
|
|
50
|
+
response.status = 404
|
|
51
|
+
next "Gemfile not found" unless project && File.file?(project.gemfile_path)
|
|
52
|
+
|
|
53
|
+
response["Content-Type"] = "text/plain; charset=utf-8"
|
|
54
|
+
File.read(project.gemfile_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
r.on "projects", String do |project_id|
|
|
58
|
+
response.redirect "/?project=#{project_id}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def apply_no_cache_headers!
|
|
65
|
+
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
66
|
+
response["Pragma"] = "no-cache"
|
|
67
|
+
response["Expires"] = "0"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def page_title
|
|
71
|
+
return "Gemstar" unless @selected_project
|
|
72
|
+
|
|
73
|
+
"#{@selected_project.name}: Gemstar"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def load_state(params)
|
|
77
|
+
@selected_project_index = selected_project_index(params["project"])
|
|
78
|
+
@selected_project = @projects[@selected_project_index]
|
|
79
|
+
@revision_options = @selected_project ? @selected_project.revision_options : []
|
|
80
|
+
@selected_to_revision_id = selected_to_revision_id(params["to"])
|
|
81
|
+
@selected_from_revision_id = selected_from_revision_id(params["from"])
|
|
82
|
+
@selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id)
|
|
83
|
+
@gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : []
|
|
84
|
+
@requested_gem_name = params["gem"]
|
|
85
|
+
@selected_filter = selected_filter(params["filter"], params["gem"])
|
|
86
|
+
@selected_gem = selected_gem_state(params["gem"])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def prioritize_selected_gem
|
|
90
|
+
@cache_warmer&.prioritize(@selected_gem[:name]) if @selected_gem
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def selected_project_index(raw_index)
|
|
94
|
+
return nil if @projects.empty?
|
|
95
|
+
return 0 if raw_index.nil? || raw_index.empty?
|
|
96
|
+
|
|
97
|
+
index = Integer(raw_index, 10)
|
|
98
|
+
return 0 if index.negative? || @projects[index].nil?
|
|
99
|
+
|
|
100
|
+
index
|
|
101
|
+
rescue ArgumentError
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def selected_from_revision_id(raw_revision_id)
|
|
106
|
+
return "worktree" unless @selected_project
|
|
107
|
+
|
|
108
|
+
valid_ids = valid_from_revision_ids
|
|
109
|
+
default_id = default_from_revision_id_for(@selected_to_revision_id)
|
|
110
|
+
candidate = raw_revision_id.nil? || raw_revision_id.empty? ? default_id : raw_revision_id
|
|
111
|
+
|
|
112
|
+
valid_ids.include?(candidate) ? candidate : default_id
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def selected_to_revision_id(raw_revision_id)
|
|
116
|
+
return "worktree" unless @selected_project
|
|
117
|
+
|
|
118
|
+
valid_ids = valid_to_revision_ids
|
|
119
|
+
candidate = raw_revision_id.nil? || raw_revision_id.empty? ? "worktree" : raw_revision_id
|
|
120
|
+
|
|
121
|
+
valid_ids.include?(candidate) ? candidate : valid_ids.first || "worktree"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def selected_filter(raw_filter, raw_gem_name)
|
|
125
|
+
return "all" if @gem_states.empty?
|
|
126
|
+
return raw_filter if %w[updated all].include?(raw_filter)
|
|
127
|
+
|
|
128
|
+
selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
|
|
129
|
+
return "all" if selected_gem && selected_gem[:status] == :unchanged
|
|
130
|
+
|
|
131
|
+
@gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def selected_gem_state(raw_gem_name)
|
|
135
|
+
return nil if @gem_states.empty?
|
|
136
|
+
|
|
137
|
+
exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name }
|
|
138
|
+
return exact_match if exact_match
|
|
139
|
+
|
|
140
|
+
@gem_states.find { |gem| gem_visible_in_selected_filter?(gem) && gem[:status] != :unchanged } ||
|
|
141
|
+
@gem_states.find { |gem| gem_visible_in_selected_filter?(gem) } ||
|
|
142
|
+
@gem_states.find { |gem| gem[:status] != :unchanged } ||
|
|
143
|
+
@gem_states.first
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def gem_visible_in_selected_filter?(gem_state)
|
|
147
|
+
return true if @selected_filter != "updated"
|
|
148
|
+
|
|
149
|
+
gem_state[:status] != :unchanged
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def render_shell
|
|
153
|
+
return render_empty_workspace if @projects.empty?
|
|
154
|
+
|
|
155
|
+
<<~HTML
|
|
156
|
+
<div class="app-shell">
|
|
157
|
+
#{render_topbar}
|
|
158
|
+
#{render_workspace}
|
|
159
|
+
</div>
|
|
160
|
+
#{render_behavior_script}
|
|
161
|
+
HTML
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def render_empty_workspace
|
|
165
|
+
<<~HTML
|
|
166
|
+
<div class="app-shell">
|
|
167
|
+
<header class="topbar">
|
|
168
|
+
<div class="brand-lockup">
|
|
169
|
+
<div class="brand-mark">G</div>
|
|
170
|
+
<div>
|
|
171
|
+
<p class="brand-kicker">Gemstar</p>
|
|
172
|
+
<h1>Gemstar</h1>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</header>
|
|
176
|
+
<section class="empty-state">
|
|
177
|
+
<h2>No projects loaded</h2>
|
|
178
|
+
<p>Gemstar loads the current directory by default. Use <code>--project</code> to add other project paths.</p>
|
|
179
|
+
<p>Config home: <code>#{h(@config_home)}</code></p>
|
|
180
|
+
</section>
|
|
181
|
+
</div>
|
|
182
|
+
HTML
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def render_topbar
|
|
186
|
+
<<~HTML
|
|
187
|
+
<header class="topbar">
|
|
188
|
+
<div class="brand-lockup">
|
|
189
|
+
<div class="brand-mark">G</div>
|
|
190
|
+
<h1>Gemstar</h1>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="picker-row">
|
|
193
|
+
<label class="picker picker-project">
|
|
194
|
+
<span class="picker-prefix">📁</span>
|
|
195
|
+
<select data-project-select>
|
|
196
|
+
#{project_options_html}
|
|
197
|
+
<option value="" disabled>────────</option>
|
|
198
|
+
<option value="__add__">Add...</option>
|
|
199
|
+
</select>
|
|
200
|
+
</label>
|
|
201
|
+
<label class="picker">
|
|
202
|
+
<span class="picker-prefix" data-text-label="true">From:</span>
|
|
203
|
+
<select data-from-select #{'disabled="disabled"' unless @selected_project}>
|
|
204
|
+
#{from_revision_options_html}
|
|
205
|
+
</select>
|
|
206
|
+
</label>
|
|
207
|
+
<label class="picker">
|
|
208
|
+
<span class="picker-prefix" data-text-label="true">To:</span>
|
|
209
|
+
<select data-to-select #{'disabled="disabled"' unless @selected_project}>
|
|
210
|
+
#{to_revision_options_html}
|
|
211
|
+
</select>
|
|
212
|
+
</label>
|
|
213
|
+
</div>
|
|
214
|
+
</header>
|
|
215
|
+
HTML
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def project_options_html
|
|
219
|
+
@projects.each_with_index.map do |project, index|
|
|
220
|
+
selected = index == @selected_project_index ? ' selected="selected"' : ""
|
|
221
|
+
<<~HTML
|
|
222
|
+
<option value="#{index}"#{selected}>#{h(project.name)} · #{h(project.directory)}</option>
|
|
223
|
+
HTML
|
|
224
|
+
end.join
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def from_revision_options_html
|
|
228
|
+
return '<option value="worktree">Worktree</option>' unless @selected_project
|
|
229
|
+
|
|
230
|
+
@revision_options.map do |option|
|
|
231
|
+
selected = option[:id] == @selected_from_revision_id ? ' selected="selected"' : ""
|
|
232
|
+
disabled = valid_from_revision_ids.include?(option[:id]) ? "" : ' disabled="disabled"'
|
|
233
|
+
<<~HTML
|
|
234
|
+
<option value="#{h(option[:id])}"#{selected}#{disabled}>#{h(option[:label])} · #{h(option[:description])}</option>
|
|
235
|
+
HTML
|
|
236
|
+
end.join
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def to_revision_options_html
|
|
240
|
+
return '<option value="worktree">Worktree</option>' unless @selected_project
|
|
241
|
+
|
|
242
|
+
@revision_options.map do |option|
|
|
243
|
+
selected = option[:id] == @selected_to_revision_id ? ' selected="selected"' : ""
|
|
244
|
+
disabled = valid_to_revision_ids.include?(option[:id]) ? "" : ' disabled="disabled"'
|
|
245
|
+
<<~HTML
|
|
246
|
+
<option value="#{h(option[:id])}"#{selected}#{disabled}>#{h(option[:label])} · #{h(option[:description])}</option>
|
|
247
|
+
HTML
|
|
248
|
+
end.join
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def revision_option_index(revision_id)
|
|
252
|
+
@revision_options.index { |option| option[:id] == revision_id }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def valid_from_revision_ids
|
|
256
|
+
return [] unless @selected_project
|
|
257
|
+
|
|
258
|
+
to_index = revision_option_index(@selected_to_revision_id) || 0
|
|
259
|
+
@revision_options.filter_map.with_index do |option, index|
|
|
260
|
+
option[:id] if index > to_index
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def valid_to_revision_ids
|
|
265
|
+
return [] unless @selected_project
|
|
266
|
+
return @revision_options.map { |option| option[:id] } unless @selected_from_revision_id
|
|
267
|
+
|
|
268
|
+
from_index = revision_option_index(@selected_from_revision_id)
|
|
269
|
+
return @revision_options.map { |option| option[:id] } if from_index.nil?
|
|
270
|
+
|
|
271
|
+
@revision_options.filter_map.with_index do |option, index|
|
|
272
|
+
option[:id] if index < from_index
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def default_from_revision_id_for(to_revision_id)
|
|
277
|
+
default_id = @selected_project.default_from_revision_id
|
|
278
|
+
return default_id if valid_from_revision_ids.include?(default_id)
|
|
279
|
+
|
|
280
|
+
valid_from_revision_ids.first || default_id
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def render_workspace
|
|
284
|
+
<<~HTML
|
|
285
|
+
<main class="workspace">
|
|
286
|
+
#{render_toolbar}
|
|
287
|
+
<div class="workspace-body">
|
|
288
|
+
#{render_sidebar}
|
|
289
|
+
#{render_detail}
|
|
290
|
+
</div>
|
|
291
|
+
</main>
|
|
292
|
+
HTML
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def render_toolbar
|
|
296
|
+
<<~HTML
|
|
297
|
+
<section class="toolbar">
|
|
298
|
+
<div class="toolbar-meta">
|
|
299
|
+
<strong>#{@gem_states.count}</strong> gems
|
|
300
|
+
<span>·</span>
|
|
301
|
+
<strong>#{@gem_states.count { |gem| gem[:status] != :unchanged }}</strong> changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)}
|
|
302
|
+
</div>
|
|
303
|
+
<div class="toolbar-actions">
|
|
304
|
+
<button type="button" class="action" disabled="disabled">bundle install</button>
|
|
305
|
+
<button type="button" class="action action-primary" disabled="disabled">bundle update</button>
|
|
306
|
+
</div>
|
|
307
|
+
</section>
|
|
308
|
+
HTML
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def selected_from_revision_label
|
|
312
|
+
@revision_options.find { |option| option[:id] == @selected_from_revision_id }&.dig(:label) || "worktree"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def selected_to_revision_label
|
|
316
|
+
@revision_options.find { |option| option[:id] == @selected_to_revision_id }&.dig(:label) || "worktree"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def render_sidebar
|
|
320
|
+
<<~HTML
|
|
321
|
+
<aside class="sidebar" data-sidebar-panel tabindex="0">
|
|
322
|
+
<div class="sidebar-header">
|
|
323
|
+
<div class="sidebar-header-row">
|
|
324
|
+
<h2>Gems</h2>
|
|
325
|
+
<div class="list-filters" data-list-filters>
|
|
326
|
+
<button type="button" class="list-filter-button#{' is-active' if @selected_filter == "updated"}" data-filter-button="updated">Updated</button>
|
|
327
|
+
<button type="button" class="list-filter-button#{' is-active' if @selected_filter == "all"}" data-filter-button="all">All</button>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
<input
|
|
331
|
+
type="search"
|
|
332
|
+
class="gem-search"
|
|
333
|
+
data-gem-search
|
|
334
|
+
placeholder="Filter gems"
|
|
335
|
+
autocomplete="off"
|
|
336
|
+
spellcheck="false"
|
|
337
|
+
>
|
|
338
|
+
</div>
|
|
339
|
+
#{render_gem_list}
|
|
340
|
+
</aside>
|
|
341
|
+
HTML
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def render_gem_list
|
|
345
|
+
return <<~HTML if @gem_states.empty?
|
|
346
|
+
<section class="empty-panel">
|
|
347
|
+
<p>No gems found in the current lockfile.</p>
|
|
348
|
+
</section>
|
|
349
|
+
HTML
|
|
350
|
+
|
|
351
|
+
items = @gem_states.map do |gem|
|
|
352
|
+
selected = gem[:name] == @selected_gem[:name] ? " is-selected" : ""
|
|
353
|
+
status_class = " status-#{gem[:status]}"
|
|
354
|
+
updated = gem[:status] != :unchanged
|
|
355
|
+
hidden = @selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name ? ' hidden="hidden"' : ""
|
|
356
|
+
<<~HTML
|
|
357
|
+
<a
|
|
358
|
+
class="gem-row#{selected}#{status_class}"
|
|
359
|
+
href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, gem: gem[:name])}"
|
|
360
|
+
data-gem-link="true"
|
|
361
|
+
data-gem-name="#{h(gem[:name])}"
|
|
362
|
+
data-gem-updated="#{updated}"
|
|
363
|
+
data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, gem: gem[:name]))}"
|
|
364
|
+
#{hidden}
|
|
365
|
+
>
|
|
366
|
+
<span class="gem-name-row">
|
|
367
|
+
<span class="gem-name">#{h(gem[:name])}</span>
|
|
368
|
+
#{updated ? '<span class="gem-updated-dot" aria-label="Updated"></span>' : ""}
|
|
369
|
+
</span>
|
|
370
|
+
<span class="gem-version">#{h(gem[:version_label])}</span>
|
|
371
|
+
</a>
|
|
372
|
+
HTML
|
|
373
|
+
end.join
|
|
374
|
+
|
|
375
|
+
<<~HTML
|
|
376
|
+
<nav class="gem-list">
|
|
377
|
+
#{items}
|
|
378
|
+
</nav>
|
|
379
|
+
<section class="empty-panel gem-list-empty" data-gem-list-empty hidden="hidden">
|
|
380
|
+
<p>No updated gems in this revision range.</p>
|
|
381
|
+
</section>
|
|
382
|
+
HTML
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def render_detail
|
|
386
|
+
return empty_detail_html unless @selected_gem
|
|
387
|
+
|
|
388
|
+
metadata = metadata_for(@selected_gem[:name])
|
|
389
|
+
detail_pending = detail_pending?(@selected_gem[:name], metadata)
|
|
390
|
+
|
|
391
|
+
<<~HTML
|
|
392
|
+
<section class="detail" data-detail-panel data-detail-pending="#{detail_pending}" data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, gem: @selected_gem[:name]))}">
|
|
393
|
+
#{render_detail_hero(metadata)}
|
|
394
|
+
#{render_detail_loading_notice if detail_pending}
|
|
395
|
+
#{render_detail_revision_panel}
|
|
396
|
+
</section>
|
|
397
|
+
HTML
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def empty_detail_html
|
|
401
|
+
<<~HTML
|
|
402
|
+
<section class="detail" data-detail-panel>
|
|
403
|
+
<div class="empty-panel">
|
|
404
|
+
<h2>No gem selected</h2>
|
|
405
|
+
<p>Choose a gem from the list to inspect its current version and changelog revisions.</p>
|
|
406
|
+
</div>
|
|
407
|
+
</section>
|
|
408
|
+
HTML
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def render_detail_hero(metadata)
|
|
412
|
+
description = metadata&.dig("info")
|
|
413
|
+
bundle_origins = Array(@selected_gem[:bundle_origins])
|
|
414
|
+
requirement_names = selected_gem_requirements
|
|
415
|
+
bundled_version = @selected_gem[:new_version]
|
|
416
|
+
added_on = selected_gem_added_on
|
|
417
|
+
title_url = metadata&.dig("homepage_uri")
|
|
418
|
+
title_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) if title_url.to_s.empty?
|
|
419
|
+
title_markup = if title_url.to_s.empty?
|
|
420
|
+
h(@selected_gem[:name])
|
|
421
|
+
else
|
|
422
|
+
%(<a href="#{h(title_url)}" target="_blank" rel="noreferrer">#{h(@selected_gem[:name])}</a>)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
<<~HTML
|
|
426
|
+
<section class="detail-hero">
|
|
427
|
+
<div class="detail-hero-copy">
|
|
428
|
+
<div class="detail-title-row">
|
|
429
|
+
<h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
|
|
430
|
+
#{render_detail_links(metadata)}
|
|
431
|
+
</div>
|
|
432
|
+
<p class="detail-subtitle">#{description ? h(description) : "Metadata will appear here when RubyGems information is available."}</p>
|
|
433
|
+
#{render_added_on(added_on)}
|
|
434
|
+
#{render_dependency_origins(bundle_origins)}
|
|
435
|
+
#{render_requirements(requirement_names)}
|
|
436
|
+
</div>
|
|
437
|
+
</section>
|
|
438
|
+
HTML
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def render_added_on(added_on)
|
|
442
|
+
return "" unless added_on
|
|
443
|
+
|
|
444
|
+
revision_markup = if added_on[:revision_url]
|
|
445
|
+
%(<a href="#{h(added_on[:revision_url])}" target="_blank" rel="noreferrer" data-gem-link-inline="true">#{h(added_on[:revision])}</a>)
|
|
446
|
+
else
|
|
447
|
+
h(added_on[:revision])
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
<<~HTML
|
|
451
|
+
<div class="detail-origin">
|
|
452
|
+
<p>Added to #{h(added_on[:project_name])} on #{h(added_on[:date])} (#{revision_markup}).</p>
|
|
453
|
+
</div>
|
|
454
|
+
HTML
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def render_dependency_origins(bundle_origins)
|
|
458
|
+
origins = Array(bundle_origins).filter_map do |origin|
|
|
459
|
+
path = Array(origin[:path]).compact
|
|
460
|
+
display_path = path.dup
|
|
461
|
+
display_path.pop if display_path.last == @selected_gem[:name]
|
|
462
|
+
|
|
463
|
+
next if origin[:type] != :direct && display_path.empty?
|
|
464
|
+
|
|
465
|
+
linked_path = linked_gem_chain(["Gemfile", *display_path])
|
|
466
|
+
origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
|
|
467
|
+
end.uniq
|
|
468
|
+
return "" if origins.empty?
|
|
469
|
+
|
|
470
|
+
items = origins.map { |origin| "<li>#{origin}</li>" }.join
|
|
471
|
+
<<~HTML
|
|
472
|
+
<div class="detail-origin">
|
|
473
|
+
<strong>Required by</strong>
|
|
474
|
+
<ul class="detail-origin-list">
|
|
475
|
+
#{items}
|
|
476
|
+
</ul>
|
|
477
|
+
</div>
|
|
478
|
+
HTML
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def render_detail_links(metadata)
|
|
482
|
+
repo_url = metadata ? Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) : nil
|
|
483
|
+
homepage_url = metadata&.dig("homepage_uri")
|
|
484
|
+
rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
|
|
485
|
+
|
|
486
|
+
buttons = []
|
|
487
|
+
buttons << icon_button("RubyGems", rubygems_url, icon_type: :rubygems)
|
|
488
|
+
buttons << icon_button("GitHub", repo_url, icon_type: :github) if repo_url && !repo_url.empty?
|
|
489
|
+
buttons << icon_button("Homepage", homepage_url, icon_type: :home) if homepage_url && !homepage_url.empty?
|
|
490
|
+
|
|
491
|
+
<<~HTML
|
|
492
|
+
<section class="link-strip">
|
|
493
|
+
#{buttons.join}
|
|
494
|
+
</section>
|
|
495
|
+
HTML
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def render_requirements(requirement_names)
|
|
499
|
+
names = Array(requirement_names).compact.uniq
|
|
500
|
+
return "" if names.empty?
|
|
501
|
+
|
|
502
|
+
items = names.map { |name| "<li>#{internal_gem_link(name)}</li>" }.join
|
|
503
|
+
<<~HTML
|
|
504
|
+
<div class="detail-origin">
|
|
505
|
+
<strong>Requires</strong>
|
|
506
|
+
<ul class="detail-origin-list">
|
|
507
|
+
#{items}
|
|
508
|
+
</ul>
|
|
509
|
+
</div>
|
|
510
|
+
HTML
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def selected_gem_requirements
|
|
514
|
+
lockfile = if @selected_gem[:new_version]
|
|
515
|
+
@selected_project&.lockfile_for_revision(@selected_to_revision_id)
|
|
516
|
+
else
|
|
517
|
+
@selected_project&.lockfile_for_revision(@selected_from_revision_id)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
Array(lockfile&.dependency_graph&.fetch(@selected_gem[:name], nil))
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def selected_gem_added_on
|
|
524
|
+
revision_id = @selected_gem[:new_version] ? @selected_to_revision_id : @selected_from_revision_id
|
|
525
|
+
@selected_project&.gem_added_on(@selected_gem[:name], revision_id: revision_id)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def linked_gem_chain(names)
|
|
529
|
+
Array(names).map.with_index do |name, index|
|
|
530
|
+
if index.zero?
|
|
531
|
+
gemfile_link(name)
|
|
532
|
+
else
|
|
533
|
+
internal_gem_link(name)
|
|
534
|
+
end
|
|
535
|
+
end.join(" → ")
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def gemfile_link(label = "Gemfile")
|
|
539
|
+
return h(label) unless @selected_project
|
|
540
|
+
|
|
541
|
+
href = "/gemfile?#{URI.encode_www_form(project: @selected_project_index)}"
|
|
542
|
+
%(<a href="#{h(href)}" target="_blank" rel="noreferrer" data-gem-link-inline="true">#{h(label)}</a>)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def internal_gem_link(name)
|
|
546
|
+
href = project_query(
|
|
547
|
+
project: @selected_project_index,
|
|
548
|
+
from: @selected_from_revision_id,
|
|
549
|
+
to: @selected_to_revision_id,
|
|
550
|
+
filter: @selected_filter,
|
|
551
|
+
gem: name
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
%(<a href="#{h(href)}" data-gem-link-inline="true">#{h(name)}</a>)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def render_detail_revision_panel
|
|
558
|
+
groups = grouped_change_sections(@selected_gem)
|
|
559
|
+
|
|
560
|
+
<<~HTML
|
|
561
|
+
<section class="revision-panel">
|
|
562
|
+
#{render_revision_group("Latest", groups[:latest], empty_message: nil) if groups[:latest].any?}
|
|
563
|
+
#{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")}
|
|
564
|
+
#{render_revision_group("Previous changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
|
|
565
|
+
</section>
|
|
566
|
+
HTML
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def render_detail_loading_notice
|
|
570
|
+
<<~HTML
|
|
571
|
+
<section class="empty-panel">
|
|
572
|
+
<p>Loading gem metadata and changelog in the background...</p>
|
|
573
|
+
</section>
|
|
574
|
+
HTML
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def render_revision_group(title, sections, empty_message:)
|
|
578
|
+
cards = if sections.empty?
|
|
579
|
+
return "" unless empty_message
|
|
580
|
+
|
|
581
|
+
<<~HTML
|
|
582
|
+
<div class="empty-panel">
|
|
583
|
+
<p>#{h(empty_message)}</p>
|
|
584
|
+
</div>
|
|
585
|
+
HTML
|
|
586
|
+
else
|
|
587
|
+
sections.map { |section| render_revision_card(section) }.join
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
<<~HTML
|
|
591
|
+
<section class="revision-group">
|
|
592
|
+
<header class="revision-group-header">
|
|
593
|
+
<h4>#{h(title)}</h4>
|
|
594
|
+
</header>
|
|
595
|
+
#{cards}
|
|
596
|
+
</section>
|
|
597
|
+
HTML
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def render_revision_card(section)
|
|
601
|
+
title_links = revision_card_links(section)
|
|
602
|
+
status_class = @selected_gem ? " status-#{@selected_gem[:status]}" : ""
|
|
603
|
+
|
|
604
|
+
<<~HTML
|
|
605
|
+
<article class="revision-card revision-#{section[:kind]}#{status_class}">
|
|
606
|
+
<header class="revision-card-header">
|
|
607
|
+
<div class="revision-card-titlebar">
|
|
608
|
+
<h5>#{h(section[:title] || section[:version])}</h5>
|
|
609
|
+
<div class="revision-card-actions">
|
|
610
|
+
#{title_links.join}
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
</header>
|
|
614
|
+
<div class="revision-markup">
|
|
615
|
+
#{section[:html]}
|
|
616
|
+
</div>
|
|
617
|
+
</article>
|
|
618
|
+
HTML
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def grouped_change_sections(gem_state)
|
|
622
|
+
sections = change_sections(gem_state)
|
|
623
|
+
latest = sections.select { |section| section[:kind] == :future }
|
|
624
|
+
current = sections.select { |section| section[:kind] == :current }
|
|
625
|
+
previous = sections.select { |section| section[:kind] == :previous }
|
|
626
|
+
|
|
627
|
+
if current.empty?
|
|
628
|
+
fallback = fallback_current_section(gem_state, previous, latest)
|
|
629
|
+
current = [fallback] if fallback
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
{
|
|
633
|
+
latest: latest,
|
|
634
|
+
current: current,
|
|
635
|
+
previous: previous
|
|
636
|
+
}
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def change_sections(gem_state)
|
|
640
|
+
return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil?
|
|
641
|
+
|
|
642
|
+
metadata = Gemstar::RubyGemsMetadata.new(gem_state[:name])
|
|
643
|
+
sections = Gemstar::ChangeLog.new(metadata).sections(cache_only: true)
|
|
644
|
+
return [] if sections.nil? || sections.empty?
|
|
645
|
+
|
|
646
|
+
current_version = gem_state[:new_version] || gem_state[:old_version]
|
|
647
|
+
previous_version = gem_state[:old_version]
|
|
648
|
+
|
|
649
|
+
rendered_sections = sections.keys.filter_map do |version|
|
|
650
|
+
kind = section_kind(version, previous_version, current_version, gem_state[:status])
|
|
651
|
+
next unless kind
|
|
652
|
+
content = changelog_content(sections[version], heading_version: version)
|
|
653
|
+
|
|
654
|
+
{
|
|
655
|
+
version: version,
|
|
656
|
+
title: content[:title],
|
|
657
|
+
kind: kind,
|
|
658
|
+
previous_version: previous_section_version(sections.keys, version),
|
|
659
|
+
html: content[:html]
|
|
660
|
+
}
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
rendered_sections.sort_by { |section| section_sort_key(section) }
|
|
664
|
+
rescue StandardError
|
|
665
|
+
[]
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def section_kind(version, previous_version, current_version, status)
|
|
669
|
+
return :future if compare_versions(version, current_version) == 1
|
|
670
|
+
return :current if status == :added && compare_versions(version, current_version) <= 0
|
|
671
|
+
|
|
672
|
+
lower_bound = previous_version || current_version
|
|
673
|
+
if compare_versions(version, lower_bound) == 1 && compare_versions(version, current_version) <= 0
|
|
674
|
+
return :current
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
if [:downgrade, :removed].include?(status)
|
|
678
|
+
upper_bound = previous_version || current_version
|
|
679
|
+
lower_bound = current_version || "0.0.0"
|
|
680
|
+
|
|
681
|
+
return :current if compare_versions(version, lower_bound) == 1 &&
|
|
682
|
+
compare_versions(version, upper_bound) <= 0
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
:previous if compare_versions(version, lower_bound) <= 0
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def section_sort_key(section)
|
|
689
|
+
kind_rank = { future: 0, current: 1, previous: 2 }.fetch(section[:kind], 9)
|
|
690
|
+
[kind_rank, -sortable_version_number(section[:version])]
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def sortable_version_number(version)
|
|
694
|
+
Gem::Version.new(version.to_s.gsub(/-[\w\-]+$/, "")).segments.take(6).each_with_index.sum do |segment, index|
|
|
695
|
+
segment.to_i * (10**(10 - index * 2))
|
|
696
|
+
end
|
|
697
|
+
rescue ArgumentError
|
|
698
|
+
0
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def changelog_content(lines, heading_version: nil)
|
|
702
|
+
text = Array(lines).flatten.join
|
|
703
|
+
return { title: heading_version.to_s, html: "<p>No changelog text available.</p>" } if text.strip.empty?
|
|
704
|
+
|
|
705
|
+
if heading_version
|
|
706
|
+
text = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
options = { hard_wrap: false }
|
|
710
|
+
options[:input] = "GFM" if defined?(Kramdown::Parser::GFM)
|
|
711
|
+
html = Kramdown::Document.new(text, options).to_html
|
|
712
|
+
extract_card_title(with_external_links(html), fallback_title: heading_version.to_s, version: heading_version.to_s)
|
|
713
|
+
rescue Kramdown::Error
|
|
714
|
+
{ title: heading_version.to_s, html: "<pre>#{h(text)}</pre>" }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def extract_card_title(html, fallback_title:, version:)
|
|
718
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
|
|
719
|
+
first_heading = fragment.at_css("h1, h2, h3, h4, h5, h6")
|
|
720
|
+
title = fallback_title
|
|
721
|
+
|
|
722
|
+
if first_heading
|
|
723
|
+
heading_text = first_heading.text.to_s.strip
|
|
724
|
+
if heading_text.include?(version.to_s)
|
|
725
|
+
title = heading_text
|
|
726
|
+
first_heading.remove
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
{ title: title, html: fragment.to_html }
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def compare_versions(left, right)
|
|
734
|
+
Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, ""))
|
|
735
|
+
rescue ArgumentError
|
|
736
|
+
left.to_s <=> right.to_s
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def metadata_for(gem_name)
|
|
740
|
+
@metadata_cache[gem_name] ||= Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
|
|
741
|
+
rescue StandardError
|
|
742
|
+
nil
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def detail_pending?(gem_name, metadata)
|
|
746
|
+
metadata.nil? && change_sections({ name: gem_name, old_version: @selected_gem[:old_version], new_version: @selected_gem[:new_version], status: @selected_gem[:status] }).empty?
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def icon_button(label, url, icon_type:)
|
|
750
|
+
<<~HTML
|
|
751
|
+
<a class="link-button icon-button" href="#{h(url)}" target="_blank" rel="noreferrer" aria-label="#{h(label)}" title="#{h(label)}">
|
|
752
|
+
#{icon_svg(icon_type)}
|
|
753
|
+
</a>
|
|
754
|
+
HTML
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def icon_svg(icon_type)
|
|
758
|
+
case icon_type
|
|
759
|
+
when :github
|
|
760
|
+
'<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.78.4.08.55-.18.55-.4 0-.2-.01-.86-.01-1.56-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.97-.81-1.17-.27-.15-.66-.52-.01-.53.61-.01 1.04.58 1.18.82.7 1.2 1.82.86 2.27.66.07-.52.27-.86.49-1.06-1.78-.21-3.64-.92-3.64-4.07 0-.9.31-1.64.82-2.22-.08-.21-.36-1.06.08-2.21 0 0 .67-.22 2.2.85a7.36 7.36 0 0 1 4 0c1.53-1.07 2.2-.85 2.2-.85.44 1.15.16 2 .08 2.21.51.58.82 1.31.82 2.22 0 3.16-1.87 3.86-3.65 4.07.28.25.53.73.53 1.48 0 1.07-.01 1.94-.01 2.2 0 .22.15.49.55.4A8.24 8.24 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"/></svg>'
|
|
761
|
+
when :home
|
|
762
|
+
'<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 .8 1.2 6.3v8.9h4.3V10h5v5.2h4.3V6.3L8 .8Zm5.2 13.3h-1.8V8.9H4.6v5.2H2.8V6.8L8 2.6l5.2 4.2v7.3Z"/></svg>'
|
|
763
|
+
when :rubygems
|
|
764
|
+
'<svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="2.6" fill="#fff"/><path fill="#111" d="m8 2.35 4.55 2.63v5.24L8 12.85l-4.55-2.63V4.98L8 2.35Zm0 1.3L4.58 5.62v3.96L8 11.55l3.42-1.97V5.62L8 3.65Zm0 1.07 2.5 1.44v2.88L8 10.48 5.5 9.04V6.16L8 4.72Z"/></svg>'
|
|
765
|
+
else
|
|
766
|
+
'<svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="2.6" fill="#fff"/><path fill="#111" d="m8 2.35 4.55 2.63v5.24L8 12.85l-4.55-2.63V4.98L8 2.35Zm0 1.3L4.58 5.62v3.96L8 11.55l3.42-1.97V5.62L8 3.65Zm0 1.07 2.5 1.44v2.88L8 10.48 5.5 9.04V6.16L8 4.72Z"/></svg>'
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def current_section_title
|
|
771
|
+
if @selected_to_revision_id == "worktree"
|
|
772
|
+
"Worktree changes since #{selected_from_revision_label}"
|
|
773
|
+
else
|
|
774
|
+
"Changes from #{selected_from_revision_label} to #{selected_to_revision_label}"
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def range_label(gem_state)
|
|
779
|
+
old_version = gem_state[:old_version]
|
|
780
|
+
new_version = gem_state[:new_version]
|
|
781
|
+
return new_version.to_s if old_version == new_version
|
|
782
|
+
return "new-#{new_version}" if old_version.nil? && new_version
|
|
783
|
+
return "#{old_version}-removed" if old_version && new_version.nil?
|
|
784
|
+
|
|
785
|
+
"#{old_version}-#{new_version}"
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def previous_section_version(versions, current_version)
|
|
789
|
+
ordered_versions = versions.sort_by { |version| -sortable_version_number(version) }
|
|
790
|
+
current_index = ordered_versions.index(current_version)
|
|
791
|
+
return nil if current_index.nil?
|
|
792
|
+
|
|
793
|
+
ordered_versions[current_index + 1]
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def revision_card_links(section)
|
|
797
|
+
repo_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true)
|
|
798
|
+
return [] if repo_url.to_s.empty?
|
|
799
|
+
|
|
800
|
+
links = []
|
|
801
|
+
compare_url = github_compare_url(repo_url, section[:previous_version], section[:version])
|
|
802
|
+
links << icon_button("Git diff", compare_url, icon_type: :github) if compare_url
|
|
803
|
+
|
|
804
|
+
release_url = github_release_url(repo_url, section[:version])
|
|
805
|
+
links << icon_button("Release", release_url, icon_type: :github) if release_url && compare_url.nil?
|
|
806
|
+
links
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def fallback_current_section(gem_state, previous_sections, latest_sections)
|
|
810
|
+
version = gem_state[:new_version] || gem_state[:old_version]
|
|
811
|
+
return nil if version.nil?
|
|
812
|
+
return nil if previous_sections.any? { |section| section[:version] == version }
|
|
813
|
+
return nil if latest_sections.any? { |section| section[:version] == version }
|
|
814
|
+
|
|
815
|
+
repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true)
|
|
816
|
+
repo_link = if repo_url.to_s.empty?
|
|
817
|
+
"the gem repository"
|
|
818
|
+
else
|
|
819
|
+
%(<a href="#{h(repo_url)}" target="_blank" rel="noreferrer">the gem repository</a>)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
{
|
|
823
|
+
version: version,
|
|
824
|
+
title: version,
|
|
825
|
+
kind: :current,
|
|
826
|
+
previous_version: fallback_previous_version_for(gem_state, previous_sections),
|
|
827
|
+
html: "<p>No release information available. Check #{repo_link} for more information.</p>"
|
|
828
|
+
}
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def fallback_previous_version_for(gem_state, previous_sections)
|
|
832
|
+
return gem_state[:old_version] if gem_state[:new_version]
|
|
833
|
+
return previous_sections.first[:version] if previous_sections.any?
|
|
834
|
+
|
|
835
|
+
nil
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def github_compare_url(repo_url, previous_version, current_version)
|
|
839
|
+
return nil unless repo_url.include?("github.com")
|
|
840
|
+
return nil if previous_version.nil? || current_version.nil?
|
|
841
|
+
|
|
842
|
+
"#{repo_url}/compare/#{github_tag_name(previous_version)}...#{github_tag_name(current_version)}"
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
def github_release_url(repo_url, version)
|
|
846
|
+
return nil unless repo_url.include?("github.com")
|
|
847
|
+
return nil if version.nil?
|
|
848
|
+
|
|
849
|
+
"#{repo_url}/releases/tag/#{github_tag_name(version)}"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def github_tag_name(version)
|
|
853
|
+
version.to_s.start_with?("v") ? version.to_s : "v#{version}"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def with_external_links(html)
|
|
857
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
|
|
858
|
+
fragment.css("a[href]").each do |link|
|
|
859
|
+
link["target"] = "_blank"
|
|
860
|
+
link["rel"] = "noreferrer"
|
|
861
|
+
end
|
|
862
|
+
fragment.to_html
|
|
863
|
+
rescue StandardError
|
|
864
|
+
html
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def detail_query(project:, from:, to:, filter:, gem:)
|
|
868
|
+
"/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, gem: gem)}"
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def project_query(project:, from:, to:, filter:, gem:)
|
|
872
|
+
params = {
|
|
873
|
+
project: project,
|
|
874
|
+
from: from,
|
|
875
|
+
to: to,
|
|
876
|
+
filter: filter,
|
|
877
|
+
gem: gem
|
|
878
|
+
}.compact
|
|
879
|
+
|
|
880
|
+
"/?#{URI.encode_www_form(params)}"
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def render_page(title)
|
|
884
|
+
render_template(
|
|
885
|
+
"page.html.erb",
|
|
886
|
+
title: h(title),
|
|
887
|
+
favicon_data_uri: favicon_data_uri,
|
|
888
|
+
styles_css: template_source("app.css"),
|
|
889
|
+
body_html: yield
|
|
890
|
+
)
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def favicon_data_uri
|
|
894
|
+
svg = <<~SVG
|
|
895
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
896
|
+
<rect width="64" height="64" rx="14" fill="#b44d25"/>
|
|
897
|
+
<text x="32" y="44" text-anchor="middle" font-family="Avenir Next, Helvetica Neue, Segoe UI, sans-serif" font-size="34" font-weight="700" fill="#ffffff">G</text>
|
|
898
|
+
</svg>
|
|
899
|
+
SVG
|
|
900
|
+
|
|
901
|
+
"data:image/svg+xml,#{URI.encode_www_form_component(svg)}"
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def render_behavior_script
|
|
905
|
+
script = render_template(
|
|
906
|
+
"app.js.erb",
|
|
907
|
+
empty_detail_html_json: empty_detail_html.dump,
|
|
908
|
+
selected_filter_json: @selected_filter.dump,
|
|
909
|
+
selected_project_index: @selected_project_index || 0
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
<<~HTML
|
|
913
|
+
<script>
|
|
914
|
+
#{script}
|
|
915
|
+
</script>
|
|
916
|
+
HTML
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def template_source(name)
|
|
920
|
+
File.read(template_path(name))
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def render_template(name, locals = {})
|
|
924
|
+
ERB.new(template_source(name), trim_mode: "-").result_with_hash(locals)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def template_path(name)
|
|
928
|
+
File.expand_path(File.join("templates", name), __dir__)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def h(value)
|
|
932
|
+
CGI.escapeHTML(value.to_s)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|