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,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Chiridion
|
|
7
|
+
class Engine
|
|
8
|
+
# Simplified semantic renderer that outputs structured data.
|
|
9
|
+
#
|
|
10
|
+
# Rather than formatting for human reading, this outputs all extracted
|
|
11
|
+
# semantic data as JSON (or simple markdown with JSON payload). This helps:
|
|
12
|
+
#
|
|
13
|
+
# 1. Verify what data is being captured vs. missed
|
|
14
|
+
# 2. Debug the extraction pipeline
|
|
15
|
+
# 3. Provide machine-readable documentation for LLMs/agents
|
|
16
|
+
# 4. Separate concerns: extraction vs. presentation
|
|
17
|
+
#
|
|
18
|
+
# Output format: YAML frontmatter + JSON code fence with all data.
|
|
19
|
+
class SemanticRenderer
|
|
20
|
+
def initialize(namespace_strip: nil, project_title: "API Documentation")
|
|
21
|
+
@namespace_strip = namespace_strip
|
|
22
|
+
@project_title = project_title
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Render complete project documentation.
|
|
26
|
+
#
|
|
27
|
+
# @param project [ProjectDoc] Complete documentation from SemanticExtractor
|
|
28
|
+
# @return [Hash{String => String}] filename -> content mapping
|
|
29
|
+
def render(project)
|
|
30
|
+
files = {}
|
|
31
|
+
|
|
32
|
+
# Index
|
|
33
|
+
files["index.md"] = render_index(project)
|
|
34
|
+
|
|
35
|
+
# Type aliases are embedded where used (in each namespace's referenced_types)
|
|
36
|
+
# No separate types.md needed.
|
|
37
|
+
|
|
38
|
+
# Each namespace
|
|
39
|
+
project.namespaces.each do |ns|
|
|
40
|
+
filename = namespace_to_filename(ns.path)
|
|
41
|
+
files[filename] = render_namespace(ns)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
files
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Render index page.
|
|
48
|
+
def render_index(project)
|
|
49
|
+
frontmatter = {
|
|
50
|
+
"generated" => project.generated_at.iso8601,
|
|
51
|
+
"title" => @project_title,
|
|
52
|
+
"type" => "index",
|
|
53
|
+
"description" => project.description || "Auto-generated API documentation",
|
|
54
|
+
"class_count" => project.classes.size,
|
|
55
|
+
"module_count" => project.modules.size
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
classes = project.classes.map { |c| { path: c.path, file: c.file } }
|
|
59
|
+
modules = project.modules.map { |m| { path: m.path, file: m.file } }
|
|
60
|
+
|
|
61
|
+
body_data = {
|
|
62
|
+
classes: classes,
|
|
63
|
+
modules: modules
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
render_document(frontmatter, body_data)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Render type aliases reference.
|
|
70
|
+
def render_type_aliases(project)
|
|
71
|
+
frontmatter = {
|
|
72
|
+
"generated" => project.generated_at.iso8601,
|
|
73
|
+
"title" => "Type Aliases Reference",
|
|
74
|
+
"type" => "reference",
|
|
75
|
+
"description" => "RBS type aliases defined across the codebase"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Convert DocumentModel structs to hashes for JSON
|
|
79
|
+
aliases_by_namespace = project.type_aliases.transform_values do |types|
|
|
80
|
+
types.map { |t| type_alias_to_hash(t) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
body_data = { type_aliases: aliases_by_namespace }
|
|
84
|
+
|
|
85
|
+
render_document(frontmatter, body_data)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Render a namespace (class or module).
|
|
89
|
+
def render_namespace(ns)
|
|
90
|
+
frontmatter = build_frontmatter(ns)
|
|
91
|
+
body_data = build_body_data(ns)
|
|
92
|
+
|
|
93
|
+
render_document(frontmatter, body_data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def build_frontmatter(ns)
|
|
99
|
+
fm = {
|
|
100
|
+
"generated" => Time.now.utc.iso8601,
|
|
101
|
+
"title" => ns.path,
|
|
102
|
+
"type" => ns.type.to_s,
|
|
103
|
+
"description" => ns.docstring.to_s.lines.first&.strip || "",
|
|
104
|
+
"source" => ns.file ? "#{ns.file}:#{ns.line}" : nil,
|
|
105
|
+
"tags" => build_tags(ns)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fm["inherits"] = ns.superclass if ns.superclass
|
|
109
|
+
fm["api"] = ns.api if ns.api
|
|
110
|
+
fm["deprecated"] = ns.deprecated if ns.deprecated
|
|
111
|
+
fm["abstract"] = true if ns.abstract
|
|
112
|
+
fm["since"] = ns.since if ns.since
|
|
113
|
+
|
|
114
|
+
fm.compact
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_tags(ns)
|
|
118
|
+
tags = [ns.type.to_s]
|
|
119
|
+
tags << "abstract" if ns.abstract
|
|
120
|
+
tags << "deprecated" if ns.deprecated
|
|
121
|
+
tags << ns.api if ns.api
|
|
122
|
+
tags.compact
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_body_data(ns)
|
|
126
|
+
{
|
|
127
|
+
identity: {
|
|
128
|
+
name: ns.name,
|
|
129
|
+
path: ns.path,
|
|
130
|
+
type: ns.type,
|
|
131
|
+
superclass: ns.superclass,
|
|
132
|
+
file: ns.file,
|
|
133
|
+
line: ns.line,
|
|
134
|
+
end_line: ns.end_line,
|
|
135
|
+
rbs_file: ns.rbs_file
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
documentation: {
|
|
139
|
+
docstring: ns.docstring,
|
|
140
|
+
examples: ns.examples.map { |e| example_to_hash(e) },
|
|
141
|
+
notes: ns.notes,
|
|
142
|
+
see_also: ns.see_also.map { |s| see_to_hash(s) },
|
|
143
|
+
deprecated: ns.deprecated,
|
|
144
|
+
abstract: ns.abstract,
|
|
145
|
+
since: ns.since,
|
|
146
|
+
todo: ns.todo,
|
|
147
|
+
api: ns.api
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
relationships: {
|
|
151
|
+
includes: ns.includes,
|
|
152
|
+
extends: ns.extends,
|
|
153
|
+
referenced_types: ns.referenced_types.map { |t| type_alias_to_hash(t) }
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
members: {
|
|
157
|
+
constants: ns.constants.map { |c| constant_to_hash(c) },
|
|
158
|
+
type_aliases: ns.type_aliases.map { |t| type_alias_to_hash(t) },
|
|
159
|
+
ivars: ns.ivars.map { |i| ivar_to_hash(i) },
|
|
160
|
+
attributes: ns.attributes.map { |a| attribute_to_hash(a) },
|
|
161
|
+
methods: ns.methods.map { |m| method_to_hash(m) }
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
private_methods: ns.private_methods.map { |m| method_summary(m) }
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Convert DocumentModel structs to plain hashes for JSON serialization.
|
|
169
|
+
|
|
170
|
+
def example_to_hash(e) = { name: e.name, code: e.code }
|
|
171
|
+
|
|
172
|
+
def see_to_hash(s) = { target: s.target, text: s.text }
|
|
173
|
+
|
|
174
|
+
def type_alias_to_hash(t)
|
|
175
|
+
{
|
|
176
|
+
name: t.name,
|
|
177
|
+
definition: t.definition,
|
|
178
|
+
description: t.description,
|
|
179
|
+
namespace: t.namespace
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def constant_to_hash(c)
|
|
184
|
+
{
|
|
185
|
+
name: c.name,
|
|
186
|
+
value: c.value,
|
|
187
|
+
type: c.type,
|
|
188
|
+
description: c.description
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ivar_to_hash(i)
|
|
193
|
+
{
|
|
194
|
+
name: i.name,
|
|
195
|
+
type: i.type,
|
|
196
|
+
description: i.description
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def attribute_to_hash(a)
|
|
201
|
+
{
|
|
202
|
+
name: a.name,
|
|
203
|
+
type: a.type,
|
|
204
|
+
description: a.description,
|
|
205
|
+
mode: a.mode
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def param_to_hash(p)
|
|
210
|
+
{
|
|
211
|
+
name: p.name,
|
|
212
|
+
type: p.type,
|
|
213
|
+
description: p.description,
|
|
214
|
+
default: p.default,
|
|
215
|
+
prefix: p.prefix
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def option_to_hash(o)
|
|
220
|
+
{
|
|
221
|
+
param_name: o.param_name,
|
|
222
|
+
key: o.key,
|
|
223
|
+
type: o.type,
|
|
224
|
+
description: o.description
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def return_to_hash(r)
|
|
229
|
+
return nil unless r
|
|
230
|
+
|
|
231
|
+
{ type: r.type, description: r.description }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def yield_to_hash(y)
|
|
235
|
+
return nil unless y
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
description: y.description,
|
|
239
|
+
params: y.params.map { |p| param_to_hash(p) },
|
|
240
|
+
return_type: y.return_type,
|
|
241
|
+
return_desc: y.return_desc,
|
|
242
|
+
block_type: y.block_type
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def raise_to_hash(r) = { type: r.type, description: r.description }
|
|
247
|
+
|
|
248
|
+
def overload_to_hash(o) = { signature: o.signature, description: o.description }
|
|
249
|
+
|
|
250
|
+
def method_to_hash(m)
|
|
251
|
+
{
|
|
252
|
+
name: m.name.to_s,
|
|
253
|
+
scope: m.scope,
|
|
254
|
+
visibility: m.visibility,
|
|
255
|
+
signature: m.signature,
|
|
256
|
+
rbs_signature: m.rbs_signature,
|
|
257
|
+
|
|
258
|
+
docstring: m.docstring,
|
|
259
|
+
params: m.params.map { |p| param_to_hash(p) },
|
|
260
|
+
options: m.options.map { |o| option_to_hash(o) },
|
|
261
|
+
returns: return_to_hash(m.returns),
|
|
262
|
+
yields: yield_to_hash(m.yields),
|
|
263
|
+
raises: m.raises.map { |r| raise_to_hash(r) },
|
|
264
|
+
examples: m.examples.map { |e| example_to_hash(e) },
|
|
265
|
+
notes: m.notes,
|
|
266
|
+
see_also: m.see_also.map { |s| see_to_hash(s) },
|
|
267
|
+
|
|
268
|
+
api: m.api,
|
|
269
|
+
deprecated: m.deprecated,
|
|
270
|
+
abstract: m.abstract,
|
|
271
|
+
since: m.since,
|
|
272
|
+
todo: m.todo,
|
|
273
|
+
|
|
274
|
+
overloads: m.overloads.map { |o| overload_to_hash(o) },
|
|
275
|
+
|
|
276
|
+
source: m.source,
|
|
277
|
+
source_body_lines: m.source_body_lines,
|
|
278
|
+
file: m.file,
|
|
279
|
+
line: m.line
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def method_summary(m) = {
|
|
284
|
+
name: m.name.to_s,
|
|
285
|
+
scope: m.scope,
|
|
286
|
+
line: m.line
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def render_document(frontmatter, body_data)
|
|
290
|
+
lines = []
|
|
291
|
+
|
|
292
|
+
# YAML frontmatter
|
|
293
|
+
lines << "---"
|
|
294
|
+
lines << frontmatter.to_yaml.sub(/\A---\n/, "").chomp
|
|
295
|
+
lines << "---"
|
|
296
|
+
lines << ""
|
|
297
|
+
|
|
298
|
+
# Title
|
|
299
|
+
lines << "# #{frontmatter['title']}"
|
|
300
|
+
lines << ""
|
|
301
|
+
|
|
302
|
+
# Description if present
|
|
303
|
+
if frontmatter["description"] && !frontmatter["description"].empty?
|
|
304
|
+
lines << frontmatter["description"]
|
|
305
|
+
lines << ""
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# JSON data block
|
|
309
|
+
lines << "## Semantic Data"
|
|
310
|
+
lines << ""
|
|
311
|
+
lines << "```json"
|
|
312
|
+
lines << JSON.pretty_generate(body_data)
|
|
313
|
+
lines << "```"
|
|
314
|
+
lines << ""
|
|
315
|
+
|
|
316
|
+
lines.join("\n")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def namespace_to_filename(path)
|
|
320
|
+
stripped = @namespace_strip ? path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : path
|
|
321
|
+
parts = stripped.split("::")
|
|
322
|
+
kebab = parts.map { |p| to_kebab_case(p) }
|
|
323
|
+
"#{kebab.join('/')}.md".sub(%r{^/}, "")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def to_kebab_case(str)
|
|
327
|
+
str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
|
|
328
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
329
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
330
|
+
.downcase
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Extracts usage examples from RSpec files.
|
|
6
|
+
#
|
|
7
|
+
# Parses spec files to find `let` declarations, `subject` blocks, and
|
|
8
|
+
# test descriptions that can serve as documentation examples.
|
|
9
|
+
class SpecExampleLoader
|
|
10
|
+
def initialize(spec_path, verbose, logger)
|
|
11
|
+
@spec_path = spec_path
|
|
12
|
+
@verbose = verbose
|
|
13
|
+
@logger = logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load spec examples for all spec files.
|
|
17
|
+
#
|
|
18
|
+
# @return [Hash{String => Hash}] Class path => { method_examples:, behaviors:, lets:, subjects: }
|
|
19
|
+
def load
|
|
20
|
+
examples = {}
|
|
21
|
+
spec_files = Dir.glob("#{@spec_path}/**/*_spec.rb")
|
|
22
|
+
|
|
23
|
+
return examples if spec_files.empty?
|
|
24
|
+
|
|
25
|
+
@logger.info "Loading examples from #{spec_files.size} spec files..." if @verbose
|
|
26
|
+
spec_files.each { |file| parse_file(file, examples) }
|
|
27
|
+
examples
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_file(file, examples)
|
|
33
|
+
content = File.read(file)
|
|
34
|
+
current_class = extract_described_class(content)
|
|
35
|
+
return unless current_class
|
|
36
|
+
|
|
37
|
+
examples[current_class] ||= {
|
|
38
|
+
method_examples: Hash.new { |h, k| h[k] = [] },
|
|
39
|
+
behaviors: Hash.new { |h, k| h[k] = [] },
|
|
40
|
+
lets: [],
|
|
41
|
+
subjects: []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
extract_lets(content, examples[current_class])
|
|
45
|
+
extract_subjects(content, examples[current_class])
|
|
46
|
+
extract_behaviors(content, examples[current_class])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_described_class(content)
|
|
50
|
+
# Match: RSpec.describe ClassName or describe ClassName
|
|
51
|
+
return unless content =~ /(?:RSpec\.)?describe\s+([A-Z][\w:]+)/
|
|
52
|
+
|
|
53
|
+
Regexp.last_match(1)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_lets(content, data)
|
|
57
|
+
# Match: let(:name) { ... } or let!(:name) { ... }
|
|
58
|
+
content.scan(/let!?\(:(\w+)\)\s*\{([^}]+)\}/) do |name, code|
|
|
59
|
+
data[:lets] << { name: name, code: code.strip }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract_subjects(content, data)
|
|
64
|
+
# Match: subject { ... } or subject(:name) { ... }
|
|
65
|
+
content.scan(/subject(?:\(:(\w+)\))?\s*\{([^}]+)\}/) do |name, code|
|
|
66
|
+
data[:subjects] << { name: name || "subject", code: code.strip }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_behaviors(content, data)
|
|
71
|
+
# Match: describe "#method" do or describe ".method" do
|
|
72
|
+
current_method = nil
|
|
73
|
+
content.each_line do |line|
|
|
74
|
+
if line =~ /describe\s+['"](#|\.)\w+['"]/
|
|
75
|
+
current_method = line[/['"](#|\.)(\w+)['"]/, 0]&.tr("'\"", "")
|
|
76
|
+
elsif line =~ /it\s+['"]([^'"]+)['"]/ && current_method
|
|
77
|
+
behavior = Regexp.last_match(1)
|
|
78
|
+
data[:behaviors][current_method] << behavior
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module Chiridion
|
|
6
|
+
class Engine
|
|
7
|
+
# Renders documentation using Liquid templates.
|
|
8
|
+
#
|
|
9
|
+
# Templates are loaded from the gem's templates/ directory by default,
|
|
10
|
+
# but can be overridden by specifying a custom templates_path.
|
|
11
|
+
#
|
|
12
|
+
# Available templates:
|
|
13
|
+
# - index.liquid: Documentation index page
|
|
14
|
+
# - document.liquid: Class/module documentation
|
|
15
|
+
# - method.liquid: Individual method documentation
|
|
16
|
+
# - constants.liquid: Constants table and complex constant sections
|
|
17
|
+
class TemplateRenderer
|
|
18
|
+
# Custom Liquid filters for documentation rendering.
|
|
19
|
+
module Filters
|
|
20
|
+
# Escape pipe characters for markdown table cells.
|
|
21
|
+
def escape_pipes(input)
|
|
22
|
+
return "" if input.nil?
|
|
23
|
+
|
|
24
|
+
input.to_s.gsub("|", "\\|")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Remove newlines for single-line table cells.
|
|
28
|
+
def strip_newlines(input)
|
|
29
|
+
return "" if input.nil?
|
|
30
|
+
|
|
31
|
+
input.to_s.gsub(/\s*\n\s*/, " ").strip
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convert to kebab case for file paths.
|
|
35
|
+
def kebab_case(input)
|
|
36
|
+
return "" if input.nil?
|
|
37
|
+
|
|
38
|
+
input.to_s
|
|
39
|
+
.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
|
|
40
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
41
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
42
|
+
.downcase
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Strip @rbs! blocks from docstrings (type metadata shouldn't be in docs).
|
|
46
|
+
def strip_rbs_blocks(input)
|
|
47
|
+
return "" if input.nil?
|
|
48
|
+
|
|
49
|
+
input.to_s.gsub(/@rbs![\s\S]*?(?=\n\n|\z)/, "").strip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Normalize markdown headers to be subordinate to a given level.
|
|
53
|
+
# Usage: {{ docstring | normalize_headers: 4 }}
|
|
54
|
+
# Adjusts all headers so the minimum level becomes the specified level.
|
|
55
|
+
def normalize_headers(input, min_level = 3)
|
|
56
|
+
return "" if input.nil? || input.to_s.empty?
|
|
57
|
+
|
|
58
|
+
text = input.to_s
|
|
59
|
+
lines = text.lines
|
|
60
|
+
|
|
61
|
+
# Find the minimum header level in the text
|
|
62
|
+
header_levels = lines.filter_map do |line|
|
|
63
|
+
match = line.match(/^(#+)\s/)
|
|
64
|
+
match[1].length if match
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return text if header_levels.empty?
|
|
68
|
+
|
|
69
|
+
current_min = header_levels.min
|
|
70
|
+
offset = min_level.to_i - current_min
|
|
71
|
+
return text if offset <= 0
|
|
72
|
+
|
|
73
|
+
# Prepend offset number of # to all header lines
|
|
74
|
+
prefix = "#" * offset
|
|
75
|
+
lines.map do |line|
|
|
76
|
+
if line.match?(/^#+\s/)
|
|
77
|
+
prefix + line
|
|
78
|
+
else
|
|
79
|
+
line
|
|
80
|
+
end
|
|
81
|
+
end.join
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(templates_path: nil)
|
|
86
|
+
@templates_path = templates_path || default_templates_path
|
|
87
|
+
@templates = {}
|
|
88
|
+
@environment = Liquid::Environment.build do |env|
|
|
89
|
+
env.register_filter(Filters)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Render the index template.
|
|
94
|
+
#
|
|
95
|
+
# @param title [String] Project title
|
|
96
|
+
# @param description [String] Index description
|
|
97
|
+
# @param classes [Array<Hash>] Class objects with :path and :link_path
|
|
98
|
+
# @param modules [Array<Hash>] Module objects with :path and :link_path
|
|
99
|
+
# @return [String] Rendered markdown
|
|
100
|
+
def render_index(title:, description:, classes:, modules:)
|
|
101
|
+
render("index", {
|
|
102
|
+
"title" => title,
|
|
103
|
+
"description" => description,
|
|
104
|
+
"classes" => stringify_keys(classes),
|
|
105
|
+
"modules" => stringify_keys(modules)
|
|
106
|
+
})
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Render a class or module document.
|
|
110
|
+
#
|
|
111
|
+
# @param title [String] Class/module full path
|
|
112
|
+
# @param docstring [String] Main documentation (linkified)
|
|
113
|
+
# @param mixins [String, nil] Mixin line (e.g., "**Includes:** ...")
|
|
114
|
+
# @param examples [Array<Hash>] YARD examples with :name and :text
|
|
115
|
+
# @param spec_examples [String, nil] Rendered spec examples section
|
|
116
|
+
# @param see_also [String, nil] See also links
|
|
117
|
+
# @param constants_section [String] Rendered constants section
|
|
118
|
+
# @param types_section [String] Rendered types section (type aliases used by this class)
|
|
119
|
+
# @param attributes_section [String] Rendered attributes section
|
|
120
|
+
# @param methods_section [String] Rendered methods section
|
|
121
|
+
# @return [String] Rendered markdown
|
|
122
|
+
def render_document(
|
|
123
|
+
title:,
|
|
124
|
+
docstring:,
|
|
125
|
+
mixins: nil,
|
|
126
|
+
examples: [],
|
|
127
|
+
spec_examples: nil,
|
|
128
|
+
see_also: nil,
|
|
129
|
+
constants_section: "",
|
|
130
|
+
types_section: "",
|
|
131
|
+
attributes_section: "",
|
|
132
|
+
methods_section: ""
|
|
133
|
+
)
|
|
134
|
+
render("document", {
|
|
135
|
+
"title" => title,
|
|
136
|
+
"docstring" => docstring,
|
|
137
|
+
"mixins" => mixins,
|
|
138
|
+
"examples" => stringify_keys(examples),
|
|
139
|
+
"spec_examples" => spec_examples,
|
|
140
|
+
"see_also" => see_also,
|
|
141
|
+
"constants_section" => constants_section,
|
|
142
|
+
"types_section" => types_section,
|
|
143
|
+
"attributes_section" => attributes_section,
|
|
144
|
+
"methods_section" => methods_section
|
|
145
|
+
})
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Render a single method.
|
|
149
|
+
#
|
|
150
|
+
# @param display_name [String] Method name (with class prefix if needed)
|
|
151
|
+
# @param has_params [Boolean] Whether method has parameters
|
|
152
|
+
# @param docstring [String, nil] Method description
|
|
153
|
+
# @param params [Array<String>] Formatted parameter lines
|
|
154
|
+
# @param return_line [String, nil] Formatted return line
|
|
155
|
+
# @param examples [Array<Hash>] YARD examples
|
|
156
|
+
# @param behaviors [Array<String>] Spec behavior descriptions
|
|
157
|
+
# @param spec_examples [Array<Hash>] Spec code examples
|
|
158
|
+
# @param inline_source [String, nil] Method source code to display inline
|
|
159
|
+
# @return [String] Rendered markdown
|
|
160
|
+
def render_method(
|
|
161
|
+
display_name:,
|
|
162
|
+
has_params: false,
|
|
163
|
+
docstring: nil,
|
|
164
|
+
params: [],
|
|
165
|
+
return_line: nil,
|
|
166
|
+
examples: [],
|
|
167
|
+
behaviors: [],
|
|
168
|
+
spec_examples: [],
|
|
169
|
+
inline_source: nil
|
|
170
|
+
)
|
|
171
|
+
render("method", {
|
|
172
|
+
"display_name" => display_name,
|
|
173
|
+
"has_params" => has_params,
|
|
174
|
+
"docstring" => docstring,
|
|
175
|
+
"params" => params,
|
|
176
|
+
"return_line" => return_line,
|
|
177
|
+
"examples" => stringify_keys(examples),
|
|
178
|
+
"behaviors" => behaviors,
|
|
179
|
+
"spec_examples" => stringify_keys(spec_examples),
|
|
180
|
+
"inline_source" => inline_source
|
|
181
|
+
})
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Render the methods section with separators.
|
|
185
|
+
#
|
|
186
|
+
# @param methods [Array<String>] Pre-rendered method strings
|
|
187
|
+
# @return [String] Rendered markdown
|
|
188
|
+
def render_methods(methods:) = render("methods", {
|
|
189
|
+
"methods" => methods
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
# Render the constants section.
|
|
193
|
+
#
|
|
194
|
+
# @param constants [Array<Hash>] Constants with :name, :value, :docstring, :is_complex
|
|
195
|
+
# @param complex_constants [Array<Hash>] Complex constants for expanded rendering
|
|
196
|
+
# @return [String] Rendered markdown
|
|
197
|
+
def render_constants(constants:, complex_constants:)
|
|
198
|
+
render("constants", {
|
|
199
|
+
"constants" => stringify_keys(constants),
|
|
200
|
+
"complex_constants" => stringify_keys(complex_constants)
|
|
201
|
+
})
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Render the types section (type aliases used by a class/module).
|
|
205
|
+
#
|
|
206
|
+
# @param types [Array<Hash>] Types with :name, :definition, :description, :namespace
|
|
207
|
+
# @return [String] Rendered markdown
|
|
208
|
+
def render_types(types:) = render("types", {
|
|
209
|
+
"types" => stringify_keys(types)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
# Render the type aliases reference page.
|
|
213
|
+
#
|
|
214
|
+
# @param title [String] Page title
|
|
215
|
+
# @param description [String] Page description
|
|
216
|
+
# @param namespaces [Array<Hash>] Namespaces with :name and :types arrays
|
|
217
|
+
# @return [String] Rendered markdown
|
|
218
|
+
def render_type_aliases(title:, description:, namespaces:)
|
|
219
|
+
render("type_aliases", {
|
|
220
|
+
"title" => title,
|
|
221
|
+
"description" => description,
|
|
222
|
+
"namespaces" => stringify_keys(namespaces)
|
|
223
|
+
})
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Render a per-file documentation page.
|
|
227
|
+
#
|
|
228
|
+
# @param path [String] Source file path (relative)
|
|
229
|
+
# @param filename [String] Just the filename
|
|
230
|
+
# @param line_count [Integer, nil] Total lines in source
|
|
231
|
+
# @param namespaces [Array<Hash>] Namespace data with pre-rendered sections
|
|
232
|
+
# @param type_aliases [Array<Hash>] File-level type aliases
|
|
233
|
+
# @return [String] Rendered markdown
|
|
234
|
+
def render_file(path:, filename:, line_count: nil, namespaces: [], type_aliases: [])
|
|
235
|
+
render("file", {
|
|
236
|
+
"path" => path,
|
|
237
|
+
"filename" => filename,
|
|
238
|
+
"line_count" => line_count,
|
|
239
|
+
"namespaces" => stringify_keys(namespaces),
|
|
240
|
+
"type_aliases" => stringify_keys(type_aliases)
|
|
241
|
+
})
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def default_templates_path = File.expand_path("../../../templates", __dir__)
|
|
247
|
+
|
|
248
|
+
def render(template_name, variables)
|
|
249
|
+
template = load_template(template_name)
|
|
250
|
+
template.render(variables).strip
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def load_template(name)
|
|
254
|
+
@templates[name] ||= begin
|
|
255
|
+
path = File.join(@templates_path, "#{name}.liquid")
|
|
256
|
+
raise "Template not found: #{path}" unless File.exist?(path)
|
|
257
|
+
|
|
258
|
+
Liquid::Template.parse(File.read(path), environment: @environment)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Convert symbol keys to string keys for Liquid compatibility.
|
|
263
|
+
def stringify_keys(obj)
|
|
264
|
+
case obj
|
|
265
|
+
when Array
|
|
266
|
+
obj.map { |item| stringify_keys(item) }
|
|
267
|
+
when Hash
|
|
268
|
+
obj.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
|
|
269
|
+
else
|
|
270
|
+
obj
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|