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.
@@ -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