gemstar 1.0.2 → 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.
@@ -13,6 +13,7 @@ module Gemstar
13
13
  module Web
14
14
  class App < Roda
15
15
  MISSING_METADATA = Object.new
16
+ CACHE_VERSION = "v6"
16
17
 
17
18
  class << self
18
19
  def build(projects:, config_home:, cache_warmer: nil)
@@ -44,6 +45,16 @@ module Gemstar
44
45
  end
45
46
  end
46
47
 
48
+ r.get "favicon.svg" do
49
+ response["Content-Type"] = "image/svg+xml; charset=utf-8"
50
+ favicon_svg
51
+ end
52
+
53
+ r.get "favicon.ico" do
54
+ response["Content-Type"] = "image/svg+xml; charset=utf-8"
55
+ favicon_svg
56
+ end
57
+
47
58
  r.get "detail" do
48
59
  request_cache_key = detail_request_cache_key(r.params)
49
60
  request_cache = self.class.opts[:detail_request_cache]
@@ -62,7 +73,7 @@ module Gemstar
62
73
  project_index = selected_project_index(r.params["project"])
63
74
  project = @projects[project_index]
64
75
  response.status = 404
65
- next "Gemfile not found" unless project && File.file?(project.gemfile_path)
76
+ next "Gemfile not found" unless project&.gemfile?
66
77
 
67
78
  response["Content-Type"] = "text/plain; charset=utf-8"
68
79
  File.read(project.gemfile_path)
@@ -86,23 +97,39 @@ module Gemstar
86
97
  project = @projects[project_index]
87
98
  return nil unless project
88
99
 
89
- lockfile_stamp =
90
- if File.file?(project.lockfile_path)
91
- File.mtime(project.lockfile_path).to_i
92
- else
93
- 0
94
- end
100
+ lockfile_stamp = File.file?(project.lockfile_path) ? File.mtime(project.lockfile_path).to_i : 0
101
+ importmap_stamp = File.file?(project.importmap_path) ? File.mtime(project.importmap_path).to_i : 0
102
+ importmap_vendor_stamp = importmap_vendor_mtime(project)
103
+ package_lock_stamp = File.file?(project.package_lock_path) ? File.mtime(project.package_lock_path).to_i : 0
95
104
 
96
105
  [
106
+ CACHE_VERSION,
97
107
  project_index,
98
108
  params["from"],
99
109
  params["to"],
100
110
  params["filter"],
101
- params["gem"],
102
- lockfile_stamp
111
+ params["scope"],
112
+ package_param(params),
113
+ lockfile_stamp,
114
+ importmap_stamp,
115
+ importmap_vendor_stamp,
116
+ package_lock_stamp
103
117
  ]
104
118
  end
105
119
 
120
+ def importmap_vendor_mtime(project)
121
+ return 0 unless project&.importmap?
122
+
123
+ project.current_importmap.specs.values.filter_map do |target|
124
+ next unless target.to_s.end_with?(".js", ".mjs")
125
+
126
+ path = File.join(project.directory, "vendor", "javascript", target.to_s)
127
+ File.mtime(path).to_i if File.file?(path)
128
+ end.max || 0
129
+ rescue StandardError
130
+ 0
131
+ end
132
+
106
133
  def page_title
107
134
  return "Gemstar" unless @selected_project
108
135
 
@@ -117,9 +144,15 @@ module Gemstar
117
144
  @selected_from_revision_id = selected_from_revision_id(params["from"])
118
145
  @selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id)
119
146
  @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"])
147
+ requested_package_name = package_param(params)
148
+ @requested_gem_name = requested_package_name
149
+ @selected_package_scope = selected_package_scope(params["scope"], requested_package_name)
150
+ @selected_filter = selected_filter(params["filter"], requested_package_name)
151
+ @selected_gem = selected_gem_state(requested_package_name)
152
+ end
153
+
154
+ def package_param(params)
155
+ params["package"] || params["gem"]
123
156
  end
124
157
 
125
158
  def prioritize_selected_gem
@@ -167,10 +200,24 @@ module Gemstar
167
200
  @gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all"
168
201
  end
169
202
 
203
+ def selected_package_scope(raw_scope, raw_gem_name)
204
+ return "all" if @gem_states.empty?
205
+
206
+ available_scopes = available_package_scopes
207
+ default_scope = available_scopes == ["gems"] ? "gems" : "all"
208
+ return raw_scope if raw_scope == "all" || available_scopes.include?(raw_scope)
209
+
210
+ selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
211
+ return selected_gem[:package_scope] if selected_gem
212
+
213
+ default_scope
214
+ end
215
+
170
216
  def selected_gem_state(raw_gem_name)
171
217
  return nil if @gem_states.empty?
172
218
 
173
- exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name }
219
+ exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
220
+ exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name }
174
221
  return exact_match if exact_match
175
222
 
176
223
  @gem_states.find { |gem| gem_visible_in_selected_filter?(gem) && gem[:status] != :unchanged } ||
@@ -180,11 +227,20 @@ module Gemstar
180
227
  end
181
228
 
182
229
  def gem_visible_in_selected_filter?(gem_state)
230
+ return false unless gem_visible_in_selected_scope?(gem_state)
183
231
  return true if @selected_filter != "updated"
184
232
 
185
233
  gem_state[:status] != :unchanged
186
234
  end
187
235
 
236
+ def gem_visible_in_selected_scope?(gem_state)
237
+ @selected_package_scope == "all" || gem_state[:package_scope] == @selected_package_scope
238
+ end
239
+
240
+ def available_package_scopes
241
+ @selected_project ? @selected_project.package_scope_options.map { |option| option[:id] } : []
242
+ end
243
+
188
244
  def render_shell
189
245
  return render_empty_workspace if @projects.empty?
190
246
 
@@ -227,7 +283,7 @@ module Gemstar
227
283
  </div>
228
284
  <div class="picker-row">
229
285
  <label class="picker picker-project">
230
- <span class="picker-prefix">📁</span>
286
+ <span class="picker-prefix" data-text-label="true">Project:</span>
231
287
  <select data-project-select>
232
288
  #{project_options_html}
233
289
  <option value="" disabled>────────</option>
@@ -322,7 +378,7 @@ module Gemstar
322
378
  #{render_toolbar}
323
379
  <div class="workspace-body">
324
380
  #{render_sidebar}
325
- #{render_detail}
381
+ #{render_initial_detail}
326
382
  </div>
327
383
  </main>
328
384
  HTML
@@ -332,7 +388,7 @@ module Gemstar
332
388
  <<~HTML
333
389
  <section class="toolbar">
334
390
  <div class="toolbar-meta">
335
- <strong>#{@gem_states.count}</strong> gems
391
+ <strong>#{@gem_states.count}</strong> #{h(@selected_project&.package_collection_label&.downcase || "packages")}
336
392
  <span>·</span>
337
393
  <strong>#{@gem_states.count { |gem| gem[:status] != :unchanged }}</strong> changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)}
338
394
  </div>
@@ -357,17 +413,18 @@ module Gemstar
357
413
  <aside class="sidebar" data-sidebar-panel tabindex="0">
358
414
  <div class="sidebar-header">
359
415
  <div class="sidebar-header-row">
360
- <h2>Gems</h2>
416
+ <h2>#{h(@selected_project&.package_collection_label || "Packages")}</h2>
361
417
  <div class="list-filters" data-list-filters>
362
418
  <button type="button" class="list-filter-button#{' is-active' if @selected_filter == "updated"}" data-filter-button="updated">Updated</button>
363
419
  <button type="button" class="list-filter-button#{' is-active' if @selected_filter == "all"}" data-filter-button="all">All</button>
364
420
  </div>
365
421
  </div>
422
+ #{render_package_scope_filters}
366
423
  <input
367
424
  type="search"
368
425
  class="gem-search"
369
426
  data-gem-search
370
- placeholder="Filter gems"
427
+ placeholder="Search"
371
428
  autocomplete="off"
372
429
  spellcheck="false"
373
430
  >
@@ -380,7 +437,7 @@ module Gemstar
380
437
  def render_gem_list
381
438
  return <<~HTML if @gem_states.empty?
382
439
  <section class="empty-panel">
383
- <p>No gems found in the current lockfile.</p>
440
+ <p>No #{h(@selected_project&.package_collection_label&.downcase || "packages")} found in the current lockfile.</p>
384
441
  </section>
385
442
  HTML
386
443
 
@@ -388,19 +445,23 @@ module Gemstar
388
445
  selected = gem[:name] == @selected_gem[:name] ? " is-selected" : ""
389
446
  status_class = " status-#{gem[:status]}"
390
447
  updated = gem[:status] != :unchanged
391
- hidden = @selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name ? ' hidden="hidden"' : ""
448
+ hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name)) ? ' hidden="hidden"' : ""
392
449
  <<~HTML
393
450
  <a
394
451
  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])}"
452
+ 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
453
  data-gem-link="true"
397
454
  data-gem-name="#{h(gem[:name])}"
398
455
  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]))}"
456
+ data-package-scope="#{h(gem[:package_scope])}"
457
+ 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
458
  #{hidden}
401
459
  >
402
460
  <span class="gem-name-row">
403
- <span class="gem-name">#{h(gem[:name])}</span>
461
+ <span class="gem-name-lockup">
462
+ <span class="gem-name">#{h(gem[:name])}</span>
463
+ <span class="package-type-tag">#{h(gem[:package_type_label])}</span>
464
+ </span>
404
465
  #{updated ? '<span class="gem-updated-dot" aria-label="Updated"></span>' : ""}
405
466
  </span>
406
467
  <span class="gem-version">#{h(gem[:version_label])}</span>
@@ -413,7 +474,7 @@ module Gemstar
413
474
  #{items}
414
475
  </nav>
415
476
  <section class="empty-panel gem-list-empty" data-gem-list-empty hidden="hidden">
416
- <p>No updated gems in this revision range.</p>
477
+ <p>No updated #{h(@selected_project&.package_collection_label&.downcase || "packages")} in this revision range.</p>
417
478
  </section>
418
479
  HTML
419
480
  end
@@ -421,25 +482,32 @@ module Gemstar
421
482
  def render_detail
422
483
  return empty_detail_html unless @selected_gem
423
484
 
485
+ metadata = metadata_for(@selected_gem, refresh_if_missing: true)
424
486
  cache_key = [
487
+ CACHE_VERSION,
425
488
  @selected_project_index,
426
489
  @selected_from_revision_id,
427
490
  @selected_to_revision_id,
428
491
  @selected_filter,
492
+ @selected_package_scope,
429
493
  @selected_gem[:name],
494
+ @selected_gem[:package_scope],
495
+ @selected_gem[:package_source_file],
430
496
  @selected_gem[:old_version],
431
497
  @selected_gem[:new_version],
498
+ @selected_gem[:raw_old_version],
499
+ @selected_gem[:raw_new_version],
500
+ effective_package_version(@selected_gem, metadata || {}),
432
501
  @selected_gem[:status]
433
502
  ]
434
503
  detail_cache = self.class.opts[:detail_html_cache]
435
504
  return detail_cache[cache_key] if detail_cache.key?(cache_key)
436
505
 
437
- metadata = metadata_for(@selected_gem[:name], refresh_if_missing: true)
438
506
  groups = grouped_change_sections(@selected_gem)
439
507
  detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
440
508
 
441
509
  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]))}">
510
+ <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
511
  #{render_detail_hero(metadata)}
444
512
  #{render_detail_loading_notice if detail_pending}
445
513
  #{render_detail_revision_panel(groups)}
@@ -450,12 +518,33 @@ module Gemstar
450
518
  detail_html
451
519
  end
452
520
 
453
- def empty_detail_html
521
+ def render_initial_detail
522
+ return empty_detail_html(loading: true) unless @selected_gem
523
+
524
+ detail_url = detail_query(
525
+ project: @selected_project_index,
526
+ from: @selected_from_revision_id,
527
+ to: @selected_to_revision_id,
528
+ filter: @selected_filter,
529
+ scope: @selected_package_scope,
530
+ package: @selected_gem[:name]
531
+ )
532
+
533
+ <<~HTML
534
+ <section class="detail" data-detail-panel tabindex="0" data-detail-pending="false" data-detail-deferred="true" data-detail-url="#{h(detail_url)}">
535
+ <div class="detail-loading-shell" aria-hidden="true">
536
+ <div class="detail-loading-spinner"></div>
537
+ </div>
538
+ </section>
539
+ HTML
540
+ end
541
+
542
+ def empty_detail_html(loading: false)
454
543
  <<~HTML
455
544
  <section class="detail" data-detail-panel tabindex="0">
456
545
  <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>
546
+ <h2>#{loading ? "Loading details" : "No gem selected"}</h2>
547
+ <p>#{loading ? "Preparing the selected gem details." : "Choose a gem from the list to inspect its current version and changelog revisions."}</p>
459
548
  </div>
460
549
  </section>
461
550
  HTML
@@ -465,10 +554,10 @@ module Gemstar
465
554
  description = metadata&.dig("info")
466
555
  bundle_origins = Array(@selected_gem[:bundle_origins])
467
556
  requirement_names = selected_gem_requirements
468
- bundled_version = @selected_gem[:new_version]
557
+ bundled_version = detail_bundled_version(metadata)
469
558
  added_on = selected_gem_added_on
470
559
  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?
560
+ title_url = repo_url_for(@selected_gem, metadata: metadata) if title_url.to_s.empty?
472
561
  title_markup = if title_url.to_s.empty?
473
562
  h(@selected_gem[:name])
474
563
  else
@@ -484,13 +573,24 @@ module Gemstar
484
573
  </div>
485
574
  #{render_detail_links(metadata)}
486
575
  </div>
487
- <p class="detail-subtitle">#{description ? h(description) : "Metadata will appear here when RubyGems information is available."}</p>
576
+ <div class="detail-subtitle">#{render_detail_subtitle(description)}</div>
488
577
  #{render_dependency_details(bundle_origins, requirement_names, added_on)}
489
578
  </div>
490
579
  </section>
491
580
  HTML
492
581
  end
493
582
 
583
+ def render_detail_subtitle(description)
584
+ text = description.to_s.strip
585
+ return "<p>Metadata will appear here when package information is available.</p>" if text.empty?
586
+
587
+ options = { hard_wrap: false }
588
+ options[:input] = "GFM" if defined?(Kramdown::Parser::GFM)
589
+ with_external_links(Kramdown::Document.new(text, options).to_html)
590
+ rescue Kramdown::Error
591
+ "<p>#{h(text)}</p>"
592
+ end
593
+
494
594
  def render_added_on(added_on)
495
595
  return "" unless added_on
496
596
 
@@ -516,17 +616,22 @@ module Gemstar
516
616
  next if origin[:type] != :direct && display_path.empty?
517
617
 
518
618
  linked_path = linked_gem_chain(["Gemfile", *display_path])
519
- origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
619
+ label = origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
620
+ origin[:requirement] ? "#{label} (#{h(origin[:requirement])})" : label
520
621
  end.uniq
521
622
  end
522
623
 
523
624
  def render_detail_links(metadata)
524
- repo_url = metadata ? Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) : nil
625
+ repo_url = repo_url_for(@selected_gem, metadata: metadata)
525
626
  homepage_url = metadata&.dig("homepage_uri")
526
- rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
527
627
 
528
628
  buttons = []
529
- buttons << icon_button("RubyGems", rubygems_url, icon_type: :rubygems)
629
+ if @selected_gem[:package_scope] == "gems"
630
+ rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
631
+ buttons << icon_button("RubyGems", rubygems_url, icon_type: :rubygems)
632
+ elsif homepage_url && !homepage_url.empty? && (!repo_url || homepage_url != repo_url)
633
+ buttons << icon_button("Source", homepage_url, icon_type: :home)
634
+ end
530
635
  buttons << icon_button("GitHub", repo_url, icon_type: :github) if repo_url && !repo_url.empty?
531
636
  buttons << icon_button("Homepage", homepage_url, icon_type: :home) if homepage_url && !homepage_url.empty?
532
637
 
@@ -540,14 +645,18 @@ module Gemstar
540
645
  def render_dependency_details(bundle_origins, requirement_names, added_on)
541
646
  required_by = dependency_origin_items(bundle_origins)
542
647
  requires = Array(requirement_names).compact.uniq.map { |name| internal_gem_link(name) }
648
+ platforms = selected_gem_platform_items
649
+ source_items = selected_gem_source_items
543
650
  added_markup = render_added_on(added_on)
544
- return "" if required_by.empty? && requires.empty? && added_markup.empty?
651
+ return "" if required_by.empty? && requires.empty? && platforms.empty? && source_items.empty? && added_markup.empty?
545
652
 
546
653
  <<~HTML
547
654
  <details class="detail-disclosure">
548
655
  <summary><span class="detail-disclosure-caret" aria-hidden="true"></span><h3>Details</h3></summary>
549
656
  <div class="detail-disclosure-panel">
550
657
  #{added_markup}
658
+ #{render_dependency_popover_section("Platforms", platforms)}
659
+ #{render_dependency_popover_section("Source", source_items)}
551
660
  #{render_dependency_popover_section("Required by", required_by)}
552
661
  #{render_dependency_popover_section("Requires", requires)}
553
662
  </div>
@@ -570,6 +679,8 @@ module Gemstar
570
679
  end
571
680
 
572
681
  def selected_gem_requirements
682
+ return [] unless @selected_gem[:package_scope] == "gems"
683
+
573
684
  lockfile = if @selected_gem[:new_version]
574
685
  @selected_project&.lockfile_for_revision(@selected_to_revision_id)
575
686
  else
@@ -581,7 +692,70 @@ module Gemstar
581
692
 
582
693
  def selected_gem_added_on
583
694
  revision_id = @selected_gem[:new_version] ? @selected_to_revision_id : @selected_from_revision_id
584
- @selected_project&.gem_added_on(@selected_gem[:name], revision_id: revision_id)
695
+ @selected_project&.package_added_on(
696
+ @selected_gem[:name],
697
+ package_scope: @selected_gem[:package_scope],
698
+ source_file: @selected_gem[:package_source_file],
699
+ revision_id: revision_id
700
+ )
701
+ end
702
+
703
+ def selected_gem_platform_items
704
+ platform = @selected_gem[:platform]
705
+ return [] if platform.to_s.empty?
706
+
707
+ [h(platform)]
708
+ end
709
+
710
+ def selected_gem_source_items
711
+ source = @selected_gem[:source] || {}
712
+ source_type = source[:type]
713
+
714
+ case source_type
715
+ when :path
716
+ location = source[:path] || source[:remote]
717
+ return [] if location.to_s.empty?
718
+
719
+ ["Path (#{h(location)})"]
720
+ when :git
721
+ remote = source[:remote]
722
+ pieces = ["Git"]
723
+ pieces << h(remote) unless remote.to_s.empty?
724
+ pieces << "@#{h(source[:branch])}" if source[:branch]
725
+ pieces << "##{h(source[:tag])}" if source[:tag]
726
+ pieces << h(source[:revision].to_s[0, 8]) if source[:revision]
727
+ [pieces.join(" ")]
728
+ when :rubygems
729
+ remote = source[:remote]
730
+ [remote.to_s.empty? ? "RubyGems" : "RubyGems (#{h(remote)})"]
731
+ when :importmap
732
+ remote = source[:remote]
733
+ package_name = source[:package_name]
734
+ package_version = source[:package_version]
735
+ label =
736
+ if package_name && package_version
737
+ "Importmap (#{h(package_name)} @ #{h(package_version)})"
738
+ elsif package_name
739
+ "Importmap (#{h(package_name)})"
740
+ elsif remote.to_s.empty?
741
+ "Importmap"
742
+ else
743
+ "Importmap (#{h(remote)})"
744
+ end
745
+ [label]
746
+ when :npm
747
+ remote = source[:remote]
748
+ registry_url = source[:registry_url]
749
+ if remote.to_s.empty? && registry_url.to_s.empty?
750
+ ["npm"]
751
+ elsif remote.to_s.empty?
752
+ ["npm (#{h(registry_url)})"]
753
+ else
754
+ ["npm (#{h(remote)})"]
755
+ end
756
+ else
757
+ []
758
+ end
585
759
  end
586
760
 
587
761
  def linked_gem_chain(names)
@@ -607,7 +781,8 @@ module Gemstar
607
781
  from: @selected_from_revision_id,
608
782
  to: @selected_to_revision_id,
609
783
  filter: @selected_filter,
610
- gem: name
784
+ scope: @selected_package_scope,
785
+ package: name
611
786
  )
612
787
 
613
788
  %(<a href="#{h(href)}" data-gem-link-inline="true">#{h(name)}</a>)
@@ -626,7 +801,7 @@ module Gemstar
626
801
  def render_detail_loading_notice
627
802
  <<~HTML
628
803
  <section class="empty-panel">
629
- <p>Loading gem metadata and changelog in the background...</p>
804
+ <p>Loading package metadata and changelog in the background...</p>
630
805
  </section>
631
806
  HTML
632
807
  end
@@ -694,17 +869,31 @@ module Gemstar
694
869
  end
695
870
 
696
871
  def change_sections(gem_state)
697
- cache_key = [gem_state[:name], gem_state[:old_version], gem_state[:new_version], gem_state[:status]]
872
+ metadata_hash = metadata_for(gem_state) || {}
873
+ current_version = effective_package_version(gem_state, metadata_hash)
874
+ cache_key = [
875
+ CACHE_VERSION,
876
+ gem_state[:name],
877
+ gem_state[:package_scope],
878
+ gem_state[:package_source_file],
879
+ gem_state[:old_version],
880
+ gem_state[:new_version],
881
+ gem_state[:raw_old_version],
882
+ gem_state[:raw_new_version],
883
+ current_version,
884
+ gem_state[:status]
885
+ ]
698
886
  change_sections_cache = self.class.opts[:change_sections_cache]
699
887
  return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
700
888
 
701
- return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil?
702
-
703
- metadata = Gemstar::RubyGemsMetadata.new(gem_state[:name])
889
+ return [] if gem_state[:new_version].nil? &&
890
+ gem_state[:old_version].nil? &&
891
+ (current_version.nil? || current_version.to_s.empty?)
892
+ metadata = metadata_adapter_for(gem_state)
893
+ return change_sections_cache[cache_key] = [] unless metadata
704
894
  sections = resolved_sections(metadata, gem_state)
705
895
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
706
896
 
707
- current_version = gem_state[:new_version] || gem_state[:old_version]
708
897
  previous_version = gem_state[:old_version]
709
898
 
710
899
  rendered_sections = sections.keys.filter_map do |version|
@@ -731,18 +920,35 @@ module Gemstar
731
920
  cached_sections = changelog.sections(cache_only: true) || {}
732
921
  return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
733
922
 
734
- @metadata_cache.delete(gem_state[:name])
923
+ @metadata_cache.delete([gem_state[:package_scope], gem_state[:name]])
735
924
  metadata.meta(cache_only: false, force_refresh: true)
736
925
  metadata.repo_uri(cache_only: false, force_refresh: true)
737
- Gemstar::ChangeLog.new(metadata).sections(cache_only: false, force_refresh: true) || cached_sections
926
+ refreshed_sections = metadata.changelog_sections(
927
+ versions: relevant_package_versions(gem_state, metadata),
928
+ cache_only: false,
929
+ force_refresh: true
930
+ )
931
+ cached_sections.merge(refreshed_sections || {})
932
+ end
933
+
934
+ def relevant_package_versions(gem_state, metadata)
935
+ metadata_hash = metadata_for(gem_state) || {}
936
+ [
937
+ gem_state[:old_version],
938
+ gem_state[:new_version],
939
+ gem_state[:raw_old_version],
940
+ gem_state[:raw_new_version],
941
+ gem_state.dig(:source, :package_version),
942
+ metadata_hash["version"]
943
+ ].compact
738
944
  end
739
945
 
740
946
  def selected_gem_requires_refresh?(gem_state, cached_sections)
741
947
  return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
742
948
 
743
- bundled_version = gem_state[:new_version] || gem_state[:old_version]
744
- return false if bundled_version.nil?
745
- metadata = metadata_for(gem_state[:name]) || {}
949
+ metadata = metadata_for(gem_state) || {}
950
+ bundled_version = effective_package_version(gem_state, metadata)
951
+ return false if bundled_version.nil? || bundled_version.to_s.empty?
746
952
  has_upstream_release_source =
747
953
  !metadata["changelog_uri"].to_s.empty? ||
748
954
  !metadata["source_code_uri"].to_s.empty? ||
@@ -763,6 +969,7 @@ module Gemstar
763
969
  def section_kind(version, previous_version, current_version, status)
764
970
  return :future if compare_versions(version, current_version) == 1
765
971
  return :current if status == :added && compare_versions(version, current_version) <= 0
972
+ return :current if previous_version.nil? && current_version && compare_versions(version, current_version) == 0
766
973
 
767
974
  lower_bound = previous_version || current_version
768
975
  if compare_versions(version, lower_bound) == 1 && compare_versions(version, current_version) <= 0
@@ -798,7 +1005,7 @@ module Gemstar
798
1005
  return { title: heading_version.to_s, html: "<p>No changelog text available.</p>" } if text.strip.empty?
799
1006
 
800
1007
  if heading_version
801
- text = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1008
+ text = strip_leading_version_heading(text, heading_version)
802
1009
  end
803
1010
 
804
1011
  options = { hard_wrap: false }
@@ -825,29 +1032,135 @@ module Gemstar
825
1032
  { title: title, html: fragment.to_html }
826
1033
  end
827
1034
 
1035
+ def strip_leading_version_heading(text, heading_version)
1036
+ stripped = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1037
+ return strip_leading_hash_separator(stripped) unless stripped == text
1038
+
1039
+ lines = text.lines
1040
+ return text if lines.empty?
1041
+
1042
+ first_line = lines.first.to_s
1043
+ heading_like =
1044
+ first_line.match?(/\A\s*v?#{Regexp.escape(heading_version)}\b/i) ||
1045
+ first_line.match?(/\A\s*[\[(]?v?#{Regexp.escape(heading_version)}\b/i)
1046
+
1047
+ return text unless heading_like
1048
+
1049
+ remaining = lines.drop(1)
1050
+ remaining.shift while remaining.first&.strip&.empty?
1051
+ strip_leading_hash_separator(remaining.join)
1052
+ end
1053
+
1054
+ def strip_leading_hash_separator(text)
1055
+ text.sub(/\A\s*#{Regexp.escape("#")}{4,}\s*\n+/, "")
1056
+ end
1057
+
828
1058
  def compare_versions(left, right)
829
1059
  Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, ""))
830
1060
  rescue ArgumentError
831
1061
  left.to_s <=> right.to_s
832
1062
  end
833
1063
 
834
- def metadata_for(gem_name, refresh_if_missing: false)
835
- cached = @metadata_cache[gem_name]
1064
+ def metadata_for(package_state_or_name, refresh_if_missing: false)
1065
+ package_state = package_state_or_name.is_a?(Hash) ? package_state_or_name : { name: package_state_or_name, package_scope: "gems" }
1066
+ gem_name = package_state[:name]
1067
+ cache_key = [package_state[:package_scope], gem_name]
1068
+ cached = @metadata_cache[cache_key]
836
1069
  return nil if cached.equal?(MISSING_METADATA)
837
1070
  return cached if cached
838
1071
 
1072
+ if package_state[:package_scope] != "gems"
1073
+ metadata = local_package_metadata(package_state)
1074
+ adapter = metadata_adapter_for(package_state)
1075
+ remote_metadata = adapter&.meta(cache_only: true)
1076
+ remote_metadata = adapter&.meta(cache_only: false, force_refresh: true) if remote_metadata.nil? && refresh_if_missing
1077
+ metadata = metadata.compact.merge(remote_metadata || {})
1078
+ @metadata_cache[cache_key] = metadata || MISSING_METADATA
1079
+ return metadata
1080
+ end
1081
+
839
1082
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
840
1083
  if metadata.nil? && refresh_if_missing
841
1084
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
842
1085
  end
843
1086
 
844
- @metadata_cache[gem_name] = metadata || MISSING_METADATA
1087
+ @metadata_cache[cache_key] = metadata || MISSING_METADATA
845
1088
  metadata
846
1089
  rescue StandardError
847
- @metadata_cache[gem_name] = MISSING_METADATA
1090
+ @metadata_cache[cache_key] = MISSING_METADATA
848
1091
  nil
849
1092
  end
850
1093
 
1094
+ def local_package_metadata(package_state)
1095
+ source = package_state[:source] || {}
1096
+ remote = source[:remote].to_s
1097
+ repo_url = source[:repo_url].to_s
1098
+ package_name = source[:package_name].to_s
1099
+ package_version = source[:package_version].to_s
1100
+ registry_url = source[:registry_url].to_s
1101
+ provider_gem = source[:provider_gem].to_s
1102
+ package_name = package_state[:name].to_s if package_name.empty? && source[:type] == :npm
1103
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty? && source[:type] == :npm
1104
+ registry_url = "https://www.npmjs.com/package/#{package_name}" if registry_url.empty? && !package_name.empty?
1105
+ info = if package_name.empty?
1106
+ "JavaScript package pinned in config/importmap.rb"
1107
+ elsif !provider_gem.empty? && !package_version.empty?
1108
+ "JavaScript package `#{package_name}` provided by the `#{provider_gem}` gem at version `#{package_version}`"
1109
+ elsif !provider_gem.empty?
1110
+ "JavaScript package `#{package_name}` provided by the `#{provider_gem}` gem"
1111
+ elsif source[:type] == :npm && !package_version.empty?
1112
+ "JavaScript package `#{package_name}` locked to `#{package_version}` in package-lock.json"
1113
+ elsif package_version.empty?
1114
+ "JavaScript package `#{package_name}` pinned in config/importmap.rb"
1115
+ else
1116
+ "JavaScript package `#{package_name}` pinned to `#{package_version}` in config/importmap.rb"
1117
+ end
1118
+
1119
+ {
1120
+ "info" => info,
1121
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1122
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1123
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1124
+ }
1125
+ end
1126
+
1127
+ def repo_url_for(package_state, metadata: nil)
1128
+ return nil unless package_state
1129
+ if package_state[:package_scope] != "gems"
1130
+ url = package_state.dig(:source, :repo_url) || metadata&.dig("source_code_uri")
1131
+ return normalize_repo_url(url)
1132
+ end
1133
+
1134
+ normalize_repo_url(Gemstar::RubyGemsMetadata.new(package_state[:name]).repo_uri(cache_only: true))
1135
+ end
1136
+
1137
+ def normalize_repo_url(url)
1138
+ value = url.to_s
1139
+ return nil if value.empty?
1140
+
1141
+ value = value.sub(/\Agit\+/, "")
1142
+ value = value.sub(/\Agit:\/\//, "https://")
1143
+ value = value.gsub(/\.git\z/, "")
1144
+ value
1145
+ end
1146
+
1147
+ def metadata_adapter_for(package_state)
1148
+ return Gemstar::RubyGemsMetadata.new(package_state[:name]) if package_state[:package_scope] == "gems"
1149
+ return nil unless package_state[:package_scope] == "js"
1150
+
1151
+ provider_gem = package_state.dig(:source, :provider_gem)
1152
+ return Gemstar::RubyGemsMetadata.new(provider_gem) unless provider_gem.to_s.empty?
1153
+
1154
+ package_name = package_state.dig(:source, :package_name) || package_state[:name]
1155
+ return nil if package_name.to_s.empty?
1156
+
1157
+ Gemstar::NpmMetadata.new(package_name)
1158
+ end
1159
+
1160
+ def absolute_url?(value)
1161
+ value.to_s.match?(%r{\Ahttps?://}i)
1162
+ end
1163
+
851
1164
  def detail_pending?(gem_name, metadata, groups)
852
1165
  false
853
1166
  end
@@ -902,7 +1215,7 @@ module Gemstar
902
1215
  end
903
1216
 
904
1217
  def revision_card_links(section)
905
- repo_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true)
1218
+ repo_url = repo_url_for(@selected_gem)
906
1219
  return [] if repo_url.to_s.empty?
907
1220
 
908
1221
  links = []
@@ -915,25 +1228,33 @@ module Gemstar
915
1228
  end
916
1229
 
917
1230
  def fallback_current_section(gem_state, previous_sections, latest_sections)
918
- version = gem_state[:new_version] || gem_state[:old_version]
1231
+ metadata = metadata_for(gem_state) || {}
1232
+ version = effective_package_version(gem_state, metadata)
919
1233
  return nil if version.nil?
920
1234
  return nil if previous_sections.any? { |section| section[:version] == version }
921
1235
  return nil if latest_sections.any? { |section| section[:version] == version }
922
1236
 
923
- metadata = metadata_for(gem_state[:name]) || {}
924
- repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true)
1237
+ repo_url = repo_url_for(gem_state, metadata: metadata)
925
1238
  fallback_url =
926
1239
  if !repo_url.to_s.empty?
927
1240
  repo_url
928
1241
  elsif metadata["project_uri"]
929
1242
  metadata["project_uri"]
1243
+ elsif metadata["source_code_uri"]
1244
+ metadata["source_code_uri"]
1245
+ elsif metadata["homepage_uri"]
1246
+ metadata["homepage_uri"]
930
1247
  else
931
1248
  metadata["documentation_uri"]
932
1249
  end
933
1250
  fallback_label = if repo_url.to_s.empty?
934
- metadata["project_uri"] ? "the RubyGems page" : "the gem documentation"
1251
+ if gem_state[:package_scope] == "gems"
1252
+ metadata["project_uri"] ? "the RubyGems page" : "the gem documentation"
1253
+ else
1254
+ fallback_url == metadata["documentation_uri"] ? "the package documentation" : "the package source"
1255
+ end
935
1256
  else
936
- "the gem repository"
1257
+ gem_state[:package_scope] == "gems" ? "the gem repository" : "the package repository"
937
1258
  end
938
1259
  fallback_link = if fallback_url.to_s.empty?
939
1260
  fallback_label
@@ -957,6 +1278,16 @@ module Gemstar
957
1278
  nil
958
1279
  end
959
1280
 
1281
+ def detail_bundled_version(metadata)
1282
+ effective_package_version(@selected_gem, metadata || {})
1283
+ end
1284
+
1285
+ def effective_package_version(package_state, metadata)
1286
+ package_state[:new_version] ||
1287
+ package_state[:old_version] ||
1288
+ (package_state[:package_scope] == "js" ? metadata["version"] : nil)
1289
+ end
1290
+
960
1291
  def github_compare_url(repo_url, previous_version, current_version)
961
1292
  return nil unless repo_url.include?("github.com")
962
1293
  return nil if previous_version.nil? || current_version.nil?
@@ -986,17 +1317,18 @@ module Gemstar
986
1317
  html
987
1318
  end
988
1319
 
989
- def detail_query(project:, from:, to:, filter:, gem:)
990
- "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, gem: gem)}"
1320
+ def detail_query(project:, from:, to:, filter:, scope:, package:)
1321
+ "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, scope: scope, package: package)}"
991
1322
  end
992
1323
 
993
- def project_query(project:, from:, to:, filter:, gem:)
1324
+ def project_query(project:, from:, to:, filter:, scope:, package:)
994
1325
  params = {
995
1326
  project: project,
996
1327
  from: from,
997
1328
  to: to,
998
1329
  filter: filter,
999
- gem: gem
1330
+ scope: scope,
1331
+ package: package
1000
1332
  }.compact
1001
1333
 
1002
1334
  "/?#{URI.encode_www_form(params)}"
@@ -1006,21 +1338,18 @@ module Gemstar
1006
1338
  render_template(
1007
1339
  "page.html.erb",
1008
1340
  title: h(title),
1009
- favicon_data_uri: favicon_data_uri,
1010
1341
  styles_css: template_source("app.css"),
1011
1342
  body_html: yield
1012
1343
  )
1013
1344
  end
1014
1345
 
1015
- def favicon_data_uri
1016
- svg = <<~SVG
1346
+ def favicon_svg
1347
+ <<~SVG
1017
1348
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
1018
1349
  <rect width="64" height="64" rx="14" fill="#b44d25"/>
1019
1350
  <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>
1020
1351
  </svg>
1021
1352
  SVG
1022
-
1023
- "data:image/svg+xml,#{URI.encode_www_form_component(svg)}"
1024
1353
  end
1025
1354
 
1026
1355
  def render_behavior_script
@@ -1028,6 +1357,7 @@ module Gemstar
1028
1357
  "app.js.erb",
1029
1358
  empty_detail_html_json: empty_detail_html.dump,
1030
1359
  selected_filter_json: @selected_filter.dump,
1360
+ selected_package_scope_json: @selected_package_scope.dump,
1031
1361
  selected_project_index: @selected_project_index || 0
1032
1362
  )
1033
1363
 
@@ -1053,6 +1383,24 @@ module Gemstar
1053
1383
  def h(value)
1054
1384
  CGI.escapeHTML(value.to_s)
1055
1385
  end
1386
+
1387
+ def render_package_scope_filters
1388
+ options = @selected_project&.package_scope_options || []
1389
+ return "" if options.size <= 1
1390
+
1391
+ buttons = []
1392
+ buttons << %(<button type="button" class="list-filter-button#{' is-active' if @selected_package_scope == "all"}" data-ecosystem-button="all">All</button>)
1393
+
1394
+ buttons.concat(options.map do |option|
1395
+ %(<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>)
1396
+ end)
1397
+
1398
+ <<~HTML
1399
+ <div class="list-filters list-filters-secondary" data-ecosystem-filters>
1400
+ #{buttons.join}
1401
+ </div>
1402
+ HTML
1403
+ end
1056
1404
  end
1057
1405
  end
1058
1406
  end