gemstar 1.0.4 → 1.1.1

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.
@@ -2,6 +2,7 @@ require "cgi"
2
2
  require "erb"
3
3
  require "uri"
4
4
  require "kramdown"
5
+ require "nokogiri"
5
6
  require "roda"
6
7
 
7
8
  begin
@@ -13,6 +14,7 @@ module Gemstar
13
14
  module Web
14
15
  class App < Roda
15
16
  MISSING_METADATA = Object.new
17
+ CACHE_VERSION = "v7"
16
18
 
17
19
  class << self
18
20
  def build(projects:, config_home:, cache_warmer: nil)
@@ -44,6 +46,16 @@ module Gemstar
44
46
  end
45
47
  end
46
48
 
49
+ r.get "favicon.svg" do
50
+ response["Content-Type"] = "image/svg+xml; charset=utf-8"
51
+ favicon_svg
52
+ end
53
+
54
+ r.get "favicon.ico" do
55
+ response["Content-Type"] = "image/svg+xml; charset=utf-8"
56
+ favicon_svg
57
+ end
58
+
47
59
  r.get "detail" do
48
60
  request_cache_key = detail_request_cache_key(r.params)
49
61
  request_cache = self.class.opts[:detail_request_cache]
@@ -62,7 +74,7 @@ module Gemstar
62
74
  project_index = selected_project_index(r.params["project"])
63
75
  project = @projects[project_index]
64
76
  response.status = 404
65
- next "Gemfile not found" unless project && File.file?(project.gemfile_path)
77
+ next "Gemfile not found" unless project&.gemfile?
66
78
 
67
79
  response["Content-Type"] = "text/plain; charset=utf-8"
68
80
  File.read(project.gemfile_path)
@@ -86,23 +98,39 @@ module Gemstar
86
98
  project = @projects[project_index]
87
99
  return nil unless project
88
100
 
89
- lockfile_stamp =
90
- if File.file?(project.lockfile_path)
91
- File.mtime(project.lockfile_path).to_i
92
- else
93
- 0
94
- end
101
+ lockfile_stamp = File.file?(project.lockfile_path) ? File.mtime(project.lockfile_path).to_i : 0
102
+ importmap_stamp = File.file?(project.importmap_path) ? File.mtime(project.importmap_path).to_i : 0
103
+ importmap_vendor_stamp = importmap_vendor_mtime(project)
104
+ package_lock_stamp = File.file?(project.package_lock_path) ? File.mtime(project.package_lock_path).to_i : 0
95
105
 
96
106
  [
107
+ CACHE_VERSION,
97
108
  project_index,
98
109
  params["from"],
99
110
  params["to"],
100
111
  params["filter"],
101
- params["gem"],
102
- lockfile_stamp
112
+ params["scope"],
113
+ package_param(params),
114
+ lockfile_stamp,
115
+ importmap_stamp,
116
+ importmap_vendor_stamp,
117
+ package_lock_stamp
103
118
  ]
104
119
  end
105
120
 
121
+ def importmap_vendor_mtime(project)
122
+ return 0 unless project&.importmap?
123
+
124
+ project.current_importmap.specs.values.filter_map do |target|
125
+ next unless target.to_s.end_with?(".js", ".mjs")
126
+
127
+ path = File.join(project.directory, "vendor", "javascript", target.to_s)
128
+ File.mtime(path).to_i if File.file?(path)
129
+ end.max || 0
130
+ rescue StandardError
131
+ 0
132
+ end
133
+
106
134
  def page_title
107
135
  return "Gemstar" unless @selected_project
108
136
 
@@ -117,9 +145,15 @@ module Gemstar
117
145
  @selected_from_revision_id = selected_from_revision_id(params["from"])
118
146
  @selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id)
119
147
  @gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : []
120
- @requested_gem_name = params["gem"]
121
- @selected_filter = selected_filter(params["filter"], params["gem"])
122
- @selected_gem = selected_gem_state(params["gem"])
148
+ requested_package_name = package_param(params)
149
+ @requested_gem_name = requested_package_name
150
+ @selected_package_scope = selected_package_scope(params["scope"], requested_package_name)
151
+ @selected_filter = selected_filter(params["filter"], requested_package_name)
152
+ @selected_gem = selected_gem_state(requested_package_name)
153
+ end
154
+
155
+ def package_param(params)
156
+ params["package"] || params["gem"]
123
157
  end
124
158
 
125
159
  def prioritize_selected_gem
@@ -167,10 +201,24 @@ module Gemstar
167
201
  @gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all"
168
202
  end
169
203
 
204
+ def selected_package_scope(raw_scope, raw_gem_name)
205
+ return "all" if @gem_states.empty?
206
+
207
+ available_scopes = available_package_scopes
208
+ default_scope = available_scopes == ["gems"] ? "gems" : "all"
209
+ return raw_scope if raw_scope == "all" || available_scopes.include?(raw_scope)
210
+
211
+ selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
212
+ return selected_gem[:package_scope] if selected_gem
213
+
214
+ default_scope
215
+ end
216
+
170
217
  def selected_gem_state(raw_gem_name)
171
218
  return nil if @gem_states.empty?
172
219
 
173
- exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name }
220
+ exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
221
+ exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name }
174
222
  return exact_match if exact_match
175
223
 
176
224
  @gem_states.find { |gem| gem_visible_in_selected_filter?(gem) && gem[:status] != :unchanged } ||
@@ -180,11 +228,20 @@ module Gemstar
180
228
  end
181
229
 
182
230
  def gem_visible_in_selected_filter?(gem_state)
231
+ return false unless gem_visible_in_selected_scope?(gem_state)
183
232
  return true if @selected_filter != "updated"
184
233
 
185
234
  gem_state[:status] != :unchanged
186
235
  end
187
236
 
237
+ def gem_visible_in_selected_scope?(gem_state)
238
+ @selected_package_scope == "all" || gem_state[:package_scope] == @selected_package_scope
239
+ end
240
+
241
+ def available_package_scopes
242
+ @selected_project ? @selected_project.package_scope_options.map { |option| option[:id] } : []
243
+ end
244
+
188
245
  def render_shell
189
246
  return render_empty_workspace if @projects.empty?
190
247
 
@@ -322,7 +379,7 @@ module Gemstar
322
379
  #{render_toolbar}
323
380
  <div class="workspace-body">
324
381
  #{render_sidebar}
325
- #{render_detail}
382
+ #{render_initial_detail}
326
383
  </div>
327
384
  </main>
328
385
  HTML
@@ -332,7 +389,7 @@ module Gemstar
332
389
  <<~HTML
333
390
  <section class="toolbar">
334
391
  <div class="toolbar-meta">
335
- <strong>#{@gem_states.count}</strong> gems
392
+ <strong>#{@gem_states.count}</strong> #{h(@selected_project&.package_collection_label&.downcase || "packages")}
336
393
  <span>·</span>
337
394
  <strong>#{@gem_states.count { |gem| gem[:status] != :unchanged }}</strong> changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)}
338
395
  </div>
@@ -357,17 +414,18 @@ module Gemstar
357
414
  <aside class="sidebar" data-sidebar-panel tabindex="0">
358
415
  <div class="sidebar-header">
359
416
  <div class="sidebar-header-row">
360
- <h2>Gems</h2>
417
+ <h2>#{h(@selected_project&.package_collection_label || "Packages")}</h2>
361
418
  <div class="list-filters" data-list-filters>
362
419
  <button type="button" class="list-filter-button#{' is-active' if @selected_filter == "updated"}" data-filter-button="updated">Updated</button>
363
420
  <button type="button" class="list-filter-button#{' is-active' if @selected_filter == "all"}" data-filter-button="all">All</button>
364
421
  </div>
365
422
  </div>
423
+ #{render_package_scope_filters}
366
424
  <input
367
425
  type="search"
368
426
  class="gem-search"
369
427
  data-gem-search
370
- placeholder="Filter gems"
428
+ placeholder="Search"
371
429
  autocomplete="off"
372
430
  spellcheck="false"
373
431
  >
@@ -380,7 +438,7 @@ module Gemstar
380
438
  def render_gem_list
381
439
  return <<~HTML if @gem_states.empty?
382
440
  <section class="empty-panel">
383
- <p>No gems found in the current lockfile.</p>
441
+ <p>No #{h(@selected_project&.package_collection_label&.downcase || "packages")} found in the current lockfile.</p>
384
442
  </section>
385
443
  HTML
386
444
 
@@ -388,19 +446,23 @@ module Gemstar
388
446
  selected = gem[:name] == @selected_gem[:name] ? " is-selected" : ""
389
447
  status_class = " status-#{gem[:status]}"
390
448
  updated = gem[:status] != :unchanged
391
- hidden = @selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name ? ' hidden="hidden"' : ""
449
+ hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name)) ? ' hidden="hidden"' : ""
392
450
  <<~HTML
393
451
  <a
394
452
  class="gem-row#{selected}#{status_class}"
395
- href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, gem: gem[:name])}"
453
+ href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name])}"
396
454
  data-gem-link="true"
397
455
  data-gem-name="#{h(gem[:name])}"
398
456
  data-gem-updated="#{updated}"
399
- 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]))}"
457
+ data-package-scope="#{h(gem[:package_scope])}"
458
+ data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name]))}"
400
459
  #{hidden}
401
460
  >
402
461
  <span class="gem-name-row">
403
- <span class="gem-name">#{h(gem[:name])}</span>
462
+ <span class="gem-name-lockup">
463
+ <span class="gem-name">#{h(gem[:name])}</span>
464
+ <span class="package-type-tag">#{h(gem[:package_type_label])}</span>
465
+ </span>
404
466
  #{updated ? '<span class="gem-updated-dot" aria-label="Updated"></span>' : ""}
405
467
  </span>
406
468
  <span class="gem-version">#{h(gem[:version_label])}</span>
@@ -413,7 +475,7 @@ module Gemstar
413
475
  #{items}
414
476
  </nav>
415
477
  <section class="empty-panel gem-list-empty" data-gem-list-empty hidden="hidden">
416
- <p>No updated gems in this revision range.</p>
478
+ <p>No updated #{h(@selected_project&.package_collection_label&.downcase || "packages")} in this revision range.</p>
417
479
  </section>
418
480
  HTML
419
481
  end
@@ -421,25 +483,32 @@ module Gemstar
421
483
  def render_detail
422
484
  return empty_detail_html unless @selected_gem
423
485
 
486
+ metadata = metadata_for(@selected_gem, refresh_if_missing: true)
424
487
  cache_key = [
488
+ CACHE_VERSION,
425
489
  @selected_project_index,
426
490
  @selected_from_revision_id,
427
491
  @selected_to_revision_id,
428
492
  @selected_filter,
493
+ @selected_package_scope,
429
494
  @selected_gem[:name],
495
+ @selected_gem[:package_scope],
496
+ @selected_gem[:package_source_file],
430
497
  @selected_gem[:old_version],
431
498
  @selected_gem[:new_version],
499
+ @selected_gem[:raw_old_version],
500
+ @selected_gem[:raw_new_version],
501
+ effective_package_version(@selected_gem, metadata || {}),
432
502
  @selected_gem[:status]
433
503
  ]
434
504
  detail_cache = self.class.opts[:detail_html_cache]
435
505
  return detail_cache[cache_key] if detail_cache.key?(cache_key)
436
506
 
437
- metadata = metadata_for(@selected_gem[:name], refresh_if_missing: true)
438
507
  groups = grouped_change_sections(@selected_gem)
439
508
  detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
440
509
 
441
510
  detail_html = <<~HTML
442
- <section class="detail" data-detail-panel tabindex="0" 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]))}">
511
+ <section class="detail" data-detail-panel tabindex="0" 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, scope: @selected_package_scope, package: @selected_gem[:name]))}">
443
512
  #{render_detail_hero(metadata)}
444
513
  #{render_detail_loading_notice if detail_pending}
445
514
  #{render_detail_revision_panel(groups)}
@@ -450,12 +519,33 @@ module Gemstar
450
519
  detail_html
451
520
  end
452
521
 
453
- def empty_detail_html
522
+ def render_initial_detail
523
+ return empty_detail_html(loading: true) unless @selected_gem
524
+
525
+ detail_url = detail_query(
526
+ project: @selected_project_index,
527
+ from: @selected_from_revision_id,
528
+ to: @selected_to_revision_id,
529
+ filter: @selected_filter,
530
+ scope: @selected_package_scope,
531
+ package: @selected_gem[:name]
532
+ )
533
+
534
+ <<~HTML
535
+ <section class="detail" data-detail-panel tabindex="0" data-detail-pending="false" data-detail-deferred="true" data-detail-url="#{h(detail_url)}">
536
+ <div class="detail-loading-shell" aria-hidden="true">
537
+ <div class="detail-loading-spinner"></div>
538
+ </div>
539
+ </section>
540
+ HTML
541
+ end
542
+
543
+ def empty_detail_html(loading: false)
454
544
  <<~HTML
455
545
  <section class="detail" data-detail-panel tabindex="0">
456
546
  <div class="empty-panel">
457
- <h2>No gem selected</h2>
458
- <p>Choose a gem from the list to inspect its current version and changelog revisions.</p>
547
+ <h2>#{loading ? "Loading details" : "No gem selected"}</h2>
548
+ <p>#{loading ? "Preparing the selected gem details." : "Choose a gem from the list to inspect its current version and changelog revisions."}</p>
459
549
  </div>
460
550
  </section>
461
551
  HTML
@@ -465,10 +555,10 @@ module Gemstar
465
555
  description = metadata&.dig("info")
466
556
  bundle_origins = Array(@selected_gem[:bundle_origins])
467
557
  requirement_names = selected_gem_requirements
468
- bundled_version = @selected_gem[:new_version]
558
+ bundled_version = detail_bundled_version(metadata)
469
559
  added_on = selected_gem_added_on
470
560
  title_url = metadata&.dig("homepage_uri")
471
- title_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) if title_url.to_s.empty?
561
+ title_url = repo_url_for(@selected_gem, metadata: metadata) if title_url.to_s.empty?
472
562
  title_markup = if title_url.to_s.empty?
473
563
  h(@selected_gem[:name])
474
564
  else
@@ -484,13 +574,24 @@ module Gemstar
484
574
  </div>
485
575
  #{render_detail_links(metadata)}
486
576
  </div>
487
- <p class="detail-subtitle">#{description ? h(description) : "Metadata will appear here when RubyGems information is available."}</p>
577
+ <div class="detail-subtitle">#{render_detail_subtitle(description)}</div>
488
578
  #{render_dependency_details(bundle_origins, requirement_names, added_on)}
489
579
  </div>
490
580
  </section>
491
581
  HTML
492
582
  end
493
583
 
584
+ def render_detail_subtitle(description)
585
+ text = description.to_s.strip
586
+ return "<p>Metadata will appear here when package information is available.</p>" if text.empty?
587
+
588
+ options = { hard_wrap: false }
589
+ options[:input] = "GFM" if defined?(Kramdown::Parser::GFM)
590
+ with_external_links(Kramdown::Document.new(text, options).to_html)
591
+ rescue Kramdown::Error
592
+ "<p>#{h(text)}</p>"
593
+ end
594
+
494
595
  def render_added_on(added_on)
495
596
  return "" unless added_on
496
597
 
@@ -522,12 +623,16 @@ module Gemstar
522
623
  end
523
624
 
524
625
  def render_detail_links(metadata)
525
- repo_url = metadata ? Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) : nil
626
+ repo_url = repo_url_for(@selected_gem, metadata: metadata)
526
627
  homepage_url = metadata&.dig("homepage_uri")
527
- rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
528
628
 
529
629
  buttons = []
530
- buttons << icon_button("RubyGems", rubygems_url, icon_type: :rubygems)
630
+ if @selected_gem[:package_scope] == "gems"
631
+ rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
632
+ buttons << icon_button("RubyGems", rubygems_url, icon_type: :rubygems)
633
+ elsif homepage_url && !homepage_url.empty? && (!repo_url || homepage_url != repo_url)
634
+ buttons << icon_button("Source", homepage_url, icon_type: :home)
635
+ end
531
636
  buttons << icon_button("GitHub", repo_url, icon_type: :github) if repo_url && !repo_url.empty?
532
637
  buttons << icon_button("Homepage", homepage_url, icon_type: :home) if homepage_url && !homepage_url.empty?
533
638
 
@@ -575,6 +680,8 @@ module Gemstar
575
680
  end
576
681
 
577
682
  def selected_gem_requirements
683
+ return [] unless @selected_gem[:package_scope] == "gems"
684
+
578
685
  lockfile = if @selected_gem[:new_version]
579
686
  @selected_project&.lockfile_for_revision(@selected_to_revision_id)
580
687
  else
@@ -586,7 +693,12 @@ module Gemstar
586
693
 
587
694
  def selected_gem_added_on
588
695
  revision_id = @selected_gem[:new_version] ? @selected_to_revision_id : @selected_from_revision_id
589
- @selected_project&.gem_added_on(@selected_gem[:name], revision_id: revision_id)
696
+ @selected_project&.package_added_on(
697
+ @selected_gem[:name],
698
+ package_scope: @selected_gem[:package_scope],
699
+ source_file: @selected_gem[:package_source_file],
700
+ revision_id: revision_id
701
+ )
590
702
  end
591
703
 
592
704
  def selected_gem_platform_items
@@ -617,6 +729,31 @@ module Gemstar
617
729
  when :rubygems
618
730
  remote = source[:remote]
619
731
  [remote.to_s.empty? ? "RubyGems" : "RubyGems (#{h(remote)})"]
732
+ when :importmap
733
+ remote = source[:remote]
734
+ package_name = source[:package_name]
735
+ package_version = source[:package_version]
736
+ label =
737
+ if package_name && package_version
738
+ "Importmap (#{h(package_name)} @ #{h(package_version)})"
739
+ elsif package_name
740
+ "Importmap (#{h(package_name)})"
741
+ elsif remote.to_s.empty?
742
+ "Importmap"
743
+ else
744
+ "Importmap (#{h(remote)})"
745
+ end
746
+ [label]
747
+ when :npm
748
+ remote = source[:remote]
749
+ registry_url = source[:registry_url]
750
+ if remote.to_s.empty? && registry_url.to_s.empty?
751
+ ["npm"]
752
+ elsif remote.to_s.empty?
753
+ ["npm (#{h(registry_url)})"]
754
+ else
755
+ ["npm (#{h(remote)})"]
756
+ end
620
757
  else
621
758
  []
622
759
  end
@@ -645,7 +782,8 @@ module Gemstar
645
782
  from: @selected_from_revision_id,
646
783
  to: @selected_to_revision_id,
647
784
  filter: @selected_filter,
648
- gem: name
785
+ scope: @selected_package_scope,
786
+ package: name
649
787
  )
650
788
 
651
789
  %(<a href="#{h(href)}" data-gem-link-inline="true">#{h(name)}</a>)
@@ -664,7 +802,7 @@ module Gemstar
664
802
  def render_detail_loading_notice
665
803
  <<~HTML
666
804
  <section class="empty-panel">
667
- <p>Loading gem metadata and changelog in the background...</p>
805
+ <p>Loading package metadata and changelog in the background...</p>
668
806
  </section>
669
807
  HTML
670
808
  end
@@ -700,7 +838,10 @@ module Gemstar
700
838
  <article class="revision-card revision-#{section[:kind]}#{status_class}">
701
839
  <header class="revision-card-header">
702
840
  <div class="revision-card-titlebar">
703
- <h5>#{h(section[:title] || section[:version])}</h5>
841
+ <div class="revision-card-heading">
842
+ <h5>#{h(section[:title] || section[:version])}</h5>
843
+ #{render_release_date(section[:release_date])}
844
+ </div>
704
845
  <div class="revision-card-actions">
705
846
  #{title_links.join}
706
847
  </div>
@@ -713,6 +854,12 @@ module Gemstar
713
854
  HTML
714
855
  end
715
856
 
857
+ def render_release_date(release_date)
858
+ return "" if release_date.to_s.empty?
859
+
860
+ %(<span class="revision-release-date" title="Estimated release date">#{h(release_date)}</span>)
861
+ end
862
+
716
863
  def grouped_change_sections(gem_state)
717
864
  sections = change_sections(gem_state)
718
865
  latest = sections.select { |section| section[:kind] == :future }
@@ -732,18 +879,33 @@ module Gemstar
732
879
  end
733
880
 
734
881
  def change_sections(gem_state)
735
- cache_key = [gem_state[:name], gem_state[:old_version], gem_state[:new_version], gem_state[:status]]
882
+ metadata_hash = metadata_for(gem_state) || {}
883
+ current_version = effective_package_version(gem_state, metadata_hash)
884
+ cache_key = [
885
+ CACHE_VERSION,
886
+ gem_state[:name],
887
+ gem_state[:package_scope],
888
+ gem_state[:package_source_file],
889
+ gem_state[:old_version],
890
+ gem_state[:new_version],
891
+ gem_state[:raw_old_version],
892
+ gem_state[:raw_new_version],
893
+ current_version,
894
+ gem_state[:status]
895
+ ]
736
896
  change_sections_cache = self.class.opts[:change_sections_cache]
737
897
  return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
738
898
 
739
- return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil?
740
-
741
- metadata = Gemstar::RubyGemsMetadata.new(gem_state[:name])
899
+ return [] if gem_state[:new_version].nil? &&
900
+ gem_state[:old_version].nil? &&
901
+ (current_version.nil? || current_version.to_s.empty?)
902
+ metadata = metadata_adapter_for(gem_state)
903
+ return change_sections_cache[cache_key] = [] unless metadata
742
904
  sections = resolved_sections(metadata, gem_state)
743
905
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
744
906
 
745
- current_version = gem_state[:new_version] || gem_state[:old_version]
746
907
  previous_version = gem_state[:old_version]
908
+ release_dates = resolved_release_dates(metadata, sections.keys, gem_state)
747
909
 
748
910
  rendered_sections = sections.keys.filter_map do |version|
749
911
  kind = section_kind(version, previous_version, current_version, gem_state[:status])
@@ -755,6 +917,7 @@ module Gemstar
755
917
  title: content[:title],
756
918
  kind: kind,
757
919
  previous_version: previous_section_version(sections.keys, version),
920
+ release_date: release_date_for(release_dates, version),
758
921
  html: content[:html]
759
922
  }
760
923
  end
@@ -769,18 +932,67 @@ module Gemstar
769
932
  cached_sections = changelog.sections(cache_only: true) || {}
770
933
  return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
771
934
 
772
- @metadata_cache.delete(gem_state[:name])
935
+ @metadata_cache.delete([gem_state[:package_scope], gem_state[:name]])
773
936
  metadata.meta(cache_only: false, force_refresh: true)
774
937
  metadata.repo_uri(cache_only: false, force_refresh: true)
775
- Gemstar::ChangeLog.new(metadata).sections(cache_only: false, force_refresh: true) || cached_sections
938
+ refreshed_sections = metadata.changelog_sections(
939
+ versions: relevant_package_versions(gem_state, metadata),
940
+ cache_only: false,
941
+ force_refresh: true
942
+ )
943
+ cached_sections.merge(refreshed_sections || {})
944
+ end
945
+
946
+ def resolved_release_dates(metadata, versions, gem_state)
947
+ changelog = Gemstar::ChangeLog.new(metadata)
948
+ cached_dates = changelog.release_dates(versions: versions, cache_only: true) || {}
949
+ return cached_dates unless selected_gem_missing_release_dates?(gem_state, versions, cached_dates)
950
+
951
+ fetched_dates = changelog.release_dates(versions: versions, cache_only: false) || {}
952
+ cached_dates.merge(fetched_dates)
953
+ rescue StandardError
954
+ {}
955
+ end
956
+
957
+ def release_date_for(release_dates, version)
958
+ release_dates.find do |candidate_version, _date|
959
+ normalize_release_version_key(candidate_version) == normalize_release_version_key(version)
960
+ end&.last
961
+ end
962
+
963
+ def selected_gem_missing_release_dates?(gem_state, versions, release_dates)
964
+ return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
965
+
966
+ requested_versions = Array(versions).map { |version| normalize_release_version_key(version) }.compact
967
+ dated_versions = release_dates.keys.map { |version| normalize_release_version_key(version) }.compact
968
+ (requested_versions - dated_versions).any?
969
+ end
970
+
971
+ def normalize_release_version_key(version)
972
+ value = version.to_s.strip
973
+ return nil if value.empty?
974
+
975
+ value.sub(/\Av/i, "")
976
+ end
977
+
978
+ def relevant_package_versions(gem_state, metadata)
979
+ metadata_hash = metadata_for(gem_state) || {}
980
+ [
981
+ gem_state[:old_version],
982
+ gem_state[:new_version],
983
+ gem_state[:raw_old_version],
984
+ gem_state[:raw_new_version],
985
+ gem_state.dig(:source, :package_version),
986
+ metadata_hash["version"]
987
+ ].compact
776
988
  end
777
989
 
778
990
  def selected_gem_requires_refresh?(gem_state, cached_sections)
779
991
  return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
780
992
 
781
- bundled_version = gem_state[:new_version] || gem_state[:old_version]
782
- return false if bundled_version.nil?
783
- metadata = metadata_for(gem_state[:name]) || {}
993
+ metadata = metadata_for(gem_state) || {}
994
+ bundled_version = effective_package_version(gem_state, metadata)
995
+ return false if bundled_version.nil? || bundled_version.to_s.empty?
784
996
  has_upstream_release_source =
785
997
  !metadata["changelog_uri"].to_s.empty? ||
786
998
  !metadata["source_code_uri"].to_s.empty? ||
@@ -801,6 +1013,7 @@ module Gemstar
801
1013
  def section_kind(version, previous_version, current_version, status)
802
1014
  return :future if compare_versions(version, current_version) == 1
803
1015
  return :current if status == :added && compare_versions(version, current_version) <= 0
1016
+ return :current if previous_version.nil? && current_version && compare_versions(version, current_version) == 0
804
1017
 
805
1018
  lower_bound = previous_version || current_version
806
1019
  if compare_versions(version, lower_bound) == 1 && compare_versions(version, current_version) <= 0
@@ -836,7 +1049,7 @@ module Gemstar
836
1049
  return { title: heading_version.to_s, html: "<p>No changelog text available.</p>" } if text.strip.empty?
837
1050
 
838
1051
  if heading_version
839
- text = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1052
+ text = strip_leading_version_heading(text, heading_version)
840
1053
  end
841
1054
 
842
1055
  options = { hard_wrap: false }
@@ -863,29 +1076,135 @@ module Gemstar
863
1076
  { title: title, html: fragment.to_html }
864
1077
  end
865
1078
 
1079
+ def strip_leading_version_heading(text, heading_version)
1080
+ stripped = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1081
+ return strip_leading_heading_separator(stripped) unless stripped == text
1082
+
1083
+ lines = text.lines
1084
+ return text if lines.empty?
1085
+
1086
+ first_line = lines.first.to_s
1087
+ heading_like =
1088
+ first_line.match?(/\A\s*v?#{Regexp.escape(heading_version)}\b/i) ||
1089
+ first_line.match?(/\A\s*[\[(]?v?#{Regexp.escape(heading_version)}\b/i)
1090
+
1091
+ return text unless heading_like
1092
+
1093
+ remaining = lines.drop(1)
1094
+ remaining.shift while remaining.first&.strip&.empty?
1095
+ strip_leading_heading_separator(remaining.join)
1096
+ end
1097
+
1098
+ def strip_leading_heading_separator(text)
1099
+ text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=]{3,})\s*\n+/, "")
1100
+ end
1101
+
866
1102
  def compare_versions(left, right)
867
1103
  Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, ""))
868
1104
  rescue ArgumentError
869
1105
  left.to_s <=> right.to_s
870
1106
  end
871
1107
 
872
- def metadata_for(gem_name, refresh_if_missing: false)
873
- cached = @metadata_cache[gem_name]
1108
+ def metadata_for(package_state_or_name, refresh_if_missing: false)
1109
+ package_state = package_state_or_name.is_a?(Hash) ? package_state_or_name : { name: package_state_or_name, package_scope: "gems" }
1110
+ gem_name = package_state[:name]
1111
+ cache_key = [package_state[:package_scope], gem_name]
1112
+ cached = @metadata_cache[cache_key]
874
1113
  return nil if cached.equal?(MISSING_METADATA)
875
1114
  return cached if cached
876
1115
 
1116
+ if package_state[:package_scope] != "gems"
1117
+ metadata = local_package_metadata(package_state)
1118
+ adapter = metadata_adapter_for(package_state)
1119
+ remote_metadata = adapter&.meta(cache_only: true)
1120
+ remote_metadata = adapter&.meta(cache_only: false, force_refresh: true) if remote_metadata.nil? && refresh_if_missing
1121
+ metadata = metadata.compact.merge(remote_metadata || {})
1122
+ @metadata_cache[cache_key] = metadata || MISSING_METADATA
1123
+ return metadata
1124
+ end
1125
+
877
1126
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
878
1127
  if metadata.nil? && refresh_if_missing
879
1128
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
880
1129
  end
881
1130
 
882
- @metadata_cache[gem_name] = metadata || MISSING_METADATA
1131
+ @metadata_cache[cache_key] = metadata || MISSING_METADATA
883
1132
  metadata
884
1133
  rescue StandardError
885
- @metadata_cache[gem_name] = MISSING_METADATA
1134
+ @metadata_cache[cache_key] = MISSING_METADATA
886
1135
  nil
887
1136
  end
888
1137
 
1138
+ def local_package_metadata(package_state)
1139
+ source = package_state[:source] || {}
1140
+ remote = source[:remote].to_s
1141
+ repo_url = source[:repo_url].to_s
1142
+ package_name = source[:package_name].to_s
1143
+ package_version = source[:package_version].to_s
1144
+ registry_url = source[:registry_url].to_s
1145
+ provider_gem = source[:provider_gem].to_s
1146
+ package_name = package_state[:name].to_s if package_name.empty? && source[:type] == :npm
1147
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty? && source[:type] == :npm
1148
+ registry_url = "https://www.npmjs.com/package/#{package_name}" if registry_url.empty? && !package_name.empty?
1149
+ info = if package_name.empty?
1150
+ "JavaScript package pinned in config/importmap.rb"
1151
+ elsif !provider_gem.empty? && !package_version.empty?
1152
+ "JavaScript package `#{package_name}` provided by the `#{provider_gem}` gem at version `#{package_version}`"
1153
+ elsif !provider_gem.empty?
1154
+ "JavaScript package `#{package_name}` provided by the `#{provider_gem}` gem"
1155
+ elsif source[:type] == :npm && !package_version.empty?
1156
+ "JavaScript package `#{package_name}` locked to `#{package_version}` in package-lock.json"
1157
+ elsif package_version.empty?
1158
+ "JavaScript package `#{package_name}` pinned in config/importmap.rb"
1159
+ else
1160
+ "JavaScript package `#{package_name}` pinned to `#{package_version}` in config/importmap.rb"
1161
+ end
1162
+
1163
+ {
1164
+ "info" => info,
1165
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1166
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1167
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1168
+ }
1169
+ end
1170
+
1171
+ def repo_url_for(package_state, metadata: nil)
1172
+ return nil unless package_state
1173
+ if package_state[:package_scope] != "gems"
1174
+ url = package_state.dig(:source, :repo_url) || metadata&.dig("source_code_uri")
1175
+ return normalize_repo_url(url)
1176
+ end
1177
+
1178
+ normalize_repo_url(Gemstar::RubyGemsMetadata.new(package_state[:name]).repo_uri(cache_only: true))
1179
+ end
1180
+
1181
+ def normalize_repo_url(url)
1182
+ value = url.to_s
1183
+ return nil if value.empty?
1184
+
1185
+ value = value.sub(/\Agit\+/, "")
1186
+ value = value.sub(/\Agit:\/\//, "https://")
1187
+ value = value.gsub(/\.git\z/, "")
1188
+ value
1189
+ end
1190
+
1191
+ def metadata_adapter_for(package_state)
1192
+ return Gemstar::RubyGemsMetadata.new(package_state[:name]) if package_state[:package_scope] == "gems"
1193
+ return nil unless package_state[:package_scope] == "js"
1194
+
1195
+ provider_gem = package_state.dig(:source, :provider_gem)
1196
+ return Gemstar::RubyGemsMetadata.new(provider_gem) unless provider_gem.to_s.empty?
1197
+
1198
+ package_name = package_state.dig(:source, :package_name) || package_state[:name]
1199
+ return nil if package_name.to_s.empty?
1200
+
1201
+ Gemstar::NpmMetadata.new(package_name)
1202
+ end
1203
+
1204
+ def absolute_url?(value)
1205
+ value.to_s.match?(%r{\Ahttps?://}i)
1206
+ end
1207
+
889
1208
  def detail_pending?(gem_name, metadata, groups)
890
1209
  false
891
1210
  end
@@ -940,7 +1259,7 @@ module Gemstar
940
1259
  end
941
1260
 
942
1261
  def revision_card_links(section)
943
- repo_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true)
1262
+ repo_url = repo_url_for(@selected_gem)
944
1263
  return [] if repo_url.to_s.empty?
945
1264
 
946
1265
  links = []
@@ -953,25 +1272,33 @@ module Gemstar
953
1272
  end
954
1273
 
955
1274
  def fallback_current_section(gem_state, previous_sections, latest_sections)
956
- version = gem_state[:new_version] || gem_state[:old_version]
1275
+ metadata = metadata_for(gem_state) || {}
1276
+ version = effective_package_version(gem_state, metadata)
957
1277
  return nil if version.nil?
958
1278
  return nil if previous_sections.any? { |section| section[:version] == version }
959
1279
  return nil if latest_sections.any? { |section| section[:version] == version }
960
1280
 
961
- metadata = metadata_for(gem_state[:name]) || {}
962
- repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true)
1281
+ repo_url = repo_url_for(gem_state, metadata: metadata)
963
1282
  fallback_url =
964
1283
  if !repo_url.to_s.empty?
965
1284
  repo_url
966
1285
  elsif metadata["project_uri"]
967
1286
  metadata["project_uri"]
1287
+ elsif metadata["source_code_uri"]
1288
+ metadata["source_code_uri"]
1289
+ elsif metadata["homepage_uri"]
1290
+ metadata["homepage_uri"]
968
1291
  else
969
1292
  metadata["documentation_uri"]
970
1293
  end
971
1294
  fallback_label = if repo_url.to_s.empty?
972
- metadata["project_uri"] ? "the RubyGems page" : "the gem documentation"
1295
+ if gem_state[:package_scope] == "gems"
1296
+ metadata["project_uri"] ? "the RubyGems page" : "the gem documentation"
1297
+ else
1298
+ fallback_url == metadata["documentation_uri"] ? "the package documentation" : "the package source"
1299
+ end
973
1300
  else
974
- "the gem repository"
1301
+ gem_state[:package_scope] == "gems" ? "the gem repository" : "the package repository"
975
1302
  end
976
1303
  fallback_link = if fallback_url.to_s.empty?
977
1304
  fallback_label
@@ -995,6 +1322,16 @@ module Gemstar
995
1322
  nil
996
1323
  end
997
1324
 
1325
+ def detail_bundled_version(metadata)
1326
+ effective_package_version(@selected_gem, metadata || {})
1327
+ end
1328
+
1329
+ def effective_package_version(package_state, metadata)
1330
+ package_state[:new_version] ||
1331
+ package_state[:old_version] ||
1332
+ (package_state[:package_scope] == "js" ? metadata["version"] : nil)
1333
+ end
1334
+
998
1335
  def github_compare_url(repo_url, previous_version, current_version)
999
1336
  return nil unless repo_url.include?("github.com")
1000
1337
  return nil if previous_version.nil? || current_version.nil?
@@ -1024,17 +1361,18 @@ module Gemstar
1024
1361
  html
1025
1362
  end
1026
1363
 
1027
- def detail_query(project:, from:, to:, filter:, gem:)
1028
- "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, gem: gem)}"
1364
+ def detail_query(project:, from:, to:, filter:, scope:, package:)
1365
+ "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, scope: scope, package: package)}"
1029
1366
  end
1030
1367
 
1031
- def project_query(project:, from:, to:, filter:, gem:)
1368
+ def project_query(project:, from:, to:, filter:, scope:, package:)
1032
1369
  params = {
1033
1370
  project: project,
1034
1371
  from: from,
1035
1372
  to: to,
1036
1373
  filter: filter,
1037
- gem: gem
1374
+ scope: scope,
1375
+ package: package
1038
1376
  }.compact
1039
1377
 
1040
1378
  "/?#{URI.encode_www_form(params)}"
@@ -1044,21 +1382,18 @@ module Gemstar
1044
1382
  render_template(
1045
1383
  "page.html.erb",
1046
1384
  title: h(title),
1047
- favicon_data_uri: favicon_data_uri,
1048
1385
  styles_css: template_source("app.css"),
1049
1386
  body_html: yield
1050
1387
  )
1051
1388
  end
1052
1389
 
1053
- def favicon_data_uri
1054
- svg = <<~SVG
1390
+ def favicon_svg
1391
+ <<~SVG
1055
1392
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
1056
1393
  <rect width="64" height="64" rx="14" fill="#b44d25"/>
1057
1394
  <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>
1058
1395
  </svg>
1059
1396
  SVG
1060
-
1061
- "data:image/svg+xml,#{URI.encode_www_form_component(svg)}"
1062
1397
  end
1063
1398
 
1064
1399
  def render_behavior_script
@@ -1066,6 +1401,7 @@ module Gemstar
1066
1401
  "app.js.erb",
1067
1402
  empty_detail_html_json: empty_detail_html.dump,
1068
1403
  selected_filter_json: @selected_filter.dump,
1404
+ selected_package_scope_json: @selected_package_scope.dump,
1069
1405
  selected_project_index: @selected_project_index || 0
1070
1406
  )
1071
1407
 
@@ -1091,6 +1427,24 @@ module Gemstar
1091
1427
  def h(value)
1092
1428
  CGI.escapeHTML(value.to_s)
1093
1429
  end
1430
+
1431
+ def render_package_scope_filters
1432
+ options = @selected_project&.package_scope_options || []
1433
+ return "" if options.size <= 1
1434
+
1435
+ buttons = []
1436
+ buttons << %(<button type="button" class="list-filter-button#{' is-active' if @selected_package_scope == "all"}" data-ecosystem-button="all">All</button>)
1437
+
1438
+ buttons.concat(options.map do |option|
1439
+ %(<button type="button" class="list-filter-button#{' is-active' if @selected_package_scope == option[:id]}" data-ecosystem-button="#{h(option[:id])}">#{h(option[:label])}</button>)
1440
+ end)
1441
+
1442
+ <<~HTML
1443
+ <div class="list-filters list-filters-secondary" data-ecosystem-filters>
1444
+ #{buttons.join}
1445
+ </div>
1446
+ HTML
1447
+ end
1094
1448
  end
1095
1449
  end
1096
1450
  end