boondoc 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +345 -0
- data/exe/boondoc +6 -0
- data/lib/boondoc/assets.rb +112 -0
- data/lib/boondoc/cli.rb +99 -0
- data/lib/boondoc/config.rb +201 -0
- data/lib/boondoc/containment.rb +40 -0
- data/lib/boondoc/context.rb +48 -0
- data/lib/boondoc/core_constants.rb +23 -0
- data/lib/boondoc/docstring_parser.rb +119 -0
- data/lib/boondoc/filter.rb +34 -0
- data/lib/boondoc/frontend/comments.rb +67 -0
- data/lib/boondoc/frontend/prism_parser.rb +40 -0
- data/lib/boondoc/frontend/visitor.rb +359 -0
- data/lib/boondoc/generator.rb +116 -0
- data/lib/boondoc/linker.rb +175 -0
- data/lib/boondoc/links.rb +78 -0
- data/lib/boondoc/markdown.rb +90 -0
- data/lib/boondoc/model/code_object.rb +165 -0
- data/lib/boondoc/model/docstring.rb +70 -0
- data/lib/boondoc/model/registry.rb +150 -0
- data/lib/boondoc/path_map.rb +108 -0
- data/lib/boondoc/preview.rb +81 -0
- data/lib/boondoc/rake_task.rb +39 -0
- data/lib/boondoc/references.rb +59 -0
- data/lib/boondoc/renderer/base.rb +50 -0
- data/lib/boondoc/renderer/docstring.rb +140 -0
- data/lib/boondoc/renderer/index.rb +49 -0
- data/lib/boondoc/renderer/method_section.rb +84 -0
- data/lib/boondoc/renderer/namespace_page.rb +165 -0
- data/lib/boondoc/reporter.rb +36 -0
- data/lib/boondoc/stitcher.rb +89 -0
- data/lib/boondoc/type_parser.rb +78 -0
- data/lib/boondoc/version.rb +5 -0
- data/lib/boondoc/writer.rb +163 -0
- data/lib/boondoc/yard_bridge.rb +113 -0
- data/lib/boondoc/yardopts.rb +165 -0
- data/lib/boondoc.rb +48 -0
- metadata +116 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Serves generated docs locally using the `gh markdown-preview` extension of
|
|
5
|
+
# the GitHub CLI.
|
|
6
|
+
#
|
|
7
|
+
# The previewer resolves links relative to the directory it was started in,
|
|
8
|
+
# so it is always started from inside the output directory.
|
|
9
|
+
class Preview
|
|
10
|
+
EXTENSION = 'yusukebe/gh-markdown-preview'
|
|
11
|
+
DEFAULT_HOST = '127.0.0.1'
|
|
12
|
+
LOOPBACK_HOSTS = %w[::1 localhost].freeze
|
|
13
|
+
SILENT = { out: File::NULL, err: File::NULL }.freeze
|
|
14
|
+
|
|
15
|
+
# @param directory [String] the directory holding the generated docs
|
|
16
|
+
# @param host [String] address the previewer binds to
|
|
17
|
+
# @param port [Integer, nil] port to listen on; the previewer's default when nil
|
|
18
|
+
# @yieldreturn [Boolean] whether the extension may be installed; asked only
|
|
19
|
+
# when it is missing
|
|
20
|
+
def initialize(directory, host: DEFAULT_HOST, port: nil, &confirm_install)
|
|
21
|
+
@directory = directory
|
|
22
|
+
@host = host
|
|
23
|
+
@port = port
|
|
24
|
+
@confirm_install = confirm_install
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Starts the previewer and blocks until it exits.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @raise [Error] when the docs, the GitHub CLI, or the extension are missing
|
|
31
|
+
def start
|
|
32
|
+
raise Error, "#{@directory} does not exist; run `boondoc` first to generate the docs" unless docs?
|
|
33
|
+
raise Error, 'serve needs the GitHub CLI (gh); see https://cli.github.com' unless gh?
|
|
34
|
+
|
|
35
|
+
install unless extension?
|
|
36
|
+
warn_exposed unless loopback?
|
|
37
|
+
serve
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def loopback?
|
|
43
|
+
LOOPBACK_HOSTS.include?(@host) || @host.start_with?('127.')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The extension answers `/__/md?path=` for any path, not only the docs, and
|
|
47
|
+
# does not check the Host header, so whoever can reach the port can read
|
|
48
|
+
# every file the current user can.
|
|
49
|
+
def warn_exposed
|
|
50
|
+
warn "boondoc: serving on #{@host} exposes the preview to the network. gh markdown-preview will " \
|
|
51
|
+
'return any file the current user can read, not only the docs. Use the default 127.0.0.1 ' \
|
|
52
|
+
'unless everyone who can reach this machine is trusted.'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def docs?
|
|
56
|
+
File.directory?(@directory)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def gh?
|
|
60
|
+
system('gh', '--version', **SILENT) == true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extension?
|
|
64
|
+
system('gh', 'markdown-preview', '--version', **SILENT) == true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def install
|
|
68
|
+
manual = "install it with `gh extension install #{EXTENSION}`"
|
|
69
|
+
raise Error, "serve needs the gh markdown-preview extension; #{manual}" unless @confirm_install&.call
|
|
70
|
+
raise Error, "could not install the extension; #{manual}" unless system('gh', 'extension', 'install', EXTENSION)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def serve
|
|
74
|
+
command = ['gh', 'markdown-preview', '--host', @host]
|
|
75
|
+
command.push('--port', @port.to_s) if @port
|
|
76
|
+
system(*command, chdir: @directory)
|
|
77
|
+
rescue Interrupt
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rake'
|
|
4
|
+
require 'rake/tasklib'
|
|
5
|
+
require 'boondoc'
|
|
6
|
+
|
|
7
|
+
module Boondoc
|
|
8
|
+
# Defines a Rake task that generates the documentation.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# require 'boondoc/rake_task'
|
|
12
|
+
#
|
|
13
|
+
# Boondoc::RakeTask.new(:docs) do |t|
|
|
14
|
+
# t.options = { output: 'docs', files: ['CHANGELOG.md'], strict: true }
|
|
15
|
+
# end
|
|
16
|
+
class RakeTask < ::Rake::TaskLib
|
|
17
|
+
# @return [Symbol] the task name
|
|
18
|
+
attr_accessor :name
|
|
19
|
+
# @return [Hash{Symbol => Object}] options passed to {Config.load}
|
|
20
|
+
attr_accessor :options
|
|
21
|
+
|
|
22
|
+
# @param name [Symbol]
|
|
23
|
+
# @yieldparam task [RakeTask] the task, for setting {#options}
|
|
24
|
+
def initialize(name = :docs)
|
|
25
|
+
super()
|
|
26
|
+
@name = name
|
|
27
|
+
@options = {}
|
|
28
|
+
yield self if block_given?
|
|
29
|
+
define
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def define
|
|
35
|
+
desc 'Generate markdown documentation with boondoc'
|
|
36
|
+
task(name) { Generator.new(Config.load(options)).run }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Expands YARD reference docstrings: `(see Foo#bar)` as a whole docstring, or
|
|
5
|
+
# as the text of a tag such as `@param (see Foo#bar)`.
|
|
6
|
+
class References
|
|
7
|
+
SEE = /\A\(see (\S+)\)\z/
|
|
8
|
+
|
|
9
|
+
# @param registry [Model::Registry]
|
|
10
|
+
def initialize(registry)
|
|
11
|
+
@registry = registry
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [void]
|
|
15
|
+
def expand
|
|
16
|
+
@visited = {}.compare_by_identity
|
|
17
|
+
@registry.each { |object| expand_object(object) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Targets are expanded before they are copied, so chains such as
|
|
23
|
+
# `(see B)` -> `(see C)` work in any order. A cycle stops at the object
|
|
24
|
+
# already being expanded.
|
|
25
|
+
def expand_object(object) # rubocop:disable Metrics/MethodLength
|
|
26
|
+
return if @visited.key?(object)
|
|
27
|
+
|
|
28
|
+
@visited[object] = true
|
|
29
|
+
docstring = object.docstring
|
|
30
|
+
tags = docstring.tags.flat_map { |tag| expand_tag(object, tag) }
|
|
31
|
+
text = docstring.text
|
|
32
|
+
target = referenced(object, text.strip)
|
|
33
|
+
if target
|
|
34
|
+
text = target.docstring.text
|
|
35
|
+
tags += inherited_tags(target, tags)
|
|
36
|
+
end
|
|
37
|
+
object.docstring = Model::Docstring.new(text, tags, docstring.directives)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inherited_tags(target, own_tags)
|
|
41
|
+
own = own_tags.map(&:tag_name)
|
|
42
|
+
target.docstring.tags.reject { |tag| own.include?(tag.tag_name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def expand_tag(object, tag)
|
|
46
|
+
target = referenced(object, tag.text.to_s.strip)
|
|
47
|
+
target ? target.docstring.tags_named(tag.tag_name) : [tag]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def referenced(object, text)
|
|
51
|
+
match = SEE.match(text)
|
|
52
|
+
target = match && @registry.resolve(object, match[1])
|
|
53
|
+
return nil if target.nil? || target.equal?(object)
|
|
54
|
+
|
|
55
|
+
expand_object(target)
|
|
56
|
+
target
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Classes that turn the model into markdown.
|
|
5
|
+
module Renderer
|
|
6
|
+
# Shared helpers for all renderers.
|
|
7
|
+
class Base
|
|
8
|
+
# @param context [Context]
|
|
9
|
+
def initialize(context)
|
|
10
|
+
@context = context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :context
|
|
16
|
+
|
|
17
|
+
def registry = context.registry
|
|
18
|
+
def filter = context.filter
|
|
19
|
+
def linker = context.linker
|
|
20
|
+
def path_map = context.path_map
|
|
21
|
+
|
|
22
|
+
def code(text)
|
|
23
|
+
"`#{text}`"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A fenced Ruby block whose fence is longer than any fence inside the source.
|
|
27
|
+
def ruby(source)
|
|
28
|
+
longest = source.scan(/^\s*(`{3,})/).flatten.map(&:size).max || 2
|
|
29
|
+
fence = '`' * [3, longest + 1].max
|
|
30
|
+
"#{fence}ruby\n#{source}\n#{fence}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Joins blocks into a document with exactly one blank line between them.
|
|
34
|
+
def document(blocks)
|
|
35
|
+
blocks.flatten.compact.map { |block| block.sub(/\A\n+/, '').rstrip }.reject(&:empty?).join("\n\n") << "\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A one-line summary of an object, with references resolved.
|
|
39
|
+
def summary(object, from)
|
|
40
|
+
text = linker.text(object.docstring.summary, object: object, from: from)
|
|
41
|
+
text = "**Deprecated.** #{text}".strip if object.docstring.tag?('deprecated')
|
|
42
|
+
Markdown.table_cell(text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bullet(link, summary)
|
|
46
|
+
summary.empty? ? "- #{link}" : "- #{link} — #{summary}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders a docstring: callouts, body, then one section per kind of tag.
|
|
6
|
+
class Docstring < Base
|
|
7
|
+
CALLOUTS = { 'deprecated' => 'Deprecated', 'abstract' => 'Abstract', 'note' => 'Note',
|
|
8
|
+
'todo' => 'Todo' }.freeze
|
|
9
|
+
TYPED = { 'yieldparam' => 'Yield parameters', 'yieldreturn' => 'Yield returns',
|
|
10
|
+
'return' => 'Returns', 'raise' => 'Raises' }.freeze
|
|
11
|
+
META = { 'since' => 'Since', 'version' => 'Version', 'author' => 'Author', 'api' => 'API' }.freeze
|
|
12
|
+
HANDLED = (CALLOUTS.keys + TYPED.keys + META.keys +
|
|
13
|
+
%w[param option yield example see overload private attr attr_reader attr_writer]).freeze
|
|
14
|
+
|
|
15
|
+
# @param context [Context]
|
|
16
|
+
# @param object [Model::CodeObject] the object being documented
|
|
17
|
+
# @param from [String] the output file being written
|
|
18
|
+
def initialize(context, object, from)
|
|
19
|
+
super(context)
|
|
20
|
+
@object = object
|
|
21
|
+
@from = from
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param docstring [Model::Docstring]
|
|
25
|
+
# @return [Array<String>] markdown blocks, in order
|
|
26
|
+
def render(docstring)
|
|
27
|
+
[
|
|
28
|
+
callouts(docstring), text(docstring.text), parameters(docstring), yields(docstring),
|
|
29
|
+
TYPED.map { |name, title| section(title, docstring.tags_named(name).map { |tag| typed(tag) }) },
|
|
30
|
+
examples(docstring), see_also(docstring), meta(docstring), overloads(docstring)
|
|
31
|
+
].flatten.compact.reject(&:empty?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def text(text)
|
|
37
|
+
linker.text(text, object: @object, from: @from).strip
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def types(tag)
|
|
41
|
+
linker.types(tag.types, object: @object, from: @from)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def callouts(docstring)
|
|
45
|
+
docstring.tags.select { |tag| CALLOUTS.key?(tag.tag_name) }.map do |tag|
|
|
46
|
+
body = text(tag.text)
|
|
47
|
+
label = "**#{CALLOUTS[tag.tag_name]}#{':' unless body.empty?}** #{body}".strip
|
|
48
|
+
label.lines.map { |line| "> #{line}".rstrip }.join("\n")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def section(title, items)
|
|
53
|
+
"**#{title}:**\n\n#{items.join("\n")}" unless items.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def item(lead, description, indent: '')
|
|
57
|
+
line = [lead, description].compact.reject(&:empty?).join(' — ')
|
|
58
|
+
"#{indent}- #{line.gsub("\n", "\n#{indent} ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def typed(tag)
|
|
62
|
+
types = types(tag)
|
|
63
|
+
item([tag.name && code(tag.name), types && "(#{types})"].compact.join(' '), text(tag.text))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parameters(docstring)
|
|
67
|
+
params = docstring.tags_named('param')
|
|
68
|
+
options = docstring.tags_named('option')
|
|
69
|
+
items = params.flat_map { |tag| [typed(tag), *option_items(options, tag.name, ' ')] }
|
|
70
|
+
[section('Parameters', items), *orphan_options(options, params.map(&:name))]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Options whose hash is not itself documented with @param get their own section.
|
|
74
|
+
def orphan_options(options, param_names)
|
|
75
|
+
orphans = options.reject { |option| param_names.include?(option.name) }
|
|
76
|
+
orphans.map(&:name).uniq.map do |name|
|
|
77
|
+
section("Options (#{code(name)})", option_items(orphans, name, ''))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def option_items(options, name, indent)
|
|
82
|
+
options.select { |option| option.name == name }.map do |option|
|
|
83
|
+
types = types(option)
|
|
84
|
+
lead = [code(option.key), types && "(#{types})"].compact.join(' ')
|
|
85
|
+
default = option.default && "(default: #{code(option.default)})"
|
|
86
|
+
item(lead, [text(option.text), default].compact.reject(&:empty?).join(' '), indent: indent)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def yields(docstring)
|
|
91
|
+
items = docstring.tags_named('yield').map do |tag|
|
|
92
|
+
names = tag.types&.map { |name| code(name) }&.join(', ')
|
|
93
|
+
item(names && "(#{names})", text(tag.text))
|
|
94
|
+
end
|
|
95
|
+
section('Yields', items)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def examples(docstring)
|
|
99
|
+
tags = docstring.tags_named('example')
|
|
100
|
+
return nil if tags.empty?
|
|
101
|
+
|
|
102
|
+
['**Examples:**', *tags.flat_map { |tag| [("*#{tag.name}*" unless tag.name.to_s.empty?), fenced(tag.text)] }]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fenced(source)
|
|
106
|
+
source.match?(/\A\s*(?:```|~~~)/) ? source : ruby(source)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def see_also(docstring)
|
|
110
|
+
section('See also', docstring.tags_named('see').map { |tag| item(see_link(tag.name.to_s), text(tag.text)) })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def see_link(name)
|
|
114
|
+
return "<#{name}>" if Linker::URL.match?(name)
|
|
115
|
+
|
|
116
|
+
found = registry.resolve(@object, name)
|
|
117
|
+
found ? linker.link(found, from: @from, label: code(name)) : text(name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def meta(docstring)
|
|
121
|
+
known = META.flat_map { |name, title| docstring.tags_named(name).map { |tag| [title, tag] } }
|
|
122
|
+
other = docstring.tags.reject { |tag| HANDLED.include?(tag.tag_name) }
|
|
123
|
+
.map { |tag| [tag.tag_name.tr('_', ' ').capitalize, tag] }
|
|
124
|
+
items = (known + other).map { |title, tag| meta_item(title, tag) }
|
|
125
|
+
items.join("\n") unless items.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def meta_item(title, tag)
|
|
129
|
+
types = types(tag)
|
|
130
|
+
"- **#{title}:** #{[types && "(#{types})", text(tag.text)].compact.join(' ')}".rstrip
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def overloads(docstring)
|
|
134
|
+
docstring.tags_named('overload').flat_map do |tag|
|
|
135
|
+
["**Overload:**\n\n#{ruby("def #{tag.signature}")}", *render(tag.docstring)]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders the index page (README plus tables of contents) and stitched files.
|
|
6
|
+
class Index < Base
|
|
7
|
+
# @return [String] the index page
|
|
8
|
+
def render
|
|
9
|
+
from = context.config.index
|
|
10
|
+
readme = context.stitcher.readme
|
|
11
|
+
intro = readme ? file_body(readme) : "# #{context.config.title}"
|
|
12
|
+
document([intro, api(from), files(from)])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param file [ExtraFile]
|
|
16
|
+
# @return [String] a stitched file with its links and references resolved
|
|
17
|
+
def render_file(file)
|
|
18
|
+
document([file_body(file)])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def file_body(file)
|
|
24
|
+
linker.text(context.stitcher.relocated_content(file), object: registry.root, from: file.output, assets: false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def api(from)
|
|
28
|
+
lines = tree(registry.root, from, 0)
|
|
29
|
+
top = NamespacePage.new(context, registry.root)
|
|
30
|
+
lines << "- [Top level](#{path_map.relative(PathMap::TOP_LEVEL, from)})" if top.content?
|
|
31
|
+
['## API reference', lines.join("\n")] unless lines.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def tree(namespace, from, depth)
|
|
35
|
+
namespace.namespaces.select { |child| filter.visible?(child) }.flat_map do |child|
|
|
36
|
+
entry = bullet(linker.link(child, from: from), summary(child, from))
|
|
37
|
+
["#{' ' * depth}#{entry}", *tree(child, from, depth + 1)]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def files(from)
|
|
42
|
+
extras = context.stitcher.files.reject(&:readme)
|
|
43
|
+
return nil if extras.empty?
|
|
44
|
+
|
|
45
|
+
['## Files', extras.map { |file| "- [#{file.title}](#{path_map.relative(file.output, from)})" }.join("\n")]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders the detail section for one method or one attribute.
|
|
6
|
+
class MethodSection < Base
|
|
7
|
+
# @param context [Context]
|
|
8
|
+
# @param from [String] the output file being written
|
|
9
|
+
def initialize(context, from)
|
|
10
|
+
super(context)
|
|
11
|
+
@from = from
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param meth [Model::MethodObject]
|
|
15
|
+
# @return [Array<String>] markdown blocks
|
|
16
|
+
def render(meth)
|
|
17
|
+
[
|
|
18
|
+
heading(meth.display_name, [meth]),
|
|
19
|
+
ruby(signature(meth)),
|
|
20
|
+
facts(meth),
|
|
21
|
+
*Docstring.new(context, meth, @from).render(meth.docstring)
|
|
22
|
+
]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Renders a reader and/or writer as a single attribute entry.
|
|
26
|
+
#
|
|
27
|
+
# @param accessors [Array<Model::MethodObject>] the reader, the writer, or both
|
|
28
|
+
# @return [Array<String>] markdown blocks
|
|
29
|
+
def render_attribute(accessors)
|
|
30
|
+
reader = accessors.find { |meth| meth.attribute == :reader }
|
|
31
|
+
writer = accessors.find { |meth| meth.attribute == :writer }
|
|
32
|
+
primary = reader || writer
|
|
33
|
+
name = primary.name.delete_suffix('=')
|
|
34
|
+
macro = reader && writer ? 'attr_accessor' : "attr_#{primary.attribute}"
|
|
35
|
+
[
|
|
36
|
+
heading("#{primary.separator}#{name}", accessors),
|
|
37
|
+
ruby("#{macro} :#{name}"),
|
|
38
|
+
facts(primary),
|
|
39
|
+
*Docstring.new(context, primary, @from).render(documented(accessors).docstring)
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param meth [Model::MethodObject]
|
|
44
|
+
# @return [String] the method as it would be defined, such as `def self.build(name)`
|
|
45
|
+
def signature(meth)
|
|
46
|
+
params = meth.parameters ? "(#{meth.parameters})" : ''
|
|
47
|
+
"def #{'self.' if meth.scope == :class}#{meth.name}#{params}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def documented(accessors)
|
|
53
|
+
accessors.find { |meth| !meth.docstring.empty? } || accessors.first
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Explicit anchors, because heading slugs cannot tell `foo`, `foo?`, and `foo!` apart.
|
|
57
|
+
def heading(title, objects)
|
|
58
|
+
anchors = objects.map { |object| %(<a id="#{path_map.anchor_for(object)}"></a>) }.join
|
|
59
|
+
"### #{anchors}#{code(title)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def facts(meth)
|
|
63
|
+
lines = []
|
|
64
|
+
lines << "- **Visibility:** #{meth.visibility}" unless meth.visibility == :public
|
|
65
|
+
lines << "- **Alias for:** #{alias_link(meth)}" if meth.alias_for
|
|
66
|
+
aliases = aliases_of(meth)
|
|
67
|
+
lines << "- **Also known as:** #{aliases.join(', ')}" unless aliases.empty?
|
|
68
|
+
lines.join("\n") unless lines.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def alias_link(meth)
|
|
72
|
+
original = registry[Model::MethodObject.path_for(meth.namespace, meth.scope, meth.alias_for)]
|
|
73
|
+
label = code("#{meth.separator}#{meth.alias_for}")
|
|
74
|
+
original ? linker.link(original, from: @from, label: label) : label
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def aliases_of(meth)
|
|
78
|
+
aliases = context.aliases(meth.namespace, meth.scope).fetch(meth.name, [])
|
|
79
|
+
aliases.select { |other| filter.visible?(other) }
|
|
80
|
+
.map { |other| linker.link(other, from: @from, label: code(other.display_name)) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders the page for one class or module, or for the top-level namespace.
|
|
6
|
+
class NamespacePage < Base # rubocop:disable Metrics/ClassLength
|
|
7
|
+
SCOPES = { class: 'Class methods', instance: 'Instance methods' }.freeze
|
|
8
|
+
MIXINS = { include: 'Includes', extend: 'Extended by', prepend: 'Prepended by' }.freeze
|
|
9
|
+
MAX_CONSTANT_LINES = 12
|
|
10
|
+
# A guard against a superclass chain that never ends; real chains are far shorter.
|
|
11
|
+
MAX_SUPERCLASS_CHAIN = 20
|
|
12
|
+
|
|
13
|
+
# @param context [Context]
|
|
14
|
+
# @param namespace [Model::NamespaceObject]
|
|
15
|
+
def initialize(context, namespace)
|
|
16
|
+
super(context)
|
|
17
|
+
@namespace = namespace
|
|
18
|
+
@from = path_map.file_for(namespace)
|
|
19
|
+
@methods = MethodSection.new(context, @from)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] false when there is nothing worth writing a page for
|
|
23
|
+
def content?
|
|
24
|
+
!@namespace.root? || visible(@namespace.children.grep_v(Model::NamespaceObject)).any?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] the whole page
|
|
28
|
+
def render
|
|
29
|
+
document([
|
|
30
|
+
"# #{title}", facts, Docstring.new(context, @namespace, @from).render(@namespace.docstring),
|
|
31
|
+
namespaces, constants, attributes,
|
|
32
|
+
SCOPES.keys.map { |scope| method_sections(scope) }, inherited_methods
|
|
33
|
+
])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def visible(objects)
|
|
39
|
+
objects.select { |object| filter.visible?(object) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def title
|
|
43
|
+
return 'Top level' if @namespace.root?
|
|
44
|
+
|
|
45
|
+
"#{@namespace.kind.to_s.capitalize}: #{@namespace.path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- header -------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def facts
|
|
51
|
+
lines = []
|
|
52
|
+
lines << "- **Inherits:** #{superclass_chain.join(' < ')}" if @namespace.superclass
|
|
53
|
+
lines.concat(mixin_facts)
|
|
54
|
+
lines << "- **Defined in:** #{source_links.join(', ')}" unless @namespace.files.empty?
|
|
55
|
+
lines.join("\n") unless lines.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mixin_facts
|
|
59
|
+
MIXINS.filter_map do |kind, label|
|
|
60
|
+
names = @namespace.mixins[kind]
|
|
61
|
+
"- **#{label}:** #{names.map { |name| constant_link(name) }.join(', ')}" unless names.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def constant_link(name)
|
|
66
|
+
found = registry.resolve(@namespace, name)
|
|
67
|
+
found.is_a?(Model::NamespaceObject) ? linker.link(found, from: @from) : code(name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def superclass_chain
|
|
71
|
+
chain = []
|
|
72
|
+
current = @namespace
|
|
73
|
+
while current&.superclass && chain.size < MAX_SUPERCLASS_CHAIN
|
|
74
|
+
found = registry.superclass(current)
|
|
75
|
+
chain << (found ? linker.link(found, from: @from) : code(current.superclass))
|
|
76
|
+
current = found
|
|
77
|
+
break if current.equal?(@namespace)
|
|
78
|
+
end
|
|
79
|
+
chain
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def source_links
|
|
83
|
+
@namespace.files.map { |file, _| "[#{code(file)}](#{path_map.source_href(file, from: @from)})" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- sections -----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def namespaces
|
|
89
|
+
children = visible(@namespace.namespaces)
|
|
90
|
+
return nil if children.empty?
|
|
91
|
+
|
|
92
|
+
['## Namespaces', children.map { |child| bullet(linker.link(child, from: @from), summary(child, @from)) }
|
|
93
|
+
.join("\n")]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def constants
|
|
97
|
+
constants = visible(@namespace.constants)
|
|
98
|
+
return nil if constants.empty?
|
|
99
|
+
|
|
100
|
+
['## Constants', *constants.flat_map { |constant| constant_section(constant) }]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def constant_section(constant)
|
|
104
|
+
[
|
|
105
|
+
%(### <a id="#{path_map.anchor_for(constant)}"></a>#{code(constant.name)}),
|
|
106
|
+
ruby("#{constant.name} = #{truncate(constant.value)}"),
|
|
107
|
+
*Docstring.new(context, constant, @from).render(constant.docstring)
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def truncate(value)
|
|
112
|
+
lines = value.lines
|
|
113
|
+
return value if lines.size <= MAX_CONSTANT_LINES
|
|
114
|
+
|
|
115
|
+
"#{lines.first(MAX_CONSTANT_LINES).join.rstrip}\n# ... (#{lines.size - MAX_CONSTANT_LINES} more lines)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def attributes
|
|
119
|
+
groups = SCOPES.keys.flat_map do |scope|
|
|
120
|
+
visible(context.meths(@namespace, scope)).select(&:attribute)
|
|
121
|
+
.group_by { |meth| meth.name.delete_suffix('=') }.values
|
|
122
|
+
end
|
|
123
|
+
return nil if groups.empty?
|
|
124
|
+
|
|
125
|
+
['## Attributes', *groups.flat_map { |accessors| @methods.render_attribute(accessors) }]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def method_sections(scope)
|
|
129
|
+
meths = visible(context.meths(@namespace, scope)).reject(&:attribute)
|
|
130
|
+
return nil if meths.empty?
|
|
131
|
+
|
|
132
|
+
["## #{SCOPES[scope]}", summary_table(meths), *meths.flat_map { |meth| @methods.render(meth) }]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def summary_table(meths)
|
|
136
|
+
rows = meths.map do |meth|
|
|
137
|
+
label = Markdown.table_cell(@methods.signature(meth).delete_prefix('def ').delete_prefix('self.'))
|
|
138
|
+
"| [#{code("#{meth.separator}#{label}")}](#{path_map.href(meth, from: @from)}) | #{summary(meth, @from)} |"
|
|
139
|
+
end
|
|
140
|
+
['| Method | Summary |', '| --- | --- |', *rows].join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Each method is listed under the nearest ancestor that defines it, the
|
|
144
|
+
# one Ruby would actually call.
|
|
145
|
+
def inherited_methods
|
|
146
|
+
seen = context.meths(@namespace, :instance).to_set(&:name)
|
|
147
|
+
items = registry.ancestors(@namespace).drop(1).filter_map { |ancestor| inherited_from_ancestor(ancestor, seen) }
|
|
148
|
+
['## Inherited methods', items.join("\n")] unless items.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def inherited_from_ancestor(ancestor, seen)
|
|
152
|
+
meths = context.meths(ancestor, :instance).reject { |meth| seen.include?(meth.name) }
|
|
153
|
+
seen.merge(meths.map(&:name))
|
|
154
|
+
inherited_from(ancestor, visible(meths)) if filter.visible?(ancestor)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def inherited_from(ancestor, meths)
|
|
158
|
+
return nil if meths.empty?
|
|
159
|
+
|
|
160
|
+
links = meths.map { |meth| linker.link(meth, from: @from, label: code(meth.display_name)) }
|
|
161
|
+
"- From #{linker.link(ancestor, from: @from)}: #{links.join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|