gemstar 1.0.4 → 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
 
@@ -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
 
@@ -522,12 +622,16 @@ module Gemstar
522
622
  end
523
623
 
524
624
  def render_detail_links(metadata)
525
- 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)
526
626
  homepage_url = metadata&.dig("homepage_uri")
527
- rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}"
528
627
 
529
628
  buttons = []
530
- 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
531
635
  buttons << icon_button("GitHub", repo_url, icon_type: :github) if repo_url && !repo_url.empty?
532
636
  buttons << icon_button("Homepage", homepage_url, icon_type: :home) if homepage_url && !homepage_url.empty?
533
637
 
@@ -575,6 +679,8 @@ module Gemstar
575
679
  end
576
680
 
577
681
  def selected_gem_requirements
682
+ return [] unless @selected_gem[:package_scope] == "gems"
683
+
578
684
  lockfile = if @selected_gem[:new_version]
579
685
  @selected_project&.lockfile_for_revision(@selected_to_revision_id)
580
686
  else
@@ -586,7 +692,12 @@ module Gemstar
586
692
 
587
693
  def selected_gem_added_on
588
694
  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)
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
+ )
590
701
  end
591
702
 
592
703
  def selected_gem_platform_items
@@ -617,6 +728,31 @@ module Gemstar
617
728
  when :rubygems
618
729
  remote = source[:remote]
619
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
620
756
  else
621
757
  []
622
758
  end
@@ -645,7 +781,8 @@ module Gemstar
645
781
  from: @selected_from_revision_id,
646
782
  to: @selected_to_revision_id,
647
783
  filter: @selected_filter,
648
- gem: name
784
+ scope: @selected_package_scope,
785
+ package: name
649
786
  )
650
787
 
651
788
  %(<a href="#{h(href)}" data-gem-link-inline="true">#{h(name)}</a>)
@@ -664,7 +801,7 @@ module Gemstar
664
801
  def render_detail_loading_notice
665
802
  <<~HTML
666
803
  <section class="empty-panel">
667
- <p>Loading gem metadata and changelog in the background...</p>
804
+ <p>Loading package metadata and changelog in the background...</p>
668
805
  </section>
669
806
  HTML
670
807
  end
@@ -732,17 +869,31 @@ module Gemstar
732
869
  end
733
870
 
734
871
  def change_sections(gem_state)
735
- 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
+ ]
736
886
  change_sections_cache = self.class.opts[:change_sections_cache]
737
887
  return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
738
888
 
739
- return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil?
740
-
741
- 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
742
894
  sections = resolved_sections(metadata, gem_state)
743
895
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
744
896
 
745
- current_version = gem_state[:new_version] || gem_state[:old_version]
746
897
  previous_version = gem_state[:old_version]
747
898
 
748
899
  rendered_sections = sections.keys.filter_map do |version|
@@ -769,18 +920,35 @@ module Gemstar
769
920
  cached_sections = changelog.sections(cache_only: true) || {}
770
921
  return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
771
922
 
772
- @metadata_cache.delete(gem_state[:name])
923
+ @metadata_cache.delete([gem_state[:package_scope], gem_state[:name]])
773
924
  metadata.meta(cache_only: false, force_refresh: true)
774
925
  metadata.repo_uri(cache_only: false, force_refresh: true)
775
- 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
776
944
  end
777
945
 
778
946
  def selected_gem_requires_refresh?(gem_state, cached_sections)
779
947
  return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
780
948
 
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]) || {}
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?
784
952
  has_upstream_release_source =
785
953
  !metadata["changelog_uri"].to_s.empty? ||
786
954
  !metadata["source_code_uri"].to_s.empty? ||
@@ -801,6 +969,7 @@ module Gemstar
801
969
  def section_kind(version, previous_version, current_version, status)
802
970
  return :future if compare_versions(version, current_version) == 1
803
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
804
973
 
805
974
  lower_bound = previous_version || current_version
806
975
  if compare_versions(version, lower_bound) == 1 && compare_versions(version, current_version) <= 0
@@ -836,7 +1005,7 @@ module Gemstar
836
1005
  return { title: heading_version.to_s, html: "<p>No changelog text available.</p>" } if text.strip.empty?
837
1006
 
838
1007
  if heading_version
839
- text = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1008
+ text = strip_leading_version_heading(text, heading_version)
840
1009
  end
841
1010
 
842
1011
  options = { hard_wrap: false }
@@ -863,29 +1032,135 @@ module Gemstar
863
1032
  { title: title, html: fragment.to_html }
864
1033
  end
865
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
+
866
1058
  def compare_versions(left, right)
867
1059
  Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, ""))
868
1060
  rescue ArgumentError
869
1061
  left.to_s <=> right.to_s
870
1062
  end
871
1063
 
872
- def metadata_for(gem_name, refresh_if_missing: false)
873
- 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]
874
1069
  return nil if cached.equal?(MISSING_METADATA)
875
1070
  return cached if cached
876
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
+
877
1082
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
878
1083
  if metadata.nil? && refresh_if_missing
879
1084
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
880
1085
  end
881
1086
 
882
- @metadata_cache[gem_name] = metadata || MISSING_METADATA
1087
+ @metadata_cache[cache_key] = metadata || MISSING_METADATA
883
1088
  metadata
884
1089
  rescue StandardError
885
- @metadata_cache[gem_name] = MISSING_METADATA
1090
+ @metadata_cache[cache_key] = MISSING_METADATA
886
1091
  nil
887
1092
  end
888
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
+
889
1164
  def detail_pending?(gem_name, metadata, groups)
890
1165
  false
891
1166
  end
@@ -940,7 +1215,7 @@ module Gemstar
940
1215
  end
941
1216
 
942
1217
  def revision_card_links(section)
943
- repo_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true)
1218
+ repo_url = repo_url_for(@selected_gem)
944
1219
  return [] if repo_url.to_s.empty?
945
1220
 
946
1221
  links = []
@@ -953,25 +1228,33 @@ module Gemstar
953
1228
  end
954
1229
 
955
1230
  def fallback_current_section(gem_state, previous_sections, latest_sections)
956
- version = gem_state[:new_version] || gem_state[:old_version]
1231
+ metadata = metadata_for(gem_state) || {}
1232
+ version = effective_package_version(gem_state, metadata)
957
1233
  return nil if version.nil?
958
1234
  return nil if previous_sections.any? { |section| section[:version] == version }
959
1235
  return nil if latest_sections.any? { |section| section[:version] == version }
960
1236
 
961
- metadata = metadata_for(gem_state[:name]) || {}
962
- repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true)
1237
+ repo_url = repo_url_for(gem_state, metadata: metadata)
963
1238
  fallback_url =
964
1239
  if !repo_url.to_s.empty?
965
1240
  repo_url
966
1241
  elsif metadata["project_uri"]
967
1242
  metadata["project_uri"]
1243
+ elsif metadata["source_code_uri"]
1244
+ metadata["source_code_uri"]
1245
+ elsif metadata["homepage_uri"]
1246
+ metadata["homepage_uri"]
968
1247
  else
969
1248
  metadata["documentation_uri"]
970
1249
  end
971
1250
  fallback_label = if repo_url.to_s.empty?
972
- 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
973
1256
  else
974
- "the gem repository"
1257
+ gem_state[:package_scope] == "gems" ? "the gem repository" : "the package repository"
975
1258
  end
976
1259
  fallback_link = if fallback_url.to_s.empty?
977
1260
  fallback_label
@@ -995,6 +1278,16 @@ module Gemstar
995
1278
  nil
996
1279
  end
997
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
+
998
1291
  def github_compare_url(repo_url, previous_version, current_version)
999
1292
  return nil unless repo_url.include?("github.com")
1000
1293
  return nil if previous_version.nil? || current_version.nil?
@@ -1024,17 +1317,18 @@ module Gemstar
1024
1317
  html
1025
1318
  end
1026
1319
 
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)}"
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)}"
1029
1322
  end
1030
1323
 
1031
- def project_query(project:, from:, to:, filter:, gem:)
1324
+ def project_query(project:, from:, to:, filter:, scope:, package:)
1032
1325
  params = {
1033
1326
  project: project,
1034
1327
  from: from,
1035
1328
  to: to,
1036
1329
  filter: filter,
1037
- gem: gem
1330
+ scope: scope,
1331
+ package: package
1038
1332
  }.compact
1039
1333
 
1040
1334
  "/?#{URI.encode_www_form(params)}"
@@ -1044,21 +1338,18 @@ module Gemstar
1044
1338
  render_template(
1045
1339
  "page.html.erb",
1046
1340
  title: h(title),
1047
- favicon_data_uri: favicon_data_uri,
1048
1341
  styles_css: template_source("app.css"),
1049
1342
  body_html: yield
1050
1343
  )
1051
1344
  end
1052
1345
 
1053
- def favicon_data_uri
1054
- svg = <<~SVG
1346
+ def favicon_svg
1347
+ <<~SVG
1055
1348
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
1056
1349
  <rect width="64" height="64" rx="14" fill="#b44d25"/>
1057
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>
1058
1351
  </svg>
1059
1352
  SVG
1060
-
1061
- "data:image/svg+xml,#{URI.encode_www_form_component(svg)}"
1062
1353
  end
1063
1354
 
1064
1355
  def render_behavior_script
@@ -1066,6 +1357,7 @@ module Gemstar
1066
1357
  "app.js.erb",
1067
1358
  empty_detail_html_json: empty_detail_html.dump,
1068
1359
  selected_filter_json: @selected_filter.dump,
1360
+ selected_package_scope_json: @selected_package_scope.dump,
1069
1361
  selected_project_index: @selected_project_index || 0
1070
1362
  )
1071
1363
 
@@ -1091,6 +1383,24 @@ module Gemstar
1091
1383
  def h(value)
1092
1384
  CGI.escapeHTML(value.to_s)
1093
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
1094
1404
  end
1095
1405
  end
1096
1406
  end