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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/lib/chiridion/config.rb +128 -0
  6. data/lib/chiridion/engine/class_linker.rb +204 -0
  7. data/lib/chiridion/engine/document_model.rb +299 -0
  8. data/lib/chiridion/engine/drift_checker.rb +146 -0
  9. data/lib/chiridion/engine/extractor.rb +311 -0
  10. data/lib/chiridion/engine/file_renderer.rb +717 -0
  11. data/lib/chiridion/engine/file_writer.rb +160 -0
  12. data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
  13. data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
  14. data/lib/chiridion/engine/github_linker.rb +87 -0
  15. data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
  16. data/lib/chiridion/engine/post_processor.rb +86 -0
  17. data/lib/chiridion/engine/rbs_loader.rb +150 -0
  18. data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
  19. data/lib/chiridion/engine/renderer.rb +598 -0
  20. data/lib/chiridion/engine/semantic_extractor.rb +740 -0
  21. data/lib/chiridion/engine/semantic_renderer.rb +334 -0
  22. data/lib/chiridion/engine/spec_example_loader.rb +84 -0
  23. data/lib/chiridion/engine/template_renderer.rb +275 -0
  24. data/lib/chiridion/engine/type_merger.rb +126 -0
  25. data/lib/chiridion/engine/writer.rb +134 -0
  26. data/lib/chiridion/engine.rb +359 -0
  27. data/lib/chiridion/semantic_engine.rb +186 -0
  28. data/lib/chiridion/version.rb +5 -0
  29. data/lib/chiridion.rb +106 -0
  30. data/templates/constants.liquid +27 -0
  31. data/templates/document.liquid +48 -0
  32. data/templates/file.liquid +108 -0
  33. data/templates/index.liquid +21 -0
  34. data/templates/method.liquid +43 -0
  35. data/templates/methods.liquid +11 -0
  36. data/templates/type_aliases.liquid +26 -0
  37. data/templates/types.liquid +11 -0
  38. 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