yard-markdown 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'rdoc'
5
+
6
+ include Helpers::ModuleHelper
7
+
8
+ def init
9
+ sections :header,
10
+ :relationships,
11
+ :docstring_section,
12
+ :tags_section,
13
+ :constants_section,
14
+ :attributes_section,
15
+ :public_class_methods_section,
16
+ :public_instance_methods_section
17
+ end
18
+
19
+ def run(opts = nil, sects = sections, start_at = 0, break_first = false, &block)
20
+ output = super
21
+ return output unless top_level_render?(sects, start_at, break_first)
22
+
23
+ finalize_markdown(output, options.serializer.serialized_path(object))
24
+ end
25
+
26
+ def top_level_render?(sects, start_at, break_first)
27
+ !break_first && start_at.zero? && sects == sections
28
+ end
29
+
30
+ def header
31
+ render_section_content(heading_with_anchors("# #{object.type.to_s.capitalize} #{object.path}", object))
32
+ end
33
+
34
+ def relationships
35
+ render_section_content(object_relationships(object))
36
+ end
37
+
38
+ def docstring_section
39
+ render_section_content(rdoc_to_md(object.docstring))
40
+ end
41
+
42
+ def tags_section
43
+ render_section_content(render_tags(object))
44
+ end
45
+
46
+ def constants_section
47
+ constants = constant_listing(object).reject { |item| hidden_object?(item) }
48
+ return '' unless constants.any?
49
+
50
+ render_section_content(render_constants(constants, Array(object.groups)))
51
+ end
52
+
53
+ def attributes_section
54
+ attrs = attr_listing(object).reject { |item| hidden_object?(item) }
55
+ return '' unless attrs.any?
56
+
57
+ render_section_content(render_attributes(attrs, Array(object.groups)))
58
+ end
59
+
60
+ def public_class_methods_section
61
+ methods = public_class_methods(object)
62
+ return '' unless methods.any?
63
+
64
+ render_section_content(render_methods('Public Class Methods', methods, Array(object.groups)))
65
+ end
66
+
67
+ def public_instance_methods_section
68
+ methods = public_instance_methods(object)
69
+ return '' unless methods.any?
70
+
71
+ render_section_content(render_methods('Public Instance Methods', methods, Array(object.groups)))
72
+ end
73
+
74
+ def render_section_content(content)
75
+ text = content.to_s.strip
76
+ return '' if text.empty?
77
+
78
+ "#{text}\n\n"
79
+ end
80
+
81
+ def object_relationships(object)
82
+ lines = []
83
+
84
+ lines << "**Inherits:** `#{object.superclass.path}`" if object.is_a?(CodeObjects::ClassObject) && object.superclass
85
+
86
+ [[:class, 'Extended by'], [:instance, 'Includes']].each do |scope, label|
87
+ mixins = run_verifier(object.mixins(scope)).sort_by { |item| item.path }
88
+ next if mixins.empty?
89
+
90
+ lines << "**#{label}:** #{mixins.map { |mixin| "`#{mixin.path}`" }.join(', ')}"
91
+ end
92
+
93
+ lines.join("\n")
94
+ end
95
+
96
+ def render_constants(constants, group_order)
97
+ lines = ['## Constants']
98
+ grouped_constants = grouped_items(constants.sort_by { |item| item.name.to_s }, group_order)
99
+ uses_groups = grouped_constants.any? { |name, _items| !name.nil? }
100
+
101
+ grouped_constants.each do |group_name, items|
102
+ if uses_groups
103
+ lines << "### #{group_name || 'General'}"
104
+ item_heading = '####'
105
+ else
106
+ item_heading = '###'
107
+ end
108
+
109
+ items.each_with_index do |item, index|
110
+ lines << '' if index.positive?
111
+ lines << heading_with_anchors("#{item_heading} `#{item.name(false)}`", item)
112
+ append_lines(lines, documented_text(item), separated: false)
113
+ append_lines(lines, render_tags(item), separated: false)
114
+ end
115
+ end
116
+
117
+ lines.join("\n")
118
+ end
119
+
120
+ def render_attributes(attrs, group_order)
121
+ lines = ['## Attributes']
122
+ grouped_attrs = grouped_items(attrs, group_order)
123
+ uses_groups = grouped_attrs.any? { |name, _items| !name.nil? }
124
+
125
+ grouped_attrs.each do |group_name, items|
126
+ if uses_groups
127
+ lines << "### #{group_name || 'General'}"
128
+ item_heading = '####'
129
+ else
130
+ item_heading = '###'
131
+ end
132
+
133
+ items.each_with_index do |item, index|
134
+ lines << '' if index.positive?
135
+ lines << heading_with_anchors("#{item_heading} `#{item.name(false)}` [#{attribute_access(item)}]", item)
136
+ append_lines(lines, documented_text(item), separated: false)
137
+ append_lines(lines, render_tags(item), separated: false)
138
+ end
139
+ end
140
+
141
+ lines.join("\n")
142
+ end
143
+
144
+ def render_methods(section_title, methods, group_order)
145
+ lines = ["## #{section_title}"]
146
+ grouped_methods = grouped_items(methods, group_order)
147
+ uses_groups = grouped_methods.any? { |name, _items| !name.nil? }
148
+
149
+ grouped_methods.each do |group_name, items|
150
+ if uses_groups
151
+ lines << "### #{group_name || 'General'}"
152
+ item_heading = '####'
153
+ else
154
+ item_heading = '###'
155
+ end
156
+
157
+ items.each_with_index do |item, index|
158
+ lines << '' if index.positive?
159
+ lines << heading_with_anchors("#{item_heading} `#{formatted_method_heading(item)}`", item)
160
+ append_lines(lines, documented_text(item), separated: false)
161
+ append_lines(lines, render_tags(item), separated: false)
162
+ end
163
+ end
164
+
165
+ lines.join("\n")
166
+ end
167
+
168
+ def formatted_method_heading(method_object)
169
+ name = method_object.name(false).to_s
170
+ signature = method_signature(method_object)
171
+ signature = " #{signature}" if name.end_with?(']') && signature.start_with?('(')
172
+ "#{name}#{signature}"
173
+ end
174
+
175
+ def method_signature(method_object)
176
+ return '()' if method_object.parameters.nil? || method_object.parameters.empty?
177
+
178
+ rendered = method_object.parameters.map do |name, default|
179
+ default.nil? || default.empty? ? name : "#{name} = #{default}"
180
+ end
181
+
182
+ "(#{rendered.join(', ')})"
183
+ end
184
+
185
+ def attribute_access(attribute)
186
+ info = attribute.attr_info || {}
187
+ return 'RW' if info[:read] && info[:write]
188
+ return 'R' if info[:read]
189
+ return 'W' if info[:write]
190
+
191
+ return 'RW' if attribute.reader? && attribute.writer?
192
+ return 'R' if attribute.reader?
193
+
194
+ 'W'
195
+ end
196
+
197
+ def documented_text(object)
198
+ text = rdoc_to_md(object.docstring)
199
+ return text unless text.empty?
200
+ return '' unless object.tags.empty?
201
+
202
+ 'Not documented.'
203
+ end
204
+
205
+ def rdoc_to_md(docstring)
206
+ text = docstring.to_s
207
+ return '' if text.strip.empty?
208
+
209
+ RDoc::Markup::ToMarkdown.new.convert(text).to_s.strip
210
+ end
211
+
212
+ def render_tags(object)
213
+ return '' if object.tags.empty?
214
+
215
+ lines = []
216
+ regular_tags = object.tags.reject { |tag| tag.tag_name == 'example' }
217
+ example_tags = object.tags.select { |tag| tag.tag_name == 'example' }
218
+
219
+ regular_tags.each do |tag|
220
+ lines << "- #{format_tag(tag)}"
221
+ end
222
+
223
+ example_tags.each do |tag|
224
+ lines << '' unless lines.empty?
225
+ title = tag.name.to_s.strip.empty? ? '**@example**' : "**@example #{tag.name}**"
226
+ lines << title
227
+ lines << '```ruby'
228
+ lines << tag.text.to_s.rstrip
229
+ lines << '```'
230
+ end
231
+
232
+ lines.join("\n")
233
+ end
234
+
235
+ def format_tag(tag)
236
+ parts = ["**@#{tag.tag_name}**"]
237
+ parts << "`#{tag.name}`" unless tag.name.to_s.strip.empty?
238
+
239
+ cleaned_types = normalized_tag_types(tag.types)
240
+ parts << "[#{cleaned_types.join(', ')}]" unless cleaned_types.empty?
241
+ parts << tag.text.to_s.strip unless tag.text.to_s.strip.empty?
242
+
243
+ parts.join(' ')
244
+ end
245
+
246
+ def normalized_tag_types(types)
247
+ values = if types.is_a?(Hash)
248
+ types.map { |name, value| format_hash_tag_type(name, value) }
249
+ else
250
+ Array(types)
251
+ end
252
+
253
+ values.map(&:to_s).map(&:strip).reject(&:empty?)
254
+ end
255
+
256
+ def format_hash_tag_type(name, value)
257
+ key = name.to_s.strip
258
+ return '' if key.empty?
259
+ return key if value.nil? || value == true || (value.respond_to?(:empty?) && value.empty?)
260
+
261
+ "#{key}: #{value}"
262
+ end
263
+
264
+ def aref(object)
265
+ type = object.type
266
+
267
+ return "class-#{anchor_component(object.path.gsub('::', '-'))}" if type == :class
268
+ return "module-#{anchor_component(object.path.gsub('::', '-'))}" if type == :module
269
+ return "constant-#{anchor_component(object.name(false))}" if type == :constant
270
+ return "classvariable-#{anchor_component(object.name(false))}" if type == :classvariable
271
+
272
+ scope = object.scope == :class ? 'c' : 'i'
273
+
274
+ if object.respond_to?(:attr_info) && !object.attr_info.nil?
275
+ "attribute-#{scope}-#{anchor_component(object.name(false))}"
276
+ else
277
+ "method-#{scope}-#{anchor_component(object.name(false))}"
278
+ end
279
+ end
280
+
281
+ def legacy_aref(object)
282
+ type = object.type
283
+
284
+ return "#{object.name(false)}-constant" if type == :constant
285
+ return "#{object.name(false)}-classvariable" if type == :classvariable
286
+ return nil unless object.respond_to?(:scope)
287
+
288
+ return "#{object.name(false)}-class_method" if object.scope == :class
289
+ return "#{object.name(false)}-instance_method" if object.scope == :instance
290
+
291
+ nil
292
+ end
293
+
294
+ def anchor_tags_for(object)
295
+ anchors = [aref(object), legacy_aref(object)].compact.uniq
296
+ anchors.map { |id| anchor_tag(id) }
297
+ end
298
+
299
+ def heading_with_anchors(heading, object)
300
+ anchors = anchor_tags_for(object)
301
+ return heading if anchors.empty?
302
+
303
+ "#{heading} #{anchors.join(' ')}"
304
+ end
305
+
306
+ def anchor_component(value)
307
+ value.to_s.each_char.map do |char|
308
+ char.match?(/[A-Za-z0-9_-]/) ? char : format('-%X', char.ord)
309
+ end.join
310
+ end
311
+
312
+ def constant_listing(object)
313
+ constants = object.constants(included: false, inherited: false)
314
+ constants + object.cvars
315
+ end
316
+
317
+ def public_method_list(object)
318
+ prune_method_listing(
319
+ object.meths(inherited: false, visibility: [:public]),
320
+ included: false
321
+ ).reject { |item| hidden_object?(item) }
322
+ .sort_by { |m| m.name.to_s }
323
+ end
324
+
325
+ def public_class_methods(object)
326
+ public_method_list(object).select { |o| o.scope == :class }
327
+ end
328
+
329
+ def public_instance_methods(object)
330
+ public_method_list(object).select { |o| o.scope == :instance }
331
+ end
332
+
333
+ def attr_listing(object)
334
+ attrs = []
335
+ object.inheritance_tree(true).each do |superclass|
336
+ next if superclass.is_a?(CodeObjects::Proxy)
337
+ next if !options.embed_mixins.empty? && !options.embed_mixins_match?(superclass)
338
+
339
+ %i[class instance].each do |scope|
340
+ superclass.attributes[scope].each do |_name, rw|
341
+ attr = prune_method_listing([rw[:read], rw[:write]].compact, false).first
342
+ attrs << attr if attr
343
+ end
344
+ end
345
+ break if options.embed_mixins.empty?
346
+ end
347
+ sort_listing(attrs)
348
+ end
349
+
350
+ def sort_listing(list)
351
+ list.sort_by { |o| [o.scope.to_s, o.name.to_s.downcase] }
352
+ end
353
+
354
+ def grouped_items(items, group_order)
355
+ grouped = Hash.new { |hash, key| hash[key] = [] }
356
+ items.each { |item| grouped[item.group] << item }
357
+
358
+ ordered = []
359
+
360
+ Array(group_order).each do |name|
361
+ next unless grouped.key?(name)
362
+
363
+ ordered << [name, grouped.delete(name)]
364
+ end
365
+
366
+ grouped.keys.compact.sort.each do |name|
367
+ ordered << [name, grouped.delete(name)]
368
+ end
369
+
370
+ ordered << [nil, grouped.delete(nil)] if grouped.key?(nil)
371
+ ordered
372
+ end
373
+
374
+ def hidden_object?(object)
375
+ object.docstring.to_s.strip.start_with?(':nodoc:')
376
+ end
377
+
378
+ def append_lines(lines, content, separated: true)
379
+ return if content.to_s.strip.empty?
380
+
381
+ lines << '' if separated && !lines.empty? && !lines.last.empty?
382
+ lines.concat(content.to_s.split("\n"))
383
+ end
384
+
385
+ def anchor_tag(id)
386
+ %(<a id="#{id}"></a>)
387
+ end
388
+
389
+ def finalize_markdown(content, current_path)
390
+ output = content.is_a?(Array) ? content.join("\n") : content.to_s
391
+ output = output.lines.map(&:rstrip).join("\n")
392
+ output = normalize_local_links(output, current_path)
393
+ output = normalize_malformed_local_links(output)
394
+ output = output.gsub(/\n{3,}/, "\n\n").strip
395
+ "#{output}\n"
396
+ end
397
+
398
+ def normalize_local_links(markdown, current_path)
399
+ current_dir = Pathname.new(current_path).dirname
400
+
401
+ markdown.gsub(%r{\[(.+?)\]\((?!https?://|mailto:|#)([^)\n]+)\)}) do
402
+ label = Regexp.last_match(1)
403
+ target = Regexp.last_match(2)
404
+ path = target.sub(/[?#].*\z/, '')
405
+ suffix = target[path.length..] || ''
406
+ rewritten_path = resolve_local_link_target(path, current_dir)
407
+
408
+ if rewritten_path.nil?
409
+ "`#{label.tr('`', '')}`"
410
+ else
411
+ "[#{label}](#{rewritten_path}#{suffix})"
412
+ end
413
+ end
414
+ end
415
+
416
+ def resolve_registry_object(path, current_dir)
417
+ cleaned = path.to_s.sub(%r{\A(?:\.\./)+}, '').delete_prefix('./').delete_prefix('/')
418
+ candidates = [path.to_s, path.to_s.tr('/', '::')]
419
+
420
+ if constant_reference_path?(cleaned)
421
+ current_parts = current_dir.to_s.split('/').reject { |part| part.empty? || part == '.' }
422
+ target_parts = cleaned.split(%r{::|/})
423
+
424
+ current_parts.length.downto(0) do |depth|
425
+ candidates << (current_parts.first(depth) + target_parts).join('::')
426
+ end
427
+ end
428
+
429
+ candidates.uniq.each do |candidate|
430
+ obj = Registry.at(candidate)
431
+ return obj if obj && obj.name != :root
432
+ end
433
+
434
+ nil
435
+ end
436
+
437
+ def resolve_local_link_target(path, current_dir)
438
+ normalized = path.to_s.delete_prefix('./')
439
+ normalized = normalized.delete_prefix('/')
440
+
441
+ obj = resolve_registry_object(normalized, current_dir)
442
+ if obj
443
+ object_path = options.serializer.serialized_path(obj)
444
+ return relative_output_path(current_dir, Pathname.new(object_path).cleanpath)
445
+ end
446
+
447
+ if normalized.end_with?('.html')
448
+ normalized = normalized.sub(/\.html\z/i, '.md')
449
+ elsif File.extname(normalized).empty?
450
+ return nil if unresolved_identifier_target?(normalized)
451
+
452
+ normalized = "#{normalized}.md" if normalized.include?('/')
453
+ end
454
+
455
+ relative_output_path(current_dir, Pathname.new(normalized).cleanpath)
456
+ end
457
+
458
+ def constant_reference_path?(value)
459
+ parts = value.to_s.split(%r{::|/}).reject(&:empty?)
460
+ return false if parts.empty?
461
+
462
+ parts.all? { |part| part.match?(/\A[A-Z]\w*\z/) }
463
+ end
464
+
465
+ def unresolved_identifier_target?(path)
466
+ cleaned = path.to_s.sub(%r{\A(?:\.\./)+}, '').delete_prefix('./')
467
+ return false if cleaned.include?('/') || !File.extname(cleaned).empty?
468
+ return true if cleaned.start_with?(':') || cleaned.match?(/\A\d/)
469
+
470
+ cleaned.match?(/\A[a-z_]\w*\z/)
471
+ end
472
+
473
+ def relative_output_path(current_dir, target_path)
474
+ target = target_path.to_s
475
+ return target if target.start_with?('../')
476
+
477
+ Pathname.new(target).relative_path_from(current_dir).to_s
478
+ rescue StandardError
479
+ target
480
+ end
481
+
482
+ def normalize_malformed_local_links(markdown)
483
+ markdown.gsub(%r{\[([^\]]+)\]\((?!https?://|mailto:|#)([^)\n]*['"][^)\n]*)\)}, '`\1`')
484
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yard-markdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stanislav (Stas) Katkov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2024-12-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: yard
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rdoc
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  description: yard plugin to generate markdown documentation for gems
41
55
  email:
42
56
  - yard-markdown@skatkov.com
@@ -63,6 +77,7 @@ files:
63
77
  - lib/yard-markdown.rb
64
78
  - sig/yard/markdown.rbs
65
79
  - templates/default/fulldoc/markdown/setup.rb
80
+ - templates/default/module/markdown/setup.rb
66
81
  homepage: https://poshtui.com
67
82
  licenses:
68
83
  - MIT
@@ -83,7 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
98
  - !ruby/object:Gem::Version
84
99
  version: '0'
85
100
  requirements: []
86
- rubygems_version: 3.6.2
101
+ rubygems_version: 4.0.3
87
102
  specification_version: 4
88
103
  summary: yard plugin to generate markdown documentation
89
104
  test_files: []