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.
@@ -0,0 +1,193 @@
1
+ require "json"
2
+
3
+ module Gemstar
4
+ class ImportmapFile
5
+ Pin = Struct.new(:name, :target, :source, keyword_init: true)
6
+
7
+ IMPORTMAP_PACKAGE_METADATA_PATH = File.expand_path("data/importmap_package_metadata.json", __dir__)
8
+
9
+ def self.package_metadata
10
+ @package_metadata ||= begin
11
+ JSON.parse(File.read(IMPORTMAP_PACKAGE_METADATA_PATH)).transform_values do |attributes|
12
+ attributes.each_with_object({}) do |(key, value), metadata|
13
+ metadata[key.to_sym] = value
14
+ end
15
+ end
16
+ rescue Errno::ENOENT, JSON::ParserError
17
+ {}
18
+ end
19
+ end
20
+
21
+ def initialize(path: nil, content: nil, vendor_reader: nil)
22
+ @path = path
23
+ @vendor_reader = vendor_reader
24
+ @pins = parse_content(content || File.read(path))
25
+ end
26
+
27
+ attr_reader :pins
28
+
29
+ def specs
30
+ pins.transform_values(&:target)
31
+ end
32
+
33
+ def source_for(name)
34
+ pins[name]&.source
35
+ end
36
+
37
+ private
38
+
39
+ def parse_content(content)
40
+ content.each_line.with_object({}) do |line, pins|
41
+ next unless line =~ /^\s*pin\s+["']([^"']+)["'](.*)$/
42
+
43
+ name = Regexp.last_match(1)
44
+ rest = Regexp.last_match(2).to_s
45
+ target = rest[/\bto:\s*["']([^"']+)["']/, 1] || name
46
+ inline_version = rest[/#\s*@([^\s]+)/, 1]
47
+
48
+ pins[name] = Pin.new(
49
+ name: name,
50
+ target: target,
51
+ source: build_source_for(name, target, inline_version: inline_version)
52
+ )
53
+ end
54
+ end
55
+
56
+ def build_source_for(name, target, inline_version: nil)
57
+ {
58
+ type: :importmap,
59
+ remote: target
60
+ }
61
+ .merge(default_package_metadata_for(name, inline_version: inline_version))
62
+ .merge(vendored_package_metadata_for(target))
63
+ .merge(cdn_package_metadata_for(target))
64
+ .merge(repo_source_for(name, target))
65
+ end
66
+
67
+ def default_package_metadata_for(name, inline_version: nil)
68
+ return {} unless package_like_pin_name?(name)
69
+
70
+ metadata = { package_name: name, registry_url: "https://www.npmjs.com/package/#{name}" }
71
+ unless inline_version.to_s.empty?
72
+ key = exact_package_version?(inline_version) ? :package_version : :package_requirement
73
+ metadata[key] = inline_version
74
+ end
75
+ metadata
76
+ end
77
+
78
+ def vendored_package_metadata_for(target)
79
+ return {} unless local_javascript_target?(target)
80
+
81
+ first_line = vendored_file_first_line(target)
82
+ return {} if first_line.empty?
83
+
84
+ if first_line =~ %r{\A//\s+((?:@[^/]+/)?[^@\s]+(?:/[^@\s]+)?)@([^\s]+)\s+downloaded from\s+(https?://\S+)}
85
+ package_name = Regexp.last_match(1)
86
+ package_version = Regexp.last_match(2)
87
+ remote = Regexp.last_match(3)
88
+ remote_metadata = cdn_package_metadata_for(remote)
89
+ package_name = remote_metadata[:package_name] || package_name
90
+ package_version = remote_metadata[:package_version] || package_version
91
+ {
92
+ package_name: package_name,
93
+ package_version: package_version,
94
+ registry_url: "https://www.npmjs.com/package/#{package_name}",
95
+ remote: remote
96
+ }
97
+ else
98
+ {}
99
+ end
100
+ rescue EOFError
101
+ {}
102
+ end
103
+
104
+ def vendored_file_first_line(target)
105
+ if @vendor_reader
106
+ content = @vendor_reader.call(target.to_s)
107
+ return content.to_s.lines.first.to_s if content
108
+ end
109
+
110
+ return "" unless @path
111
+
112
+ vendor_path = File.expand_path(File.join(File.dirname(@path), "..", "vendor", "javascript", target.to_s))
113
+ return "" unless File.file?(vendor_path)
114
+
115
+ File.open(vendor_path, &:readline).to_s
116
+ rescue EOFError
117
+ ""
118
+ end
119
+
120
+ def cdn_package_metadata_for(target)
121
+ value = target.to_s
122
+ return {} if value.empty?
123
+
124
+ package_name, package_version =
125
+ case value
126
+ when %r{\Ahttps://esm\.sh/((?:@[^/]+/)?[^@/?]+)@([^/?]+)}
127
+ [Regexp.last_match(1), Regexp.last_match(2)]
128
+ when %r{\Ahttps://ga\.jspm\.io/npm:((?:@[^/]+/)?[^@/]+)@([^/]+)/}
129
+ [Regexp.last_match(1), Regexp.last_match(2)]
130
+ when %r{\Ahttps://cdn\.jsdelivr\.net/npm/((?:@[^/]+/)?[^@/]+)@([^/]+)/}
131
+ [Regexp.last_match(1), Regexp.last_match(2)]
132
+ when %r{\Ahttps://unpkg\.com/((?:@[^/]+/)?[^@/?]+)@([^/?]+)}
133
+ [Regexp.last_match(1), Regexp.last_match(2)]
134
+ else
135
+ [nil, nil]
136
+ end
137
+
138
+ return {} unless package_name
139
+
140
+ version_metadata = exact_package_version?(package_version) ? { package_version: package_version } : { package_requirement: package_version }
141
+
142
+ version_metadata.merge(
143
+ package_name: package_name,
144
+ registry_url: "https://www.npmjs.com/package/#{package_name}"
145
+ )
146
+ end
147
+
148
+ def repo_source_for(name, target)
149
+ package_metadata = self.class.package_metadata[name]
150
+ repo_url = github_repo_url_for(target) || package_metadata&.dig(:repo_url)
151
+ metadata = {}
152
+ metadata[:repo_url] = repo_url if repo_url
153
+ metadata[:provider_gem] = package_metadata[:provider_gem] if package_metadata&.dig(:provider_gem)
154
+ metadata
155
+ end
156
+
157
+ def github_repo_url_for(target)
158
+ value = target.to_s
159
+ return nil if value.empty?
160
+
161
+ case value
162
+ when %r{\Ahttps://raw\.githubusercontent\.com/([^/]+/[^/]+)/}
163
+ "https://github.com/#{Regexp.last_match(1)}"
164
+ when %r{\Ahttps://github\.com/([^/]+/[^/]+)}
165
+ "https://github.com/#{Regexp.last_match(1)}"
166
+ else
167
+ nil
168
+ end
169
+ end
170
+
171
+ def package_like_pin_name?(name)
172
+ value = name.to_s
173
+ return false if value.empty?
174
+ return false if value.start_with?("controllers/")
175
+ return false if value.include?("_controller")
176
+ return false if value.start_with?("./", "../")
177
+
178
+ value.start_with?("@") || value.match?(/\A[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*\z/i)
179
+ end
180
+
181
+ def exact_package_version?(version)
182
+ version.to_s.match?(/\Av?\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*\z/)
183
+ end
184
+
185
+ def local_javascript_target?(target)
186
+ value = target.to_s
187
+ return false if value.empty?
188
+ return false if value.match?(%r{\A[a-z]+://}i)
189
+
190
+ value.end_with?(".js", ".mjs")
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,159 @@
1
+ require "open-uri"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Gemstar
6
+ class NpmMetadata
7
+ def initialize(package_name)
8
+ @gem_name = package_name
9
+ end
10
+
11
+ attr_reader :gem_name
12
+
13
+ def cache_key
14
+ "npm-#{gem_name}"
15
+ end
16
+
17
+ def meta(cache_only: false, force_refresh: false)
18
+ return @meta if !cache_only && defined?(@meta)
19
+
20
+ json = if cache_only
21
+ Cache.peek(cache_key)
22
+ else
23
+ url = "https://registry.npmjs.org/#{URI.encode_www_form_component(gem_name)}"
24
+ Cache.fetch(cache_key, force: force_refresh) do
25
+ URI.open(url, read_timeout: 8).read
26
+ end
27
+ end
28
+
29
+ parsed = begin
30
+ JSON.parse(json) if json
31
+ rescue
32
+ nil
33
+ end
34
+
35
+ normalized = normalize_meta(parsed)
36
+ @meta = normalized unless cache_only
37
+ normalized
38
+ end
39
+
40
+ def repo_uri(cache_only: false, force_refresh: false)
41
+ resolved_meta = meta(cache_only: cache_only, force_refresh: force_refresh)
42
+ return nil unless resolved_meta
43
+
44
+ return @repo_uri if !cache_only && defined?(@repo_uri)
45
+
46
+ repo = begin
47
+ uri = resolved_meta["source_code_uri"]
48
+ uri ||= resolved_meta["homepage_uri"] if resolved_meta["homepage_uri"].to_s.include?("github.com")
49
+ uri ||= ""
50
+
51
+ uri = uri.sub(/\Agit\+/, "")
52
+ uri = uri.sub("git://", "https://")
53
+ uri = uri.sub("http://", "https://")
54
+ uri = uri.gsub(/\.git$/, "")
55
+
56
+ if uri.include?("github.com")
57
+ uri = uri[%r{\Ahttps?://github\.com/[^/]+/[^/]+}] || uri
58
+ end
59
+
60
+ uri
61
+ end
62
+
63
+ @repo_uri = repo unless cache_only
64
+ repo
65
+ end
66
+
67
+ def changelog_sections(versions: nil, cache_only: false, force_refresh: false)
68
+ requested_versions = Array(versions).compact
69
+ changelog = Gemstar::ChangeLog.new(self)
70
+ if requested_versions.empty?
71
+ changelog.sections(cache_only: cache_only, force_refresh: force_refresh)
72
+ else
73
+ changelog.sections_for_versions(requested_versions, cache_only: cache_only, force_refresh: force_refresh)
74
+ end
75
+ end
76
+
77
+ def warm_cache(versions: nil)
78
+ meta
79
+ repo_uri
80
+ changelog_sections(versions: versions)
81
+ end
82
+
83
+ def discover_github_tag_sections?
84
+ true
85
+ end
86
+
87
+ def github_tag_candidates(version)
88
+ raw = version.to_s
89
+ candidates = [raw, (raw.start_with?("v") ? raw : "v#{raw}")]
90
+
91
+ package_name = gem_name.to_s
92
+ unless package_name.empty?
93
+ candidates << "#{package_name}@#{raw}"
94
+ candidates << "#{package_name}@v#{raw}" unless raw.start_with?("v")
95
+
96
+ short_name = package_name.split("/").last
97
+ if short_name && short_name != package_name
98
+ candidates << "#{short_name}@#{raw}"
99
+ candidates << "#{short_name}@v#{raw}" unless raw.start_with?("v")
100
+ end
101
+ end
102
+
103
+ candidates.uniq
104
+ end
105
+
106
+ def github_tag_matches?(tag_name)
107
+ decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
108
+ package_name = gem_name.to_s
109
+ return true if package_name.empty?
110
+
111
+ if decoded.include?("@")
112
+ prefix = decoded.sub(/@v?\d[\w.\-]*\z/i, "")
113
+ return true if prefix == package_name
114
+
115
+ short_name = package_name.split("/").last
116
+ return true if short_name && prefix == short_name
117
+
118
+ return false
119
+ end
120
+
121
+ true
122
+ end
123
+
124
+ private
125
+
126
+ def normalize_meta(parsed)
127
+ return nil unless parsed.is_a?(Hash)
128
+
129
+ latest_version = parsed.dig("dist-tags", "latest").to_s
130
+ version_meta = parsed.dig("versions", latest_version)
131
+ source_code_uri = repository_url(version_meta || parsed)
132
+ homepage_uri = homepage_url(version_meta || parsed)
133
+
134
+ {
135
+ "name" => parsed["name"],
136
+ "version" => latest_version.empty? ? nil : latest_version,
137
+ "info" => (version_meta || parsed)["description"],
138
+ "homepage_uri" => homepage_uri,
139
+ "source_code_uri" => source_code_uri,
140
+ "project_uri" => "https://www.npmjs.com/package/#{parsed["name"]}",
141
+ "documentation_uri" => homepage_uri
142
+ }
143
+ end
144
+
145
+ def repository_url(meta)
146
+ repository = meta["repository"]
147
+ case repository
148
+ when Hash
149
+ repository["url"]
150
+ when String
151
+ repository
152
+ end
153
+ end
154
+
155
+ def homepage_url(meta)
156
+ meta["homepage"]
157
+ end
158
+ end
159
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "basic"
4
4
  require "pathname"
5
+ require "cgi"
5
6
  require "kramdown"
6
7
  begin
7
8
  require "kramdown-parser-gfm"
@@ -14,7 +15,7 @@ module Gemstar
14
15
  class HTML < Basic
15
16
  def render_diff(diff_command)
16
17
  body = diff_command.updates.sort.map do |gem_name, info|
17
- icon = info[:homepage_url]&.include?("github.com") ? "🐙" : "💎"
18
+ icon = icon_for(info)
18
19
  tooltip = info[:description] ? "title=\"#{info[:description].gsub('"', "&quot;")}\"" : ""
19
20
  link = "<a href=\"#{info[:homepage_url]}\" #{tooltip} target=\"_blank\">#{gem_name}</a>"
20
21
  html = if info[:sections]
@@ -38,14 +39,14 @@ module Gemstar
38
39
 
39
40
  <<~HTML
40
41
  <section>
41
- <h2>#{icon} #{link}: #{info[:old] || "new"} → #{info[:new]}</h2>
42
+ <h2>#{icon} #{link}: #{version_label(info)}</h2>
42
43
  #{"<p><a href='#{info[:release_page]}' target='_blank'>View all GitHub release notes</a></p>" if info[:release_page]}
43
44
  #{html}
44
45
  </section>
45
46
  HTML
46
47
  end.join("\n")
47
48
 
48
- project_name = Pathname.getwd.basename.to_s
49
+ project_name = diff_command.project_name
49
50
 
50
51
  <<~HTML
51
52
  <!DOCTYPE html>
@@ -63,13 +64,61 @@ module Gemstar
63
64
  </style>
64
65
  </head>
65
66
  <body>
66
- <h1>#{project_name}: Gem Updates</h1>
67
+ <h1>#{project_name}: Package Updates</h1>
67
68
  <p><i>Showing changes from #{diff_command.from} to #{diff_command.to || "now"}, generated on #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}.</i></p>
69
+ #{range_details(diff_command)}
68
70
  #{body}
71
+ #{considered_commits(diff_command)}
69
72
  </body>
70
73
  </html>
71
74
  HTML
72
75
  end
76
+
77
+ private
78
+
79
+ def icon_for(info)
80
+ return "📦" if info[:package_scope] == "js"
81
+ return "🐙" if info[:homepage_url]&.include?("github.com")
82
+
83
+ "💎"
84
+ end
85
+
86
+ def version_label(info)
87
+ info[:version_label] || "#{info[:old] || "new"} → #{info[:new]}"
88
+ end
89
+
90
+ def range_details(diff_command)
91
+ return "" unless diff_command.since
92
+
93
+ cutoff = diff_command.format_commit(diff_command.since_cutoff_commit, fallback_revision: diff_command.from)
94
+ <<~HTML
95
+ <section>
96
+ <h2>Diff Range</h2>
97
+ <p>Since cutoff <code>#{h(diff_command.since)}</code> resolved to #{h(cutoff)}.</p>
98
+ </section>
99
+ HTML
100
+ end
101
+
102
+ def considered_commits(diff_command)
103
+ commits = Array(diff_command.considered_commits)
104
+ items = commits.map do |commit|
105
+ %(<li><code>#{h(commit[:short_sha] || commit[:id])}</code> #{h(commit[:authored_at])} #{h(commit[:subject])}</li>)
106
+ end.join("\n")
107
+ items = "<li>No commits found in this range.</li>" if items.empty?
108
+
109
+ <<~HTML
110
+ <section>
111
+ <h2>Commits Considered</h2>
112
+ <ul>
113
+ #{items}
114
+ </ul>
115
+ </section>
116
+ HTML
117
+ end
118
+
119
+ def h(value)
120
+ CGI.escapeHTML(value.to_s)
121
+ end
73
122
  end
74
123
  end
75
124
  end
@@ -9,17 +9,21 @@ module Gemstar
9
9
  module Outputs
10
10
  class Markdown < Basic
11
11
  def render_diff(diff_command)
12
- project_name = Pathname.getwd.basename.to_s
12
+ project_name = diff_command.project_name
13
13
  body = diff_command.updates.sort.map do |gem_name, info|
14
14
  render_entry(gem_name, info)
15
15
  end.join("\n\n---\n\n")
16
16
 
17
17
  <<~MARKDOWN
18
- # #{project_name}: Gem Updates
18
+ # #{project_name}: Package Updates
19
19
 
20
20
  _Showing changes from #{diff_command.from} to #{diff_command.to || "now"}, generated on #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}._
21
21
 
22
+ #{range_details(diff_command)}
23
+
22
24
  #{body}
25
+
26
+ #{considered_commits(diff_command)}
23
27
  MARKDOWN
24
28
  end
25
29
 
@@ -35,7 +39,7 @@ module Gemstar
35
39
  parts = []
36
40
  parts << title
37
41
  parts << ""
38
- parts << "*#{info[:old] || "new"} → #{info[:new]}*"
42
+ parts << "*#{info[:version_label] || "#{info[:old] || "new"} → #{info[:new]}"}*"
39
43
  parts << ""
40
44
  parts << info[:description].to_s unless info[:description].to_s.empty?
41
45
  parts << "" unless info[:description].to_s.empty?
@@ -53,6 +57,28 @@ module Gemstar
53
57
  parts.join("\n").strip
54
58
  end
55
59
 
60
+ def range_details(diff_command)
61
+ return "" unless diff_command.since
62
+
63
+ cutoff = diff_command.format_commit(diff_command.since_cutoff_commit, fallback_revision: diff_command.from)
64
+ "## Diff Range\n\nSince cutoff `#{diff_command.since}` resolved to #{cutoff}."
65
+ end
66
+
67
+ def considered_commits(diff_command)
68
+ commits = Array(diff_command.considered_commits)
69
+ lines = ["## Commits Considered", ""]
70
+
71
+ if commits.empty?
72
+ lines << "No commits found in this range."
73
+ else
74
+ lines.concat(commits.map do |commit|
75
+ "- `#{commit[:short_sha] || commit[:id]}` #{commit[:authored_at]} #{commit[:subject]}"
76
+ end)
77
+ end
78
+
79
+ lines.join("\n")
80
+ end
81
+
56
82
  def link_lines(info)
57
83
  lines = []
58
84
  lines << "[Compare changes](#{info[:compare_url]})" if info[:compare_url]
@@ -0,0 +1,101 @@
1
+ require "json"
2
+
3
+ module Gemstar
4
+ class PackageLockFile
5
+ def initialize(path: nil, content: nil)
6
+ @path = path
7
+ parsed = parse_content(content || File.read(path))
8
+ @specs = parsed[:specs]
9
+ @spec_sources = parsed[:spec_sources]
10
+ end
11
+
12
+ attr_reader :specs
13
+ attr_reader :spec_sources
14
+
15
+ def source_for(name)
16
+ spec_sources[name]
17
+ end
18
+
19
+ private
20
+
21
+ def parse_content(content)
22
+ parsed = JSON.parse(content)
23
+ specs = {}
24
+ spec_sources = {}
25
+
26
+ if parsed["packages"].is_a?(Hash)
27
+ parse_packages_map(parsed["packages"], specs, spec_sources)
28
+ elsif parsed["dependencies"].is_a?(Hash)
29
+ parse_dependencies_hash(parsed["dependencies"], specs, spec_sources)
30
+ end
31
+
32
+ {
33
+ specs: specs,
34
+ spec_sources: spec_sources
35
+ }
36
+ end
37
+
38
+ def parse_packages_map(packages, specs, spec_sources)
39
+ packages.each do |path, package|
40
+ next if path.to_s.empty?
41
+
42
+ name = package["name"] || package_name_from_path(path)
43
+ version = package["version"]
44
+ next if name.to_s.empty? || version.to_s.empty?
45
+
46
+ specs[name] = version
47
+ spec_sources[name] = {
48
+ type: :npm,
49
+ remote: package["resolved"],
50
+ integrity: package["integrity"],
51
+ registry_url: "https://www.npmjs.com/package/#{name}"
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ def parse_dependencies_hash(dependencies, specs, spec_sources)
57
+ dependencies.each do |name, package|
58
+ version = package["version"]
59
+ next if name.to_s.empty? || version.to_s.empty?
60
+
61
+ specs[name] = version
62
+ spec_sources[name] = {
63
+ type: :npm,
64
+ remote: package["resolved"],
65
+ integrity: package["integrity"],
66
+ registry_url: "https://www.npmjs.com/package/#{name}"
67
+ }.compact
68
+
69
+ child_dependencies = package["dependencies"]
70
+ parse_dependencies_hash(child_dependencies, specs, spec_sources) if child_dependencies.is_a?(Hash)
71
+ end
72
+ end
73
+
74
+ def package_name_from_path(path)
75
+ value = path.to_s
76
+ return nil if value.empty?
77
+
78
+ segments = value.split("/").reject(&:empty?)
79
+ package_segments = []
80
+ index = 0
81
+ while index < segments.length
82
+ if segments[index] == "node_modules"
83
+ index += 1
84
+ next
85
+ end
86
+
87
+ if segments[index].start_with?("@") && segments[index + 1]
88
+ package_segments = [segments[index], segments[index + 1]]
89
+ index += 2
90
+ else
91
+ package_segments = [segments[index]]
92
+ index += 1
93
+ end
94
+ end
95
+
96
+ return nil if package_segments.empty?
97
+
98
+ package_segments.join("/")
99
+ end
100
+ end
101
+ end