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