hoe-halostatue 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hoe::Halostatue::Gemspec
4
+ # Whether a fixed date should be used for reproducible gemspec values. This is ignored
5
+ # if `$SOURCE_DATE_EPOCH` is set. Acceptable values are:
6
+ #
7
+ # - `:default` or `true`: uses the RubyGems default source date epoch
8
+ # - `:current`: uses the date stored in the most recent gemspec file
9
+ # - `false`: sets the release date to the current date
10
+ # - An epoch value, either as an Integer or a String
11
+ #
12
+ # [default: `:default`]
13
+ attr_accessor :reproducible_gemspec
14
+
15
+ private
16
+
17
+ LINKS = /\[(?<name>.+?)\](?:\(.+?\)|\[.+?\])/ # :nodoc:
18
+ PERMITTED_CLASSES = [ # :nodoc:
19
+ Symbol, Time, Date, Gem::Dependency, Gem::Platform, Gem::Requirement,
20
+ Gem::Specification, Gem::Version, Gem::Version::Requirement
21
+ ].freeze
22
+ PERMITTED_SYMBOLS = %i[development runtime].freeze # :nodoc:
23
+
24
+ private_constant :LINKS, :PERMITTED_CLASSES, :PERMITTED_SYMBOLS
25
+
26
+ def initialize_halostatue_gemspec
27
+ self.reproducible_gemspec = :default
28
+ end
29
+
30
+ def define_halostatue_gemspec_tasks
31
+ gemspec = "#{spec.name}.gemspec"
32
+
33
+ with_config do
34
+ unless ".gemspec".match?(_1["exclude"])
35
+ warn "WARNING You should add .gemspec to your .hoerc exclude list"
36
+ end
37
+ end
38
+
39
+ epoch = resolve_source_date_epoch
40
+ ENV["SOURCE_DATE_EPOCH"] = epoch&.to_s
41
+
42
+ file gemspec => %w[clobber Manifest.txt] + spec.files do
43
+ spec2 = resolve_gemspec
44
+ spec2.date = epoch if spec2.respond_to?(:date=)
45
+
46
+ clear_rubygem_signing(spec2)
47
+ clean_markdown_links(spec2)
48
+
49
+ File.write(gemspec, spec2.to_ruby)
50
+ end
51
+
52
+ desc "Regenerate #{gemspec}"
53
+ task gemspec: gemspec
54
+ task default: gemspec
55
+ end
56
+
57
+ def clear_rubygem_signing(spec)
58
+ spec.signing_key = spec.default_value(:signing_key)
59
+ spec.cert_chain = spec.default_value(:cert_chain)
60
+ end
61
+
62
+ def clean_markdown_links(spec)
63
+ spec.description = spec.description.gsub(LINKS, '\k<name>').gsub(/\r?\n/, " ")
64
+ spec.summary = spec.summary.gsub(LINKS, '\k<name>').gsub(/\r?\n/, " ")
65
+ end
66
+
67
+ def resolve_source_date_epoch
68
+ epoch = ENV["SOURCE_DATE_EPOCH"]
69
+ epoch = nil if !epoch.nil? && epoch.strip.empty?
70
+
71
+ if epoch
72
+ Time.at(epoch.to_i).utc.freeze
73
+ elsif reproducible_gemspec == :default || reproducible_gemspec == true
74
+ Gem.source_date_epoch
75
+ elsif reproducible_gemspec == :current
76
+ Gem::Specification.load(gemspec)&.date&.freeze || Gem.source_date_epoch
77
+ elsif reproducible_gemspec.is_a?(String)
78
+ Time.at(reproducible_gemspec.to_i).utc.freeze
79
+ elsif reproducible_gemspec.is_a?(Integer)
80
+ Time.at(reproducible_gemspec).utc.freeze
81
+ elsif reproducible_gemspec == false
82
+ nil
83
+ else
84
+ raise ArgumentError,
85
+ "Invalid value for `reproducible_gemspec`: #{reproducible_gemspec.inspect}"
86
+ end
87
+ end
88
+
89
+ def resolve_gemspec
90
+ YAML.safe_load(
91
+ YAML.safe_dump(
92
+ spec,
93
+ permitted_classes: PERMITTED_CLASSES,
94
+ permitted_symbols: PERMITTED_SYMBOLS,
95
+ aliases: true
96
+ ),
97
+ permitted_classes: PERMITTED_CLASSES,
98
+ permitted_symbols: PERMITTED_SYMBOLS,
99
+ aliases: true
100
+ )
101
+ rescue
102
+ YAML.safe_load(YAML.dump(spec), PERMITTED_CLASSES, PERMITTED_SYMBOLS, true)
103
+ end
104
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Hoe::Halostatue::Git
6
+ # What do you want at the front of your release tags?
7
+ # [default: `"v"`]
8
+ attr_accessor :git_release_tag_prefix
9
+
10
+ # Which remotes do you want to push tags, etc. to?
11
+ # [default: `%w[origin]`]
12
+ attr_accessor :git_remotes
13
+
14
+ # Should git tags be created on release? [default: `true`]
15
+ attr_accessor :git_tag_enabled
16
+
17
+ private
18
+
19
+ def initialize_halostatue_git # :nodoc:
20
+ self.git_release_tag_prefix = "v"
21
+ self.git_remotes = %w[origin]
22
+ self.git_tag_enabled = true
23
+ end
24
+
25
+ def define_halostatue_git_tasks # :nodoc:
26
+ return unless __run_git("rev-parse", "--is-inside-work-tree") == "true"
27
+
28
+ desc "Update the manifest with Git's file list. Use Hoe's excludes."
29
+ task "git:manifest" do
30
+ with_config do |config, _|
31
+ files = __run_git("ls-files")
32
+ .split($/)
33
+ .grep_v(config["exclude"])
34
+
35
+ File.write "Manifest.txt", files.sort.join("\n") + "\n"
36
+ end
37
+ end
38
+
39
+ desc "Create and push a TAG (default #{git_release_tag_prefix}#{version})."
40
+ task "git:tag" do
41
+ if git_tag_enabled
42
+ tag = ENV["TAG"]
43
+ ver = ENV["VERSION"] || version
44
+ pre = ENV["PRERELEASE"] || ENV["PRE"]
45
+ ver += ".#{pre}" if pre && !ver.ends_with?(pre)
46
+ tag ||= "#{git_release_tag_prefix}#{ver}"
47
+
48
+ git_tag_and_push tag
49
+ end
50
+ end
51
+
52
+ task :release_sanity do
53
+ unless __run_git("status", "--porcelain").empty?
54
+ abort "Won't release: Dirty index or untracked files present!"
55
+ end
56
+ end
57
+
58
+ task release_to: "git:tag"
59
+ end
60
+
61
+ def __git(command, *params)
62
+ "git #{command.shellescape} #{params.compact.shelljoin}"
63
+ end
64
+
65
+ def __run_git(command, *params)
66
+ `#{__git(command, *params)}`.strip.chomp
67
+ end
68
+
69
+ def git_svn?
70
+ File.exist?(File.join(__run_git("rev-parse", "--show-toplevel"), ".git/svn"))
71
+ end
72
+
73
+ def git_tag_and_push tag
74
+ msg = "Tagging #{tag}."
75
+
76
+ flags = "-s" unless __run_git("config", "--get", "user.signingkey").empty?
77
+
78
+ sh __git("tag", flags, "-f", tag, "-m", msg)
79
+ git_remotes.each { |remote| sh __git("push", "-f", remote, "tag", tag) }
80
+ end
81
+ end
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "set"
5
+
6
+ class Hoe
7
+ module Halostatue
8
+ module Markdown
9
+ end
10
+ end
11
+ end
12
+
13
+ class Hoe::Halostatue::Markdown::Linkify # :nodoc:
14
+ # Match GitHub username (@username) but not email addresses or Mastodon handles
15
+ # Basic pattern - additional validation done post-match
16
+ USERNAME_PATTERN = %r{
17
+ (?<![A-Za-z0-9]) # Not preceded by alphanumeric (prevents email match)
18
+ @ # Literal @ sign
19
+ ([A-Za-z0-9-]{1,39}) # 1-39 chars of alphanumeric or hyphen
20
+ (?![-A-Za-z0-9@]) # Not followed by alphanumeric, hyphen, or @ (prevents Mastodon/email)
21
+ }x
22
+
23
+ def self.linkify_file(filename, ...)
24
+ new(...).linkify(File.read(filename))
25
+ end
26
+
27
+ def self.linkify(source, ...)
28
+ new(...).linkify(source)
29
+ end
30
+
31
+ def self.normalize_uri_prefixes(prefixes)
32
+ case prefixes
33
+ when true
34
+ {issue: "issue", pull: "pull request", discussion: "discussion"}
35
+ when false, nil
36
+ {}
37
+ else
38
+ extra = prefixes.keys - %i[issue pull discussion]
39
+
40
+ if extra.empty?
41
+ prefixes
42
+ else
43
+ raise ArgumentError, "Extra keys for markdown_linkify_uri_prefixes: #{extra.inspect}"
44
+ end
45
+ end
46
+ end
47
+
48
+ def initialize(bug_tracker_uri: nil, style: :reference, uri_prefixes: {})
49
+ @bug_tracker_uri = bug_tracker_uri
50
+ @repo_owner, @repo_name = extract_repo_info(bug_tracker_uri)
51
+ @style = style
52
+ @uri_prefixes = self.class.normalize_uri_prefixes(uri_prefixes)
53
+ end
54
+
55
+ def linkify(source)
56
+ scan_existing_references(source) => {existing_by_key:, existing_by_ref:}
57
+ new_by_key = {}
58
+ new_by_ref = {}
59
+ replacements = []
60
+ processed_positions = Set.new
61
+
62
+ doc = Kramdown::Document.new(source, input: "GFM")
63
+
64
+ walk(doc.root) do |element, ancestors|
65
+ next unless element.type == :text
66
+ next if in_skip_context?(ancestors)
67
+
68
+ text = element.value
69
+
70
+ # Find this text occurrence in source that we haven't processed yet
71
+ source_offset = 0
72
+ while (text_pos = source.index(text, source_offset))
73
+ break if processed_positions.include?(text_pos)
74
+
75
+ # Mark this position as processed
76
+ processed_positions.add(text_pos)
77
+
78
+ # Scan for patterns in this text occurrence
79
+ scan_usernames(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
80
+ scan_owner_repo_issues(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
81
+ scan_issue_mentions(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
82
+ scan_github_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
83
+ scan_github_repo_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
84
+ scan_github_user_uris(source, text, text_pos, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
85
+
86
+ break # Only process first unprocessed occurrence
87
+ end
88
+ end
89
+
90
+ return {markdown: source, changed: false} if replacements.empty?
91
+
92
+ # Apply replacements in reverse
93
+ result = source.dup
94
+
95
+ replacements.reverse_each do |r|
96
+ result[r[:start]...r[:end]] = r[:replacement]
97
+ end
98
+
99
+ # Append new references if using reference style
100
+ if @style == :reference && !new_by_key.empty?
101
+ result = append_references(result, new_by_key)
102
+ end
103
+
104
+ {markdown: result, changed: true}
105
+ end
106
+
107
+ private
108
+
109
+ def extract_repo_info(uri)
110
+ return [nil, nil] unless uri
111
+
112
+ match = uri.match(%r{github\.com/([^/]+)/([^/]+)})
113
+ match ? [match[1], match[2]] : [nil, nil]
114
+ end
115
+
116
+ def walk(element, ancestors = [], &block)
117
+ new_ancestors = ancestors + [element]
118
+ yield element, ancestors
119
+ element&.children&.each { |child| walk(child, new_ancestors, &block) }
120
+ end
121
+
122
+ def in_skip_context?(ancestors)
123
+ ancestors.any? { |el| %i[codeblock codespan a html_element].include?(el.type) }
124
+ end
125
+
126
+ def scan_existing_references(source)
127
+ existing_by_key = {}
128
+ existing_by_ref = {}
129
+
130
+ source.scan(/^\[([^\]]+)\]:\s*(.+)$/) do |key, uri|
131
+ existing_by_key[key] = uri.strip
132
+ existing_by_ref[uri.strip] ||= key
133
+ end
134
+
135
+ {existing_by_key:, existing_by_ref:}
136
+ end
137
+
138
+ def scan_usernames(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
139
+ offset = 0
140
+
141
+ while (match = text.match(USERNAME_PATTERN, offset))
142
+ pos = base_offset + match.begin(0)
143
+ username = match[1]
144
+
145
+ # Validate username
146
+ next offset = pos + match[0].length unless valid_username?(username)
147
+
148
+ # Check the actual source to ensure no consecutive hyphens follow
149
+ # (kramdown may split text nodes at typographic symbols like --)
150
+ source_match = source[pos, username.length + 3]
151
+ next offset = pos + match[0].length if source_match&.include?("--")
152
+
153
+ next offset = pos + match[0].length if already_linked?(source, pos)
154
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
155
+
156
+ uri = "https://github.com/#{username}"
157
+ link_text = "@#{username}"
158
+ ref_base = "gh-user-#{username}"
159
+
160
+ replacement = if @style == :reference
161
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
162
+ "[#{link_text}][#{ref_id}]"
163
+ else
164
+ "[#{link_text}](#{uri})"
165
+ end
166
+
167
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
168
+ offset = pos + match[0].length
169
+ end
170
+ end
171
+
172
+ def scan_issue_mentions(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
173
+ return unless @repo_owner && @repo_name
174
+
175
+ pattern = /#(\d+)(?=\s|$|[^\d])/
176
+ offset = 0
177
+
178
+ while (match = text.match(pattern, offset))
179
+ pos = base_offset + match.begin(0)
180
+ number = match[1]
181
+
182
+ next offset = pos + match[0].length if already_linked?(source, pos)
183
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
184
+
185
+ uri = "https://github.com/#{@repo_owner}/#{@repo_name}/issues/#{number}"
186
+ link_text = "##{number}"
187
+ ref_base = "gh-issue-#{number}"
188
+
189
+ replacement = if @style == :reference
190
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
191
+ "[#{link_text}][#{ref_id}]"
192
+ else
193
+ "[#{link_text}](#{uri})"
194
+ end
195
+
196
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
197
+ offset = pos + match[0].length
198
+ end
199
+ end
200
+
201
+ def scan_github_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
202
+ pattern = %r{https://github\.com/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)/(issues|pull|discussions)/(\d+)(?:#(issuecomment|discussioncomment|discussion_r)-?(\d+))?([?#][^\s)]*)?}
203
+ offset = 0
204
+
205
+ while (match = text.match(pattern, offset))
206
+ pos = base_offset + match.begin(0)
207
+ owner = match[1]
208
+ repo = match[2]
209
+ type = match[3]
210
+ number = match[4]
211
+ comment_type = match[5]
212
+ comment_id = match[6]
213
+ extra_fragment = match[7]
214
+
215
+ next offset = pos + match[0].length if already_linked?(source, pos)
216
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
217
+
218
+ # Build URI (preserve all fragments and query params)
219
+ uri = "https://github.com/#{owner}/#{repo}/#{type}/#{number}"
220
+ if comment_type
221
+ separator = (comment_type == "discussion_r") ? "" : "-"
222
+ uri += "##{comment_type}#{separator}#{comment_id}"
223
+ end
224
+ uri += extra_fragment if extra_fragment
225
+
226
+ # Build link text
227
+ is_same_repo = @repo_owner == owner && @repo_name == repo
228
+ link_text = is_same_repo ? "##{number}" : "#{owner}/#{repo}##{number}"
229
+
230
+ # Add comment indicator for comment fragments
231
+ if comment_type
232
+ link_text += (comment_type == "discussion_r") ? " (review comment)" : " (comment)"
233
+ end
234
+
235
+ # Apply prefix if configured
236
+ type_key = case type
237
+ when "issues" then :issue
238
+ when "pull" then :pull
239
+ when "discussions" then :discussion
240
+ end
241
+
242
+ if is_same_repo && type_key && @uri_prefixes[type_key]
243
+ link_text = "#{@uri_prefixes[type_key]} #{link_text}"
244
+ end
245
+
246
+ replacement = if @style == :reference
247
+ ref_base = is_same_repo ? "gh-issue-#{number}" : "gh-#{owner}-#{repo}-#{number}"
248
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref, comment_id)
249
+ "[#{link_text}][#{ref_id}]"
250
+ else
251
+ "[#{link_text}](#{uri})"
252
+ end
253
+
254
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
255
+ offset = pos + match[0].length
256
+ end
257
+ end
258
+
259
+ def scan_owner_repo_issues(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
260
+ pattern = %r{([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)#(\d+)(?=\s|$|[^\d])}
261
+ offset = 0
262
+
263
+ while (match = text.match(pattern, offset))
264
+ pos = base_offset + match.begin(0)
265
+ owner = match[1]
266
+ repo = match[2]
267
+ number = match[3]
268
+
269
+ next offset = pos + match[0].length if already_linked?(source, pos)
270
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
271
+
272
+ uri = "https://github.com/#{owner}/#{repo}/issues/#{number}"
273
+ link_text = "#{owner}/#{repo}##{number}"
274
+ ref_base = "gh-#{owner}-#{repo}-#{number}"
275
+
276
+ replacement = if @style == :reference
277
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
278
+ "[#{link_text}][#{ref_id}]"
279
+ else
280
+ "[#{link_text}](#{uri})"
281
+ end
282
+
283
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
284
+ offset = pos + match[0].length
285
+ end
286
+ end
287
+
288
+ def scan_github_user_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
289
+ pattern = %r{https://github\.com/([A-Za-z0-9_-]+)(?=/|\s|$)}
290
+ offset = 0
291
+
292
+ while (match = text.match(pattern, offset))
293
+ pos = base_offset + match.begin(0)
294
+ username = match[1]
295
+
296
+ next offset = pos + match[0].length unless valid_username?(username)
297
+ next offset = pos + match[0].length if already_linked?(source, pos)
298
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
299
+
300
+ uri = "https://github.com/#{username}"
301
+ link_text = "@#{username}"
302
+ ref_base = "gh-user-#{username}"
303
+
304
+ replacement = if @style == :reference
305
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
306
+ "[#{link_text}][#{ref_id}]"
307
+ else
308
+ "[#{link_text}](#{uri})"
309
+ end
310
+
311
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
312
+ offset = pos + match[0].length
313
+ end
314
+ end
315
+
316
+ def scan_github_repo_uris(source, text, base_offset, replacements, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
317
+ pattern = %r{https://github\.com/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)(?=/|\s|$)}
318
+ offset = 0
319
+
320
+ while (match = text.match(pattern, offset))
321
+ pos = base_offset + match.begin(0)
322
+ owner = match[1]
323
+ repo = match[2]
324
+
325
+ next offset = pos + match[0].length if already_linked?(source, pos)
326
+ next offset = pos + match[0].length if overlaps_replacement?(replacements, pos, match[0].length)
327
+
328
+ uri = "https://github.com/#{owner}/#{repo}"
329
+ link_text = "#{owner}/#{repo}"
330
+ ref_base = "gh-#{owner}-#{repo}"
331
+
332
+ replacement = if @style == :reference
333
+ ref_id = find_or_create_ref_id(ref_base, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref)
334
+ "[#{link_text}][#{ref_id}]"
335
+ else
336
+ "[#{link_text}](#{uri})"
337
+ end
338
+
339
+ replacements << {start: pos, end: pos + match[0].length, replacement:}
340
+ offset = pos + match[0].length
341
+ end
342
+ end
343
+
344
+ def already_linked?(source, offset)
345
+ # Check if preceded by [ or ]( within reasonable distance
346
+ check_start = [offset - 100, 0].max
347
+ preceding = source[check_start...offset]
348
+
349
+ # If we find [text] or [text]( before our position, we're likely in a link
350
+ if /\[[^\]]*\](?:\([^)]*)?$/.match?(preceding)
351
+ return true
352
+ end
353
+
354
+ # Check if we're inside a link reference [text][ref] or inline link [text](url)
355
+ if /\[[^\]]*$/.match?(preceding)
356
+ return true
357
+ end
358
+
359
+ # Check if followed by ]
360
+ following = source[offset, 10]
361
+ if following&.match(/^[^\[]*\]/)
362
+ return true
363
+ end
364
+
365
+ false
366
+ end
367
+
368
+ def overlaps_replacement?(replacements, start_pos, length)
369
+ end_pos = start_pos + length
370
+ replacements.any? { |r| (start_pos < r[:end]) && (end_pos > r[:start]) }
371
+ end
372
+
373
+ def make_ref_id(text, suffix = nil)
374
+ base = text.gsub(/[^A-Za-z0-9-]/, "-").downcase.gsub(/^-+|-+$/, "")
375
+ base = "#{base}-#{suffix}" if suffix
376
+ base
377
+ end
378
+
379
+ def find_or_create_ref_id(link_text, uri, existing_by_key, existing_by_ref, new_by_key, new_by_ref, suffix = nil)
380
+ # Check if URI already has a ref
381
+ return existing_by_ref[uri] if existing_by_ref.key?(uri)
382
+ return new_by_ref[uri] if new_by_ref.key?(uri)
383
+
384
+ # Generate new unique ref ID
385
+ base = make_ref_id(link_text, suffix)
386
+ ref_id = base
387
+ counter = 2
388
+
389
+ while existing_by_key.key?(ref_id) || new_by_key.key?(ref_id)
390
+ ref_id = "#{base}-#{counter}"
391
+ counter += 1
392
+ end
393
+
394
+ # Register in both indexes
395
+ new_by_key[ref_id] = uri
396
+ new_by_ref[uri] = ref_id
397
+
398
+ ref_id
399
+ end
400
+
401
+ def append_references(markdown, new_by_key)
402
+ return markdown if new_by_key.empty?
403
+
404
+ result = markdown.chomp
405
+
406
+ if markdown.match?(/^\[.+?\]:\s+.+\z/m)
407
+ result << "\n" unless result.end_with?("\n")
408
+ else
409
+ result << "\n\n" unless result.end_with?("\n\n")
410
+ end
411
+
412
+ new_by_key.each { |key, uri| result << "[#{key}]: #{uri}\n" }
413
+ result
414
+ end
415
+
416
+ def valid_username?(username)
417
+ return false if username.start_with?("-") || username.end_with?("-")
418
+ return false if username.include?("--")
419
+ return false if username.length > 39 || username.length < 1
420
+
421
+ true
422
+ end
423
+ end