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