rdoc-markdown 0.5.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.
@@ -2,65 +2,63 @@
2
2
 
3
3
  gem "rdoc"
4
4
 
5
- require "pathname"
6
5
  require "erb"
7
6
  require "reverse_markdown"
8
- require "unindent"
9
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
16
-
14
+ # Directory containing ERB templates.
17
15
  TEMPLATE_DIR = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "templates"))
18
16
 
19
- ##
20
- # The RDoc::Store that is the source of the generated content
21
-
17
+ # Source store for generated content.
18
+ #
19
+ # @return [RDoc::Store]
22
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.
27
-
22
+ # Working directory captured when the generator is created.
23
+ #
24
+ # @return [Pathname]
28
25
  attr_reader :base_dir
29
26
 
30
- ##
31
- # Classes and modules to be used by this generator, not necessarily
32
- # displayed.
33
-
27
+ # Classes and modules selected for output.
28
+ #
29
+ # @return [Array<RDoc::Context>, nil]
34
30
  attr_reader :classes
35
31
 
36
- ##
37
- # Directory where generated class HTML files live relative to the output
38
- # dir.
32
+ # Text files selected for output.
33
+ #
34
+ # @return [Array<RDoc::TopLevel>, nil]
35
+ attr_reader :pages
39
36
 
37
+ # Required by RDoc's generator interface; markdown output has no class subdirectory.
38
+ #
39
+ # @return [nil]
40
40
  def class_dir
41
- nil
42
41
  end
43
42
 
44
43
  # this alias is required for rdoc to work
45
44
  alias_method :file_dir, :class_dir
46
45
 
47
- ##
48
- # Initializer method for Rdoc::Generator::Markdown
49
-
50
- 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)
51
51
  @store = store
52
- @options = options
53
-
54
- @base_dir = Pathname.pwd.expand_path
52
+ @options = rdoc_options
55
53
 
56
- @classes = nil
54
+ @base_dir = Pathname.pwd
57
55
  end
58
56
 
59
- ##
60
- # Generates markdown files and search index file
61
-
57
+ # Writes class files, page files, and the search index.
58
+ #
59
+ # @return [void]
62
60
  def generate
63
- debug("Setting things up #{@output_dir}")
61
+ debug("Setting things up ")
64
62
 
65
63
  setup
66
64
 
@@ -68,6 +66,10 @@ class RDoc::Generator::Markdown
68
66
 
69
67
  emit_classfiles
70
68
 
69
+ debug("Generate pages in #{@output_dir}")
70
+
71
+ emit_pagefiles
72
+
71
73
  debug("Generate index file in #{@output_dir}")
72
74
 
73
75
  emit_csv_index
@@ -75,136 +77,664 @@ class RDoc::Generator::Markdown
75
77
 
76
78
  private
77
79
 
78
- attr_reader :options
79
- attr_reader :output_dir
80
-
81
- ##
82
- # This method is used to output debugging information in case rdoc is run with --debug parameter
80
+ attr_reader :options, :output_dir
83
81
 
84
- def debug(str = nil)
85
- if $DEBUG_RDOC
86
- puts "[rdoc-markdown] #{str}" if str
87
- yield if block_given?
88
- end
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
90
+ return unless $DEBUG_RDOC
91
+ # standard:enable Style/GlobalVars
92
+
93
+ puts "[rdoc-markdown] #{str}"
89
94
  end
90
95
 
91
- ##
92
- # This class emits a search index for generated documentation as sqlite database
96
+ # Writes a CSV search index for generated documentation.
93
97
  #
94
-
95
- def emit_csv_index(name = "index.csv")
96
- filepath = "#{output_dir}/#{name}"
98
+ # @return [void]
99
+ def emit_csv_index
100
+ filepath = "#{output_dir}/index.csv"
97
101
 
98
102
  CSV.open(filepath, "wb") do |csv|
99
- csv << %w[name type path]
103
+ csv << %w[name type path]
100
104
 
101
- @classes.map do |klass|
105
+ @classes.each do |klass|
102
106
  csv << [
103
- klass.full_name,
107
+ display_name(klass),
104
108
  klass.type.capitalize,
105
- turn_to_path(klass.full_name),
109
+ output_path_for(klass)
106
110
  ]
107
111
 
108
- klass.method_list.each do |method|
109
- next if method.visibility.to_s.eql?("private")
110
- next if method.visibility.to_s.eql?("protected")
111
-
112
+ klass.method_list.select(&:display?).each do |method|
112
113
  csv << [
113
- "#{klass.full_name}.#{method.name}",
114
+ "#{display_name(klass)}.#{method.name}",
114
115
  "Method",
115
- "#{turn_to_path(klass.full_name)}##{method.aref}",
116
+ "#{output_path_for(klass)}##{method.aref}"
116
117
  ]
117
118
  end
118
119
 
119
120
  klass
120
121
  .constants
121
- .sort_by { |x| x.name }
122
+ .select(&:display?)
123
+ .sort
122
124
  .each do |const|
123
125
  csv << [
124
- "#{klass.full_name}.#{const.name}",
126
+ "#{display_name(klass)}.#{const.name}",
125
127
  "Constant",
126
- "#{turn_to_path(klass.full_name)}##{const.name}",
128
+ "#{output_path_for(klass)}##{const.name}"
127
129
  ]
128
130
  end
129
131
 
130
132
  klass
131
133
  .attributes
132
- .sort_by { |x| x.name }
134
+ .select(&:display?)
135
+ .sort
133
136
  .each do |attr|
134
137
  csv << [
135
- "#{klass.full_name}.#{attr.name}",
138
+ "#{display_name(klass)}.#{attr.name}",
136
139
  "Attribute",
137
- "#{turn_to_path(klass.full_name)}##{attr.aref}",
140
+ "#{output_path_for(klass)}##{attr.aref}"
138
141
  ]
139
142
  end
140
143
  end
144
+
145
+ @pages.each do |page|
146
+ csv << [
147
+ page.page_name,
148
+ "Page",
149
+ page_output_path(page)
150
+ ]
151
+ end
141
152
  end
142
153
  end
143
154
 
155
+ # Writes one Markdown file per selected class or module.
156
+ #
157
+ # @return [void]
144
158
  def emit_classfiles
145
- @classes.each do |klass|
146
- template_content = File.read(File.join(TEMPLATE_DIR, "classfile.md.erb"))
159
+ template_content = File.read(File.join(TEMPLATE_DIR, "classfile.md.erb"))
160
+ template = ERB.new(template_content, trim_mode: "-")
147
161
 
148
- template = ERB.new template_content, trim_mode: ">"
162
+ @classes.each do |klass|
163
+ result = finalize_markdown(template.result(binding), current_output_path: output_path_for(klass))
149
164
 
150
- out_file = Pathname.new("#{output_dir}/#{turn_to_path klass.full_name}")
165
+ out_file = Pathname.new("#{output_dir}/#{output_path_for(klass)}")
151
166
  out_file.dirname.mkpath
152
-
153
- result = template.result(binding).squeeze(" ")
154
-
155
167
  File.write(out_file, result)
168
+
169
+ legacy_paths_for(klass).each do |legacy_path|
170
+ legacy_file = Pathname.new("#{output_dir}/#{legacy_path}")
171
+ legacy_file.dirname.mkpath
172
+ File.write(legacy_file, result)
173
+ end
156
174
  end
157
175
  end
158
176
 
159
- ##
160
- # Takes a class name and converts it into a Pathname
177
+ # Writes one Markdown file per selected text page.
178
+ #
179
+ # @return [void]
180
+ def emit_pagefiles
181
+ @pages.each do |page|
182
+ out_file = Pathname.new("#{output_dir}/#{page_output_path(page)}")
183
+ out_file.dirname.mkpath
161
184
 
185
+ content = markdownify(page.description)
186
+ File.write(out_file, finalize_markdown(content, current_output_path: page_output_path(page)))
187
+ end
188
+ end
189
+
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.
162
195
  def turn_to_path(class_name)
163
196
  "#{class_name.gsub("::", "/")}.md"
164
197
  end
165
198
 
166
- ##
167
- # Converts HTML string into a Markdown string with some cleaning and improvements.
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.
204
+ def page_output_path(page)
205
+ source_path = normalize_input_path_for_output(page.relative_name)
206
+ dirname = File.dirname(source_path)
207
+ basename = "#{File.basename(source_path).tr(".", "_")}.md"
208
+
209
+ return basename if dirname == "."
210
+
211
+ "#{dirname}/#{basename}"
212
+ end
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.
219
+ def display_name(code_object)
220
+ class_doc_for(code_object).fetch(:display_name)
221
+ end
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.
228
+ def output_path_for(code_object)
229
+ class_doc_for(code_object).fetch(:output_path)
230
+ end
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.
237
+ def legacy_paths_for(code_object)
238
+ class_doc_for(code_object).fetch(:legacy_paths)
239
+ end
168
240
 
169
- # 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.
170
246
  def markdownify(input)
171
- # TODO: I should be able to set unknown_tags to "raise" for debugging purposes. Probably through rdoc parameters?
172
- # Allowed parameters:
247
+ # ReverseMarkdown supports these unknown-tag modes:
173
248
  # - pass_through - (default) Include the unknown tag completely into the result
174
249
  # - drop - Drop the unknown tag and its content
175
250
  # - bypass - Ignore the unknown tag but try to convert its content
176
251
  # - raise - Raise an error to let you know
177
252
 
178
- md = ReverseMarkdown.convert input, unknown_tags: :pass_through, github_flavored: true
253
+ html = normalize_rdoc_pre_blocks(input)
254
+
255
+ md = ReverseMarkdown.convert(html, github_flavored: true)
179
256
 
180
- # unintent multiline strings
181
- md.unindent!
257
+ # Flatten headings whose visible text is wrapped in a self-link.
258
+ md.gsub!(/^(#+)\s\[([^\]]+)\]\((?:#[^)]+)\)$/) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)}" }
182
259
 
183
- # Replace .html to .md extension in all markdown links
184
- md.gsub(/\[(.+)\]\((.+).html(.*)\)/) do |_|
185
- match = Regexp.last_match
260
+ # Replace .html to .md extension in all local markdown links.
261
+ md.gsub!(%r{\]\((?!https?://|mailto:|#)([^)]+?)\.html((?:[?#][^)]+)?)\)}i) do
262
+ "](#{Regexp.last_match(1)}.md#{Regexp.last_match(2)})"
263
+ end
264
+
265
+ # Turn site-root markdown links into relative links.
266
+ md.gsub!(%r{\]\(/([^)]+?\.md(?:[?#][^)]+)?)\)}) { "](#{Regexp.last_match(1)})" }
186
267
 
187
- "[#{match[1]}](#{match[2]}.md#{match[3]})"
268
+ # Strip RDoc structural path segments from internal links.
269
+ md.gsub!(%r{\]\(((?:\.\./)*)files/([^)]+?\.md(?:[?#][^)]+)?)\)}) do
270
+ "](#{Regexp.last_match(1)}#{Regexp.last_match(2)})"
271
+ end
272
+ md.gsub!(%r{\]\(((?:\.\./)*)classes/([^)]+?\.md(?:[?#][^)]+)?)\)}) do
273
+ "](#{Regexp.last_match(1)}#{Regexp.last_match(2)})"
274
+ end
275
+ md.gsub!(%r{\]\(((?:\.\./)*)modules/([^)]+?\.md(?:[?#][^)]+)?)\)}) do
276
+ "](#{Regexp.last_match(1)}#{Regexp.last_match(2)})"
188
277
  end
189
278
 
190
- md.gsub("=== ", "### ").gsub("== ", "## ")
279
+ normalize_definition_list_code_blocks(md).rstrip
191
280
  end
192
281
 
193
- # Aliasing a shorter method name for use in templates
282
+ # Short alias used by ERB templates.
194
283
  alias_method :h, :markdownify
195
284
 
196
- ##
197
- # Prepares for document generation, by creating required folders and initializing variables.
198
- # Could be called multiple times.
285
+ # Builds an HTML anchor tag.
286
+ #
287
+ # @param id [String] Fragment identifier for the generated anchor.
288
+ #
289
+ # @return [String] HTML anchor tag.
290
+ def anchor(id)
291
+ %(<a id="#{id}"></a>)
292
+ end
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.
301
+ def describe(code_object, fallback: nil, heading_level_offset: 0)
302
+ description = code_object.description
303
+ return fallback.to_s if description.empty?
304
+
305
+ shift_headings(markdownify(description), heading_level_offset)
306
+ end
307
+
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)
316
+ end
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.
323
+ def method_signature(method)
324
+ signature = method.param_seq
325
+ return "()" unless signature.match?(/\S/)
326
+
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)
331
+ end
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
199
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.
460
+ def method_description(method, current_class:)
461
+ text = describe(method, heading_level_offset: 4)
462
+ return text unless text.empty?
463
+
464
+ aliased_method = method.is_alias_for
465
+ return "Not documented." unless aliased_method
466
+
467
+ "Alias for: [`#{aliased_method.name}`](#{method_link(aliased_method, current_class: current_class)})"
468
+ end
469
+
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:)
477
+ output = content.lines.map(&:rstrip).join("\n")
478
+ output = normalize_internal_links(output, current_output_path: current_output_path)
479
+ output = output.sub(/\n{3,}/, "\n\n")
480
+ "#{output}\n"
481
+ end
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.
489
+ def shift_headings(markdown, heading_level_offset)
490
+ markdown.gsub(/^(#+)(\s)/) do
491
+ hashes = Regexp.last_match(1)
492
+ spaces = Regexp.last_match(2)
493
+ level = [hashes.length + heading_level_offset, 6].min
494
+ "#{"#" * level}#{spaces}"
495
+ end
496
+ end
497
+
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.
503
+ def normalize_definition_list_code_blocks(markdown)
504
+ markdown.gsub(/```\n(.+?)\n```/m) do
505
+ body = Regexp.last_match(1)
506
+ converted = convert_definition_list_block(body)
507
+ converted.nil? ? Regexp.last_match : converted
508
+ end
509
+ end
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.
516
+ def convert_definition_list_block(body)
517
+ lines = body.lines
518
+ return nil unless lines.all? { |line| definition_list_line?(line) }
519
+
520
+ lines.map do |line|
521
+ stripped = line.strip
522
+ next if stripped.empty?
523
+ next "#{stripped.sub(/::\z/, "")}:" if stripped.end_with?("::")
524
+
525
+ "- #{stripped.sub(/\A\*\s/, "")}"
526
+ end.join("\n")
527
+ end
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.
534
+ def definition_list_line?(line)
535
+ stripped = line.strip
536
+ stripped.empty? || stripped.end_with?("::") || stripped.match?(/\A\*\s/)
537
+ end
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.
545
+ def method_link(method, current_class:)
546
+ target_parent = method.parent
547
+ return "##{method.aref}" if target_parent == current_class
548
+
549
+ "#{output_path_for(target_parent)}##{method.aref}"
550
+ end
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.
557
+ def normalize_rdoc_pre_blocks(html)
558
+ html.gsub(%r{<pre\b[^>]*>(?:.+?)</pre>}m) do
559
+ raw = Regexp.last_match(0)
560
+ text = raw.gsub(/<[^>]+>/, "")
561
+ "<pre>#{CGI.unescapeHTML(text)}</pre>"
562
+ end
563
+ end
564
+
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.
571
+ def normalize_internal_links(markdown, current_output_path:)
572
+ current_dir = Pathname.new(current_output_path).dirname
573
+
574
+ markdown.gsub(%r{\]\(([^)]+)\)}) do
575
+ target = Regexp.last_match(1)
576
+ path = target.sub(/[?#].*\z/, "")
577
+ suffix = target[path.length..]
578
+
579
+ resolved = resolve_output_path(path, current_dir)
580
+ rewritten = resolved ? Pathname.new(resolved).relative_path_from(current_dir) : path
581
+ "](#{rewritten}#{suffix})"
582
+ end
583
+ end
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.
591
+ def resolve_output_path(path, current_dir)
592
+ candidates = [path, path.delete_prefix("#{@root_path_segment}/")]
593
+
594
+ candidates.each do |candidate|
595
+ return candidate if @known_output_paths.include?(candidate)
596
+ end
597
+
598
+ candidates.each do |candidate|
599
+ expanded = current_dir.join(candidate).cleanpath.to_s
600
+ return expanded if @known_output_paths.include?(expanded)
601
+ end
602
+
603
+ nil
604
+ end
605
+
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.
611
+ def normalize_input_path_for_output(path)
612
+ normalized = path.tr("\\", "/").sub(%r{\A\./}, "")
613
+
614
+ root = File.expand_path(@options.root.to_s)
615
+ normalized = normalized.sub(%r{\A#{Regexp.escape(root)}/}, "")
616
+ normalized = normalized.sub(%r{\A/}, "")
617
+
618
+ root_basename = File.basename(root)
619
+ normalized.sub(%r{\A#{Regexp.escape(root_basename)}/}, "")
620
+ end
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.
627
+ def class_doc_for(code_object)
628
+ @class_docs_by_object_id.fetch(code_object.object_id)
629
+ end
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.
636
+ def build_class_docs(classes)
637
+ docs_by_name = {}
638
+
639
+ classes.each do |klass|
640
+ display_name = normalized_full_name(klass.full_name)
641
+ output_path = turn_to_path(display_name)
642
+ legacy_path = turn_to_path(klass.full_name)
643
+ score = class_content_score(klass)
644
+
645
+ candidate = {
646
+ klass: klass,
647
+ display_name: display_name,
648
+ output_path: output_path,
649
+ legacy_paths: [legacy_path],
650
+ score: score
651
+ }
652
+
653
+ existing = docs_by_name[display_name]
654
+
655
+ if existing.nil?
656
+ docs_by_name[display_name] = candidate
657
+ elsif candidate.fetch(:score) > existing.fetch(:score)
658
+ if existing.fetch(:score).positive?
659
+ candidate[:legacy_paths] |= existing.fetch(:legacy_paths)
660
+ end
661
+ docs_by_name[display_name] = candidate
662
+ elsif candidate.fetch(:score).positive?
663
+ existing[:legacy_paths] |= candidate.fetch(:legacy_paths)
664
+ end
665
+ end
666
+
667
+ docs_by_name.values
668
+ .select do |doc|
669
+ doc.fetch(:score).positive? ||
670
+ !synthetic_full_name?(doc.fetch(:klass).full_name)
671
+ end
672
+ .sort_by { |doc| doc.fetch(:display_name) }
673
+ end
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.
680
+ def normalized_full_name(full_name)
681
+ normalized = full_name
682
+
683
+ loop do
684
+ if normalized =~ /\A([^:]+)(?:::[^:]+)+::\1::(.+)\z/
685
+ normalized = "#{Regexp.last_match(1)}::#{Regexp.last_match(2)}"
686
+ end
687
+
688
+ if normalized =~ /\A(.+?)::\1\z/
689
+ normalized = Regexp.last_match(1)
690
+ end
691
+
692
+ break
693
+ end
694
+
695
+ normalized
696
+ end
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.
703
+ def class_content_score(klass)
704
+ score = klass.method_list.size + klass.constants.size + klass.attributes.size
705
+ score += 1 unless klass.description.empty?
706
+ score
707
+ end
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.
714
+ def synthetic_full_name?(full_name)
715
+ parts = full_name.split("::")
716
+ root = parts.first
717
+ parts.count(root) > 1
718
+ end
719
+
720
+ # Prepares sorted objects and link lookup state for generation.
721
+ #
722
+ # @return [void]
200
723
  def setup
201
- return if instance_variable_defined?(:@output_dir)
724
+ @output_dir = @options.op_dir
202
725
 
203
- @output_dir = Pathname.new(@options.op_dir).expand_path(@base_dir)
204
- @output_dir.mkpath
726
+ @class_docs = build_class_docs(@store.all_classes_and_modules.sort)
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) }
729
+ @pages = @store.all_files.select(&:text?).select(&:display?).sort_by(&:base_name)
205
730
 
206
- return unless @store
731
+ @known_output_paths = Set.new
732
+ @class_docs.each do |doc|
733
+ @known_output_paths << doc.fetch(:output_path)
734
+ doc.fetch(:legacy_paths).each { |path| @known_output_paths << path }
735
+ end
736
+ @pages.each { |page| @known_output_paths << page_output_path(page) }
207
737
 
208
- @classes = @store.all_classes_and_modules.sort
738
+ @root_path_segment = Pathname.new(@options.root || ".").basename
209
739
  end
210
740
  end