HtmlWriter.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. <?php
  2. namespace Knuckles\Scribe\Writing;
  3. use http\Exception\InvalidArgumentException;
  4. use Illuminate\Support\Facades\View;
  5. use Illuminate\Support\Str;
  6. use Knuckles\Camel\Output\OutputEndpointData;
  7. use Knuckles\Scribe\Tools\DocumentationConfig;
  8. use Knuckles\Scribe\Tools\MarkdownParser;
  9. use Knuckles\Scribe\Tools\Utils;
  10. use Knuckles\Scribe\Tools\Utils as u;
  11. use Knuckles\Scribe\Tools\WritingUtils;
  12. /**
  13. * Transforms the extracted data (endpoints YAML, API details Markdown) into a HTML site
  14. */
  15. class HtmlWriter
  16. {
  17. protected DocumentationConfig $config;
  18. protected string $baseUrl;
  19. protected string $assetPathPrefix;
  20. protected MarkdownParser $markdownParser;
  21. public function __construct(?DocumentationConfig $config = null)
  22. {
  23. $this->config = $config ?: new DocumentationConfig(config('scribe', []));
  24. $this->markdownParser = new MarkdownParser();
  25. $this->baseUrl = $this->config->get('base_url') ?? config('app.url');
  26. // If they're using the default static path,
  27. // then use '../docs/{asset}', so assets can work via Laravel app or via index.html
  28. $this->assetPathPrefix = '../docs/';
  29. if (in_array($this->config->get('type'), ['static', 'external_static'])
  30. && rtrim($this->config->get('static.output_path', ''), '/') != 'public/docs'
  31. ) {
  32. $this->assetPathPrefix = './';
  33. }
  34. }
  35. public function generate(array $groupedEndpoints, string $sourceFolder, string $destinationFolder)
  36. {
  37. $intro = $this->transformMarkdownFileToHTML($sourceFolder . '/intro.md');
  38. $auth = $this->transformMarkdownFileToHTML($sourceFolder . '/auth.md');
  39. $headingsBeforeEndpoints = $this->markdownParser->headings;
  40. $this->markdownParser->headings = [];
  41. $appendFile = rtrim($sourceFolder, '/') . '/' . 'append.md';
  42. $append = file_exists($appendFile) ? $this->transformMarkdownFileToHTML($appendFile) : '';
  43. $headingsAfterEndpoints = $this->markdownParser->headings;
  44. foreach ($groupedEndpoints as &$group) {
  45. $group['subgroups'] = collect($group['endpoints'])->groupBy('metadata.subgroup')->all();
  46. }
  47. $theme = $this->config->get('theme') ?? 'default';
  48. $output = View::make("scribe::themes.$theme.index", [
  49. 'metadata' => $this->getMetadata(),
  50. 'baseUrl' => $this->baseUrl,
  51. 'tryItOut' => $this->config->get('try_it_out'),
  52. 'intro' => $intro,
  53. 'auth' => $auth,
  54. 'groupedEndpoints' => $groupedEndpoints,
  55. 'headings' => $this->getHeadings($headingsBeforeEndpoints, $groupedEndpoints, $headingsAfterEndpoints),
  56. 'append' => $append,
  57. 'assetPathPrefix' => $this->assetPathPrefix,
  58. ])->render();
  59. if (!is_dir($destinationFolder)) {
  60. mkdir($destinationFolder, 0777, true);
  61. }
  62. file_put_contents($destinationFolder . '/index.html', $output);
  63. // Copy assets
  64. $assetsFolder = __DIR__ . '/../../resources';
  65. // Prune older versioned assets
  66. if (is_dir($destinationFolder . '/css')) {
  67. Utils::deleteDirectoryAndContents($destinationFolder . '/css');
  68. }
  69. if (is_dir($destinationFolder . '/js')) {
  70. Utils::deleteDirectoryAndContents($destinationFolder . '/js');
  71. }
  72. Utils::copyDirectory("{$assetsFolder}/images/", "{$destinationFolder}/images");
  73. $assets = [
  74. "{$assetsFolder}/css/theme-$theme.style.css" => ["$destinationFolder/css/", "theme-$theme.style.css"],
  75. "{$assetsFolder}/css/theme-$theme.print.css" => ["$destinationFolder/css/", "theme-$theme.print.css"],
  76. "{$assetsFolder}/js/theme-$theme.js" => ["$destinationFolder/js/", WritingUtils::getVersionedAsset("theme-$theme.js")],
  77. ];
  78. if ($this->config->get('try_it_out.enabled', true)) {
  79. $assets["{$assetsFolder}/js/tryitout.js"] = ["$destinationFolder/js/", WritingUtils::getVersionedAsset('tryitout.js')];
  80. }
  81. foreach ($assets as $path => [$destination, $fileName]) {
  82. if (file_exists($path)) {
  83. if (!is_dir($destination)) {
  84. mkdir($destination, 0777, true);
  85. }
  86. copy($path, $destination . $fileName);
  87. }
  88. }
  89. }
  90. protected function transformMarkdownFileToHTML(string $markdownFilePath): string
  91. {
  92. return $this->markdownParser->text(file_get_contents($markdownFilePath));
  93. }
  94. public function getMetadata(): array
  95. {
  96. // todo remove 'links' in future
  97. $links = []; // Left for backwards compat
  98. // NB:These paths are wrong for laravel type but will be set correctly by the Writer class
  99. if ($this->config->get('postman.enabled', true)) {
  100. $links[] = "<a href=\"{$this->assetPathPrefix}collection.json\">".u::trans("scribe::links.postman")."</a>";
  101. $postmanCollectionUrl = "{$this->assetPathPrefix}collection.json";
  102. }
  103. if ($this->config->get('openapi.enabled', false)) {
  104. $links[] = "<a href=\"{$this->assetPathPrefix}openapi.yaml\">".u::trans("scribe::links.openapi")."</a>";
  105. $openApiSpecUrl = "{$this->assetPathPrefix}openapi.yaml";
  106. }
  107. $auth = $this->config->get('auth');
  108. if ($auth) {
  109. if ($auth['in'] === 'bearer' || $auth['in'] === 'basic') {
  110. $auth['name'] = 'Authorization';
  111. $auth['location'] = 'header';
  112. $auth['prefix'] = ucfirst($auth['in']) . ' ';
  113. } else {
  114. $auth['location'] = $auth['in'];
  115. $auth['prefix'] = '';
  116. }
  117. }
  118. return [
  119. 'title' => $this->config->get('title') ?: config('app.name', '') . ' Documentation',
  120. 'example_languages' => $this->config->get('example_languages'),
  121. 'logo' => $this->config->get('logo') ?? false,
  122. 'last_updated' => $this->getLastUpdated(),
  123. 'auth' => $auth,
  124. 'try_it_out' => $this->config->get('try_it_out'),
  125. "postman_collection_url" => $postmanCollectionUrl ?? null,
  126. "openapi_spec_url" => $openApiSpecUrl ?? null,
  127. 'links' => array_merge($links, ['<a href="http://github.com/knuckleswtf/scribe">Documentation powered by Scribe ✍</a>']),
  128. ];
  129. }
  130. protected function getLastUpdated()
  131. {
  132. $lastUpdated = $this->config->get('last_updated', 'Last updated: {date:F j, Y}');
  133. $tokens = [
  134. "date" => fn($format) => date($format),
  135. "git" => fn($format) => match ($format) {
  136. "short" => trim(shell_exec('git rev-parse --short HEAD')),
  137. "long" => trim(shell_exec('git rev-parse HEAD')),
  138. default => throw new InvalidArgumentException("The `git` token only supports formats 'short' and 'long', but you specified $format"),
  139. },
  140. ];
  141. foreach ($tokens as $token => $resolver) {
  142. $matches = [];
  143. if(preg_match('#(\{'.$token.':(.+?)})#', $lastUpdated, $matches)) {
  144. $lastUpdated = str_replace($matches[1], $resolver($matches[2]), $lastUpdated);
  145. }
  146. }
  147. return $lastUpdated;
  148. }
  149. protected function getHeadings(array $headingsBeforeEndpoints, array $endpointsByGroupAndSubgroup, array $headingsAfterEndpoints)
  150. {
  151. $headings = [];
  152. $lastL1ElementIndex = null;
  153. foreach ($headingsBeforeEndpoints as $heading) {
  154. $element = [
  155. 'slug' => $heading['slug'],
  156. 'name' => $heading['text'],
  157. 'subheadings' => [],
  158. ];;
  159. if ($heading['level'] === 1) {
  160. $headings[] = $element;
  161. $lastL1ElementIndex = count($headings) - 1;
  162. } elseif ($heading['level'] === 2 && !is_null($lastL1ElementIndex)) {
  163. $headings[$lastL1ElementIndex]['subheadings'][] = $element;
  164. }
  165. }
  166. $headings = array_merge($headings, array_values(array_map(function ($group) {
  167. $groupSlug = Str::slug($group['name']);
  168. return [
  169. 'slug' => $groupSlug,
  170. 'name' => $group['name'],
  171. 'subheadings' => collect($group['subgroups'])->flatMap(function ($endpoints, $subgroupName) use ($groupSlug) {
  172. if ($subgroupName === "") {
  173. return $endpoints->map(fn(OutputEndpointData $endpoint) => [
  174. 'slug' => $endpoint->fullSlug(),
  175. 'name' => $endpoint->name(),
  176. 'subheadings' => []
  177. ])->values();
  178. }
  179. return [
  180. [
  181. 'slug' => "$groupSlug-" . Str::slug($subgroupName),
  182. 'name' => $subgroupName,
  183. 'subheadings' => $endpoints->map(fn($endpoint) => [
  184. 'slug' => $endpoint->fullSlug(),
  185. 'name' => $endpoint->name(),
  186. 'subheadings' => []
  187. ])->values(),
  188. ],
  189. ];
  190. })->values(),
  191. ];
  192. }, $endpointsByGroupAndSubgroup)));
  193. $lastL1ElementIndex = null;
  194. foreach ($headingsAfterEndpoints as $heading) {
  195. $element = [
  196. 'slug' => $heading['slug'],
  197. 'name' => $heading['text'],
  198. 'subheadings' => [],
  199. ];;
  200. if ($heading['level'] === 1) {
  201. $headings[] = $element;
  202. $lastL1ElementIndex = count($headings) - 1;
  203. } elseif ($heading['level'] === 2 && !is_null($lastL1ElementIndex)) {
  204. $headings[$lastL1ElementIndex]['subheadings'][] = $element;
  205. }
  206. }
  207. return $headings;
  208. }
  209. }