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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Chiridion
|
|
6
|
+
class Engine
|
|
7
|
+
# Writes per-file documentation to disk.
|
|
8
|
+
#
|
|
9
|
+
# Output structure mirrors source structure:
|
|
10
|
+
# lib/archema/query.rb -> docs/sys/query.md
|
|
11
|
+
# lib/archema/result.rb -> docs/sys/result.md
|
|
12
|
+
#
|
|
13
|
+
# Handles smart write detection to avoid unnecessary file updates.
|
|
14
|
+
class FileWriter
|
|
15
|
+
def initialize(
|
|
16
|
+
output:,
|
|
17
|
+
logger:, namespace_strip: nil,
|
|
18
|
+
include_specs: false,
|
|
19
|
+
verbose: false,
|
|
20
|
+
root: Dir.pwd,
|
|
21
|
+
github_repo: nil,
|
|
22
|
+
github_branch: "main",
|
|
23
|
+
project_title: "API Documentation",
|
|
24
|
+
index_description: nil,
|
|
25
|
+
inline_source_threshold: 10,
|
|
26
|
+
templates_path: nil
|
|
27
|
+
)
|
|
28
|
+
@output = output
|
|
29
|
+
@namespace_strip = namespace_strip
|
|
30
|
+
@verbose = verbose
|
|
31
|
+
@logger = logger
|
|
32
|
+
@root = root
|
|
33
|
+
@index_description = index_description
|
|
34
|
+
|
|
35
|
+
@renderer = FileRenderer.new(
|
|
36
|
+
namespace_strip: namespace_strip,
|
|
37
|
+
include_specs: include_specs,
|
|
38
|
+
root: root,
|
|
39
|
+
github_repo: github_repo,
|
|
40
|
+
github_branch: github_branch,
|
|
41
|
+
project_title: project_title,
|
|
42
|
+
inline_source_threshold: inline_source_threshold,
|
|
43
|
+
templates_path: templates_path
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Write all per-file documentation.
|
|
48
|
+
#
|
|
49
|
+
# @param project [ProjectDoc] Documentation structure from SemanticExtractor
|
|
50
|
+
def write(project)
|
|
51
|
+
FileUtils.mkdir_p(@output)
|
|
52
|
+
|
|
53
|
+
@renderer.register_classes(project)
|
|
54
|
+
|
|
55
|
+
counts = { written: 0, skipped: 0 }
|
|
56
|
+
|
|
57
|
+
# Find root file (e.g., lib/archema.rb for Archema::)
|
|
58
|
+
root_file = find_root_file(project.files)
|
|
59
|
+
|
|
60
|
+
# Write per-file docs
|
|
61
|
+
project.files.each do |file_doc|
|
|
62
|
+
is_root = root_file && file_doc.path == root_file.path
|
|
63
|
+
write_file_doc(file_doc, counts, is_root: is_root)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Always write index.md (root file embeds it via ![[index]])
|
|
67
|
+
write_index(project, counts)
|
|
68
|
+
|
|
69
|
+
@logger.info " #{counts[:written]} files written, #{counts[:skipped]} unchanged"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Find the root lib file that matches the namespace.
|
|
75
|
+
# e.g., lib/archema.rb for Archema::, lib/chiridion.rb for Chiridion::
|
|
76
|
+
def find_root_file(files)
|
|
77
|
+
return nil unless @namespace_strip
|
|
78
|
+
|
|
79
|
+
# Convert Archema:: to archema.rb
|
|
80
|
+
expected_name = @namespace_strip.delete_suffix("::").split("::").last.downcase
|
|
81
|
+
expected_filename = "#{to_snake_case(expected_name)}.rb"
|
|
82
|
+
|
|
83
|
+
files.find { |f| f.filename == expected_filename }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_snake_case(str)
|
|
87
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
88
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
89
|
+
.gsub("-", "_")
|
|
90
|
+
.downcase
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_index(project, counts)
|
|
94
|
+
content = @renderer.render_index(project, index_description: @index_description)
|
|
95
|
+
content = PostProcessor.process(content)
|
|
96
|
+
wrote = write_file(File.join(@output, "index.md"), content)
|
|
97
|
+
counts[wrote ? :written : :skipped] += 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write_file_doc(file_doc, counts, is_root: false)
|
|
101
|
+
path = output_path(file_doc.path)
|
|
102
|
+
content = @renderer.render_file(file_doc, is_root: is_root)
|
|
103
|
+
content = PostProcessor.process(content)
|
|
104
|
+
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
106
|
+
wrote = write_file(path, content)
|
|
107
|
+
|
|
108
|
+
counts[wrote ? :written : :skipped] += 1
|
|
109
|
+
@logger.info " #{wrote ? 'Wrote' : 'Unchanged'} #{path}" if @verbose
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def write_file(path, new_content)
|
|
113
|
+
new_content = "#{new_content}\n" unless new_content.end_with?("\n")
|
|
114
|
+
return File.write(path, new_content) || true unless File.exist?(path)
|
|
115
|
+
|
|
116
|
+
old_content = File.read(path)
|
|
117
|
+
return false unless content_changed?(old_content, new_content)
|
|
118
|
+
|
|
119
|
+
File.write(path, new_content)
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def content_changed?(old, new) = normalize(old) != normalize(new)
|
|
124
|
+
|
|
125
|
+
def normalize(content)
|
|
126
|
+
content
|
|
127
|
+
.gsub(/^generated: .+$/, "generated: TIMESTAMP")
|
|
128
|
+
.gsub(/\n{2,}/, "\n\n")
|
|
129
|
+
.strip
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Map source file path to output doc path.
|
|
133
|
+
#
|
|
134
|
+
# lib/archema/query.rb -> docs/sys/query.md
|
|
135
|
+
# lib/archema/result.rb -> docs/sys/result.md
|
|
136
|
+
def output_path(source_path)
|
|
137
|
+
# Strip lib/project_name/ prefix
|
|
138
|
+
path = source_path.sub(%r{\Alib/}, "").sub(/\.rb\z/, "")
|
|
139
|
+
|
|
140
|
+
# Strip namespace prefix if configured (e.g., "archema/" from "archema/query")
|
|
141
|
+
if @namespace_strip
|
|
142
|
+
prefix = @namespace_strip.downcase.gsub("::", "/")
|
|
143
|
+
path = path.sub(%r{\A#{Regexp.escape(prefix)}/?}, "")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Convert to kebab-case
|
|
147
|
+
kebab_path = path.split("/").map { |p| to_kebab_case(p) }.join("/")
|
|
148
|
+
|
|
149
|
+
File.join(@output, "#{kebab_path}.md")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_kebab_case(str)
|
|
153
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
154
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
155
|
+
.gsub("_", "-")
|
|
156
|
+
.downcase
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Chiridion
|
|
6
|
+
class Engine
|
|
7
|
+
# Builds enhanced YAML frontmatter for documentation files.
|
|
8
|
+
#
|
|
9
|
+
# Generates Obsidian-compatible frontmatter with navigation aids,
|
|
10
|
+
# discovery metadata, and search-friendly fields. Each documentation
|
|
11
|
+
# file gets frontmatter that enables:
|
|
12
|
+
#
|
|
13
|
+
# - **Navigation**: parent links for breadcrumb traversal
|
|
14
|
+
# - **Discovery**: tags for filtering, related links for exploration
|
|
15
|
+
# - **Search**: aliases for finding by short name, description for preview
|
|
16
|
+
class FrontmatterBuilder
|
|
17
|
+
def initialize(class_linker, namespace_strip: nil, project_title: "API Documentation")
|
|
18
|
+
@class_linker = class_linker
|
|
19
|
+
@namespace_strip = namespace_strip
|
|
20
|
+
@project_title = project_title
|
|
21
|
+
@inheritance_children = {} # Maps parent class path -> array of child class paths
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Pre-compute inheritance relationships from full structure.
|
|
25
|
+
#
|
|
26
|
+
# Must be called before build() to populate inherited-by fields.
|
|
27
|
+
# Scans all classes to build parent->children mapping.
|
|
28
|
+
#
|
|
29
|
+
# @param structure [Hash] Full documentation structure from Extractor
|
|
30
|
+
def register_inheritance(structure)
|
|
31
|
+
@inheritance_children = {}
|
|
32
|
+
structure[:classes].each do |klass|
|
|
33
|
+
parent = klass[:superclass]
|
|
34
|
+
next unless parent && documentable_class?(parent)
|
|
35
|
+
|
|
36
|
+
@inheritance_children[parent] ||= []
|
|
37
|
+
@inheritance_children[parent] << klass[:path]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build frontmatter hash for a class or module.
|
|
42
|
+
#
|
|
43
|
+
# @param obj [Hash] Extracted object data from Extractor
|
|
44
|
+
# @return [Hash] Frontmatter fields in render order
|
|
45
|
+
def build(obj)
|
|
46
|
+
{
|
|
47
|
+
generated: Time.now.utc.iso8601,
|
|
48
|
+
title: obj[:path],
|
|
49
|
+
type: obj[:type].to_s, # :class or :module
|
|
50
|
+
source: relative_path(obj[:file]),
|
|
51
|
+
description: extract_description(obj[:docstring]),
|
|
52
|
+
inherits: build_inherits_link(obj[:superclass]),
|
|
53
|
+
parent: build_parent_link(obj[:path]),
|
|
54
|
+
inherited_by: build_inherited_by_links(obj[:path]),
|
|
55
|
+
includes: build_mixin_list(obj[:includes]),
|
|
56
|
+
extends: build_mixin_list(obj[:extends]),
|
|
57
|
+
rbs: obj[:rbs_file] ? relative_path(obj[:rbs_file]) : nil,
|
|
58
|
+
tags: build_tags(obj[:path]),
|
|
59
|
+
aliases: build_aliases(obj[:path]),
|
|
60
|
+
constants: build_constant_list(obj[:constants]),
|
|
61
|
+
methods: build_method_list(obj[:methods], obj[:path]),
|
|
62
|
+
related: build_related(obj)
|
|
63
|
+
}.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build frontmatter for index page.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash] Minimal frontmatter for index
|
|
69
|
+
def build_index
|
|
70
|
+
{
|
|
71
|
+
generated: Time.now.utc.iso8601,
|
|
72
|
+
title: @project_title,
|
|
73
|
+
tags: %w[index api-reference]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def documentable_class?(class_path)
|
|
80
|
+
return true unless @namespace_strip
|
|
81
|
+
|
|
82
|
+
class_path.start_with?(@namespace_strip)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Extract first sentence as description.
|
|
86
|
+
def extract_description(docstring)
|
|
87
|
+
return nil if docstring.nil? || docstring.strip.empty?
|
|
88
|
+
|
|
89
|
+
# Take first paragraph (up to blank line)
|
|
90
|
+
first_para = docstring.split(/\n\s*\n/).first&.strip
|
|
91
|
+
return nil if first_para.nil? || first_para.empty?
|
|
92
|
+
|
|
93
|
+
# Take first sentence (period/exclamation/question followed by space or end)
|
|
94
|
+
first_sentence = first_para.match(/^(.+?[.!?])(?:\s|$)/m)&.[](1)
|
|
95
|
+
result = first_sentence || first_para
|
|
96
|
+
|
|
97
|
+
# Strip markdown formatting: **bold**, `code`, [[links]]
|
|
98
|
+
result = result.gsub(/\*\*(.+?)\*\*/, '\1') # bold
|
|
99
|
+
result = result.gsub(/`(.+?)`/, '\1') # inline code
|
|
100
|
+
result = result.gsub(/\[\[.+?\|(.+?)\]\]/, '\1') # wikilinks with alias
|
|
101
|
+
result = result.gsub(/\[\[(.+?)\]\]/, '\1') # wikilinks without alias
|
|
102
|
+
|
|
103
|
+
result.strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build wikilink to parent namespace documentation.
|
|
107
|
+
def build_parent_link(path)
|
|
108
|
+
parts = path.split("::")
|
|
109
|
+
# Strip namespace prefix to count depth
|
|
110
|
+
stripped_parts = @namespace_strip ? path.sub(/^#{Regexp.escape(@namespace_strip)}/, "").split("::") : parts
|
|
111
|
+
return nil if stripped_parts.size <= 1 # Top-level has no documentable parent
|
|
112
|
+
|
|
113
|
+
parent_parts = parts[0..-2]
|
|
114
|
+
parent_path = parent_parts.join("::")
|
|
115
|
+
|
|
116
|
+
# Build link path (skip namespace prefix for file path)
|
|
117
|
+
link_parts = if @namespace_strip
|
|
118
|
+
parent_path.sub(/^#{Regexp.escape(@namespace_strip)}/,
|
|
119
|
+
"").split("::")
|
|
120
|
+
else
|
|
121
|
+
parent_parts
|
|
122
|
+
end
|
|
123
|
+
link_path = link_parts.map { |p| to_kebab_case(p) }.join("/")
|
|
124
|
+
|
|
125
|
+
"\"[[#{link_path}|#{parent_path}]]\""
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build superclass reference (linked if internal, plain text if external).
|
|
129
|
+
def build_inherits_link(superclass)
|
|
130
|
+
return nil unless superclass
|
|
131
|
+
|
|
132
|
+
linkify_class(superclass) || superclass
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Build wikilinks to classes that inherit from this one.
|
|
136
|
+
def build_inherited_by_links(path)
|
|
137
|
+
children = @inheritance_children[path]
|
|
138
|
+
return nil if children.nil? || children.empty?
|
|
139
|
+
|
|
140
|
+
children.sort.filter_map { |child| linkify_class(child) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build list of included/extended modules (short names only).
|
|
144
|
+
def build_mixin_list(mixins)
|
|
145
|
+
return nil if mixins.nil? || mixins.empty?
|
|
146
|
+
|
|
147
|
+
mixins.map { |m| m.split("::").last }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Build list of constant names.
|
|
151
|
+
def build_constant_list(constants)
|
|
152
|
+
return nil if constants.nil? || constants.empty?
|
|
153
|
+
|
|
154
|
+
constants.map { |c| c[:name].to_s }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build list of method names for frontmatter.
|
|
158
|
+
def build_method_list(methods, class_path)
|
|
159
|
+
return nil if methods.nil? || methods.empty?
|
|
160
|
+
|
|
161
|
+
class_name = class_path.split("::").last
|
|
162
|
+
|
|
163
|
+
class_methods = methods.select { |m| m[:scope] == :class }
|
|
164
|
+
.map { |m| format_method_name("#{class_name}.#{m[:name]}") }
|
|
165
|
+
.sort
|
|
166
|
+
|
|
167
|
+
instance_methods = methods.reject { |m| m[:scope] == :class }
|
|
168
|
+
.map { |m| format_method_name(m[:name].to_s) }
|
|
169
|
+
.sort
|
|
170
|
+
|
|
171
|
+
class_methods + instance_methods
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Quote method names with special characters for YAML safety.
|
|
175
|
+
def format_method_name(name) = name.match?(/[\[\]{}:,#&*!|>'"%@`]/) ? "'#{name}'" : name
|
|
176
|
+
|
|
177
|
+
# Generate tags from namespace hierarchy.
|
|
178
|
+
def build_tags(path)
|
|
179
|
+
stripped = @namespace_strip ? path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : path
|
|
180
|
+
parts = stripped.split("::")
|
|
181
|
+
parts.map { |p| to_kebab_case(p) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Build aliases for search discovery.
|
|
185
|
+
def build_aliases(path)
|
|
186
|
+
parts = path.split("::")
|
|
187
|
+
short_name = parts.last
|
|
188
|
+
short_name == path ? [] : [short_name]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Build related links from see_also, includes, extends, superclass.
|
|
192
|
+
def build_related(obj)
|
|
193
|
+
related = []
|
|
194
|
+
|
|
195
|
+
# Add see_also references
|
|
196
|
+
obj[:see_also]&.each do |see|
|
|
197
|
+
link = linkify_class(see[:name])
|
|
198
|
+
related << link if link
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Add mixins
|
|
202
|
+
obj[:includes]&.each do |mixin|
|
|
203
|
+
link = linkify_class(mixin)
|
|
204
|
+
related << link if link
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
obj[:extends]&.each do |mixin|
|
|
208
|
+
link = linkify_class(mixin)
|
|
209
|
+
related << link if link
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Add superclass (if not Object/BasicObject)
|
|
213
|
+
if obj[:superclass] && !%w[Object BasicObject].include?(obj[:superclass])
|
|
214
|
+
link = linkify_class(obj[:superclass])
|
|
215
|
+
related << link if link
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
related.empty? ? nil : related.uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Convert class name to wikilink if we document it.
|
|
222
|
+
def linkify_class(class_name)
|
|
223
|
+
return nil unless documentable_class?(class_name)
|
|
224
|
+
|
|
225
|
+
stripped = @namespace_strip ? class_name.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_name
|
|
226
|
+
link_path = stripped.split("::")
|
|
227
|
+
.map { |p| to_kebab_case(p) }
|
|
228
|
+
.join("/")
|
|
229
|
+
|
|
230
|
+
"\"[[#{link_path}|#{class_name}]]\""
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def relative_path(absolute_path)
|
|
234
|
+
return absolute_path unless absolute_path
|
|
235
|
+
|
|
236
|
+
# Remove project root prefix if present
|
|
237
|
+
absolute_path
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def to_kebab_case(str)
|
|
241
|
+
str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
|
|
242
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
243
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
244
|
+
.downcase
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|