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,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Finds the paths in markdown links, images and HTML attributes so they can be rewritten.
|
|
5
|
+
#
|
|
6
|
+
# Each path is passed on together with whether it is embedded in the page, as an image, video,
|
|
7
|
+
# audio, `<source>` or `srcset`, or only linked to. Code blocks and code spans are left alone.
|
|
8
|
+
module Links
|
|
9
|
+
# Targets that are not paths in the project: URLs, anchors, absolute paths, and `{references}`.
|
|
10
|
+
EXTERNAL = %r{\A(?:[a-z][a-z0-9+.-]*:|[#/\{])}i
|
|
11
|
+
INLINE = /(?<lead>\]\(\s*<?)(?<target>[^)\s>]+)/ # [text](target) and 
|
|
12
|
+
DEFINITION = /^(?<lead> {0,3}\[(?<label>[^\]]+)\]:\s+<?)(?<target>[^\s>]+)/ # [label]: target
|
|
13
|
+
ATTRIBUTE = /(?<lead>\b(?<name>src|href|poster|data)=["'])(?<target>[^"']+)/ # <img src="target">
|
|
14
|
+
SRCSET = /(?<lead>\bsrcset=["'])(?<value>[^"']+)/ # <source srcset="a.png 1x, b.png 2x">
|
|
15
|
+
SRCSET_ENTRY = /(?<url>[^\s,]+)(?<descriptor>\s+\d[\d.]*[wx])?/
|
|
16
|
+
IMAGE_REFERENCE = /!\[(?<alt>[^\]]*)\]\[(?<label>[^\]]*)\]/
|
|
17
|
+
EMBEDDING_ATTRIBUTES = %w[src poster data].freeze
|
|
18
|
+
IMAGE_OPENING = /!\[(?:[^\[\]]|\[[^\[\]]*\])*\z/ # an image whose alt text is still open
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param content [String] markdown
|
|
23
|
+
# @yieldparam target [String] a path as written, with any fragment or query still attached
|
|
24
|
+
# @yieldparam embedded [Boolean] whether the page embeds the file rather than linking to it
|
|
25
|
+
# @yieldreturn [String] the path to write in its place
|
|
26
|
+
# @return [String] the content with every path replaced by the block's result
|
|
27
|
+
def rewrite(content, &)
|
|
28
|
+
labels = embedded_labels(content)
|
|
29
|
+
Markdown.map_prose(content) { |prose| rewrite_prose(prose, labels, &) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
def rewrite_prose(prose, labels, &)
|
|
34
|
+
prose = rewrite_links(prose, labels, &)
|
|
35
|
+
prose = prose.gsub(ATTRIBUTE) do
|
|
36
|
+
replace(Regexp.last_match, EMBEDDING_ATTRIBUTES.include?(Regexp.last_match[:name]), &)
|
|
37
|
+
end
|
|
38
|
+
prose.gsub(SRCSET) { "#{Regexp.last_match[:lead]}#{rewrite_srcset(Regexp.last_match[:value], &)}" }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def rewrite_links(prose, labels, &)
|
|
43
|
+
inline = prose.gsub(INLINE) { replace(Regexp.last_match, embedded_inline?(prose, Regexp.last_match), &) }
|
|
44
|
+
inline.gsub(DEFINITION) { replace(Regexp.last_match, labels.include?(Regexp.last_match[:label].downcase), &) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
def replace(match, embedded)
|
|
49
|
+
return match[0] if EXTERNAL.match?(match[:target])
|
|
50
|
+
|
|
51
|
+
"#{match[:lead]}#{yield(match[:target], embedded)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
def rewrite_srcset(value)
|
|
56
|
+
value.gsub(SRCSET_ENTRY) do
|
|
57
|
+
url = Regexp.last_match[:url]
|
|
58
|
+
"#{EXTERNAL.match?(url) ? url : yield(url, true)}#{Regexp.last_match[:descriptor]}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The labels that an `![alt][label]` image refers to.
|
|
63
|
+
#
|
|
64
|
+
# @api private
|
|
65
|
+
def embedded_labels(content)
|
|
66
|
+
content.scan(IMAGE_REFERENCE).map { |alt, label| (label.empty? ? alt : label).downcase }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether the link at `match` is the text of an image, ``, rather than a plain link.
|
|
70
|
+
# The text before it, on the same line, must open an image and close only nested brackets.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
def embedded_inline?(text, match)
|
|
74
|
+
line = (text.rindex("\n", match.begin(:lead)) || -1) + 1
|
|
75
|
+
IMAGE_OPENING.match?(text[line...match.begin(:lead)])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Just enough markdown awareness to leave code alone while rewriting prose.
|
|
5
|
+
module Markdown
|
|
6
|
+
FENCE = /\A {0,3}(`{3,}|~{3,})/
|
|
7
|
+
INDENTED = /\A(?: {4}|\t)\s*\S/
|
|
8
|
+
LIST_ITEM = /\A\s*(?:[-*+]|\d+[.)])\s/
|
|
9
|
+
INLINE_CODE = /(?<!`)(`+)(?!`)(?:(?!\n\s*\n).)+?(?<!`)\1(?!`)/m
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Rewrites the prose of a markdown document, skipping fenced code blocks,
|
|
14
|
+
# indented code blocks and inline code spans.
|
|
15
|
+
#
|
|
16
|
+
# @param text [String]
|
|
17
|
+
# @yieldparam prose [String] a run of text outside any code
|
|
18
|
+
# @yieldreturn [String] its replacement
|
|
19
|
+
# @return [String]
|
|
20
|
+
def map_prose(text, &)
|
|
21
|
+
state = { fence: nil, indented: false, blank: true, list: false }
|
|
22
|
+
output = +''
|
|
23
|
+
prose = +''
|
|
24
|
+
text.each_line do |line|
|
|
25
|
+
next prose << line unless code_line?(line, state)
|
|
26
|
+
|
|
27
|
+
output << map_inline(prose, &) << line
|
|
28
|
+
prose = +''
|
|
29
|
+
end
|
|
30
|
+
output << map_inline(prose, &)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param text [String] a single table cell
|
|
34
|
+
# @return [String] the text on one line with pipes escaped
|
|
35
|
+
def table_cell(text)
|
|
36
|
+
text.gsub(/\s*\n\s*/, ' ').gsub('|', '\\|').strip
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
def code_line?(line, state)
|
|
41
|
+
return fence_line?(line, state) if state[:fence] || FENCE.match?(line)
|
|
42
|
+
|
|
43
|
+
blank = line.strip.empty?
|
|
44
|
+
code = !blank && INDENTED.match?(line) && indented_code_allowed?(state)
|
|
45
|
+
state[:indented] = code || (blank && state[:indented])
|
|
46
|
+
state[:list] = in_list?(line, blank, state)
|
|
47
|
+
state[:blank] = blank
|
|
48
|
+
code
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# An indented line is code when it continues a code block, or starts one
|
|
52
|
+
# after a blank line outside a list (where indentation means nesting).
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
def indented_code_allowed?(state)
|
|
56
|
+
state[:indented] || (state[:blank] && !state[:list])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @api private
|
|
60
|
+
def in_list?(line, blank, state)
|
|
61
|
+
LIST_ITEM.match?(line) || (state[:list] && (blank || line.start_with?(' ', "\t")))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @api private
|
|
65
|
+
def fence_line?(line, state)
|
|
66
|
+
if state[:fence]
|
|
67
|
+
closing = /\A {0,3}#{Regexp.escape(state[:fence][0])}{#{state[:fence].size},}\s*\z/
|
|
68
|
+
state[:fence] = nil if closing.match?(line)
|
|
69
|
+
else
|
|
70
|
+
state[:fence] = FENCE.match(line)[1]
|
|
71
|
+
end
|
|
72
|
+
state[:blank] = false
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def map_inline(prose)
|
|
78
|
+
return prose if prose.empty?
|
|
79
|
+
|
|
80
|
+
output = +''
|
|
81
|
+
position = 0
|
|
82
|
+
prose.scan(INLINE_CODE) do
|
|
83
|
+
match = Regexp.last_match
|
|
84
|
+
output << yield(prose[position...match.begin(0)]) << match[0]
|
|
85
|
+
position = match.end(0)
|
|
86
|
+
end
|
|
87
|
+
output << yield(prose[position..])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Model
|
|
5
|
+
# Base class for everything that can be documented.
|
|
6
|
+
class CodeObject
|
|
7
|
+
# @return [String] the unqualified name
|
|
8
|
+
attr_reader :name
|
|
9
|
+
# @return [NamespaceObject, nil] the enclosing namespace (nil only for the root)
|
|
10
|
+
attr_reader :namespace
|
|
11
|
+
# @return [Docstring]
|
|
12
|
+
attr_accessor :docstring
|
|
13
|
+
# @return [Symbol] `:public`, `:protected`, or `:private`
|
|
14
|
+
attr_accessor :visibility
|
|
15
|
+
# @return [Array<Array(String, Integer)>] `[file, line]` pairs where the object is defined
|
|
16
|
+
attr_reader :files
|
|
17
|
+
|
|
18
|
+
# @param name [String]
|
|
19
|
+
# @param namespace [NamespaceObject, nil]
|
|
20
|
+
def initialize(name, namespace)
|
|
21
|
+
@name = name
|
|
22
|
+
@namespace = namespace
|
|
23
|
+
@docstring = Docstring.new
|
|
24
|
+
@visibility = :public
|
|
25
|
+
@files = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String] the fully qualified path, such as `Foo::Bar#baz`
|
|
29
|
+
def path
|
|
30
|
+
namespace.nil? || namespace.root? ? name : "#{namespace.path}::#{name}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def root?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Records a definition site, ignoring duplicates.
|
|
39
|
+
#
|
|
40
|
+
# @param file [String]
|
|
41
|
+
# @param line [Integer]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def add_file(file, line)
|
|
44
|
+
files << [file, line] unless files.any? { |existing, _| existing == file }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] true when tagged `@private` or `@api private`
|
|
48
|
+
def private_api?
|
|
49
|
+
docstring.tag?('private') || docstring.tags_named('api').any? { |tag| tag.text.strip == 'private' }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A class, a module, or the top-level namespace.
|
|
54
|
+
class NamespaceObject < CodeObject
|
|
55
|
+
# @return [Symbol] `:class`, `:module`, or `:root`
|
|
56
|
+
attr_accessor :kind
|
|
57
|
+
# @return [String, nil] the superclass as written in source
|
|
58
|
+
attr_accessor :superclass
|
|
59
|
+
# @return [Hash{Symbol => Array<String>}] mixin names keyed by `:include`, `:extend`, `:prepend`
|
|
60
|
+
attr_reader :mixins
|
|
61
|
+
# @return [Array<CodeObject>] everything defined directly inside this namespace
|
|
62
|
+
attr_reader :children
|
|
63
|
+
|
|
64
|
+
# @param name [String]
|
|
65
|
+
# @param namespace [NamespaceObject, nil]
|
|
66
|
+
# @param kind [Symbol]
|
|
67
|
+
def initialize(name, namespace, kind: :module)
|
|
68
|
+
super(name, namespace)
|
|
69
|
+
@kind = kind
|
|
70
|
+
@mixins = { include: [], extend: [], prepend: [] }
|
|
71
|
+
@children = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [String] empty for the root
|
|
75
|
+
def path
|
|
76
|
+
root? ? '' : super
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def root?
|
|
81
|
+
kind == :root
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Array<NamespaceObject>] nested classes and modules, sorted by name
|
|
85
|
+
def namespaces
|
|
86
|
+
children.grep(NamespaceObject).sort_by(&:name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Array<ConstantObject>] constants, sorted by name
|
|
90
|
+
def constants
|
|
91
|
+
children.grep(ConstantObject).sort_by(&:name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param scope [Symbol] `:instance` or `:class`
|
|
95
|
+
# @return [Array<MethodObject>] methods in that scope, `initialize` first, then by name
|
|
96
|
+
def meths(scope)
|
|
97
|
+
children.grep(MethodObject).select { |meth| meth.scope == scope }
|
|
98
|
+
.sort_by { |meth| [meth.name == 'initialize' ? 0 : 1, meth.name] }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A method or attribute.
|
|
103
|
+
class MethodObject < CodeObject
|
|
104
|
+
# @return [Symbol] `:instance` or `:class`
|
|
105
|
+
attr_reader :scope
|
|
106
|
+
# @return [String, nil] the parameter list as written, without parentheses
|
|
107
|
+
attr_accessor :parameters
|
|
108
|
+
# @return [Symbol, nil] `:reader` or `:writer` when defined by `attr_*`
|
|
109
|
+
attr_reader :attribute
|
|
110
|
+
# @return [String, nil] name of the method this one aliases
|
|
111
|
+
attr_reader :alias_for
|
|
112
|
+
|
|
113
|
+
# @param name [String]
|
|
114
|
+
# @param namespace [NamespaceObject]
|
|
115
|
+
# @param scope [Symbol]
|
|
116
|
+
# @param parameters [String, nil]
|
|
117
|
+
# @param attribute [Symbol, nil]
|
|
118
|
+
# @param alias_for [String, nil]
|
|
119
|
+
def initialize(name, namespace, scope: :instance, parameters: nil, attribute: nil, alias_for: nil)
|
|
120
|
+
super(name, namespace)
|
|
121
|
+
@scope = scope
|
|
122
|
+
@parameters = parameters
|
|
123
|
+
@attribute = attribute
|
|
124
|
+
@alias_for = alias_for
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param namespace [NamespaceObject]
|
|
128
|
+
# @param scope [Symbol] `:instance` or `:class`
|
|
129
|
+
# @param name [String]
|
|
130
|
+
# @return [String] the path a method with that name and scope would have
|
|
131
|
+
def self.path_for(namespace, scope, name)
|
|
132
|
+
"#{namespace.path}#{scope == :class ? '.' : '#'}#{name}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [String] `#` for instance methods, `.` for class methods
|
|
136
|
+
def separator
|
|
137
|
+
scope == :class ? '.' : '#'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [String]
|
|
141
|
+
def path
|
|
142
|
+
self.class.path_for(namespace, scope, name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [String] the name with its separator, such as `#each`
|
|
146
|
+
def display_name
|
|
147
|
+
"#{separator}#{name}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A constant that is not a class or module.
|
|
152
|
+
class ConstantObject < CodeObject
|
|
153
|
+
# @return [String] the source of the assigned value
|
|
154
|
+
attr_reader :value
|
|
155
|
+
|
|
156
|
+
# @param name [String]
|
|
157
|
+
# @param namespace [NamespaceObject]
|
|
158
|
+
# @param value [String]
|
|
159
|
+
def initialize(name, namespace, value: '')
|
|
160
|
+
super(name, namespace)
|
|
161
|
+
@value = value
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Model
|
|
5
|
+
# One `@tag` from a docstring.
|
|
6
|
+
#
|
|
7
|
+
# Which fields are set depends on the tag: `@param` uses `name`, `types` and
|
|
8
|
+
# `text`; `@example` uses `name` as its title; `@option` adds `key` and
|
|
9
|
+
# `default`; `@overload` carries a `signature` and a nested `docstring`.
|
|
10
|
+
Tag = Struct.new(:tag_name, :name, :types, :text, :key, :default, :signature, :docstring,
|
|
11
|
+
keyword_init: true) do
|
|
12
|
+
def initialize(**)
|
|
13
|
+
super
|
|
14
|
+
self.text ||= ''
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A parsed documentation comment: free markdown text plus tags.
|
|
19
|
+
class Docstring
|
|
20
|
+
# @return [String] the markdown body, without tags
|
|
21
|
+
attr_reader :text
|
|
22
|
+
# @return [Array<Tag>]
|
|
23
|
+
attr_reader :tags
|
|
24
|
+
# @return [Array<String>] names of YARD directives (`@!macro`, ...) found in the comment
|
|
25
|
+
attr_reader :directives
|
|
26
|
+
|
|
27
|
+
# @param text [String]
|
|
28
|
+
# @param tags [Array<Tag>]
|
|
29
|
+
# @param directives [Array<String>]
|
|
30
|
+
def initialize(text = '', tags = [], directives = [])
|
|
31
|
+
@text = text
|
|
32
|
+
@tags = tags
|
|
33
|
+
@directives = directives
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param name [String] tag name without the `@`
|
|
37
|
+
# @return [Array<Tag>]
|
|
38
|
+
def tags_named(name)
|
|
39
|
+
tags.select { |tag| tag.tag_name == name }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param name [String]
|
|
43
|
+
# @return [Tag, nil] the first tag with that name
|
|
44
|
+
def tag(name)
|
|
45
|
+
tags.find { |tag| tag.tag_name == name }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param name [String]
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def tag?(name)
|
|
51
|
+
!tag(name).nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] true when there is neither text nor tags
|
|
55
|
+
def empty?
|
|
56
|
+
text.strip.empty? && tags.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] the first sentence of the first paragraph, on one line;
|
|
60
|
+
# falls back to the `@return` description when there is no body
|
|
61
|
+
def summary
|
|
62
|
+
paragraph = text.strip.split(/\n\s*\n/).first.to_s.gsub(/\s*\n\s*/, ' ')
|
|
63
|
+
return paragraph[/\A.*?[.!?](?=\s|\z)/] || paragraph unless paragraph.empty?
|
|
64
|
+
|
|
65
|
+
returned = tag('return')&.text.to_s.lines.first.to_s.strip
|
|
66
|
+
returned.empty? ? '' : "Returns #{returned}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Model
|
|
5
|
+
# Every documented object, keyed by path, with YARD-style reference lookup.
|
|
6
|
+
class Registry
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
REFERENCE = /\A(?<const>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)?(?:(?<sep>#|\.|::)(?<meth>[^A-Z:].*))?\z/
|
|
10
|
+
|
|
11
|
+
# @return [NamespaceObject] the top-level namespace
|
|
12
|
+
attr_reader :root
|
|
13
|
+
# @return [Array<String>] `file:line` locations of comments that use YARD directives
|
|
14
|
+
attr_reader :directive_sites
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@root = NamespaceObject.new('', nil, kind: :root)
|
|
18
|
+
@objects = { '' => @root }
|
|
19
|
+
@directive_sites = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<String>] the files that contain YARD directives, in the order they were parsed
|
|
23
|
+
def directive_files
|
|
24
|
+
@directive_sites.map { |site| site.rpartition(':').first }.uniq
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param path [String]
|
|
28
|
+
# @return [CodeObject, nil]
|
|
29
|
+
def [](path)
|
|
30
|
+
@objects[path]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Adds an object, replacing any existing object at the same path.
|
|
34
|
+
#
|
|
35
|
+
# @param object [CodeObject]
|
|
36
|
+
# @return [CodeObject] the object
|
|
37
|
+
def register(object)
|
|
38
|
+
delete(@objects[object.path])
|
|
39
|
+
@objects[object.path] = object
|
|
40
|
+
object.namespace.children << object
|
|
41
|
+
object
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param object [CodeObject, nil]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def delete(object)
|
|
47
|
+
return unless object
|
|
48
|
+
|
|
49
|
+
@objects.delete(object.path)
|
|
50
|
+
object.namespace&.children&.delete(object)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @yieldparam object [CodeObject]
|
|
54
|
+
def each(&)
|
|
55
|
+
@objects.values.each(&)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resolves a reference such as `Foo::Bar`, `Foo#bar`, `#bar`, or `bar` as
|
|
59
|
+
# seen from `context`, searching enclosing namespaces and ancestors.
|
|
60
|
+
#
|
|
61
|
+
# @param context [CodeObject] where the reference was written
|
|
62
|
+
# @param reference [String]
|
|
63
|
+
# @return [CodeObject, nil]
|
|
64
|
+
def resolve(context, reference)
|
|
65
|
+
reference = reference.to_s.strip
|
|
66
|
+
match = REFERENCE.match(reference) || /\A(?<const>)(?<sep>)(?<meth>\S+)\z/.match(reference)
|
|
67
|
+
return nil if reference.empty? || match.nil?
|
|
68
|
+
|
|
69
|
+
start = context.is_a?(NamespaceObject) ? context : context.namespace
|
|
70
|
+
resolve_parts(start, match[:const].to_s, match[:sep].to_s, match[:meth])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param namespace [NamespaceObject]
|
|
74
|
+
# @return [Array<NamespaceObject>] the namespace followed by its documented
|
|
75
|
+
# mixins and superclasses, nearest first
|
|
76
|
+
def ancestors(namespace)
|
|
77
|
+
seen = [namespace]
|
|
78
|
+
seen.each do |current|
|
|
79
|
+
mixins = (current.mixins[:prepend] + current.mixins[:include]).map { |name| resolve_constant(current, name) }
|
|
80
|
+
[*mixins, superclass(current)].each do |found|
|
|
81
|
+
seen << found if found.is_a?(NamespaceObject) && !seen.include?(found)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
seen
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Like Ruby, looks the superclass up from the scope around the class, and
|
|
88
|
+
# never finds the class itself, so `class Error < Error` inside a module
|
|
89
|
+
# refers to an outer `Error`.
|
|
90
|
+
#
|
|
91
|
+
# @param namespace [NamespaceObject]
|
|
92
|
+
# @return [NamespaceObject, nil] the documented superclass
|
|
93
|
+
def superclass(namespace)
|
|
94
|
+
return nil unless namespace.superclass && namespace.namespace
|
|
95
|
+
|
|
96
|
+
found = resolve_constant(namespace.namespace, namespace.superclass, except: namespace)
|
|
97
|
+
found if found.is_a?(NamespaceObject)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def resolve_parts(start, const, sep, meth)
|
|
103
|
+
return resolve_constant(start, const) if meth.nil?
|
|
104
|
+
return find_method(resolve_constant(start, const), sep, meth) unless const.empty?
|
|
105
|
+
|
|
106
|
+
lexical(start).each do |scope|
|
|
107
|
+
found = find_method(scope, sep, meth)
|
|
108
|
+
return found if found
|
|
109
|
+
end
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_constant(start, const, except: nil)
|
|
114
|
+
constant_candidates(start, const).each do |path|
|
|
115
|
+
found = self[path]
|
|
116
|
+
return found if found && !found.equal?(except)
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def constant_candidates(start, const)
|
|
122
|
+
return [const.delete_prefix('::')] if const.start_with?('::')
|
|
123
|
+
|
|
124
|
+
lexical(start).map { |scope| scope.root? ? const : "#{scope.path}::#{const}" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_method(namespace, sep, meth)
|
|
128
|
+
return nil unless namespace.is_a?(NamespaceObject)
|
|
129
|
+
|
|
130
|
+
separators = { '' => %w[# .], '#' => ['#'] }.fetch(sep, ['.'])
|
|
131
|
+
ancestors(namespace).each do |ancestor|
|
|
132
|
+
separators.each do |separator|
|
|
133
|
+
found = self["#{ancestor.path}#{separator}#{meth}"]
|
|
134
|
+
return found if found
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def lexical(namespace)
|
|
141
|
+
scopes = []
|
|
142
|
+
while namespace
|
|
143
|
+
scopes << namespace
|
|
144
|
+
namespace = namespace.namespace
|
|
145
|
+
end
|
|
146
|
+
scopes
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Boondoc
|
|
6
|
+
# Maps objects to output files and anchors, and computes relative links.
|
|
7
|
+
class PathMap
|
|
8
|
+
TOP_LEVEL = 'top-level.md'
|
|
9
|
+
|
|
10
|
+
# @param config [Config]
|
|
11
|
+
# @param registry [Model::Registry] consulted for classes and modules whose
|
|
12
|
+
# names differ only by case
|
|
13
|
+
def initialize(config, registry)
|
|
14
|
+
@config = config
|
|
15
|
+
@registry = registry
|
|
16
|
+
@relative = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param object [Model::CodeObject]
|
|
20
|
+
# @return [String] output file, relative to the output directory. Classes and
|
|
21
|
+
# modules whose names differ only by case get their kind appended, such as
|
|
22
|
+
# `CLI.class.md` and `Cli.module.md`, so that the files stay apart on
|
|
23
|
+
# case-insensitive filesystems.
|
|
24
|
+
def file_for(object)
|
|
25
|
+
namespace = object.is_a?(Model::NamespaceObject) ? object : object.namespace
|
|
26
|
+
namespace.root? ? TOP_LEVEL : (@files ||= {}.compare_by_identity)[namespace] ||= "#{directory(namespace)}.md"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param object [Model::CodeObject]
|
|
30
|
+
# @return [String, nil] the anchor inside its file; nil for namespaces
|
|
31
|
+
def anchor_for(object)
|
|
32
|
+
case object
|
|
33
|
+
when Model::MethodObject then "method-#{object.scope == :class ? 'c' : 'i'}-#{escape(object.name)}"
|
|
34
|
+
when Model::ConstantObject then "constant-#{escape(object.name)}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param object [Model::CodeObject]
|
|
39
|
+
# @param from [String] the output file the link is written in
|
|
40
|
+
# @return [String] a relative link to the object
|
|
41
|
+
def href(object, from:)
|
|
42
|
+
anchor = anchor_for(object)
|
|
43
|
+
file = file_for(object)
|
|
44
|
+
return "##{anchor}" if anchor && file == from
|
|
45
|
+
|
|
46
|
+
[relative(file, from), anchor].compact.join('#')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param file [String] output file to link to
|
|
50
|
+
# @param from [String] output file the link is written in
|
|
51
|
+
# @return [String] frozen; each distinct link is worked out once, because
|
|
52
|
+
# the same few targets are linked many times in a run
|
|
53
|
+
def relative(file, from)
|
|
54
|
+
@relative[[file, from]] ||= encode(Pathname.new(file).relative_path_from(Pathname.new(from).dirname).to_s).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param source [String] a path relative to the project root
|
|
58
|
+
# @param from [String] output file the link is written in
|
|
59
|
+
# @return [String] a relative link from the output file to a project file
|
|
60
|
+
def source_href(source, from:)
|
|
61
|
+
target = Pathname.new(File.expand_path(source, @config.root))
|
|
62
|
+
origin = Pathname.new(File.expand_path(from, @config.output_dir)).dirname
|
|
63
|
+
encode(target.relative_path_from(origin).to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def directory(namespace)
|
|
69
|
+
parts = []
|
|
70
|
+
while namespace && !namespace.root?
|
|
71
|
+
parts.unshift(segments[namespace])
|
|
72
|
+
namespace = namespace.namespace
|
|
73
|
+
end
|
|
74
|
+
parts.join('/')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The file or directory name of every class and module, worked out once
|
|
78
|
+
# because nothing changes the registry while pages are rendered.
|
|
79
|
+
def segments
|
|
80
|
+
@segments ||= {}.compare_by_identity.tap { |names| name_children(@registry.root, names) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def name_children(parent, names)
|
|
84
|
+
parent.namespaces.group_by { |child| child.name.downcase }.each_value do |group|
|
|
85
|
+
group.each { |child| names[child] = child.name } if group.one?
|
|
86
|
+
disambiguate(group, names) if group.size > 1
|
|
87
|
+
end
|
|
88
|
+
parent.namespaces.each { |child| name_children(child, names) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Appends the kind, and a number when two of the same kind still collide.
|
|
92
|
+
def disambiguate(group, names)
|
|
93
|
+
seen = Hash.new(0)
|
|
94
|
+
group.each do |child|
|
|
95
|
+
count = (seen[child.kind] += 1)
|
|
96
|
+
names[child] = ["#{child.name}.#{child.kind}", (count if count > 1)].compact.join('-')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def escape(name)
|
|
101
|
+
name.gsub(/[^A-Za-z0-9_]/) { |char| format('-%02X', char.ord) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def encode(path)
|
|
105
|
+
path.gsub(' ', '%20')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|