chiridion 0.3.4
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 +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/lib/chiridion/config.rb +128 -0
- data/lib/chiridion/engine/class_linker.rb +204 -0
- data/lib/chiridion/engine/document_model.rb +299 -0
- data/lib/chiridion/engine/drift_checker.rb +146 -0
- data/lib/chiridion/engine/extractor.rb +311 -0
- data/lib/chiridion/engine/file_renderer.rb +717 -0
- data/lib/chiridion/engine/file_writer.rb +160 -0
- data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
- data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
- data/lib/chiridion/engine/github_linker.rb +87 -0
- data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
- data/lib/chiridion/engine/post_processor.rb +86 -0
- data/lib/chiridion/engine/rbs_loader.rb +150 -0
- data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
- data/lib/chiridion/engine/renderer.rb +598 -0
- data/lib/chiridion/engine/semantic_extractor.rb +740 -0
- data/lib/chiridion/engine/semantic_renderer.rb +334 -0
- data/lib/chiridion/engine/spec_example_loader.rb +84 -0
- data/lib/chiridion/engine/template_renderer.rb +275 -0
- data/lib/chiridion/engine/type_merger.rb +126 -0
- data/lib/chiridion/engine/writer.rb +134 -0
- data/lib/chiridion/engine.rb +359 -0
- data/lib/chiridion/semantic_engine.rb +186 -0
- data/lib/chiridion/version.rb +5 -0
- data/lib/chiridion.rb +106 -0
- data/templates/constants.liquid +27 -0
- data/templates/document.liquid +48 -0
- data/templates/file.liquid +108 -0
- data/templates/index.liquid +21 -0
- data/templates/method.liquid +43 -0
- data/templates/methods.liquid +11 -0
- data/templates/type_aliases.liquid +26 -0
- data/templates/types.liquid +11 -0
- metadata +146 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Renders per-file documentation using Liquid templates.
|
|
6
|
+
#
|
|
7
|
+
# Takes FileDoc structures from SemanticExtractor and produces markdown
|
|
8
|
+
# files grouped by source file rather than by class/module.
|
|
9
|
+
#
|
|
10
|
+
# Design: One markdown file per source file. Each file contains documentation
|
|
11
|
+
# for all namespaces (classes/modules) defined in that source file.
|
|
12
|
+
class FileRenderer
|
|
13
|
+
def initialize(
|
|
14
|
+
namespace_strip: nil,
|
|
15
|
+
include_specs: false,
|
|
16
|
+
root: Dir.pwd,
|
|
17
|
+
github_repo: nil,
|
|
18
|
+
github_branch: "main",
|
|
19
|
+
project_title: "API Documentation",
|
|
20
|
+
inline_source_threshold: 10,
|
|
21
|
+
templates_path: nil
|
|
22
|
+
)
|
|
23
|
+
@namespace_strip = namespace_strip
|
|
24
|
+
@include_specs = include_specs
|
|
25
|
+
@root = root
|
|
26
|
+
@project_title = project_title
|
|
27
|
+
@inline_source_threshold = inline_source_threshold
|
|
28
|
+
@class_linker = ClassLinker.new(namespace_strip: namespace_strip)
|
|
29
|
+
@github_linker = GithubLinker.new(repo: github_repo, branch: github_branch, root: root)
|
|
30
|
+
@template_renderer = TemplateRenderer.new(templates_path: templates_path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register known classes for cross-reference linking.
|
|
34
|
+
#
|
|
35
|
+
# @param project [ProjectDoc] Documentation structure
|
|
36
|
+
def register_classes(project)
|
|
37
|
+
structure = {
|
|
38
|
+
classes: project.classes.map { |c| { path: c.path } },
|
|
39
|
+
modules: project.modules.map { |m| { path: m.path } }
|
|
40
|
+
}
|
|
41
|
+
@class_linker.register_classes(structure)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Render documentation for a single source file.
|
|
45
|
+
#
|
|
46
|
+
# @param file_doc [FileDoc] File documentation from SemanticExtractor
|
|
47
|
+
# @param is_root [Boolean] If true, append Obsidian embed for index
|
|
48
|
+
# @return [String] Rendered markdown
|
|
49
|
+
def render_file(file_doc, is_root: false)
|
|
50
|
+
frontmatter = build_file_frontmatter(file_doc, is_root: is_root)
|
|
51
|
+
|
|
52
|
+
namespaces_data = file_doc.namespaces.map { |ns| build_namespace_data(ns) }
|
|
53
|
+
|
|
54
|
+
body = @template_renderer.render_file(
|
|
55
|
+
path: file_doc.path,
|
|
56
|
+
filename: file_doc.filename,
|
|
57
|
+
line_count: file_doc.line_count,
|
|
58
|
+
namespaces: namespaces_data,
|
|
59
|
+
type_aliases: [] # Type aliases are now per-namespace
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# If this is the root file, embed the index using Obsidian transclusion
|
|
63
|
+
body = "#{body}\n\n---\n\n![[index]]" if is_root
|
|
64
|
+
|
|
65
|
+
"#{render_frontmatter(frontmatter)}\n\n#{body}\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Render the documentation index.
|
|
69
|
+
#
|
|
70
|
+
# @param project [ProjectDoc] Documentation structure
|
|
71
|
+
# @param index_description [String, nil] Custom description
|
|
72
|
+
# @return [String] Rendered markdown
|
|
73
|
+
def render_index(project, index_description: nil)
|
|
74
|
+
frontmatter = {
|
|
75
|
+
generated: project.generated_at.iso8601,
|
|
76
|
+
title: @project_title,
|
|
77
|
+
type: "index",
|
|
78
|
+
description: index_description || "Auto-generated from source code."
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Group by file for per-file index
|
|
82
|
+
files = project.files.map do |f|
|
|
83
|
+
link_path = source_to_link(f.path)
|
|
84
|
+
primary = f.primary_namespace
|
|
85
|
+
{
|
|
86
|
+
path: f.path,
|
|
87
|
+
link_path: link_path,
|
|
88
|
+
filename: f.filename,
|
|
89
|
+
namespaces: f.namespaces.map(&:path).join(", "),
|
|
90
|
+
primary: primary&.path || f.filename
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
body = render_file_index(files)
|
|
95
|
+
|
|
96
|
+
"#{render_frontmatter(frontmatter)}\n\n#{body}\n"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def build_file_frontmatter(file_doc, is_root: false)
|
|
102
|
+
primary = file_doc.primary_namespace
|
|
103
|
+
|
|
104
|
+
fm = {
|
|
105
|
+
generated: Time.now.utc.iso8601,
|
|
106
|
+
title: is_root ? @project_title : file_doc.filename,
|
|
107
|
+
source: file_doc.path,
|
|
108
|
+
source_url: @github_linker.url(file_doc.path, 1, nil),
|
|
109
|
+
lines: file_doc.line_count,
|
|
110
|
+
type: is_root ? "index" : "file",
|
|
111
|
+
parent: is_root ? nil : file_parent(file_doc.path),
|
|
112
|
+
primary: primary&.path,
|
|
113
|
+
namespaces: file_doc.namespaces.map(&:path),
|
|
114
|
+
tags: build_file_tags(file_doc, is_root: is_root),
|
|
115
|
+
description: build_file_description(file_doc, primary)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Add method lists for each namespace
|
|
119
|
+
file_doc.namespaces.each do |ns|
|
|
120
|
+
key = "#{to_kebab_case(ns.name)}-methods"
|
|
121
|
+
methods = build_method_signatures(ns)
|
|
122
|
+
fm[key.to_sym] = methods unless methods.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
fm.compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build method signatures for frontmatter like ["ClassName.new(arg1, arg2)", "method_name(path)"]
|
|
129
|
+
def build_method_signatures(ns)
|
|
130
|
+
class_name = ns.name.to_s.split("::").last
|
|
131
|
+
|
|
132
|
+
signatures = ns.methods.map do |m|
|
|
133
|
+
name = if m.name == :initialize
|
|
134
|
+
"#{class_name}.new"
|
|
135
|
+
elsif m.scope == :class
|
|
136
|
+
"#{class_name}.#{m.name}"
|
|
137
|
+
else
|
|
138
|
+
m.name.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if m.params.any?
|
|
142
|
+
param_names = m.params.map { |p| "#{p.prefix}#{p.name}" }.join(", ")
|
|
143
|
+
name += "(#{param_names})"
|
|
144
|
+
end
|
|
145
|
+
name
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
signatures.sort_by(&:downcase)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Extract parent directory for navigation (e.g., "dsl" from "lib/archema/dsl/attrs.rb")
|
|
152
|
+
def file_parent(path)
|
|
153
|
+
dir = File.dirname(path).sub(%r{\Alib/}, "")
|
|
154
|
+
return nil if dir == "." || dir.empty?
|
|
155
|
+
|
|
156
|
+
# Strip namespace prefix if configured
|
|
157
|
+
if @namespace_strip
|
|
158
|
+
prefix = @namespace_strip.downcase.gsub("::", "/")
|
|
159
|
+
dir = dir.sub(%r{\A#{Regexp.escape(prefix)}/?}, "")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
dir.empty? ? nil : dir
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Build a sensible description for the file
|
|
166
|
+
def build_file_description(file_doc, primary)
|
|
167
|
+
# If single namespace, use its docstring
|
|
168
|
+
if file_doc.namespaces.size == 1
|
|
169
|
+
return primary&.docstring&.lines&.first&.strip || ""
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# If primary has a docstring, use it
|
|
173
|
+
if primary&.docstring && !primary.docstring.empty?
|
|
174
|
+
return primary.docstring.lines.first.strip
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Fall back to listing namespace count
|
|
178
|
+
classes = file_doc.classes.size
|
|
179
|
+
modules = file_doc.modules.size
|
|
180
|
+
parts = []
|
|
181
|
+
parts << "#{classes} class#{'es' if classes != 1}" if classes.positive?
|
|
182
|
+
parts << "#{modules} module#{'s' if modules != 1}" if modules.positive?
|
|
183
|
+
parts.any? ? "Contains #{parts.join(' and ')}." : ""
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def build_file_tags(file_doc, is_root: false)
|
|
187
|
+
tags = [is_root ? "index" : "file"]
|
|
188
|
+
file_doc.namespaces.each do |ns|
|
|
189
|
+
tags << ns.type.to_s
|
|
190
|
+
tags << "abstract" if ns.abstract
|
|
191
|
+
tags << "deprecated" if ns.deprecated
|
|
192
|
+
end
|
|
193
|
+
tags.uniq
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_namespace_data(ns)
|
|
197
|
+
docstring = @class_linker.linkify_docstring(ns.docstring, context: ns.path)
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
name: ns.name,
|
|
201
|
+
path: ns.path,
|
|
202
|
+
type: ns.type.to_s,
|
|
203
|
+
superclass: ns.superclass ? linkify_class(ns.superclass, ns.path) : nil,
|
|
204
|
+
abstract: ns.abstract,
|
|
205
|
+
deprecated: ns.deprecated,
|
|
206
|
+
docstring: docstring,
|
|
207
|
+
mixins: render_mixins(ns),
|
|
208
|
+
notes: ns.notes,
|
|
209
|
+
see_also: ns.see_also.map { |s| { target: linkify_class(s.target, ns.path), text: s.text } },
|
|
210
|
+
examples: ns.examples.map { |e| { name: e.name, code: e.code } },
|
|
211
|
+
type_aliases: ns.type_aliases.map { |t| { name: t.name, definition: t.definition, description: t.description } },
|
|
212
|
+
constants_section: render_constants(ns.constants),
|
|
213
|
+
types_section: render_types_section(ns.referenced_types),
|
|
214
|
+
summary_section: render_summary_section(ns.attributes, ns.methods, ns.path),
|
|
215
|
+
methods_section: render_methods_section(ns.methods, ns.path),
|
|
216
|
+
private_summary: render_private_summary(ns.private_methods)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def linkify_class(name, context) = @class_linker.link(name, context: context)
|
|
221
|
+
|
|
222
|
+
def render_mixins(ns)
|
|
223
|
+
return nil unless ns.includes.any? || ns.extends.any?
|
|
224
|
+
|
|
225
|
+
parts = []
|
|
226
|
+
if ns.includes.any?
|
|
227
|
+
linked = ns.includes.map { |m| linkify_class(m, ns.path) }
|
|
228
|
+
parts << "**Includes:** #{linked.join(', ')}"
|
|
229
|
+
end
|
|
230
|
+
if ns.extends.any?
|
|
231
|
+
linked = ns.extends.map { |m| linkify_class(m, ns.path) }
|
|
232
|
+
parts << "**Extends:** #{linked.join(', ')}"
|
|
233
|
+
end
|
|
234
|
+
parts.join(" · ")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def render_constants(constants)
|
|
238
|
+
return "" if constants.empty?
|
|
239
|
+
|
|
240
|
+
simple, complex = constants.partition { |c| simple_constant?(c) }
|
|
241
|
+
|
|
242
|
+
lines = ["## Constants", ""]
|
|
243
|
+
|
|
244
|
+
if simple.any?
|
|
245
|
+
lines << "| Name | Value | Description |"
|
|
246
|
+
lines << "|------|-------|-------------|"
|
|
247
|
+
simple.each do |c|
|
|
248
|
+
value = c.value.to_s.delete_suffix(".freeze").gsub("|", "\\|").gsub("\n", " ")[0, 60]
|
|
249
|
+
desc = c.description.to_s.gsub("|", "\\|").gsub("\n", " ")
|
|
250
|
+
lines << "| `#{c.name}` | `#{value}` | #{desc} |"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
complex.each do |c|
|
|
255
|
+
lines << ""
|
|
256
|
+
lines << "### #{c.name}"
|
|
257
|
+
lines << ""
|
|
258
|
+
lines << c.description if c.description && !c.description.empty?
|
|
259
|
+
lines << ""
|
|
260
|
+
lines << "```ruby"
|
|
261
|
+
lines << c.value.to_s.delete_suffix(".freeze")
|
|
262
|
+
lines << "```"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
lines.join("\n")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def simple_constant?(c)
|
|
269
|
+
return false if c.value.to_s.count("\n") > 1
|
|
270
|
+
return false if c.description.to_s.count("\n") > 1
|
|
271
|
+
return false if c.description.to_s.length > 80
|
|
272
|
+
|
|
273
|
+
true
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def render_types_section(referenced_types)
|
|
277
|
+
return "" if referenced_types.nil? || referenced_types.empty?
|
|
278
|
+
|
|
279
|
+
lines = ["## Types Used", ""]
|
|
280
|
+
referenced_types.each do |t|
|
|
281
|
+
desc = t.description ? " — #{t.description}" : ""
|
|
282
|
+
lines << "- `#{t.name}` = `#{t.definition}`#{desc}"
|
|
283
|
+
end
|
|
284
|
+
lines.join("\n")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# Render combined Attributes / Methods summary section.
|
|
289
|
+
#
|
|
290
|
+
# Format:
|
|
291
|
+
# `⟨attr_name : Type⟩` (Read) — description
|
|
292
|
+
# `⟨method_name(…) : ReturnType⟩` — summary
|
|
293
|
+
#
|
|
294
|
+
# Methods show (…) if they have params, and use their return type.
|
|
295
|
+
# Only the summary portion of method docstrings is used.
|
|
296
|
+
def render_summary_section(attributes, methods, context)
|
|
297
|
+
return "" if attributes.empty? && methods.empty?
|
|
298
|
+
|
|
299
|
+
lines = ["## Attributes / Methods", ""]
|
|
300
|
+
|
|
301
|
+
# Build parts for attributes
|
|
302
|
+
attr_parts = attributes.sort_by(&:name).map do |attr|
|
|
303
|
+
type_str = attr.type ? " : #{attr.type}" : ""
|
|
304
|
+
mode = case attr.mode
|
|
305
|
+
when :read then "Read"
|
|
306
|
+
when :write then "Write"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Take only first non-blank line of description
|
|
310
|
+
desc = attr.description&.lines&.map(&:strip)&.reject(&:empty?)&.first
|
|
311
|
+
|
|
312
|
+
# If has description, show "— desc", else if has mode, show "— (Mode)"
|
|
313
|
+
suffix = if desc
|
|
314
|
+
""
|
|
315
|
+
elsif mode
|
|
316
|
+
" — (#{mode})"
|
|
317
|
+
else
|
|
318
|
+
""
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
{ name: attr.name.to_s, type: type_str, suffix: suffix, desc: desc }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Build parts for methods (excluding initialize which is covered by return type)
|
|
325
|
+
meth_parts = methods.reject { |m| m.name == :initialize }.sort_by { |m| m.name.to_s }.map do |meth|
|
|
326
|
+
name = meth.name.to_s
|
|
327
|
+
name += "(…)" if meth.params.any?
|
|
328
|
+
|
|
329
|
+
# Get return type
|
|
330
|
+
ret_type = nil
|
|
331
|
+
if meth.returns&.type
|
|
332
|
+
ret_type = meth.returns.type
|
|
333
|
+
ret_type = nil if ret_type == "void"
|
|
334
|
+
end
|
|
335
|
+
type_str = ret_type ? " : #{ret_type}" : ""
|
|
336
|
+
|
|
337
|
+
# Get first non-blank line of docstring only
|
|
338
|
+
summary = nil
|
|
339
|
+
if meth.docstring && !meth.docstring.empty?
|
|
340
|
+
linkified = @class_linker.linkify_docstring(meth.docstring, context: context)
|
|
341
|
+
# Take only the first non-blank line
|
|
342
|
+
summary = linkified.lines.map(&:strip).reject(&:empty?).first
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
{ name: name, type: type_str, suffix: "", desc: summary }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
all_parts = attr_parts + meth_parts
|
|
349
|
+
return "" if all_parts.empty?
|
|
350
|
+
|
|
351
|
+
# Calculate column widths
|
|
352
|
+
max_name = all_parts.map { |p| p[:name].length }.max
|
|
353
|
+
|
|
354
|
+
# Build content strings (without brackets) to measure
|
|
355
|
+
contents = all_parts.map do |p|
|
|
356
|
+
"#{p[:name].ljust(max_name)}#{p[:type]}"
|
|
357
|
+
end
|
|
358
|
+
max_content = contents.map(&:length).max
|
|
359
|
+
|
|
360
|
+
# Render aligned with padding inside brackets: `⟨content ⟩`
|
|
361
|
+
all_parts.each_with_index do |p, i|
|
|
362
|
+
padded_content = contents[i].ljust(max_content)
|
|
363
|
+
desc_part = p[:desc] ? " — #{capitalize_first(p[:desc])}" : ""
|
|
364
|
+
lines << "`⟨#{padded_content}⟩`#{p[:suffix]}#{desc_part}"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
lines.join("\n")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def render_methods_section(methods, context)
|
|
371
|
+
return "" if methods.empty?
|
|
372
|
+
|
|
373
|
+
lines = ["## Methods", ""]
|
|
374
|
+
|
|
375
|
+
methods.sort_by { |m| [m.scope == :class ? 0 : 1, m.name.to_s] }.each_with_index do |meth, i|
|
|
376
|
+
lines << "---" if i.positive?
|
|
377
|
+
lines << ""
|
|
378
|
+
lines.concat(render_method(meth, context))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
lines.join("\n")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def render_method(meth, context)
|
|
385
|
+
lines = []
|
|
386
|
+
|
|
387
|
+
# Method header
|
|
388
|
+
display_name = method_display_name(meth, context)
|
|
389
|
+
params_hint = meth.params.any? ? "(...)" : ""
|
|
390
|
+
# Escape ( after [] to prevent markdown link interpretation: ### [](...)
|
|
391
|
+
params_hint = "\\#{params_hint}" if display_name.end_with?("[]") && params_hint.start_with?("(")
|
|
392
|
+
lines << "### #{display_name}#{params_hint}"
|
|
393
|
+
|
|
394
|
+
# Deprecation/abstract warnings
|
|
395
|
+
lines << "" << "> **Deprecated:** #{meth.deprecated}" if meth.deprecated
|
|
396
|
+
lines << "" << "> **Abstract:** Must be implemented by subclasses." if meth.abstract
|
|
397
|
+
|
|
398
|
+
# Docstring handling:
|
|
399
|
+
# - If first line is followed by blank line or ## header, it's a summary (goes above params)
|
|
400
|
+
# - Rest of docstring goes below the signature
|
|
401
|
+
summary = nil
|
|
402
|
+
description = nil
|
|
403
|
+
if meth.docstring && !meth.docstring.empty?
|
|
404
|
+
linkified = @class_linker.linkify_docstring(meth.docstring, context: context)
|
|
405
|
+
summary, description = split_docstring(linkified)
|
|
406
|
+
|
|
407
|
+
if summary
|
|
408
|
+
lines << ""
|
|
409
|
+
lines << summary
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Parameters and return (aligned together)
|
|
414
|
+
sig_lines = render_signature(meth, context)
|
|
415
|
+
if sig_lines.any?
|
|
416
|
+
lines << ""
|
|
417
|
+
lines.concat(sig_lines)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Options
|
|
421
|
+
if meth.options.any?
|
|
422
|
+
lines << ""
|
|
423
|
+
lines << "**Options:**"
|
|
424
|
+
meth.options.each do |opt|
|
|
425
|
+
type_str = opt.type ? " : #{opt.type}" : ""
|
|
426
|
+
desc = opt.description ? " — #{opt.description}" : ""
|
|
427
|
+
lines << "- `:#{opt.key}`#{type_str}#{desc}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Description (rest of docstring) goes after signature
|
|
432
|
+
if description && !description.empty?
|
|
433
|
+
lines << ""
|
|
434
|
+
lines << description
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Yields/block
|
|
438
|
+
if meth.yields
|
|
439
|
+
lines << ""
|
|
440
|
+
lines << "**Block:**"
|
|
441
|
+
lines << meth.yields.description if meth.yields.description
|
|
442
|
+
if meth.yields.params.any?
|
|
443
|
+
meth.yields.params.each do |p|
|
|
444
|
+
type_str = p.type ? " : #{p.type}" : ""
|
|
445
|
+
desc = p.description ? " — #{p.description}" : ""
|
|
446
|
+
lines << "- `#{p.name}#{type_str}`#{desc}"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
if meth.yields.return_type
|
|
450
|
+
desc = meth.yields.return_desc ? " — #{meth.yields.return_desc}" : ""
|
|
451
|
+
lines << "- Returns: `#{meth.yields.return_type}`#{desc}"
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Raises
|
|
456
|
+
if meth.raises.any?
|
|
457
|
+
lines << ""
|
|
458
|
+
lines << "**Raises:**"
|
|
459
|
+
meth.raises.each do |r|
|
|
460
|
+
desc = r.description && !r.description.strip.empty? ? " — #{r.description}" : ""
|
|
461
|
+
lines << "`#{r.type}`#{desc}"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Examples
|
|
466
|
+
meth.examples.each do |ex|
|
|
467
|
+
lines << ""
|
|
468
|
+
header = ex.name && !ex.name.empty? ? "#### Example: #{ex.name}" : "#### Example"
|
|
469
|
+
lines << header
|
|
470
|
+
lines << ""
|
|
471
|
+
lines << "```ruby"
|
|
472
|
+
lines << ex.code
|
|
473
|
+
lines << "```"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Notes
|
|
477
|
+
meth.notes.each do |note|
|
|
478
|
+
lines << ""
|
|
479
|
+
lines << "> **Note:** #{note}"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# See also
|
|
483
|
+
if meth.see_also.any?
|
|
484
|
+
links = meth.see_also.map { |s| linkify_class(s.target, context) }
|
|
485
|
+
lines << ""
|
|
486
|
+
lines << "**See also:** #{links.join(', ')}"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Inline source
|
|
490
|
+
if @inline_source_threshold&.positive? && meth.source && meth.source_body_lines &&
|
|
491
|
+
meth.source_body_lines <= @inline_source_threshold
|
|
492
|
+
lines << ""
|
|
493
|
+
lines << "#### Source"
|
|
494
|
+
lines << ""
|
|
495
|
+
if meth.file && meth.line
|
|
496
|
+
rel_path = make_relative(meth.file)
|
|
497
|
+
lines << "```ruby"
|
|
498
|
+
lines << "# #{rel_path}:#{meth.line}"
|
|
499
|
+
lines << meth.source
|
|
500
|
+
lines << "```"
|
|
501
|
+
else
|
|
502
|
+
lines << "```ruby"
|
|
503
|
+
lines << meth.source
|
|
504
|
+
lines << "```"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
lines
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def method_display_name(meth, context)
|
|
512
|
+
class_name = context.split("::").last
|
|
513
|
+
return "#{class_name}.new" if meth.name == :initialize
|
|
514
|
+
return "#{class_name}.#{meth.name}" if meth.scope == :class
|
|
515
|
+
|
|
516
|
+
meth.name.to_s
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Render params and return with aligned columns for readability.
|
|
520
|
+
#
|
|
521
|
+
# Output format:
|
|
522
|
+
# `⟨name : Type⟩ ` — Description
|
|
523
|
+
# `⟨longer_name : OtherType = default⟩` — Another description
|
|
524
|
+
# ⟶ `ReturnType ` — Return description
|
|
525
|
+
#
|
|
526
|
+
# Return is shown:
|
|
527
|
+
# - For initialize: class name (even if void)
|
|
528
|
+
# - For explicit void: shows `void`
|
|
529
|
+
# - For other types: shows the type
|
|
530
|
+
# - For undeclared (nil returns): nothing shown
|
|
531
|
+
#
|
|
532
|
+
# @param meth [MethodDoc] Method documentation
|
|
533
|
+
# @param context [String] Class context for initialize handling
|
|
534
|
+
# @return [Array<String>]
|
|
535
|
+
def render_signature(meth, context)
|
|
536
|
+
# Build raw parts for each param
|
|
537
|
+
parts = meth.params.map do |p|
|
|
538
|
+
prefix = p.prefix || ""
|
|
539
|
+
name = "#{prefix}#{p.name}"
|
|
540
|
+
type_str = p.type ? " : #{p.type}" : ""
|
|
541
|
+
default = p.default ? " = #{p.default}" : ""
|
|
542
|
+
desc = p.description&.strip
|
|
543
|
+
desc = nil if desc&.empty?
|
|
544
|
+
|
|
545
|
+
{ name: name, type_default: "#{type_str}#{default}", desc: desc, kind: :param }
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Determine return type to show (if any)
|
|
549
|
+
ret_type = nil
|
|
550
|
+
ret_desc = nil
|
|
551
|
+
if meth.returns
|
|
552
|
+
ret_type = meth.returns.type
|
|
553
|
+
ret_desc = meth.returns.description&.strip
|
|
554
|
+
ret_desc = nil if ret_desc&.empty?
|
|
555
|
+
|
|
556
|
+
# For initialize, use class name instead of void
|
|
557
|
+
if meth.name == :initialize && ret_type == "void"
|
|
558
|
+
ret_type = context.split("::").last
|
|
559
|
+
end
|
|
560
|
+
# Explicit void is shown (distinguishes from undeclared)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
return [] if parts.empty? && ret_type.nil?
|
|
564
|
+
|
|
565
|
+
# Calculate column widths
|
|
566
|
+
max_name = parts.map { |p| p[:name].length }.max || 0
|
|
567
|
+
|
|
568
|
+
# Build param content strings (without brackets) to measure
|
|
569
|
+
param_contents = parts.map do |p|
|
|
570
|
+
padded_name = p[:name].ljust(max_name)
|
|
571
|
+
"#{padded_name}#{p[:type_default]}"
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Max content width considers both params and return type
|
|
575
|
+
# For return, subtract 1 to account for "⟶ " prefix alignment
|
|
576
|
+
max_content = param_contents.map(&:length).max || 0
|
|
577
|
+
ret_content_width = ret_type ? ret_type.length : 0
|
|
578
|
+
max_content = [max_content, ret_content_width + 1].max # +1 so return aligns when -1 applied
|
|
579
|
+
|
|
580
|
+
# Render params with padding inside brackets: `⟨content ⟩`
|
|
581
|
+
lines = parts.each_with_index.map do |p, i|
|
|
582
|
+
padded_content = param_contents[i].ljust(max_content)
|
|
583
|
+
desc_part = p[:desc] ? " — #{p[:desc]}" : ""
|
|
584
|
+
"`⟨#{padded_content}⟩`#{desc_part}"
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Render return (inline with params, no blank line)
|
|
588
|
+
# Reduce padding by 1 to compensate for "⟶ " prefix
|
|
589
|
+
if ret_type
|
|
590
|
+
ret_pad = [max_content - 1, ret_type.length].max
|
|
591
|
+
padded_type = ret_type.ljust(ret_pad)
|
|
592
|
+
desc_part = ret_desc ? " — #{capitalize_first(ret_desc)}" : ""
|
|
593
|
+
lines << "⟶ `#{padded_type}`#{desc_part}"
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
lines
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Split docstring into summary (first line) and description (rest).
|
|
600
|
+
#
|
|
601
|
+
# Summary is extracted if first line is followed by:
|
|
602
|
+
# - A blank line (two consecutive newlines)
|
|
603
|
+
# - A markdown header (## or ###)
|
|
604
|
+
#
|
|
605
|
+
# @param docstring [String] Full docstring text
|
|
606
|
+
# @return [Array(String, String), Array(String, nil)] [summary, description]
|
|
607
|
+
def split_docstring(docstring)
|
|
608
|
+
return [nil, nil] if docstring.nil? || docstring.empty?
|
|
609
|
+
|
|
610
|
+
lines = docstring.lines
|
|
611
|
+
return [docstring.strip, nil] if lines.size == 1
|
|
612
|
+
|
|
613
|
+
first_line = lines[0].strip
|
|
614
|
+
second_line = lines[1]
|
|
615
|
+
|
|
616
|
+
# Check if second line is blank or a header
|
|
617
|
+
has_break = second_line.strip.empty? || second_line.match?(/\A\#{2,3}\s/)
|
|
618
|
+
|
|
619
|
+
if has_break
|
|
620
|
+
rest = lines[1..].join.strip
|
|
621
|
+
rest = nil if rest.empty?
|
|
622
|
+
[first_line, rest]
|
|
623
|
+
else
|
|
624
|
+
# No clear break - treat whole thing as description if long, else as summary
|
|
625
|
+
if lines.size <= 2
|
|
626
|
+
[docstring.strip, nil]
|
|
627
|
+
else
|
|
628
|
+
[nil, docstring.strip]
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def render_private_summary(private_methods)
|
|
634
|
+
return "" if private_methods.empty?
|
|
635
|
+
|
|
636
|
+
sorted = private_methods.sort_by { |m| [m.scope == :class ? 0 : 1, m.name.to_s] }
|
|
637
|
+
items = sorted.map do |m|
|
|
638
|
+
prefix = m.scope == :class ? "." : "#"
|
|
639
|
+
line = m.line ? ":#{m.line}" : ""
|
|
640
|
+
"`#{prefix}#{m.name}`#{line}"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
"---\n\n**Private:** #{items.join(', ')}"
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def render_file_index(files)
|
|
647
|
+
lines = ["# #{@project_title}", "", "> Per-file API documentation", "", "## Files", ""]
|
|
648
|
+
|
|
649
|
+
files.each do |f|
|
|
650
|
+
lines << "- [[#{f[:link_path]}|#{f[:filename]}]] — #{f[:namespaces]}"
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
lines.join("\n")
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def render_frontmatter(frontmatter)
|
|
657
|
+
lines = ["---"]
|
|
658
|
+
frontmatter.each do |key, value|
|
|
659
|
+
next if value.nil?
|
|
660
|
+
|
|
661
|
+
if value.is_a?(Array)
|
|
662
|
+
# Quote items containing [] to avoid YAML parsing as arrays
|
|
663
|
+
quoted_values = value.map { |v| v.to_s.include?("[") ? "\"#{v}\"" : v }
|
|
664
|
+
# Try flow-style first, use block-style if > 80 chars
|
|
665
|
+
flow_line = "#{key}: [#{quoted_values.join(', ')}]"
|
|
666
|
+
if flow_line.length <= 80
|
|
667
|
+
lines << flow_line
|
|
668
|
+
else
|
|
669
|
+
lines << "#{key}:"
|
|
670
|
+
quoted_values.each { |v| lines << " - #{v}" }
|
|
671
|
+
end
|
|
672
|
+
else
|
|
673
|
+
lines << "#{key}: #{value}"
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
lines << "---"
|
|
677
|
+
lines.join("\n")
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def make_relative(path)
|
|
681
|
+
return path unless path&.start_with?(@root)
|
|
682
|
+
|
|
683
|
+
path.delete_prefix("#{@root}/")
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def source_to_link(source_path)
|
|
687
|
+
# lib/archema/query.rb -> query
|
|
688
|
+
# Strip lib/project/ prefix and .rb extension
|
|
689
|
+
path = source_path.sub(%r{\Alib/}, "").sub(/\.rb\z/, "")
|
|
690
|
+
|
|
691
|
+
# Strip namespace prefix if configured
|
|
692
|
+
if @namespace_strip
|
|
693
|
+
prefix = @namespace_strip.downcase.gsub("::", "/")
|
|
694
|
+
path = path.sub(%r{\A#{Regexp.escape(prefix)}/?}, "")
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
to_kebab_case(path)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def to_kebab_case(str)
|
|
701
|
+
str.gsub("/", "/")
|
|
702
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
703
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
704
|
+
.gsub("_", "-")
|
|
705
|
+
.downcase
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Capitalize the first letter of a string, preserving the rest.
|
|
709
|
+
def capitalize_first(str)
|
|
710
|
+
return nil if str.nil? || str.strip.empty?
|
|
711
|
+
|
|
712
|
+
s = str.strip
|
|
713
|
+
s[0].upcase + s[1..]
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
end
|