Skip to content

Commit da6bf7d

Browse files
nficanoclaude
andcommitted
chore: add Markdown API doc generation tooling
Add scripts/gen-api-docs.php and a composer docs:api script to render the API reference into docs/api/; gitignore the generated output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6337c5b commit da6bf7d

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/.psalm/
88
/coverage.xml
99
/coverage/
10+
/docs/api/
1011
/.idea/
1112
/.vscode/*
1213
!/.vscode/settings.json

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"test": "phpunit --testdox",
7070
"coverage": "phpunit --coverage-text --coverage-clover=coverage.xml",
7171
"audit": "composer audit",
72+
"docs:api": "php scripts/gen-api-docs.php",
7273
"gates": [
7374
"@lint",
7475
"@stan",

scripts/gen-api-docs.php

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// API doc generator: walks src/, extracts docblocks + signatures via PhpToken,
6+
// emits one Markdown file per top-level namespace dir (plus index.md).
7+
// Usage: composer docs:api (or: php scripts/gen-api-docs.php)
8+
9+
$root = dirname(__DIR__);
10+
$srcDir = $root . '/src';
11+
$outDir = $root . '/docs/api';
12+
13+
if (!is_dir($srcDir)) {
14+
fwrite(STDERR, "src/ not found at {$srcDir}\n");
15+
exit(1);
16+
}
17+
if (!is_dir($outDir) && !mkdir($outDir, 0o755, true) && !is_dir($outDir)) {
18+
fwrite(STDERR, "failed to create {$outDir}\n");
19+
exit(1);
20+
}
21+
22+
/** @return list<string> */
23+
function php_files(string $dir): array
24+
{
25+
$out = [];
26+
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
27+
foreach ($it as $f) {
28+
if ($f->isFile() && $f->getExtension() === 'php') {
29+
$out[] = $f->getPathname();
30+
}
31+
}
32+
sort($out);
33+
return $out;
34+
}
35+
36+
function clean_doc(string $raw): string
37+
{
38+
$raw = preg_replace('#^/\*\*|\*/$#', '', trim($raw)) ?? '';
39+
$lines = preg_split('/\R/', $raw) ?: [];
40+
$out = [];
41+
foreach ($lines as $l) {
42+
$out[] = rtrim(preg_replace('/^\s*\*\s?/', '', $l) ?? $l);
43+
}
44+
return trim(implode("\n", $out));
45+
}
46+
47+
/** @return array{namespace:string,decls:list<array<string,mixed>>} */
48+
function parse_file(string $path): array
49+
{
50+
$tokens = PhpToken::tokenize(file_get_contents($path) ?: '');
51+
$ns = '';
52+
$decls = [];
53+
$doc = null;
54+
$n = count($tokens);
55+
56+
for ($i = 0; $i < $n; $i++) {
57+
$t = $tokens[$i];
58+
59+
if ($t->id === T_DOC_COMMENT) {
60+
$doc = clean_doc($t->text);
61+
continue;
62+
}
63+
if ($t->id === T_WHITESPACE || $t->id === T_COMMENT) {
64+
continue;
65+
}
66+
67+
if ($t->id === T_NAMESPACE) {
68+
$name = '';
69+
for ($j = $i + 1; $j < $n; $j++) {
70+
$tj = $tokens[$j];
71+
if (in_array($tj->id, [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) {
72+
$name .= $tj->text;
73+
} elseif ($tj->text === ';' || $tj->text === '{') {
74+
break;
75+
}
76+
}
77+
$ns = trim($name, '\\');
78+
$doc = null;
79+
continue;
80+
}
81+
82+
if (in_array($t->id, [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) {
83+
// skip `new class { ... }`
84+
$p = $i - 1;
85+
while ($p >= 0 && $tokens[$p]->id === T_WHITESPACE) {
86+
$p--;
87+
}
88+
if ($p >= 0 && $tokens[$p]->id === T_NEW) {
89+
continue;
90+
}
91+
$kind = str_replace('t_', '', strtolower(token_name($t->id)));
92+
$name = '';
93+
for ($j = $i + 1; $j < $n; $j++) {
94+
if ($tokens[$j]->id === T_STRING) {
95+
$name = $tokens[$j]->text;
96+
break;
97+
}
98+
}
99+
if ($name === '') {
100+
continue;
101+
}
102+
$decls[] = ['kind' => $kind, 'name' => $name, 'doc' => $doc ?? '', 'members' => []];
103+
$doc = null;
104+
continue;
105+
}
106+
107+
if ($t->id === T_FUNCTION) {
108+
// collect leading modifiers
109+
$mods = [];
110+
$b = $i - 1;
111+
while ($b >= 0) {
112+
$tb = $tokens[$b];
113+
if ($tb->id === T_WHITESPACE) {
114+
$b--;
115+
continue;
116+
}
117+
if (in_array($tb->id, [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_ABSTRACT, T_FINAL, T_READONLY], true)) {
118+
$mods[] = strtolower($tb->text);
119+
$b--;
120+
continue;
121+
}
122+
break;
123+
}
124+
$mods = array_reverse($mods);
125+
126+
// function name
127+
$j = $i + 1;
128+
while ($j < $n && $tokens[$j]->id === T_WHITESPACE) {
129+
$j++;
130+
}
131+
if ($j >= $n || $tokens[$j]->id !== T_STRING) {
132+
$doc = null;
133+
continue; // closure / arrow fn
134+
}
135+
$fname = $tokens[$j]->text;
136+
137+
// params
138+
$k = $j + 1;
139+
while ($k < $n && $tokens[$k]->text !== '(') {
140+
$k++;
141+
}
142+
$depth = 0;
143+
$params = '';
144+
for (; $k < $n; $k++) {
145+
$params .= $tokens[$k]->text;
146+
if ($tokens[$k]->text === '(') {
147+
$depth++;
148+
} elseif ($tokens[$k]->text === ')' && --$depth === 0) {
149+
$k++;
150+
break;
151+
}
152+
}
153+
// return type until { or ;
154+
$ret = '';
155+
for (; $k < $n; $k++) {
156+
if ($tokens[$k]->text === '{' || $tokens[$k]->text === ';') {
157+
break;
158+
}
159+
$ret .= $tokens[$k]->text;
160+
}
161+
$sig = preg_replace('/\s+/', ' ', trim(implode(' ', $mods) . ' function ' . $fname . $params . $ret));
162+
163+
if (!empty($decls)) {
164+
$decls[count($decls) - 1]['members'][] = ['name' => $fname, 'sig' => $sig, 'doc' => $doc ?? ''];
165+
} else {
166+
$decls[] = ['kind' => 'function', 'name' => $fname, 'sig' => $sig, 'doc' => $doc ?? '', 'members' => []];
167+
}
168+
$doc = null;
169+
}
170+
}
171+
172+
return ['namespace' => $ns, 'decls' => $decls];
173+
}
174+
175+
function render_decl(array $d): string
176+
{
177+
$out = "### `" . $d['kind'] . ' ' . $d['name'] . "`\n\n";
178+
if (!empty($d['doc'])) {
179+
$out .= $d['doc'] . "\n\n";
180+
}
181+
if ($d['kind'] === 'function' && isset($d['sig'])) {
182+
$out .= "```php\n" . $d['sig'] . "\n```\n\n";
183+
}
184+
foreach ($d['members'] as $m) {
185+
$out .= "#### `" . $m['name'] . "`\n\n```php\n" . $m['sig'] . "\n```\n\n";
186+
if (!empty($m['doc'])) {
187+
$out .= $m['doc'] . "\n\n";
188+
}
189+
}
190+
return $out;
191+
}
192+
193+
// Group by top-level namespace dir under src/
194+
$files = php_files($srcDir);
195+
$groups = [];
196+
$rootDecls = [];
197+
foreach ($files as $path) {
198+
$rel = substr($path, strlen($srcDir) + 1);
199+
$parsed = parse_file($path);
200+
$parsed['_rel'] = $rel;
201+
if (str_contains($rel, '/')) {
202+
$groups[explode('/', $rel, 2)[0]][] = $parsed;
203+
} else {
204+
$rootDecls[] = $parsed;
205+
}
206+
}
207+
ksort($groups);
208+
209+
$index = "# PHP SDK API Reference\n\n";
210+
$index .= "Auto-generated from PHPDoc blocks in `src/`. Regenerate with `composer docs:api`.\n\n";
211+
$index .= "## Namespaces\n\n";
212+
213+
foreach ($groups as $top => $entries) {
214+
$slug = strtolower($top);
215+
usort($entries, fn ($a, $b) => strcmp($a['_rel'], $b['_rel']));
216+
$content = "# Arcp\\{$top}\n\n";
217+
foreach ($entries as $e) {
218+
if (empty($e['decls'])) {
219+
continue;
220+
}
221+
$content .= "## `" . $e['namespace'] . "` &mdash; `" . $e['_rel'] . "`\n\n";
222+
foreach ($e['decls'] as $d) {
223+
$content .= render_decl($d);
224+
}
225+
}
226+
file_put_contents($outDir . '/' . $slug . '.md', $content);
227+
$index .= "- [Arcp\\{$top}](./{$slug}.md)\n";
228+
}
229+
230+
if (!empty($rootDecls)) {
231+
$content = "# Arcp (root)\n\n";
232+
foreach ($rootDecls as $e) {
233+
if (empty($e['decls'])) {
234+
continue;
235+
}
236+
$content .= "## `" . $e['namespace'] . "` &mdash; `" . $e['_rel'] . "`\n\n";
237+
foreach ($e['decls'] as $d) {
238+
$content .= render_decl($d);
239+
}
240+
}
241+
file_put_contents($outDir . '/_root.md', $content);
242+
$index .= "- [Arcp (root)](./_root.md)\n";
243+
}
244+
245+
file_put_contents($outDir . '/index.md', $index);
246+
$count = count($groups) + (empty($rootDecls) ? 0 : 1) + 1;
247+
echo "Wrote {$count} markdown files to docs/api/\n";

0 commit comments

Comments
 (0)