rdoc-markdown 0.17.2 → 0.19.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -1
  3. data/README.md +19 -82
  4. data/lib/markdown.rb +0 -4
  5. data/lib/rdoc/discover.rb +1 -7
  6. data/lib/rdoc/generator/markdown/conversion.rb +196 -0
  7. data/lib/rdoc/generator/markdown/crossref.rb +13 -5
  8. data/lib/rdoc/generator/markdown/descriptions.rb +163 -0
  9. data/lib/rdoc/generator/markdown/index.rb +47 -0
  10. data/lib/rdoc/generator/markdown/paths.rb +118 -0
  11. data/lib/rdoc/generator/markdown/selection.rb +39 -0
  12. data/lib/rdoc/generator/markdown/signatures.rb +174 -0
  13. data/lib/rdoc/generator/markdown.rb +80 -680
  14. data/lib/rdoc/markdown/version.rb +1 -1
  15. data/lib/templates/classfile.md.erb +14 -11
  16. metadata +19 -43
  17. data/.editorconfig +0 -13
  18. data/.erb_lint.yml +0 -36
  19. data/.erb_linters/no_embedded_assets.rb +0 -29
  20. data/.erb_linters/non_raw_html.rb +0 -29
  21. data/.standard.yml +0 -3
  22. data/.yard-lint.yml +0 -290
  23. data/AGENTS.md +0 -50
  24. data/CODE_OF_CONDUCT.md +0 -84
  25. data/Gemfile +0 -13
  26. data/Gemfile.lock +0 -202
  27. data/Rakefile +0 -296
  28. data/example/Bird.md +0 -21
  29. data/example/Duck.md +0 -56
  30. data/example/Object.md +0 -8
  31. data/example/Waterfowl.md +0 -9
  32. data/example/index.csv +0 -16
  33. data/example/jekyll-seo-tag/Jekyll/SeoTag/AuthorDrop.md +0 -35
  34. data/example/jekyll-seo-tag/Jekyll/SeoTag/Drop.md +0 -108
  35. data/example/jekyll-seo-tag/Jekyll/SeoTag/Filters.md +0 -8
  36. data/example/jekyll-seo-tag/Jekyll/SeoTag/ImageDrop.md +0 -29
  37. data/example/jekyll-seo-tag/Jekyll/SeoTag/JSONLD.md +0 -15
  38. data/example/jekyll-seo-tag/Jekyll/SeoTag/JSONLDDrop.md +0 -34
  39. data/example/jekyll-seo-tag/Jekyll/SeoTag/UrlHelper.md +0 -4
  40. data/example/jekyll-seo-tag/Jekyll/SeoTag.md +0 -48
  41. data/example/jekyll-seo-tag/Jekyll.md +0 -3
  42. data/example/jekyll-seo-tag/Liquid/Tag.md +0 -3
  43. data/example/jekyll-seo-tag/Liquid.md +0 -4
  44. data/example/jekyll-seo-tag/index.csv +0 -60
  45. data/mutant.yml +0 -15
  46. data/rdoc-markdown.gemspec +0 -46
@@ -4,77 +4,37 @@ require "erb"
4
4
  require "reverse_markdown"
5
5
  require "csv"
6
6
  require "fileutils"
7
- require "optparse"
8
7
 
9
8
  # Generates Markdown output and a CSV search index from an RDoc store.
10
9
  class RDoc::Generator::Markdown
11
10
  RDoc::RDoc.add_generator self
12
11
 
12
+ require_relative "markdown/conversion"
13
13
  require_relative "markdown/crossref"
14
+ require_relative "markdown/descriptions"
15
+ require_relative "markdown/index"
16
+ require_relative "markdown/paths"
17
+ require_relative "markdown/selection"
18
+ require_relative "markdown/signatures"
19
+
20
+ include Descriptions
21
+ include Index
22
+ include Paths
23
+ include Signatures
14
24
 
15
25
  # Directory containing ERB templates.
16
26
  TEMPLATE_DIR = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "templates"))
17
27
 
18
- # Supported reverse_markdown unknown-tag modes.
19
- MARKDOWN_UNKNOWN_TAGS = %i[pass_through drop bypass raise].freeze
20
-
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
30
-
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
40
-
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
51
-
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
65
-
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)
73
-
74
- expected = MARKDOWN_UNKNOWN_TAGS.map { |mode| ":#{mode}" }.join(", ")
75
- raise OptionParser::InvalidArgument,
76
- "invalid markdown_unknown_tags: #{value.inspect} (expected one of: #{expected})"
77
- end
28
+ # Prepared objects and lookup tables used during one generation run.
29
+ GenerationState = Data.define(
30
+ :output_dir,
31
+ :classes,
32
+ :pages,
33
+ :class_output_paths,
34
+ :markdown_output_object_ids,
35
+ :known_output_paths,
36
+ :root_path_segment
37
+ )
78
38
 
79
39
  # Source store for generated content.
80
40
  #
@@ -84,12 +44,16 @@ class RDoc::Generator::Markdown
84
44
  # Classes and modules selected for output.
85
45
  #
86
46
  # @return [Array<RDoc::Context>, nil]
87
- attr_reader :classes
47
+ def classes
48
+ generation_state&.classes
49
+ end
88
50
 
89
51
  # Text files selected for output.
90
52
  #
91
53
  # @return [Array<RDoc::TopLevel>, nil]
92
- attr_reader :pages
54
+ def pages
55
+ generation_state&.pages
56
+ end
93
57
 
94
58
  # Creates a generator for an RDoc store and options.
95
59
  #
@@ -99,7 +63,6 @@ class RDoc::Generator::Markdown
99
63
  @store = store
100
64
  @options = rdoc_options
101
65
  @source_dir = File.expand_path(rdoc_options.root.to_s)
102
- @markdown_unknown_tags = self.class.validate_markdown_unknown_tags(rdoc_options.markdown_unknown_tags)
103
66
  end
104
67
 
105
68
  # Writes class files, page files, and the search index.
@@ -107,25 +70,44 @@ class RDoc::Generator::Markdown
107
70
  # @return [void]
108
71
  def generate
109
72
  debug("Setting things up ")
110
-
111
73
  setup
112
74
 
113
- debug("Generate documentation in #{@output_dir}")
114
-
75
+ debug("Generate documentation in #{output_dir}")
115
76
  emit_classfiles
116
77
 
117
- debug("Generate pages in #{@output_dir}")
118
-
78
+ debug("Generate pages in #{output_dir}")
119
79
  emit_pagefiles
120
80
 
121
- debug("Generate index file in #{@output_dir}")
122
-
81
+ debug("Generate index file in #{output_dir}")
123
82
  emit_csv_index
124
83
  end
125
84
 
126
85
  private
127
86
 
128
- attr_reader :options, :output_dir
87
+ attr_reader :generation_state, :options, :source_dir
88
+
89
+ # Builds an HTML anchor tag.
90
+ #
91
+ # @param id [String] Fragment identifier for the generated anchor.
92
+ #
93
+ # @return [String] HTML anchor tag.
94
+ def anchor(id)
95
+ %(<a id="#{id}"></a>)
96
+ end
97
+
98
+ # Applies final whitespace and link normalization before writing Markdown.
99
+ #
100
+ # @param content [String] Markdown content.
101
+ # @param current_output_path [String] Output path for the file being written.
102
+ #
103
+ # @return [String] Final Markdown ending with one newline.
104
+ def finalize_markdown(content, current_output_path:)
105
+ normalized = normalize_internal_links(
106
+ content.lines.map(&:rstrip).join("\n"),
107
+ current_output_path: current_output_path
108
+ ).sub(/\n{3,}/, "\n\n").gsub(/^(#+ .+)\n\n/, "\\1\n")
109
+ "#{normalized}\n"
110
+ end
129
111
 
130
112
  # Prints a message when RDoc debug output is enabled.
131
113
  #
@@ -141,65 +123,6 @@ class RDoc::Generator::Markdown
141
123
  puts "[rdoc-markdown] #{str}"
142
124
  end
143
125
 
144
- # Writes a CSV search index for generated documentation.
145
- #
146
- # @return [void]
147
- def emit_csv_index
148
- filepath = "#{output_dir}/index.csv"
149
-
150
- CSV.open(filepath, "wb") do |csv|
151
- csv << %w[name type path]
152
-
153
- @classes.each do |klass|
154
- csv << [
155
- klass.full_name,
156
- klass.type.capitalize,
157
- output_path_for(klass)
158
- ]
159
-
160
- klass.method_list.select(&:display?).each do |method|
161
- csv << [
162
- "#{klass.full_name}.#{method.name}",
163
- "Method",
164
- "#{output_path_for(klass)}##{method.aref}"
165
- ]
166
- end
167
-
168
- klass
169
- .constants
170
- .select(&:display?)
171
- .sort
172
- .each do |const|
173
- csv << [
174
- "#{klass.full_name}.#{const.name}",
175
- "Constant",
176
- "#{output_path_for(klass)}##{const.name}"
177
- ]
178
- end
179
-
180
- klass
181
- .attributes
182
- .select(&:display?)
183
- .sort
184
- .each do |attr|
185
- csv << [
186
- "#{klass.full_name}.#{attr.name}",
187
- "Attribute",
188
- "#{output_path_for(klass)}##{attr.aref}"
189
- ]
190
- end
191
- end
192
-
193
- @pages.each do |page|
194
- csv << [
195
- page.page_name,
196
- "File",
197
- page_output_path(page)
198
- ]
199
- end
200
- end
201
- end
202
-
203
126
  # Writes one Markdown file per selected class or module.
204
127
  #
205
128
  # @return [void]
@@ -207,7 +130,7 @@ class RDoc::Generator::Markdown
207
130
  template_content = File.read(File.join(TEMPLATE_DIR, "classfile.md.erb"))
208
131
  template = ERB.new(template_content, trim_mode: "-")
209
132
 
210
- @classes.each do |klass|
133
+ classes.each do |klass|
211
134
  content = template.result(binding)
212
135
  output_path = output_path_for(klass)
213
136
  out_file = Pathname.new("#{output_dir}/#{output_path}")
@@ -220,569 +143,46 @@ class RDoc::Generator::Markdown
220
143
  #
221
144
  # @return [void]
222
145
  def emit_pagefiles
223
- @pages.each do |page|
224
- out_file = Pathname.new("#{output_dir}/#{page_output_path(page)}")
146
+ pages.each do |page|
147
+ output_path = page_output_path(page)
148
+ out_file = Pathname.new("#{output_dir}/#{output_path}")
225
149
  out_file.dirname.mkpath
226
150
 
227
- next FileUtils.cp(File.expand_path(page.absolute_name, @source_dir), out_file) if page.relative_name.match?(/\.(?:md|markdown)\z/i)
228
-
229
- content = markdownify(render_description(page))
230
- File.write(out_file, finalize_markdown(
231
- content,
232
- current_output_path: page_output_path(page)
233
- ))
234
- end
235
- end
236
-
237
- # Converts a qualified object name into a Markdown path.
238
- #
239
- # @param class_name [String] Qualified class or module name.
240
- #
241
- # @return [String] Relative Markdown path.
242
- def turn_to_path(class_name)
243
- "#{class_name.gsub("::", "/")}.md"
244
- end
245
-
246
- # Builds the Markdown output path for an RDoc page.
247
- #
248
- # @param page [RDoc::TopLevel] Page object to render.
249
- #
250
- # @return [String] Relative Markdown path.
251
- def page_output_path(page)
252
- source_path = normalize_input_path_for_output(page.relative_name)
253
- return source_path if page.relative_name.match?(/\.(?:md|markdown)\z/i)
254
-
255
- dirname = File.dirname(source_path)
256
- basename = "#{File.basename(source_path).tr(".", "_")}.md"
257
-
258
- return basename if dirname == "."
259
-
260
- "#{dirname}/#{basename}"
261
- end
262
-
263
- # Returns the canonical Markdown path for a class or module.
264
- #
265
- # @param code_object [RDoc::Context] Class or module object.
266
- #
267
- # @return [String] Relative Markdown path.
268
- def output_path_for(code_object)
269
- turn_to_path(code_object.full_name)
270
- end
271
-
272
- # Renders a class or module reference, linking it when its documentation is emitted.
273
- #
274
- # @param target [RDoc::ClassModule, String] Resolved RDoc object or unresolved name.
275
- # @param label [String] Visible reference text.
276
- #
277
- # @return [String] Markdown text or link.
278
- def metadata_reference(target, label)
279
- output_path = @class_output_paths[target.full_name] if target.respond_to?(:full_name)
280
- cell = metadata_table_cell(label)
281
- return cell unless output_path
282
-
283
- "[#{cell}](#{output_path})"
284
- end
285
-
286
- # Escapes text for a Markdown table cell.
287
- #
288
- # @param value [String] Metadata text.
289
- #
290
- # @return [String] GFM table-safe Markdown text.
291
- def metadata_table_cell(value)
292
- value.gsub(/[[:blank:]]*\R[[:blank:]]*/, " ")
293
- .gsub(/[\\|]/) { |character| "\\#{character}" }
294
- end
295
-
296
- # Converts RDoc HTML into GitHub-flavored Markdown.
297
- #
298
- # @param input [String] RDoc HTML fragment.
299
- #
300
- # @return [String] Markdown with normalized links and no trailing whitespace.
301
- def markdownify(input)
302
- # ReverseMarkdown supports these unknown-tag modes:
303
- # - pass_through - (default) Include the unknown tag completely into the result
304
- # - drop - Drop the unknown tag and its content
305
- # - bypass - Ignore the unknown tag but try to convert its content
306
- # - raise - Raise an error to let you know
307
-
308
- fragment = Nokogiri::HTML.fragment(input)
309
- fragment.css("span.legacy-anchor[id]").select do |span|
310
- span.next_element&.name == "h1" && span["id"].match?(/\A(?:class|module)-/)
311
- end.each(&:remove)
312
- fragment.css("span.legacy-anchor[id]").each do |span|
313
- heading = span.next_element
314
- heading.add_child(span) if heading&.name&.match?(/\Ah[1-6]\z/)
315
- end
316
-
317
- fragment.css("pre").each do |pre|
318
- language = pre["class"].to_s[/\A(?!highlight\z)[A-Za-z][A-Za-z0-9_+-]*\z/]
319
- pre["class"] = "brush: #{language};" if language
320
- pre.inner_html = pre.text
321
- end
322
-
323
- fragment.css("h1, h2, h3, h4, h5, h6").each do |heading|
324
- link = heading.xpath("./a[starts-with(@href, '#') and string-length(@href) > 1]").find do |anchor|
325
- anchor.text.match?(/\S/) &&
326
- anchor.xpath("preceding-sibling::node()").none? { |sibling| sibling.text.match?(/\S/) }
327
- end
328
- next unless link
329
-
330
- id = link["href"].delete_prefix("#")
331
- link.replace(link.children)
332
- next if id == RDoc::Text.to_anchor(heading.text)
333
-
334
- heading.add_child(fragment.document.create_element("span", "class" => "legacy-anchor", "id" => id))
335
- end
336
-
337
- anchor_aliases = fragment.css("span.legacy-anchor[id]").map.with_index do |span, index|
338
- token = "RDocMarkdownAnchor#{index}End"
339
- id = span["id"]
340
- span.replace(token)
341
- [token, id]
342
- end
343
-
344
- fragment.css("a").each do |link|
345
- receiver = link.text
346
- href = link["href"].to_s
347
-
348
- if receiver.match?(/\A(?:[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*|[a-z_][A-Za-z0-9_]*)\z/) &&
349
- href.match?(/\A(?::.+|".+")\z/)
350
- link.replace(fragment.document.create_element("code") { |code| code.content = "#{receiver}[#{href}]" })
351
- elsif href.start_with?("www.")
352
- link["href"] = "https://#{href}"
353
- elsif !href.match?(/\A(?:https?:\/\/|mailto:|#)/i)
354
- href = href.sub(/\.html(?=[?#]|\z)/i, ".md")
355
- href = href.sub(%r{\A/(?=.+\.md(?:[?#]|\z))}, "")
356
- href = href.sub(%r{\A((?:\.\./)*)(?:files|classes|modules)/(?=.+\.md(?:[?#]|\z))}, '\1')
357
- link["href"] = href
358
- end
359
- end
360
-
361
- md = ReverseMarkdown.convert(
362
- fragment,
363
- github_flavored: true,
364
- unknown_tags: @markdown_unknown_tags
365
- ).dup
366
- anchor_aliases.each do |token, id|
367
- md.gsub!(token, %(<a id="#{id}"></a>))
368
- end
369
-
370
- normalize_definition_list_code_blocks(md).rstrip
371
- end
372
-
373
- # Short alias used by ERB templates.
374
- alias_method :h, :markdownify
375
-
376
- # Builds an HTML anchor tag.
377
- #
378
- # @param id [String] Fragment identifier for the generated anchor.
379
- #
380
- # @return [String] HTML anchor tag.
381
- def anchor(id)
382
- %(<a id="#{id}"></a>)
383
- end
384
-
385
- # Renders an RDoc description with links limited to emitted objects.
386
- #
387
- # @param code_object [RDoc::CodeObject, RDoc::Context::Section] Object whose description is rendered.
388
- #
389
- # @return [String] HTML description.
390
- def render_description(code_object)
391
- section = RDoc::Context::Section === code_object
392
- formatter = if section
393
- code_object.parent.formatter.dup.tap { |copy| copy.code_object = code_object }
394
- else
395
- code_object.formatter
396
- end
397
- formatter.extend(CrossrefExtension)
398
- begin
399
- formatter.markdown_cross_reference = RDoc::CrossReference.new(formatter.context)
400
- formatter.markdown_output_object_ids = @markdown_output_object_ids
401
- if section
402
- locale = options.locale
403
- documents = code_object.comments.map do |comment|
404
- next comment.parse unless locale && !comment.text.empty?
405
-
406
- comment = comment.dup
407
- comment.text = RDoc::I18n::Text.new(comment).translate(locale)
408
- comment.parse
409
- end
410
- RDoc::Markup::Document.new(*documents).accept(formatter)
411
- else
412
- code_object.description
413
- end
414
- ensure
415
- formatter.markdown_cross_reference = nil
416
- end
417
- end
418
-
419
- # Renders an RDoc object's description as Markdown.
420
- #
421
- # @param code_object [RDoc::CodeObject] Object with an RDoc description.
422
- # @param fallback [String, nil] Text to use when the description is empty.
423
- # @param heading_level_offset [Integer] Heading levels to add while rendering.
424
- #
425
- # @return [String] Rendered description or fallback text.
426
- def describe(code_object, fallback: nil, heading_level_offset: 0)
427
- description = render_description(code_object)
428
- return fallback.to_s if description.empty?
151
+ next FileUtils.cp(File.expand_path(page.absolute_name, source_dir), out_file) if page.relative_name.match?(/\.(?:md|markdown)\z/i)
429
152
 
430
- shift_headings(markdownify(description), heading_level_offset)
431
- end
432
-
433
- # Renders a section description as Markdown.
434
- #
435
- # @param section [RDoc::Context::Section] RDoc section whose description appears before grouped members.
436
- # @param heading_level_offset [Integer] Heading levels to add while rendering.
437
- #
438
- # @return [String] Rendered section description.
439
- def section_description(section, heading_level_offset:)
440
- shift_headings(markdownify(render_description(section)), heading_level_offset)
441
- end
442
-
443
- # Builds the visible method signature used in headings.
444
- #
445
- # @param method [RDoc::AnyMethod] Method object to render.
446
- #
447
- # @return [String] Normalized method signature.
448
- def method_signature(method)
449
- signatures = method.type_signature_lines || @store.rbs_signature_for(method) || [method.param_seq]
450
-
451
- signatures = signatures.filter_map do |signature|
452
- next unless signature&.match?(/\S/)
453
-
454
- signature = signature.gsub("->", " -> ")
455
- signature = signature.gsub(/\s+/, " ").strip
456
- signature = " #{signature}" if signature.start_with?("->")
457
- merge_method_signature_arguments(signature, method.params)
458
- end
459
-
460
- return "()" if signatures.empty?
461
-
462
- signatures.join(" | ")
463
- end
464
-
465
- # Merges RDoc parameter names into a type-only signature.
466
- #
467
- # @param signature [String] Method signature from RDoc call sequence.
468
- # @param raw_params [String, nil] Method parameter list from RDoc.
469
- #
470
- # @return [String] Signature with names added when safe.
471
- def merge_method_signature_arguments(signature, raw_params)
472
- params = normalized_method_params(raw_params)
473
-
474
- signature_args, signature_suffix = split_signature_arguments_and_suffix(signature)
475
- return signature if signature_args.nil?
476
-
477
- param_parts = split_signature_list(params)
478
- signature_parts = split_signature_list(signature_args)
479
- return signature unless param_parts.length.eql?(signature_parts.length)
480
-
481
- param_names = param_parts.map { |part| extract_parameter_name(part) }
482
- return signature if param_names.any?(&:nil?)
483
- return signature if signature_parts.zip(param_names).all? { |part, name| signature_part_mentions_name?(part, name) }
484
-
485
- merged_args = param_parts.zip(signature_parts).map do |param, type|
486
- separator = param.end_with?(":") ? " " : ": "
487
- "#{param}#{separator}#{type}"
488
- end
489
-
490
- "(#{merged_args.join(", ")})#{signature_suffix}"
491
- end
492
-
493
- # Normalizes RDoc's raw parameter string.
494
- #
495
- # @param raw_params [String, nil] Parameter list from RDoc.
496
- #
497
- # @return [String] Parameter list without outer parentheses.
498
- def normalized_method_params(raw_params)
499
- params = raw_params.to_s.strip
500
- params = params[1...-1] if params.start_with?("(") && params.end_with?(")")
501
-
502
- params
503
- end
504
-
505
- # Splits a parenthesized signature into arguments and suffix.
506
- #
507
- # @param signature [String] Method signature.
508
- #
509
- # @return [Array<String>, nil] Argument text and suffix, or nil when not parenthesized.
510
- def split_signature_arguments_and_suffix(signature)
511
- return unless signature.start_with?("(")
512
-
513
- depth = 0
514
-
515
- signature.each_char.with_index do |char, index|
516
- depth += 1 if char == "("
517
-
518
- next unless char == ")"
519
-
520
- depth -= 1
521
- return [signature[1...index], signature[(index + 1)..]] if depth.zero?
522
- end
523
- end
524
-
525
- # Splits a comma-separated signature list while preserving nested groups.
526
- #
527
- # @param list [String] Signature argument list.
528
- #
529
- # @return [Array<String>] Signature parts.
530
- def split_signature_list(list)
531
- parts = []
532
- current = +""
533
- paren_depth = 0
534
- bracket_depth = 0
535
- brace_depth = 0
536
-
537
- list.each_char do |char|
538
- case char
539
- when "("
540
- paren_depth += 1
541
- when ")"
542
- paren_depth -= 1
543
- when "["
544
- bracket_depth += 1
545
- when "]"
546
- bracket_depth -= 1
547
- when "{"
548
- brace_depth += 1
549
- when "}"
550
- brace_depth -= 1
551
- when ","
552
- if paren_depth.zero? && bracket_depth.zero? && brace_depth.zero?
553
- parts << current.strip
554
- current = +""
555
- next
556
- end
557
- end
558
-
559
- current << char
560
- end
561
-
562
- parts << current.strip unless current.empty?
563
- parts
564
- end
565
-
566
- # Extracts a bare Ruby parameter name from a parameter fragment.
567
- #
568
- # @param parameter [String] Parameter fragment.
569
- #
570
- # @return [String, nil] Parameter name, or nil when invalid.
571
- def extract_parameter_name(parameter)
572
- match = parameter.match(/\A(?:\*\*|\*|&)?([a-z_]\w*):?\z/)
573
- match && match[1]
574
- end
575
-
576
- # Checks whether a signature fragment already includes a parameter name.
577
- #
578
- # @param text [String] Signature fragment.
579
- # @param name [String] Parameter name.
580
- #
581
- # @return [Boolean] True when the name appears as a standalone word.
582
- def signature_part_mentions_name?(text, name)
583
- text.match?(/(?<!\w)#{name}(?!\w)/)
584
- end
585
-
586
- # Renders a method description or an alias fallback.
587
- #
588
- # @param method [RDoc::AnyMethod] Method object to render.
589
- # @param current_class [RDoc::Context] Class or module currently being rendered.
590
- #
591
- # @return [String] Rendered method description.
592
- def method_description(method, current_class:)
593
- text = describe(method, heading_level_offset: 4)
594
- return text unless text.empty?
595
-
596
- aliased_method = method.is_alias_for
597
- return "Not documented." unless aliased_method
598
-
599
- link = method_link(aliased_method, current_class: current_class)
600
- return "Alias for: `#{aliased_method.name}`" unless link
601
-
602
- "Alias for: [`#{aliased_method.name}`](#{link})"
603
- end
604
-
605
- # Applies final whitespace and link normalization before writing Markdown.
606
- #
607
- # @param content [String] Markdown content.
608
- # @param current_output_path [String] Output path for the file being written.
609
- #
610
- # @return [String] Final Markdown ending with one newline.
611
- def finalize_markdown(content, current_output_path:)
612
- output = content.lines.map(&:rstrip).join("\n")
613
- output = normalize_internal_links(output, current_output_path: current_output_path)
614
- output = output.sub(/\n{3,}/, "\n\n")
615
- output = output.gsub(/^(#+ .+)\n\n/, "\\1\n")
616
- "#{output}\n"
617
- end
618
-
619
- # Increases Markdown heading levels without exceeding level six.
620
- #
621
- # @param markdown [String] Markdown content.
622
- # @param heading_level_offset [Integer] Heading levels to add.
623
- #
624
- # @return [String] Markdown with shifted headings.
625
- def shift_headings(markdown, heading_level_offset)
626
- markdown.gsub(/^(#+)(\s)/) do
627
- hashes = Regexp.last_match(1)
628
- spaces = Regexp.last_match(2)
629
- level = [hashes.length + heading_level_offset, 6].min
630
- "#{"#" * level}#{spaces}"
631
- end
632
- end
633
-
634
- # Converts RDoc definition-list code blocks into Markdown lists.
635
- #
636
- # @param markdown [String] Markdown content.
637
- #
638
- # @return [String] Markdown with convertible blocks normalized.
639
- def normalize_definition_list_code_blocks(markdown)
640
- markdown.gsub(/```[^\n]*\n(.+?)\n```/m) do
641
- body = Regexp.last_match(1)
642
- converted = convert_definition_list_block(body)
643
- converted.nil? ? Regexp.last_match : converted
644
- end
645
- end
646
-
647
- # Converts a single definition-list code block.
648
- #
649
- # @param body [String] Code block body.
650
- #
651
- # @return [String, nil] Converted Markdown, or nil when the block is not a definition list.
652
- def convert_definition_list_block(body)
653
- lines = body.lines
654
- return nil unless lines.all? { |line| definition_list_line?(line) }
655
-
656
- lines.map do |line|
657
- stripped = line.strip
658
- next if stripped.empty?
659
- next "#{stripped.sub(/::\z/, "")}:" if stripped.end_with?("::")
660
-
661
- "- #{stripped.sub(/\A\*\s/, "")}"
662
- end.join("\n")
663
- end
664
-
665
- # Checks whether a line can appear in a converted definition list.
666
- #
667
- # @param line [String] Markdown line.
668
- #
669
- # @return [Boolean] True when the line matches RDoc definition-list output.
670
- def definition_list_line?(line)
671
- stripped = line.strip
672
- stripped.empty? || stripped.end_with?("::") || stripped.match?(/\A\*\s/)
673
- end
674
-
675
- # Builds a Markdown link target for an aliased method.
676
- #
677
- # @param method [RDoc::AnyMethod] Target method.
678
- # @param current_class [RDoc::Context] Class or module currently being rendered.
679
- #
680
- # @return [String, nil] Anchor or relative Markdown link target, or nil when the target page is omitted.
681
- def method_link(method, current_class:)
682
- return unless method.display?
683
-
684
- target_parent = method.parent
685
- target_path = @class_output_paths[target_parent.full_name]
686
- return unless target_path
687
- return "##{method.aref}" if target_parent == current_class
688
-
689
- "#{target_path}##{method.aref}"
690
- end
691
-
692
- # Rewrites local Markdown links relative to the current output file.
693
- #
694
- # @param markdown [String] Markdown content.
695
- # @param current_output_path [String] Output path for the file being written.
696
- #
697
- # @return [String] Markdown with normalized internal links.
698
- def normalize_internal_links(markdown, current_output_path:)
699
- current_dir = Pathname.new(current_output_path).dirname
700
-
701
- markdown.gsub(%r{\]\(([^)]+)\)}) do
702
- target = Regexp.last_match(1)
703
- path = target.sub(/[?#].*\z/, "")
704
- suffix = target[path.length..]
705
-
706
- resolved = resolve_output_path(path, current_dir)
707
- rewritten = resolved ? Pathname.new(resolved).relative_path_from(current_dir) : path
708
- "](#{rewritten}#{suffix})"
709
- end
710
- end
711
-
712
- # Resolves an internal link path against known generated outputs.
713
- #
714
- # @param path [String] Link path from Markdown content.
715
- # @param current_dir [Pathname] Directory of the current output file.
716
- #
717
- # @return [String, nil] Resolved output path, or nil when unresolved.
718
- def resolve_output_path(path, current_dir)
719
- candidates = [path, path.delete_prefix("#{@root_path_segment}/")]
720
- candidates += candidates.map { |candidate| candidate.sub(/_(md|markdown)\.md\z/i, '.\1') }
721
-
722
- candidates.each do |candidate|
723
- return candidate if @known_output_paths.include?(candidate)
724
- end
725
-
726
- candidates.each do |candidate|
727
- expanded = current_dir.join(candidate).cleanpath.to_s
728
- return expanded if @known_output_paths.include?(expanded)
153
+ content = Conversion.markdownify(render_description(page))
154
+ File.write(out_file, finalize_markdown(content, current_output_path: output_path))
729
155
  end
730
-
731
- nil
732
- end
733
-
734
- # Normalizes an input filename into an output-relative source path.
735
- #
736
- # @param path [String] RDoc input path.
737
- #
738
- # @return [String] Normalized path without root prefixes.
739
- def normalize_input_path_for_output(path)
740
- normalized = path.tr("\\", "/").sub(%r{\A\./}, "")
741
-
742
- root = @source_dir
743
- normalized = normalized.sub(%r{\A#{Regexp.escape(root)}/}, "")
744
- normalized = normalized.sub(%r{\A/}, "")
745
-
746
- root_basename = File.basename(root)
747
- normalized.sub(%r{\A#{Regexp.escape(root_basename)}/}, "")
748
156
  end
749
157
 
750
158
  # Prepares sorted objects and link lookup state for generation.
751
159
  #
752
160
  # @return [void]
753
161
  def setup
754
- @output_dir = @options.op_dir
755
- unless @output_dir.instance_of?(String)
162
+ output_dir = options.op_dir
163
+ unless output_dir.instance_of?(String)
756
164
  raise TypeError, "RDoc markdown output directory must be a String"
757
165
  end
758
166
 
759
- @classes = @store.unique_classes_and_modules.select(&:display?).select do |klass|
760
- klass.in_files.any? ||
761
- klass.documented? ||
762
- klass.includes.any? ||
763
- klass.method_list.any?(&:display?) ||
764
- klass.constants.any?(&:display?) ||
765
- klass.attributes.any?(&:display?) ||
766
- klass.sections.any? { |section| section.title.to_s.match?(/\S/) || !section.to_document.empty? }
767
- end.sort
768
- @class_output_paths = @classes.to_h { |klass| [klass.full_name, output_path_for(klass)] }
769
- @pages = @store.all_files.select(&:text?).select(&:display?)
770
- .select { |page| page.relative_name.match?(/\.(?:md|markdown|rdoc)\z/i) }
771
- .sort_by(&:base_name)
772
- @markdown_output_object_ids = (@classes + @pages).map(&:object_id)
773
- @known_output_paths = @class_output_paths.values
774
- @pages.each { |page| @known_output_paths << page_output_path(page) }
167
+ classes = Selection.classes(store)
168
+ class_output_paths = classes.to_h { |klass| [klass.full_name, output_path_for(klass)] }
169
+ pages = Selection.pages(store)
775
170
 
776
- @root_path_segment = Pathname.new(@options.root || ".").basename
171
+ @generation_state = GenerationState.new(
172
+ output_dir: output_dir,
173
+ classes: classes,
174
+ pages: pages,
175
+ class_output_paths: class_output_paths,
176
+ markdown_output_object_ids: (classes + pages).map(&:object_id),
177
+ known_output_paths: class_output_paths.values + pages.map { |page| page_output_path(page) },
178
+ root_path_segment: Pathname.new(options.root || ".").basename
179
+ )
777
180
  end
778
- end
779
-
780
- # RDoc configuration extended with markdown generator options.
781
- class RDoc::Options
782
- prepend RDoc::Generator::Markdown::OptionsExtension
783
181
 
784
- # Controls how reverse_markdown handles unknown HTML tags.
182
+ # Returns the prepared output directory.
785
183
  #
786
- # @return [Symbol]
787
- attr_accessor :markdown_unknown_tags
184
+ # @return [String] Output directory.
185
+ def output_dir
186
+ generation_state.output_dir
187
+ end
788
188
  end