rdoc-markdown 0.6.0 → 0.7.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.
@@ -1,62 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- gem 'rdoc'
3
+ gem "rdoc"
4
4
 
5
- require 'pathname'
6
- require 'erb'
7
- require 'reverse_markdown'
8
- require 'csv'
9
- require 'cgi'
5
+ require "erb"
6
+ require "reverse_markdown"
7
+ require "csv"
8
+ require "cgi"
10
9
 
10
+ # Generates Markdown output and a CSV search index from an RDoc store.
11
11
  class RDoc::Generator::Markdown
12
12
  RDoc::RDoc.add_generator self
13
13
 
14
- ##
15
- # Defines a constant for directory where templates could be found
14
+ # Directory containing ERB templates.
15
+ TEMPLATE_DIR = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "templates"))
16
16
 
17
- TEMPLATE_DIR = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'templates'))
18
-
19
- ##
20
- # The RDoc::Store that is the source of the generated content
21
-
22
- attr_reader :store, :base_dir, :classes, :pages
17
+ # Source store for generated content.
18
+ #
19
+ # @return [RDoc::Store]
20
+ attr_reader :store
23
21
 
24
- ##
25
- # The path to generate files into, combined with <tt>--op</tt> from the
26
- # options for a full path.
22
+ # Working directory captured when the generator is created.
23
+ #
24
+ # @return [Pathname]
25
+ attr_reader :base_dir
27
26
 
28
- ##
29
- # Classes and modules to be used by this generator, not necessarily
30
- # displayed.
27
+ # Classes and modules selected for output.
28
+ #
29
+ # @return [Array<RDoc::Context>, nil]
30
+ attr_reader :classes
31
31
 
32
- ##
33
- # Directory where generated class HTML files live relative to the output
34
- # dir.
32
+ # Text files selected for output.
33
+ #
34
+ # @return [Array<RDoc::TopLevel>, nil]
35
+ attr_reader :pages
35
36
 
37
+ # Required by RDoc's generator interface; markdown output has no class subdirectory.
38
+ #
39
+ # @return [nil]
36
40
  def class_dir
37
- nil
38
41
  end
39
42
 
40
43
  # this alias is required for rdoc to work
41
- alias file_dir class_dir
44
+ alias_method :file_dir, :class_dir
42
45
 
43
- ##
44
- # Initializer method for Rdoc::Generator::Markdown
45
-
46
- def initialize(store, options)
46
+ # Creates a generator for an RDoc store and options.
47
+ #
48
+ # @param store [RDoc::Store] Source documentation store.
49
+ # @param rdoc_options [RDoc::Options] Generator options.
50
+ def initialize(store, rdoc_options)
47
51
  @store = store
48
- @options = options
52
+ @options = rdoc_options
49
53
 
50
- @base_dir = Pathname.pwd.expand_path
51
-
52
- @classes = nil
54
+ @base_dir = Pathname.pwd
53
55
  end
54
56
 
55
- ##
56
- # Generates markdown files and search index file
57
-
57
+ # Writes class files, page files, and the search index.
58
+ #
59
+ # @return [void]
58
60
  def generate
59
- debug("Setting things up #{@output_dir}")
61
+ debug("Setting things up ")
60
62
 
61
63
  setup
62
64
 
@@ -77,27 +79,30 @@ class RDoc::Generator::Markdown
77
79
 
78
80
  attr_reader :options, :output_dir
79
81
 
80
- ##
81
- # This method is used to output debugging information in case rdoc is run with --debug parameter
82
-
83
- def debug(str = nil)
82
+ # Prints a message when RDoc debug output is enabled.
83
+ #
84
+ # @param str [String] Message to print.
85
+ #
86
+ # @return [void]
87
+ def debug(str)
88
+ # RDoc exposes --debug through this global and does not mirror it on options.
89
+ # standard:disable Style/GlobalVars
84
90
  return unless $DEBUG_RDOC
91
+ # standard:enable Style/GlobalVars
85
92
 
86
- puts "[rdoc-markdown] #{str}" if str
87
- yield if block_given?
93
+ puts "[rdoc-markdown] #{str}"
88
94
  end
89
95
 
90
- ##
91
- # This class emits a search index for generated documentation as sqlite database
96
+ # Writes a CSV search index for generated documentation.
92
97
  #
98
+ # @return [void]
99
+ def emit_csv_index
100
+ filepath = "#{output_dir}/index.csv"
93
101
 
94
- def emit_csv_index(name = 'index.csv')
95
- filepath = "#{output_dir}/#{name}"
96
-
97
- CSV.open(filepath, 'wb') do |csv|
102
+ CSV.open(filepath, "wb") do |csv|
98
103
  csv << %w[name type path]
99
104
 
100
- @classes.map do |klass|
105
+ @classes.each do |klass|
101
106
  csv << [
102
107
  display_name(klass),
103
108
  klass.type.capitalize,
@@ -107,7 +112,7 @@ class RDoc::Generator::Markdown
107
112
  klass.method_list.select(&:display?).each do |method|
108
113
  csv << [
109
114
  "#{display_name(klass)}.#{method.name}",
110
- 'Method',
115
+ "Method",
111
116
  "#{output_path_for(klass)}##{method.aref}"
112
117
  ]
113
118
  end
@@ -115,11 +120,11 @@ class RDoc::Generator::Markdown
115
120
  klass
116
121
  .constants
117
122
  .select(&:display?)
118
- .sort_by { |x| x.name }
123
+ .sort
119
124
  .each do |const|
120
125
  csv << [
121
126
  "#{display_name(klass)}.#{const.name}",
122
- 'Constant',
127
+ "Constant",
123
128
  "#{output_path_for(klass)}##{const.name}"
124
129
  ]
125
130
  end
@@ -127,11 +132,11 @@ class RDoc::Generator::Markdown
127
132
  klass
128
133
  .attributes
129
134
  .select(&:display?)
130
- .sort_by { |x| x.name }
135
+ .sort
131
136
  .each do |attr|
132
137
  csv << [
133
138
  "#{display_name(klass)}.#{attr.name}",
134
- 'Attribute',
139
+ "Attribute",
135
140
  "#{output_path_for(klass)}##{attr.aref}"
136
141
  ]
137
142
  end
@@ -140,16 +145,19 @@ class RDoc::Generator::Markdown
140
145
  @pages.each do |page|
141
146
  csv << [
142
147
  page.page_name,
143
- 'Page',
148
+ "Page",
144
149
  page_output_path(page)
145
150
  ]
146
151
  end
147
152
  end
148
153
  end
149
154
 
155
+ # Writes one Markdown file per selected class or module.
156
+ #
157
+ # @return [void]
150
158
  def emit_classfiles
151
- template_content = File.read(File.join(TEMPLATE_DIR, 'classfile.md.erb'))
152
- template = ERB.new(template_content, trim_mode: '-')
159
+ template_content = File.read(File.join(TEMPLATE_DIR, "classfile.md.erb"))
160
+ template = ERB.new(template_content, trim_mode: "-")
153
161
 
154
162
  @classes.each do |klass|
155
163
  result = finalize_markdown(template.result(binding), current_output_path: output_path_for(klass))
@@ -166,73 +174,88 @@ class RDoc::Generator::Markdown
166
174
  end
167
175
  end
168
176
 
177
+ # Writes one Markdown file per selected text page.
178
+ #
179
+ # @return [void]
169
180
  def emit_pagefiles
170
181
  @pages.each do |page|
171
182
  out_file = Pathname.new("#{output_dir}/#{page_output_path(page)}")
172
183
  out_file.dirname.mkpath
173
184
 
174
- content = markdownify(page.description.to_s)
185
+ content = markdownify(page.description)
175
186
  File.write(out_file, finalize_markdown(content, current_output_path: page_output_path(page)))
176
187
  end
177
188
  end
178
189
 
179
- ##
180
- # Takes a class name and converts it into a Pathname
181
-
190
+ # Converts a qualified object name into a Markdown path.
191
+ #
192
+ # @param class_name [String] Qualified class or module name.
193
+ #
194
+ # @return [String] Relative Markdown path.
182
195
  def turn_to_path(class_name)
183
- "#{class_name.gsub('::', '/')}.md"
196
+ "#{class_name.gsub("::", "/")}.md"
184
197
  end
185
198
 
199
+ # Builds the Markdown output path for an RDoc page.
200
+ #
201
+ # @param page [RDoc::TopLevel] Page object to render.
202
+ #
203
+ # @return [String] Relative Markdown path.
186
204
  def page_output_path(page)
187
- source_path = normalize_input_path_for_output(page.relative_name.to_s)
205
+ source_path = normalize_input_path_for_output(page.relative_name)
188
206
  dirname = File.dirname(source_path)
189
- basename = "#{File.basename(source_path).tr('.', '_')}.md"
207
+ basename = "#{File.basename(source_path).tr(".", "_")}.md"
190
208
 
191
- return basename if dirname == '.'
209
+ return basename if dirname == "."
192
210
 
193
211
  "#{dirname}/#{basename}"
194
212
  end
195
213
 
214
+ # Returns the normalized display name for a class or module.
215
+ #
216
+ # @param code_object [RDoc::Context] Class or module object.
217
+ #
218
+ # @return [String] Display name used in headings and the index.
196
219
  def display_name(code_object)
197
- class_doc = class_doc_for(code_object)
198
- class_doc ? class_doc[:display_name] : code_object.full_name
220
+ class_doc_for(code_object).fetch(:display_name)
199
221
  end
200
222
 
223
+ # Returns the canonical Markdown path for a class or module.
224
+ #
225
+ # @param code_object [RDoc::Context] Class or module object.
226
+ #
227
+ # @return [String] Relative Markdown path.
201
228
  def output_path_for(code_object)
202
- class_doc = class_doc_for(code_object)
203
- class_doc ? class_doc[:output_path] : turn_to_path(code_object.full_name)
229
+ class_doc_for(code_object).fetch(:output_path)
204
230
  end
205
231
 
232
+ # Returns compatibility paths that should mirror the canonical output.
233
+ #
234
+ # @param code_object [RDoc::Context] Class or module object.
235
+ #
236
+ # @return [Array<String>] Legacy Markdown paths.
206
237
  def legacy_paths_for(code_object)
207
- class_doc = class_doc_for(code_object)
208
- class_doc ? class_doc[:legacy_paths] : []
238
+ class_doc_for(code_object).fetch(:legacy_paths)
209
239
  end
210
240
 
211
- ##
212
- # Converts HTML string into a Markdown string with some cleaning and improvements.
213
-
214
- # FIXME: This could return string with newlines in the end, which is not good.
241
+ # Converts RDoc HTML into GitHub-flavored Markdown.
242
+ #
243
+ # @param input [String] RDoc HTML fragment.
244
+ #
245
+ # @return [String] Markdown with normalized links and no trailing whitespace.
215
246
  def markdownify(input)
216
- # TODO: I should be able to set unknown_tags to "raise" for debugging purposes. Probably through rdoc parameters?
217
- # Allowed parameters:
247
+ # ReverseMarkdown supports these unknown-tag modes:
218
248
  # - pass_through - (default) Include the unknown tag completely into the result
219
249
  # - drop - Drop the unknown tag and its content
220
250
  # - bypass - Ignore the unknown tag but try to convert its content
221
251
  # - raise - Raise an error to let you know
222
252
 
223
- html = normalize_rdoc_pre_blocks(input.to_s)
253
+ html = normalize_rdoc_pre_blocks(input)
224
254
 
225
- md = String.new(ReverseMarkdown.convert(html, unknown_tags: :bypass, github_flavored: true))
226
-
227
- # unindent multiline strings
228
- md = unindent_text(md)
229
-
230
- # Remove RDoc navigation links from generated headings.
231
- md.gsub!(/(#+\s+[^\n]+?)\s*\[¶\]\([^)]+\)(?:\s*\[↑\]\(#top\))?/) { Regexp.last_match(1) }
232
- md.gsub!(/\s+\[↑\]\(#top\)$/, '')
255
+ md = ReverseMarkdown.convert(html, github_flavored: true)
233
256
 
234
257
  # Flatten headings whose visible text is wrapped in a self-link.
235
- md.gsub!(/^(#+)\s+\[([^\]]+)\]\((#[^)]+)\)\s*$/) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)}" }
258
+ md.gsub!(/^(#+)\s\[([^\]]+)\]\((?:#[^)]+)\)$/) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)}" }
236
259
 
237
260
  # Replace .html to .md extension in all local markdown links.
238
261
  md.gsub!(%r{\]\((?!https?://|mailto:|#)([^)]+?)\.html((?:[?#][^)]+)?)\)}i) do
@@ -253,173 +276,320 @@ class RDoc::Generator::Markdown
253
276
  "](#{Regexp.last_match(1)}#{Regexp.last_match(2)})"
254
277
  end
255
278
 
256
- md = md.gsub('=== ', '### ').gsub('== ', '## ')
257
- md = normalize_definition_list_code_blocks(md)
258
- md.lines.map(&:rstrip).join("\n").strip
279
+ normalize_definition_list_code_blocks(md).rstrip
259
280
  end
260
281
 
261
- # Aliasing a shorter method name for use in templates
262
- alias h markdownify
282
+ # Short alias used by ERB templates.
283
+ alias_method :h, :markdownify
263
284
 
285
+ # Builds an HTML anchor tag.
286
+ #
287
+ # @param id [String] Fragment identifier for the generated anchor.
288
+ #
289
+ # @return [String] HTML anchor tag.
264
290
  def anchor(id)
265
291
  %(<a id="#{id}"></a>)
266
292
  end
267
293
 
294
+ # Renders an RDoc object's description as Markdown.
295
+ #
296
+ # @param code_object [RDoc::CodeObject] Object with an RDoc description.
297
+ # @param fallback [String, nil] Text to use when the description is empty.
298
+ # @param heading_level_offset [Integer] Heading levels to add while rendering.
299
+ #
300
+ # @return [String] Rendered description or fallback text.
268
301
  def describe(code_object, fallback: nil, heading_level_offset: 0)
269
- description = code_object.description.to_s
270
- return fallback.to_s if description.strip.empty? && !fallback.nil?
302
+ description = code_object.description
303
+ return fallback.to_s if description.empty?
271
304
 
272
305
  shift_headings(markdownify(description), heading_level_offset)
273
306
  end
274
307
 
275
- def section_description(section, heading_level_offset: 0)
276
- description = section_description_html(section)
277
- return '' if description.strip.empty?
278
-
279
- shift_headings(markdownify(description), heading_level_offset)
308
+ # Renders a section description as Markdown.
309
+ #
310
+ # @param section [RDoc::Context::Section] RDoc section whose description appears before grouped members.
311
+ # @param heading_level_offset [Integer] Heading levels to add while rendering.
312
+ #
313
+ # @return [String] Rendered section description.
314
+ def section_description(section, heading_level_offset:)
315
+ shift_headings(markdownify(section.description), heading_level_offset)
280
316
  end
281
317
 
318
+ # Builds the visible method signature used in headings.
319
+ #
320
+ # @param method [RDoc::AnyMethod] Method object to render.
321
+ #
322
+ # @return [String] Normalized method signature.
282
323
  def method_signature(method)
283
- signature = method.param_seq.to_s
284
- return '()' if signature.strip.empty?
324
+ signature = method.param_seq
325
+ return "()" unless signature.match?(/\S/)
285
326
 
286
- signature = signature.gsub('->', ' -> ')
287
- signature = signature.gsub(/\s+/, ' ').strip
288
- signature = " #{signature}" if signature.start_with?('->')
289
- signature
327
+ signature = signature.gsub("->", " -> ")
328
+ signature = signature.gsub(/\s+/, " ").strip
329
+ signature = " #{signature}" if signature.start_with?("->")
330
+ merge_method_signature_arguments(signature, method.params)
290
331
  end
291
332
 
333
+ # Merges RDoc parameter names into a type-only signature.
334
+ #
335
+ # @param signature [String] Method signature from RDoc call sequence.
336
+ # @param raw_params [String, nil] Method parameter list from RDoc.
337
+ #
338
+ # @return [String] Signature with names added when safe.
339
+ def merge_method_signature_arguments(signature, raw_params)
340
+ params = normalized_method_params(raw_params)
341
+
342
+ signature_args, signature_suffix = split_signature_arguments_and_suffix(signature)
343
+ return signature if signature_args.nil?
344
+
345
+ param_parts = split_signature_list(params)
346
+ signature_parts = split_signature_list(signature_args)
347
+ return signature unless param_parts.length.eql?(signature_parts.length)
348
+
349
+ param_names = param_parts.map { |part| extract_parameter_name(part) }
350
+ return signature if param_names.any?(&:nil?)
351
+ return signature if signature_parts.zip(param_names).all? { |part, name| signature_part_mentions_name?(part, name) }
352
+
353
+ merged_args = param_parts.zip(signature_parts).map do |param, type|
354
+ separator = param.end_with?(":") ? " " : ": "
355
+ "#{param}#{separator}#{type}"
356
+ end
357
+
358
+ "(#{merged_args.join(", ")})#{signature_suffix}"
359
+ end
360
+
361
+ # Normalizes RDoc's raw parameter string.
362
+ #
363
+ # @param raw_params [String, nil] Parameter list from RDoc.
364
+ #
365
+ # @return [String] Parameter list without outer parentheses.
366
+ def normalized_method_params(raw_params)
367
+ params = raw_params.to_s.strip
368
+ params = params[1...-1] if params.start_with?("(") && params.end_with?(")")
369
+
370
+ params
371
+ end
372
+
373
+ # Splits a parenthesized signature into arguments and suffix.
374
+ #
375
+ # @param signature [String] Method signature.
376
+ #
377
+ # @return [Array<String>, nil] Argument text and suffix, or nil when not parenthesized.
378
+ def split_signature_arguments_and_suffix(signature)
379
+ return unless signature.start_with?("(")
380
+
381
+ depth = 0
382
+
383
+ signature.each_char.with_index do |char, index|
384
+ depth += 1 if char == "("
385
+
386
+ next unless char == ")"
387
+
388
+ depth -= 1
389
+ return [signature[1...index], signature[(index + 1)..]] if depth.zero?
390
+ end
391
+ end
392
+
393
+ # Splits a comma-separated signature list while preserving nested groups.
394
+ #
395
+ # @param list [String] Signature argument list.
396
+ #
397
+ # @return [Array<String>] Signature parts.
398
+ def split_signature_list(list)
399
+ parts = []
400
+ current = +""
401
+ paren_depth = 0
402
+ bracket_depth = 0
403
+ brace_depth = 0
404
+
405
+ list.each_char do |char|
406
+ case char
407
+ when "("
408
+ paren_depth += 1
409
+ when ")"
410
+ paren_depth -= 1
411
+ when "["
412
+ bracket_depth += 1
413
+ when "]"
414
+ bracket_depth -= 1
415
+ when "{"
416
+ brace_depth += 1
417
+ when "}"
418
+ brace_depth -= 1
419
+ when ","
420
+ if paren_depth.zero? && bracket_depth.zero? && brace_depth.zero?
421
+ parts << current.strip
422
+ current = +""
423
+ next
424
+ end
425
+ end
426
+
427
+ current << char
428
+ end
429
+
430
+ parts << current.strip unless current.empty?
431
+ parts
432
+ end
433
+
434
+ # Extracts a bare Ruby parameter name from a parameter fragment.
435
+ #
436
+ # @param parameter [String] Parameter fragment.
437
+ #
438
+ # @return [String, nil] Parameter name, or nil when invalid.
439
+ def extract_parameter_name(parameter)
440
+ match = parameter.match(/\A(?:\*\*|\*|&)?([a-z_]\w*):?\z/)
441
+ match && match[1]
442
+ end
443
+
444
+ # Checks whether a signature fragment already includes a parameter name.
445
+ #
446
+ # @param text [String] Signature fragment.
447
+ # @param name [String] Parameter name.
448
+ #
449
+ # @return [Boolean] True when the name appears as a standalone word.
450
+ def signature_part_mentions_name?(text, name)
451
+ text.match?(/(?<!\w)#{name}(?!\w)/)
452
+ end
453
+
454
+ # Renders a method description or an alias fallback.
455
+ #
456
+ # @param method [RDoc::AnyMethod] Method object to render.
457
+ # @param current_class [RDoc::Context] Class or module currently being rendered.
458
+ #
459
+ # @return [String] Rendered method description.
292
460
  def method_description(method, current_class:)
293
- text = describe(method, fallback: nil, heading_level_offset: 4)
461
+ text = describe(method, heading_level_offset: 4)
294
462
  return text unless text.empty?
295
463
 
296
- aliased_method = method.respond_to?(:is_alias_for) ? method.is_alias_for : nil
297
- return 'Not documented.' unless aliased_method
464
+ aliased_method = method.is_alias_for
465
+ return "Not documented." unless aliased_method
298
466
 
299
467
  "Alias for: [`#{aliased_method.name}`](#{method_link(aliased_method, current_class: current_class)})"
300
468
  end
301
469
 
302
- def finalize_markdown(content, current_output_path: nil)
470
+ # Applies final whitespace and link normalization before writing Markdown.
471
+ #
472
+ # @param content [String] Markdown content.
473
+ # @param current_output_path [String] Output path for the file being written.
474
+ #
475
+ # @return [String] Final Markdown ending with one newline.
476
+ def finalize_markdown(content, current_output_path:)
303
477
  output = content.lines.map(&:rstrip).join("\n")
304
- output = normalize_internal_links(output, current_output_path: current_output_path) if current_output_path
305
- output.gsub!(/\n{3,}/, "\n\n")
306
- "#{output.strip}\n"
478
+ output = normalize_internal_links(output, current_output_path: current_output_path)
479
+ output = output.sub(/\n{3,}/, "\n\n")
480
+ "#{output}\n"
307
481
  end
308
482
 
483
+ # Increases Markdown heading levels without exceeding level six.
484
+ #
485
+ # @param markdown [String] Markdown content.
486
+ # @param heading_level_offset [Integer] Heading levels to add.
487
+ #
488
+ # @return [String] Markdown with shifted headings.
309
489
  def shift_headings(markdown, heading_level_offset)
310
- return markdown if heading_level_offset.zero?
311
-
312
- markdown.gsub(/^(#+)(\s+)/) do
490
+ markdown.gsub(/^(#+)(\s)/) do
313
491
  hashes = Regexp.last_match(1)
314
492
  spaces = Regexp.last_match(2)
315
493
  level = [hashes.length + heading_level_offset, 6].min
316
- "#{'#' * level}#{spaces}"
494
+ "#{"#" * level}#{spaces}"
317
495
  end
318
496
  end
319
497
 
320
- def section_description_html(section)
321
- if section.instance_variable_defined?(:@store)
322
- section_store = section.instance_variable_get(:@store)
323
- parent_store = section.respond_to?(:parent) && section.parent.respond_to?(:store) ? section.parent.store : nil
324
- section.instance_variable_set(:@store, parent_store) if section_store.nil? && !parent_store.nil?
325
- end
326
-
327
- section.description.to_s
328
- rescue NoMethodError
329
- comments = section.respond_to?(:comments) ? section.comments : nil
330
- return '' if comments.nil? || comments.empty?
331
-
332
- comments.map { |comment| comment.respond_to?(:text) ? comment.text : comment.to_s }.join("\n")
333
- end
334
-
498
+ # Converts RDoc definition-list code blocks into Markdown lists.
499
+ #
500
+ # @param markdown [String] Markdown content.
501
+ #
502
+ # @return [String] Markdown with convertible blocks normalized.
335
503
  def normalize_definition_list_code_blocks(markdown)
336
- markdown.gsub(/```\n(.*?)\n```/m) do
504
+ markdown.gsub(/```\n(.+?)\n```/m) do
337
505
  body = Regexp.last_match(1)
338
506
  converted = convert_definition_list_block(body)
339
- converted.nil? ? Regexp.last_match(0) : converted
507
+ converted.nil? ? Regexp.last_match : converted
340
508
  end
341
509
  end
342
510
 
511
+ # Converts a single definition-list code block.
512
+ #
513
+ # @param body [String] Code block body.
514
+ #
515
+ # @return [String, nil] Converted Markdown, or nil when the block is not a definition list.
343
516
  def convert_definition_list_block(body)
344
- lines = body.lines.map(&:rstrip)
345
- return nil if lines.empty?
346
- return nil unless lines.any? { |line| line.strip.end_with?('::') }
517
+ lines = body.lines
347
518
  return nil unless lines.all? { |line| definition_list_line?(line) }
348
519
 
349
- lines.filter_map do |line|
520
+ lines.map do |line|
350
521
  stripped = line.strip
351
- next '' if stripped.empty?
352
- next "#{stripped.sub(/::\z/, '')}:" if stripped.end_with?('::')
522
+ next if stripped.empty?
523
+ next "#{stripped.sub(/::\z/, "")}:" if stripped.end_with?("::")
353
524
 
354
- "- #{stripped.sub(/^\*\s+/, '')}"
525
+ "- #{stripped.sub(/\A\*\s/, "")}"
355
526
  end.join("\n")
356
527
  end
357
528
 
529
+ # Checks whether a line can appear in a converted definition list.
530
+ #
531
+ # @param line [String] Markdown line.
532
+ #
533
+ # @return [Boolean] True when the line matches RDoc definition-list output.
358
534
  def definition_list_line?(line)
359
535
  stripped = line.strip
360
- stripped.empty? || stripped.end_with?('::') || stripped.match?(/^\*\s+/)
536
+ stripped.empty? || stripped.end_with?("::") || stripped.match?(/\A\*\s/)
361
537
  end
362
538
 
539
+ # Builds a Markdown link target for an aliased method.
540
+ #
541
+ # @param method [RDoc::AnyMethod] Target method.
542
+ # @param current_class [RDoc::Context] Class or module currently being rendered.
543
+ #
544
+ # @return [String] Anchor or relative Markdown link target.
363
545
  def method_link(method, current_class:)
364
546
  target_parent = method.parent
365
547
  return "##{method.aref}" if target_parent == current_class
366
548
 
367
- target_path = output_path_for(target_parent)
368
- current_path = output_path_for(current_class)
369
- "#{relative_output_path(current_path, target_path)}##{method.aref}"
370
- end
371
-
372
- def relative_output_path(from_path, to_path)
373
- from_dir = Pathname.new(from_path).dirname
374
- Pathname.new(to_path).relative_path_from(from_dir).to_s
549
+ "#{output_path_for(target_parent)}##{method.aref}"
375
550
  end
376
551
 
552
+ # Removes RDoc's generated HTML tags from verbatim pre blocks.
553
+ #
554
+ # @param html [String] RDoc HTML fragment.
555
+ #
556
+ # @return [String] HTML fragment with normalized pre blocks.
377
557
  def normalize_rdoc_pre_blocks(html)
378
- html.gsub(%r{<pre\b[^>]*>(.*?)</pre>}m) do
379
- raw = Regexp.last_match(1)
380
- text = raw
381
- .gsub(%r{<br\s*/?>}i, "\n")
382
- .gsub(/<[^>]+>/, '')
558
+ html.gsub(%r{<pre\b[^>]*>(?:.+?)</pre>}m) do
559
+ raw = Regexp.last_match(0)
560
+ text = raw.gsub(/<[^>]+>/, "")
383
561
  "<pre>#{CGI.unescapeHTML(text)}</pre>"
384
562
  end
385
563
  end
386
564
 
387
- def unindent_text(text)
388
- lines = text.to_s.lines
389
- indent = lines.reject { |line| line.strip.empty? }.map { |line| line[/^[ \t]*/].size }.min || 0
390
- return text if indent.zero?
391
-
392
- lines.map { |line| line.sub(/^[ \t]{0,#{indent}}/, '') }.join
393
- end
394
-
565
+ # Rewrites local Markdown links relative to the current output file.
566
+ #
567
+ # @param markdown [String] Markdown content.
568
+ # @param current_output_path [String] Output path for the file being written.
569
+ #
570
+ # @return [String] Markdown with normalized internal links.
395
571
  def normalize_internal_links(markdown, current_output_path:)
396
- return markdown if @known_output_paths.nil? || @known_output_paths.empty?
397
-
398
572
  current_dir = Pathname.new(current_output_path).dirname
399
573
 
400
- markdown.gsub(%r{\]\((?!https?://|mailto:|#)([^)]+)\)}) do
574
+ markdown.gsub(%r{\]\(([^)]+)\)}) do
401
575
  target = Regexp.last_match(1)
402
- path = target.sub(/[?#].*\z/, '')
403
- suffix = target[path.length..] || ''
576
+ path = target.sub(/[?#].*\z/, "")
577
+ suffix = target[path.length..]
404
578
 
405
579
  resolved = resolve_output_path(path, current_dir)
406
- rewritten = resolved ? Pathname.new(resolved).relative_path_from(current_dir).to_s : path
580
+ rewritten = resolved ? Pathname.new(resolved).relative_path_from(current_dir) : path
407
581
  "](#{rewritten}#{suffix})"
408
582
  end
409
583
  end
410
584
 
585
+ # Resolves an internal link path against known generated outputs.
586
+ #
587
+ # @param path [String] Link path from Markdown content.
588
+ # @param current_dir [Pathname] Directory of the current output file.
589
+ #
590
+ # @return [String, nil] Resolved output path, or nil when unresolved.
411
591
  def resolve_output_path(path, current_dir)
412
- normalized_path = path.to_s.sub(%r{\A/}, '')
413
- candidates = [normalized_path]
414
-
415
- stripped = normalized_path.sub(%r{\A(?:files|classes|modules)/}, '')
416
- candidates << stripped unless stripped == normalized_path
417
-
418
- if @root_path_segment && stripped.start_with?("#{@root_path_segment}/")
419
- candidates << stripped.delete_prefix("#{@root_path_segment}/")
420
- end
421
-
422
- candidates = candidates.flat_map { |candidate| candidate_with_parent_reductions(candidate) }.uniq
592
+ candidates = [path, path.delete_prefix("#{@root_path_segment}/")]
423
593
 
424
594
  candidates.each do |candidate|
425
595
  return candidate if @known_output_paths.include?(candidate)
@@ -433,33 +603,36 @@ class RDoc::Generator::Markdown
433
603
  nil
434
604
  end
435
605
 
436
- def candidate_with_parent_reductions(candidate)
437
- reductions = [candidate.sub(%r{\A\./}, '')]
438
- reduced = reductions.first
439
-
440
- while reduced.start_with?('../')
441
- reduced = reduced.delete_prefix('../')
442
- reductions << reduced
443
- end
444
-
445
- reductions.uniq.reject(&:empty?)
446
- end
447
-
606
+ # Normalizes an input filename into an output-relative source path.
607
+ #
608
+ # @param path [String] RDoc input path.
609
+ #
610
+ # @return [String] Normalized path without root prefixes.
448
611
  def normalize_input_path_for_output(path)
449
- normalized = path.to_s.tr('\\', '/').sub(%r{\A\./}, '')
450
- normalized = normalized.sub(%r{\A/}, '')
612
+ normalized = path.tr("\\", "/").sub(%r{\A\./}, "")
451
613
 
452
- root = File.expand_path(@options.root || '.', @base_dir).tr('\\', '/')
453
- normalized = normalized.sub(%r{\A#{Regexp.escape(root)}/}, '')
614
+ root = File.expand_path(@options.root.to_s)
615
+ normalized = normalized.sub(%r{\A#{Regexp.escape(root)}/}, "")
616
+ normalized = normalized.sub(%r{\A/}, "")
454
617
 
455
618
  root_basename = File.basename(root)
456
- normalized.sub(%r{\A#{Regexp.escape(root_basename)}/}, '')
619
+ normalized.sub(%r{\A#{Regexp.escape(root_basename)}/}, "")
457
620
  end
458
621
 
622
+ # Looks up resolved class documentation metadata.
623
+ #
624
+ # @param code_object [RDoc::Context] Class or module object.
625
+ #
626
+ # @return [Hash{Symbol => Object}] Metadata for rendering the object.
459
627
  def class_doc_for(code_object)
460
- @class_docs_by_object_id[code_object.object_id]
628
+ @class_docs_by_object_id.fetch(code_object.object_id)
461
629
  end
462
630
 
631
+ # Builds canonical class documentation metadata from RDoc objects.
632
+ #
633
+ # @param classes [Array<RDoc::Context>] Classes and modules to normalize.
634
+ #
635
+ # @return [Array<Hash{Symbol => Object}>] Metadata ordered by display name.
463
636
  def build_class_docs(classes)
464
637
  docs_by_name = {}
465
638
 
@@ -473,7 +646,7 @@ class RDoc::Generator::Markdown
473
646
  klass: klass,
474
647
  display_name: display_name,
475
648
  output_path: output_path,
476
- legacy_paths: legacy_path == output_path ? [] : [legacy_path],
649
+ legacy_paths: [legacy_path],
477
650
  score: score
478
651
  }
479
652
 
@@ -481,44 +654,39 @@ class RDoc::Generator::Markdown
481
654
 
482
655
  if existing.nil?
483
656
  docs_by_name[display_name] = candidate
484
- elsif candidate[:score] > existing[:score]
485
- if existing[:score].positive?
486
- candidate[:legacy_paths] |= existing[:legacy_paths] + [turn_to_path(existing[:klass].full_name)]
657
+ elsif candidate.fetch(:score) > existing.fetch(:score)
658
+ if existing.fetch(:score).positive?
659
+ candidate[:legacy_paths] |= existing.fetch(:legacy_paths)
487
660
  end
488
661
  docs_by_name[display_name] = candidate
489
- elsif candidate[:score].positive?
490
- existing[:legacy_paths] |= candidate[:legacy_paths] + [legacy_path]
662
+ elsif candidate.fetch(:score).positive?
663
+ existing[:legacy_paths] |= candidate.fetch(:legacy_paths)
491
664
  end
492
665
  end
493
666
 
494
667
  docs_by_name.values
495
- .select do |doc|
496
- doc[:score].positive? ||
497
- (doc[:klass].full_name == doc[:display_name] && !synthetic_full_name?(doc[:klass].full_name))
668
+ .select do |doc|
669
+ doc.fetch(:score).positive? ||
670
+ !synthetic_full_name?(doc.fetch(:klass).full_name)
498
671
  end
499
- .sort_by { |doc| doc[:display_name] }
500
- .map { |doc| doc.tap { |d| d[:legacy_paths].uniq! } }
672
+ .sort_by { |doc| doc.fetch(:display_name) }
501
673
  end
502
674
 
675
+ # Collapses repeated namespace segments from synthetic vendored names.
676
+ #
677
+ # @param full_name [String] Full RDoc object name.
678
+ #
679
+ # @return [String] Normalized object name.
503
680
  def normalized_full_name(full_name)
504
- normalized = full_name.dup
681
+ normalized = full_name
505
682
 
506
683
  loop do
507
- break unless normalized
508
-
509
- if normalized =~ /\A(.+?)::\1::(.+)\z/
510
- normalized = "#{::Regexp.last_match(1)}::#{::Regexp.last_match(2)}"
511
- next
512
- end
513
-
514
684
  if normalized =~ /\A([^:]+)(?:::[^:]+)+::\1::(.+)\z/
515
- normalized = "#{::Regexp.last_match(1)}::#{::Regexp.last_match(2)}"
516
- next
685
+ normalized = "#{Regexp.last_match(1)}::#{Regexp.last_match(2)}"
517
686
  end
518
687
 
519
688
  if normalized =~ /\A(.+?)::\1\z/
520
689
  normalized = Regexp.last_match(1)
521
- next
522
690
  end
523
691
 
524
692
  break
@@ -527,44 +695,46 @@ class RDoc::Generator::Markdown
527
695
  normalized
528
696
  end
529
697
 
698
+ # Scores how much visible content a class or module has.
699
+ #
700
+ # @param klass [RDoc::Context] Class or module object.
701
+ #
702
+ # @return [Integer] Content score used to choose duplicate docs.
530
703
  def class_content_score(klass)
531
704
  score = klass.method_list.size + klass.constants.size + klass.attributes.size
532
- score += 1 unless klass.description.to_s.strip.empty?
705
+ score += 1 unless klass.description.empty?
533
706
  score
534
707
  end
535
708
 
709
+ # Checks whether a name appears to contain duplicated root namespaces.
710
+ #
711
+ # @param full_name [String] Full RDoc object name.
712
+ #
713
+ # @return [Boolean] True when the root namespace appears more than once.
536
714
  def synthetic_full_name?(full_name)
537
- parts = full_name.split('::')
538
- return false if parts.size < 3
539
-
715
+ parts = full_name.split("::")
540
716
  root = parts.first
541
717
  parts.count(root) > 1
542
718
  end
543
719
 
544
- ##
545
- # Prepares for document generation, by creating required folders and initializing variables.
546
- # Could be called multiple times.
547
-
720
+ # Prepares sorted objects and link lookup state for generation.
721
+ #
722
+ # @return [void]
548
723
  def setup
549
- return if instance_variable_defined?(:@output_dir)
550
-
551
- @output_dir = Pathname.new(@options.op_dir).expand_path(@base_dir)
552
- @output_dir.mkpath
553
-
554
- return unless @store
724
+ @output_dir = @options.op_dir
555
725
 
556
726
  @class_docs = build_class_docs(@store.all_classes_and_modules.sort)
557
- @class_docs_by_object_id = @class_docs.to_h { |doc| [doc[:klass].object_id, doc] }
558
- @classes = @class_docs.map { |doc| doc[:klass] }
727
+ @class_docs_by_object_id = @class_docs.to_h { |doc| [doc.fetch(:klass).object_id, doc] }
728
+ @classes = @class_docs.map { |doc| doc.fetch(:klass) }
559
729
  @pages = @store.all_files.select(&:text?).select(&:display?).sort_by(&:base_name)
560
730
 
561
731
  @known_output_paths = Set.new
562
732
  @class_docs.each do |doc|
563
- @known_output_paths << doc[:output_path]
564
- doc[:legacy_paths].each { |path| @known_output_paths << path }
733
+ @known_output_paths << doc.fetch(:output_path)
734
+ doc.fetch(:legacy_paths).each { |path| @known_output_paths << path }
565
735
  end
566
736
  @pages.each { |page| @known_output_paths << page_output_path(page) }
567
737
 
568
- @root_path_segment = Pathname.new(@options.root || '.').basename.to_s
738
+ @root_path_segment = Pathname.new(@options.root || ".").basename
569
739
  end
570
740
  end