#!/usr/bin/env php getDocComment()) && strpos($doc, '@Annotation') !== false) { return true; } if (str_ends_with($class->getName(), 'Exception')) { return true; } if (str_contains($class->getName(), '_')) { return true; } return false; } if (PHP_VERSION_ID < 70400) { echo 'PHP 版本必须为 7.4 或以上'; exit(1); } function check_composer_executable(string $composer): bool { passthru($composer . ' --version 2>/dev/null', $exit_code); return $exit_code === 0; } function find_valid_root(string $current_dir, int $max_loop): string { if ($max_loop <= 0) { return ''; } if (!file_exists($current_dir . '/composer.json')) { return find_valid_root(dirname($current_dir), $max_loop - 1); } return $current_dir; } $root = find_valid_root(getcwd(), 3); if (empty($root)) { echo '找不到有效的根目录'; exit(1); } echo '根目录: ' . $root . PHP_EOL; chdir($root); if (!is_dir($root . '/src/ZM')) { echo '源码目录不存在'; exit(1); } $phpdoc_package_temp_installed = false; if (file_exists($root . '/vendor/jasny/phpdoc-parser/composer.json')) { echo '正在使用现有的 PHPDoc 解析库' . PHP_EOL; } else { echo 'PHPDoc 解析库不存在,正在尝试安装...' . PHP_EOL; echo '本次生成完成后将会自动移除' . PHP_EOL; $composers = [ 'runtime/composer', 'composer', 'php composer.phar', ]; foreach ($composers as $composer) { if (check_composer_executable($composer)) { echo '正在使用 ' . $composer . ' 安装 PHPDoc 解析库,请稍候...' . PHP_EOL; passthru("{$composer} require --quiet jasny/phpdoc-parser", $exit_code); if ($exit_code === 0) { $phpdoc_package_temp_installed = true; break; } } } if (!$phpdoc_package_temp_installed) { echo '安装失败,请手动安装:composer require jasny/phpdoc-parser' . PHP_EOL; exit(1); } } function remove_temp_phpdoc_package() { if ($GLOBALS['phpdoc_package_temp_installed']) { echo '正在移除 PHPDoc 解析库,请稍候...' . PHP_EOL; passthru('composer remove --quiet jasny/phpdoc-parser', $exit_code); if ($exit_code !== 0) { echo '移除失败,请手动移除:composer remove jasny/phpdoc-parser' . PHP_EOL; } echo '移除完成'; } } register_shutdown_function('remove_temp_phpdoc_package'); // 从这里开始是主要生成逻辑 require_once $root . '/vendor/autoload.php'; use Jasny\PhpdocParser\PhpdocParser; use Jasny\PhpdocParser\Set\PhpDocumentor; use Jasny\PhpdocParser\Tag\Summery; use ZM\Logger\ConsoleLogger; $opts = getopt('', ['show-warnings', 'show-debug']); if (array_key_exists('show-warnings', $opts)) { $show_warnings = true; } else { $show_warnings = false; } ob_logger_register(new ConsoleLogger(array_key_exists('show-debug', $opts) ? 'debug' : 'info')); $errors = []; $warnings = []; // 获取源码目录的文件遍历器 $fs = new \RecursiveDirectoryIterator($root . '/src/ZM', FilesystemIterator::SKIP_DOTS); // 初始化文档解析器 $parser = new PhpdocParser(PhpDocumentor::tags()->with([ new Summery(), ])); $metas = []; $class_count = 0; $method_count = 0; function error(string $message) { global $errors; $errors[] = $message; logger()->error($message); } function warning(string $message) { global $warnings, $show_warnings; $warnings[] = $message; if ($show_warnings) { logger()->warning($message); } } /** * 获取类的元数据 * * 包括类的注释、方法的注释、参数、返回值等 */ function get_class_metas(string $class_name, PhpdocParser $parser): array { // 尝试获取反射类 try { $class = new \ReflectionClass($class_name); } catch (\ReflectionException $e) { error('无法获取类 ' . $class_name . ' 的反射类'); return []; } // 判断是否略过该类 if (should_ignore_class($class)) { return []; } $metas = []; // 遍历类方法 foreach ($class->getMethods() as $method) { if ($method->getDeclaringClass()->getName() !== $class_name) { continue; } logger()->debug('正在解析方法:' . $class_name . '::' . $method->getName()); // 获取方法的注释并解析 $doc = $method->getDocComment(); if (!$doc) { warning('找不到文档:' . $class_name . '::' . $method->getName()); continue; } try { // 解析器对于多余空格的解析似乎有问题,这里去除一下多余的空格 $doc = preg_replace('/\s(?=\s)/', '\\1', $doc); $meta = $parser->parse($doc); } catch (\Exception $e) { error('解析失败:' . $class_name . '::' . $method->getName() . ',' . $e->getMessage()); continue; } // 少数情况解析后会带有 */,需要去除 array_walk_recursive($meta, static function (&$item) { if (is_string($item)) { $item = trim(str_replace('*/', '', $item)); } }); // 对比反射方法获取的参数和注释声明的参数 $parameters = $method->getParameters(); $params_in_doc = $meta['params'] ?? []; foreach ($parameters as $parameter) { $parameter_name = $parameter->getName(); // 不存在则添加进参数列表中 if (!isset($params_in_doc[$parameter_name])) { $params_in_doc[$parameter_name] = [ 'type' => $parameter->getType()?->getName(), 'description' => '', ]; } } // 确保所有参数都有对应的类型和描述 foreach ($params_in_doc as &$param) { if (!isset($param['type'])) { $param['type'] = 'mixed'; } if (!isset($param['description'])) { $param['description'] = ''; } } // 清除引用 unset($param); $meta['params'] = $params_in_doc; // 设定方法默认返回值 if (!isset($meta['return'])) { $meta['return'] = [ 'type' => $method->getReturnType()?->getName() ?: 'mixed', 'description' => '', ]; } // 设定默认描述 if (!isset($meta['return']['description'])) { $meta['return']['description'] = ''; } $metas[$method->getName()] = $meta; } return $metas; } /** * 将方法的元数据转换为 Markdown 格式 * * @param string $method 方法名 * @param array $meta 元数据 */ function convert_meta_to_markdown(string $method, array $meta): string { // 方法名作为标题 $markdown = '## ' . $method . "\n\n"; // 构造方法代码块 $markdown .= '```php' . "\n"; // TODO: 适配 private 等修饰符 $markdown .= 'public function ' . $method . '('; $params = []; // 添加参数 foreach ($meta['params'] as $param_name => $param_meta) { $params[] = sprintf('%s $%s', $param_meta['type'] ?? 'mixed', $param_name); } $markdown .= implode(', ', $params) . ')'; // 添加返回值 $markdown .= ': ' . $meta['return']['type']; $markdown .= "\n```\n\n"; // 方法描述 $markdown .= '### 描述' . "\n\n"; $markdown .= ($meta['description'] ?? '作者很懒,什么也没有说') . "\n\n"; // 参数 if (count($meta['params'])) { $markdown .= '### 参数' . "\n\n"; $markdown .= '| 名称 | 类型 | 描述 |' . "\n"; $markdown .= '| -------- | ---- | ----------- |' . "\n"; foreach ($meta['params'] as $param_name => $param_meta) { $markdown .= '| ' . $param_name . ' | ' . $param_meta['type'] . ' | ' . $param_meta['description'] . ' |' . "\n"; } $markdown .= "\n"; } // 返回值 $markdown .= '### 返回' . "\n\n"; $markdown .= '| 类型 | 描述 |' . "\n"; $markdown .= '| ---- | ----------- |' . "\n"; $markdown .= '| ' . $meta['return']['type'] . ' | ' . $meta['return']['description'] . ' |' . "\n"; return $markdown; } function generate_sidebar_config(string $title, array $items): array { return [ 'title' => $title, 'collapsable' => true, 'children' => $items, ]; } logger()->info('开始生成!'); // 遍历类并将元数据添加至数组中 foreach (new \RecursiveIteratorIterator($fs) as $file) { if (!$file->isFile()) { continue; } $path = $file->getPathname(); // 过滤不包含类的文件 $tokens = token_get_all(file_get_contents($path)); $found = false; foreach ($tokens as $k => $token) { if (!is_array($token)) { continue; } if ($token[0] === T_CLASS && $tokens[$k - 2][0] !== T_NEW) { $found = true; break; } if ($k >= 100) { break; } } if (!$found) { continue; } // 获取完整类名 $path = str_replace($root . '/', '', $path); $class = str_replace(['.php', 'src/', '/'], ['', '', '\\'], $path); logger()->debug('正在解析类:' . $class); $meta = get_class_metas($class, $parser); // 忽略不包含任何方法的类 if (empty($meta)) { continue; } $metas[$class] = $meta; ++$class_count; $method_count += count($meta); } // 将元数据转换为 Markdown $markdown = []; foreach ($metas as $class => $class_metas) { $markdown[$class] = []; // 将类名作为页面大标题 $markdown[$class]['class'] = '# ' . $class; foreach ($class_metas as $method => $meta) { $markdown[$class][$method] = convert_meta_to_markdown($method, $meta); } } // 生成文档 $docs = $root . '/docs/api/'; foreach ($markdown as $class => $methods) { $file = $docs . str_replace('\\', '/', $class) . '.md'; // 确保目录存在 if (!file_exists(dirname($file)) && !mkdir($concurrent_directory = dirname($file), 0777, true) && !is_dir($concurrent_directory)) { throw new \RuntimeException(sprintf('无法建立目录 %s', $concurrent_directory)); } logger()->debug('正在生成文档:' . $file); $text = implode("\n\n", $methods); file_put_contents($file, $text); } // 生成文档目录 $children = array_keys($markdown); $children = str_replace('\\', '/', $children); $class_tree = []; foreach ($children as $child) { $parent = dirname($child); $class_tree[$parent][] = $child; } ksort($class_tree); $config = 'module.exports = ['; foreach ($class_tree as $parent => $children) { $encode = json_encode(generate_sidebar_config($parent, $children)); $encode = str_replace('\/', '/', $encode); $config .= $encode . ','; } $config = rtrim($config, ','); $config .= ']'; $file = $root . '/docs/.vuepress/api.js'; file_put_contents($file, $config); if (count($warnings)) { logger()->warning('生成过程中发现 ' . count($warnings) . ' 次警告'); if (!$show_warnings) { logger()->info('生成器默认忽略了警告,你可以通过 gendoc --show-warnings 来查看警告'); } } if (count($errors)) { logger()->error('生成过程中发现错误:'); foreach ($errors as $error) { logger()->error($error); } } logger()->notice('API 文档生成完毕'); logger()->notice(sprintf('共生成 %d 个类,共 %d 个方法', $class_count, $method_count)); // 完成生成,进行善后 exit(0);