Skip to content

Commit 995722e

Browse files
nficanoclaude
andcommitted
chore: replace YARD docs with Markdown API generation
Drop .yardopts and the yard-based docs task; add a docs:api Rake task backed by scripts/gen-api-docs.rb that renders Markdown into docs/api/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 825bee9 commit 995722e

3 files changed

Lines changed: 201 additions & 12 deletions

File tree

.yardopts

Lines changed: 0 additions & 9 deletions
This file was deleted.

Rakefile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ task :diagrams do
2424
sh 'bin/render-diagrams.sh'
2525
end
2626

27-
desc 'Build YARD docs into docs/api/'
28-
task :docs do
29-
sh 'yard doc'
27+
namespace :docs do
28+
desc 'Generate Markdown API reference into docs/api/'
29+
task :api do
30+
sh 'ruby scripts/gen-api-docs.rb'
31+
end
3032
end
3133

34+
desc 'Alias for docs:api'
35+
task docs: 'docs:api'
36+
3237
task default: %i[spec rubocop]

scripts/gen-api-docs.rb

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Generates Markdown API reference docs from YARD-style comments in lib/.
5+
# Pure stdlib so it runs on Ruby 2.6+.
6+
#
7+
# Walks lib/**/*.rb. For each file, extracts:
8+
# - top-of-file comment block (module/file overview)
9+
# - each module/class/def declaration with the preceding comment block
10+
#
11+
# Emits one Markdown file per source file to docs/api/<relative_path>.md plus
12+
# a docs/api/index.md listing every page.
13+
14+
require 'fileutils'
15+
require 'pathname'
16+
17+
ROOT = Pathname.new(File.expand_path('..', __dir__))
18+
LIB = ROOT.join('lib')
19+
OUT = ROOT.join('docs', 'api')
20+
21+
# Strip a leading "# " or "#" from a comment line, preserving inner spaces.
22+
def strip_comment(line)
23+
line.sub(/\A\s*#\s?/, '').rstrip
24+
end
25+
26+
# Read the contiguous comment block ending at index `idx - 1` (lines array).
27+
# Returns (start_index, [stripped_lines]). Empty array if no block.
28+
def read_block_before(lines, idx)
29+
i = idx - 1
30+
collected = []
31+
while i >= 0 && lines[i] =~ /\A\s*#/
32+
collected.unshift(strip_comment(lines[i]))
33+
i -= 1
34+
end
35+
# Skip frozen_string_literal / encoding / shebang style magic comments at top
36+
collected.reject! { |l| l =~ /\A(frozen_string_literal|encoding|warn_indent):/ }
37+
[i + 1, collected]
38+
end
39+
40+
# Top-of-file comment block: skip shebang/magic-comments, then take leading #.
41+
def file_overview(lines)
42+
i = 0
43+
i += 1 if lines[i] =~ /\A#!/
44+
while lines[i] && lines[i] =~ /\A\s*#\s*(frozen_string_literal|encoding|warn_indent):/i
45+
i += 1
46+
end
47+
i += 1 while lines[i] && lines[i].strip.empty?
48+
block = []
49+
while lines[i] && lines[i] =~ /\A\s*#/
50+
block << strip_comment(lines[i])
51+
i += 1
52+
end
53+
block
54+
end
55+
56+
DECL_RE = /\A(\s*)(module|class|def)\s+([^\s(;]+)(.*)\z/.freeze
57+
58+
# For `def` signatures that span multiple physical lines (trailing `,` or open
59+
# paren without matching close), keep appending until balanced.
60+
def join_continued_signature(lines, idx)
61+
sig = lines[idx].rstrip
62+
i = idx
63+
loop do
64+
opens = sig.count('(') + sig.count('[')
65+
closes = sig.count(')') + sig.count(']')
66+
trailing_comma = sig =~ /,\s*\z/
67+
break unless trailing_comma || opens > closes
68+
i += 1
69+
break unless lines[i]
70+
sig = "#{sig}\n#{lines[i].rstrip}"
71+
end
72+
sig
73+
end
74+
75+
# Build a stack of enclosing module/class names by tracking `module`/`class`/`end`.
76+
def parse_declarations(lines)
77+
results = []
78+
stack = []
79+
lines.each_with_index do |raw, idx|
80+
line = raw.rstrip
81+
next if line.strip.empty?
82+
if (m = line.match(DECL_RE))
83+
kind = m[2]
84+
name = m[3]
85+
tail = m[4].to_s.rstrip
86+
_start, comment = read_block_before(lines, idx)
87+
full = kind == 'def' ? join_continued_signature(lines, idx).sub(/\A\s+/, '') : "#{kind} #{name}#{tail}"
88+
sig = full
89+
qualified = if kind == 'def'
90+
sep = name.start_with?('self.') ? '.' : '#'
91+
base = stack.join('::')
92+
base.empty? ? sig : "#{base}#{sep}#{name.sub(/\Aself\./, '')}"
93+
else
94+
(stack + [name]).join('::')
95+
end
96+
results << { kind: kind, name: name, qualified: qualified, signature: sig.strip, comment: comment }
97+
stack.push(name) if kind == 'module' || kind == 'class'
98+
elsif line =~ /\A\s*end\b/ && !stack.empty?
99+
# crude: pop on bare `end`. Method `end`s also pop nothing since we don't push for def.
100+
# We only pop when the `end` is at the indentation of the top stack frame's owner;
101+
# to avoid building a full parser we just pop on any bare `end` that isn't inside
102+
# a `def` block. Since we don't track def open/close, this overpops; mitigate by
103+
# only popping when the line is exactly "end" (no trailing modifier) and dedented
104+
# to column 0..N matching stack depth heuristically.
105+
indent = line[/\A\s*/].length
106+
expected = (stack.length - 1) * 2
107+
stack.pop if indent <= expected
108+
end
109+
end
110+
results
111+
end
112+
113+
def render_comment(lines)
114+
return '' if lines.empty?
115+
# Split into prose body vs YARD tag groups. A tag group starts at @tag and
116+
# absorbs subsequent indented continuation lines.
117+
body = []
118+
tags = []
119+
current = nil
120+
lines.each do |l|
121+
if l =~ /\A@\w/
122+
tags << (current = [l])
123+
elsif current && (l.start_with?(' ') || l.strip.empty?)
124+
current << l
125+
else
126+
current = nil
127+
body << l
128+
end
129+
end
130+
out = body.join("\n").strip
131+
unless tags.empty?
132+
out += "\n\n" unless out.empty?
133+
out += tags.map do |grp|
134+
head = grp[0]
135+
rest = grp[1..].to_a.reject { |x| x.strip.empty? }
136+
if rest.empty?
137+
"- `#{head}`"
138+
else
139+
code = rest.map { |x| x.sub(/\A /, '') }.join("\n")
140+
"**`#{head}`**\n\n```ruby\n#{code}\n```"
141+
end
142+
end.join("\n\n")
143+
end
144+
out
145+
end
146+
147+
def render_file(rel_path, lines)
148+
overview = file_overview(lines)
149+
decls = parse_declarations(lines)
150+
io = +""
151+
io << "# `#{rel_path}`\n\n"
152+
unless overview.empty?
153+
io << render_comment(overview) << "\n\n"
154+
end
155+
if decls.empty?
156+
io << "_No documented declarations._\n"
157+
return io
158+
end
159+
decls.each do |d|
160+
heading = d[:kind] == 'def' ? d[:qualified] : "#{d[:kind].capitalize} `#{d[:qualified]}`"
161+
io << "## #{heading}\n\n"
162+
io << "```ruby\n#{d[:signature]}\n```\n\n"
163+
body = render_comment(d[:comment])
164+
io << (body.empty? ? "_(undocumented)_\n\n" : body + "\n\n")
165+
end
166+
io
167+
end
168+
169+
FileUtils.rm_rf(OUT)
170+
FileUtils.mkdir_p(OUT)
171+
172+
files = Dir.glob(LIB.join('**', '*.rb')).sort
173+
pages = []
174+
175+
files.each do |path|
176+
rel = Pathname.new(path).relative_path_from(ROOT).to_s
177+
lines = File.readlines(path, chomp: true)
178+
md = render_file(rel, lines)
179+
out_rel = Pathname.new(path).relative_path_from(LIB).sub_ext('.md').to_s
180+
out_path = OUT.join(out_rel)
181+
FileUtils.mkdir_p(out_path.dirname)
182+
File.write(out_path, md)
183+
pages << out_rel
184+
end
185+
186+
index = +"# API Reference\n\nGenerated from `lib/` sources. One page per source file.\n\n"
187+
pages.sort.each do |p|
188+
title = p.sub(/\.md\z/, '')
189+
index << "- [`#{title}`](./#{p})\n"
190+
end
191+
File.write(OUT.join('index.md'), index)
192+
193+
puts "Generated #{pages.length} pages + index.md at #{OUT.relative_path_from(ROOT)}/"

0 commit comments

Comments
 (0)