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,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