diff --git a/.deployer/scripts/deploy.sh b/.deployer/scripts/deploy.sh index 5ce4b4d..b412bef 100644 --- a/.deployer/scripts/deploy.sh +++ b/.deployer/scripts/deploy.sh @@ -126,8 +126,8 @@ if [[ $framework == "laravel" ]]; then # ---- echo "→ Optimizing..." + "${DEPLOYER_PHP}" artisan optimize:clear "${DEPLOYER_PHP}" artisan optimize - fi # Symfony (uncomment as needed) diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index f5371ec..a9eeb2b 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -1017,7 +1017,7 @@ 'database.redis.cache.backoff_base' => 'integer', 'database.redis.cache.backoff_cap' => 'integer', 'docs.path' => 'string', - 'docs.cheat_sheet.cache_ttl_seconds' => 'integer', + 'docs.command_index.cache_ttl_seconds' => 'integer', 'docs.github.repo' => 'string', 'docs.github.branch' => 'string', 'docs.github.dir' => 'string', @@ -1527,7 +1527,7 @@ 'database.redis.cache.backoff_base' => 'integer', 'database.redis.cache.backoff_cap' => 'integer', 'docs.path' => 'string', - 'docs.cheat_sheet.cache_ttl_seconds' => 'integer', + 'docs.command_index.cache_ttl_seconds' => 'integer', 'docs.github.repo' => 'string', 'docs.github.branch' => 'string', 'docs.github.dir' => 'string', @@ -2037,7 +2037,7 @@ 'database.redis.cache.backoff_base' => 'integer', 'database.redis.cache.backoff_cap' => 'integer', 'docs.path' => 'string', - 'docs.cheat_sheet.cache_ttl_seconds' => 'integer', + 'docs.command_index.cache_ttl_seconds' => 'integer', 'docs.github.repo' => 'string', 'docs.github.branch' => 'string', 'docs.github.dir' => 'string', @@ -2369,7 +2369,7 @@ 'database.redis.default.max_retries','database.redis.default.backoff_algorithm','database.redis.default.backoff_base','database.redis.default.backoff_cap','database.redis.cache.url', 'database.redis.cache.host','database.redis.cache.username','database.redis.cache.password','database.redis.cache.port','database.redis.cache.database', 'database.redis.cache.max_retries','database.redis.cache.backoff_algorithm','database.redis.cache.backoff_base','database.redis.cache.backoff_cap','docs.path', -'docs.cheat_sheet.cache_ttl_seconds','docs.github.repo','docs.github.branch','docs.github.dir','filesystems.default', +'docs.command_index.cache_ttl_seconds','docs.github.repo','docs.github.branch','docs.github.dir','filesystems.default', 'filesystems.disks.local.driver','filesystems.disks.local.root','filesystems.disks.local.serve','filesystems.disks.local.throw','filesystems.disks.local.report', 'filesystems.disks.public.driver','filesystems.disks.public.root','filesystems.disks.public.url','filesystems.disks.public.visibility','filesystems.disks.public.throw', 'filesystems.disks.public.report','filesystems.disks.s3.driver','filesystems.disks.s3.key','filesystems.disks.s3.secret','filesystems.disks.s3.region', @@ -2423,10 +2423,10 @@ 'signed','throttle','verified',); registerArgumentsSet('routes', 'boost.browser-logs','default-livewire.update', -'livewire.upload-file','livewire.preview-file','home','docs.show','cheat-sheet', +'livewire.upload-file','livewire.preview-file','home','docs.show','command-index', 'storage.local',); registerArgumentsSet('views', -'8c026b058f5337eae33f20fdb97d2c88::docs-viewer','cheat-sheet','components.docs.alert','components.docs.code-block','components.docs.content', +'8c026b058f5337eae33f20fdb97d2c88::docs-viewer','command-index','components.docs.alert','components.docs.code-block','components.docs.content', 'components.docs.headings','components.docs.toc','components.layouts.docs','docs-viewer','docs.alert', 'docs.code-block','docs.content','docs.headings','docs.toc','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.github', 'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.reddit','e60dd9d2c3a62d619c9acb38f20d5aa5::icon.x','f3dff5f3846978e0b3612bcd64f9871e::docs.alert','f3dff5f3846978e0b3612bcd64f9871e::docs.code-block','f3dff5f3846978e0b3612bcd64f9871e::docs.content', diff --git a/app/Console/Commands/DocsClearCommand.php b/app/Console/Commands/DocsClearCommand.php new file mode 100644 index 0000000..5ce259e --- /dev/null +++ b/app/Console/Commands/DocsClearCommand.php @@ -0,0 +1,24 @@ +clear(); + + $this->components->info('Docs output cleared.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php deleted file mode 100644 index ced89ba..0000000 --- a/app/Http/Controllers/HomeController.php +++ /dev/null @@ -1,18 +0,0 @@ -, + * description: string + * }> + * }> + * }> + */ + public array $sections = []; + + /** + * @var array, + * description: string + * }> + * }> + */ + public array $groups = []; + + public int $commandCount = 0; + + public int $aliasCount = 0; + + /** + * @var array + * }> + */ + public array $toc = []; + + public function mount( + DocsOutputCacheService $docsOutputCache, + TocParserService $tocParser, + CommandIndexService $sheet, + ): void { + /** @var array{ + * toc: array + * }>, + * sections: array, + * description: string + * }> + * }> + * }>, + * groups: array, + * description: string + * }> + * }>, + * commandCount: int, + * aliasCount: int + * } $payload + */ + $payload = $docsOutputCache->remember( + $docsOutputCache->key('command-index'), + function () use ($tocParser, $sheet): array { + /** @var array{ + * sections: array, + * description: string + * }> + * }> + * }>, + * groups: array, + * description: string + * }> + * }>, + * commandCount: int, + * aliasCount: int + * } $commandIndex + */ + $commandIndex = $sheet->build(); + + return [ + 'toc' => $tocParser->parse(), + 'sections' => $commandIndex['sections'], + 'groups' => $commandIndex['groups'], + 'commandCount' => $commandIndex['commandCount'], + 'aliasCount' => $commandIndex['aliasCount'], + ]; + }, + ); + + $this->hydrateFromPayload($payload); + } + + public function render(): View + { + return view('livewire.command-index'); + } + + /** + * @param array{ + * toc: array + * }>, + * sections: array, + * description: string + * }> + * }> + * }>, + * groups: array, + * description: string + * }> + * }>, + * commandCount: int, + * aliasCount: int + * } $payload + */ + private function hydrateFromPayload(array $payload): void + { + $this->toc = $payload['toc']; + $this->sections = $payload['sections']; + $this->groups = $payload['groups']; + $this->commandCount = $payload['commandCount']; + $this->aliasCount = $payload['aliasCount']; + } +} diff --git a/app/Livewire/DocsViewer.php b/app/Livewire/DocsViewer.php index 276d9a5..c534a06 100644 --- a/app/Livewire/DocsViewer.php +++ b/app/Livewire/DocsViewer.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Services\DocsOutputCacheService; use App\Services\DocumentService; use App\Services\TocParserService; use Illuminate\Contracts\View\View; @@ -34,43 +35,108 @@ final class DocsViewer extends Component public array $toc = []; public function mount( + DocsOutputCacheService $docsOutputCache, TocParserService $tocParser, DocumentService $documentService, ?string $page = null, ): void { - $this->toc = $tocParser->parse(); + $routeIdentity = $page === null || $page === '' ? 'home' : "docs:{$page}"; + + /** @var array{ + * page: string, + * title: string, + * content: string, + * headings: array, + * toc: array + * }> + * } $payload + */ + $payload = $docsOutputCache->remember( + $docsOutputCache->key('docs-viewer', [$routeIdentity]), + fn (): array => $this->buildPayload($tocParser, $documentService, $page), + ); + + $this->hydrateFromPayload($payload); + } + + public function render(): View + { + return view('livewire.docs-viewer'); + } + + /** + * @return array{ + * page: string, + * title: string, + * content: string, + * headings: array, + * toc: array + * }> + * } + */ + private function buildPayload( + TocParserService $tocParser, + DocumentService $documentService, + ?string $page, + ): array { + $toc = $tocParser->parse(); - // No page specified - show README or redirect to first doc if ($page === null || $page === '') { $readme = $documentService->loadReadme(); - if ($readme !== null) { - $this->title = $readme['title']; - $this->content = $readme['content']; - $this->headings = $readme['headings']; - $this->page = ''; - - return; + if ($readme === null) { + throw new HttpResponseException(new RedirectResponse('/', 301)); } - throw new HttpResponseException(new RedirectResponse('/', 301)); + return [ + 'page' => '', + 'title' => $readme['title'], + 'content' => $readme['content'], + 'headings' => $readme['headings'], + 'toc' => $toc, + ]; } - // Load the requested document $document = $documentService->load($page); if ($document === null) { throw new HttpResponseException(new RedirectResponse('/', 301)); } - $this->page = $page; - $this->title = $document['title']; - $this->content = $document['content']; - $this->headings = $document['headings']; + return [ + 'page' => $page, + 'title' => $document['title'], + 'content' => $document['content'], + 'headings' => $document['headings'], + 'toc' => $toc, + ]; } - public function render(): View + /** + * @param array{ + * page: string, + * title: string, + * content: string, + * headings: array, + * toc: array + * }> + * } $payload + */ + private function hydrateFromPayload(array $payload): void { - return view('livewire.docs-viewer'); + $this->page = $payload['page']; + $this->title = $payload['title']; + $this->content = $payload['content']; + $this->headings = $payload['headings']; + $this->toc = $payload['toc']; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6360c78..6e484d9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; +use App\Services\DocsOutputCacheService; use App\Services\DocsPathService; use App\Services\DocumentService; use App\Services\HeadingExtractorService; @@ -20,6 +21,7 @@ final class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(DocsPathService::class); + $this->app->singleton(DocsOutputCacheService::class); $this->app->singleton(MarkdownService::class); $this->app->singleton(TocParserService::class); $this->app->singleton(HeadingExtractorService::class); @@ -31,6 +33,6 @@ public function register(): void */ public function boot(): void { - // + $this->optimizes(clear: 'docs:clear', key: 'docs'); } } diff --git a/app/Services/CommandCheatSheetService.php b/app/Services/CommandIndexService.php similarity index 53% rename from app/Services/CommandCheatSheetService.php rename to app/Services/CommandIndexService.php index 5456cc4..2f50ff3 100644 --- a/app/Services/CommandCheatSheetService.php +++ b/app/Services/CommandIndexService.php @@ -7,7 +7,7 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; -final readonly class CommandCheatSheetService +final readonly class CommandIndexService { public function __construct( private DocsPathService $docsPath, @@ -15,14 +15,27 @@ public function __construct( /** * @return array{ + * sections: array, + * description: string + * }> + * }> + * }>, * groups: array, - * description: string, - * links: array + * description: string * }> * }>, * commandCount: int, @@ -32,17 +45,15 @@ public function __construct( public function build(): array { $commands = $this->discoverCommands(); - $links = $this->resolveCommandLinks(); $groups = []; $aliasCount = 0; foreach ($commands as $command) { - $command['links'] = $links[$command['primary']] ?? []; $namespace = $this->extractNamespace($command['primary']); if (! array_key_exists($namespace, $groups)) { $groups[$namespace] = [ - 'name' => ucfirst($namespace), + 'name' => $this->formatNamespaceName($namespace), 'count' => 0, 'commands' => [], ]; @@ -54,8 +65,10 @@ public function build(): array } ksort($groups); + $sections = $this->buildRelatedSections($groups); return [ + 'sections' => $sections, 'groups' => array_values($groups), 'commandCount' => count($commands), 'aliasCount' => $aliasCount, @@ -66,8 +79,7 @@ public function build(): array * @return array, - * description: string, - * links?: array + * description: string * }> */ private function discoverCommands(): array @@ -183,97 +195,137 @@ private function parseCommandMetadata(string $path): ?array ]; } - /** - * Scan documentation markdown files to map commands to their doc sections. - * - * @return array> - */ - private function resolveCommandLinks(): array + private function extractNamespace(string $command): string { - $docsPath = $this->docsPath->path(); - - if (! is_dir($docsPath)) { - return []; - } - - $files = glob($docsPath.'/*.md'); + $segments = explode(':', $command, 2); - if ($files === false) { - return []; + if ($segments[0] === '') { + return 'general'; } - sort($files); - - /** @var array> $seen */ - $seen = []; + return $segments[0]; + } - foreach ($files as $file) { - $page = basename($file, '.md'); + private function formatNamespaceName(string $namespace): string + { + return match ($namespace) { + 'aws' => 'AWS', + 'cf' => 'Cloudflare', + 'do' => 'DigitalOcean', + 'mariadb' => 'MariaDB', + 'php' => 'PHP', + 'postgresql' => 'PostgreSQL', + default => ucfirst($namespace), + }; + } - if ($page === 'documentation' || $page === 'README') { - continue; + /** + * @param array, + * description: string + * }> + * }> $groups + * @return array, + * description: string + * }> + * }> + * }> + */ + private function buildRelatedSections(array $groups): array + { + /** @var array, + * description: string + * }> + * }> + * }> $sections + */ + $sections = []; + + foreach ($groups as $namespace => $group) { + $sectionName = $this->resolveNamespaceSection($namespace); + + if (! array_key_exists($sectionName, $sections)) { + $sections[$sectionName] = [ + 'name' => $sectionName, + 'count' => 0, + 'namespaceCount' => 0, + 'groups' => [], + ]; } - $content = file_get_contents($file); + $sections[$sectionName]['groups'][] = $group; + $sections[$sectionName]['count'] += $group['count']; + $sections[$sectionName]['namespaceCount']++; + } + + $orderedSections = []; - if ($content === false) { + foreach ($this->sectionOrder() as $sectionName) { + if (! array_key_exists($sectionName, $sections)) { continue; } - $currentAnchor = null; - - foreach (explode("\n", $content) as $line) { - if (preg_match('/^#{2,3}\s+(.+)$/', $line, $headingMatch) === 1) { - $currentAnchor = $this->slugify($headingMatch[1]); - } - - if ($currentAnchor === null) { - continue; - } - - if (preg_match_all('/deployer\s+([a-z][a-z0-9:]+)\b/', $line, $commandMatches) !== 0) { - foreach ($commandMatches[1] as $commandName) { - $key = $page.'#'.$currentAnchor; - - if (! array_key_exists($commandName, $seen)) { - $seen[$commandName] = []; - } - - $seen[$commandName][$key] = [ - 'page' => $page, - 'anchor' => $currentAnchor, - ]; - } - } - } + $orderedSections[] = $sections[$sectionName]; + unset($sections[$sectionName]); } - $links = []; - - foreach ($seen as $commandName => $references) { - $links[$commandName] = array_values($references); + if ($sections !== []) { + foreach ($sections as $section) { + $orderedSections[] = $section; + } } - return $links; + return $orderedSections; } - private function slugify(string $text): string + private function resolveNamespaceSection(string $namespace): string { - $slug = mb_strtolower($text); - $slug = (string) preg_replace('/[^a-z0-9\s-]/', '', $slug); - $slug = (string) preg_replace('/[\s-]+/', '-', $slug); - - return trim($slug, '-'); + return match ($namespace) { + 'server', 'site' => 'Server & Site Operations', + 'cron', 'supervisor' => 'Scheduling & Process Control', + 'nginx', 'php' => 'Web Runtime Services', + 'mariadb', 'postgresql', 'redis', 'memcached' => 'Data Services', + 'scaffold' => 'Scaffolding', + 'aws', 'cf', 'do' => 'Cloud Providers', + default => 'Other', + }; } - private function extractNamespace(string $command): string + /** + * @return array + */ + private function sectionOrder(): array { - $segments = explode(':', $command, 2); - - if ($segments[0] === '') { - return 'general'; - } - - return $segments[0]; + return [ + 'Server & Site Operations', + 'Scheduling & Process Control', + 'Web Runtime Services', + 'Data Services', + 'Scaffolding', + 'Cloud Providers', + 'Other', + ]; } } diff --git a/app/Services/DocsOutputCacheService.php b/app/Services/DocsOutputCacheService.php new file mode 100644 index 0000000..7145264 --- /dev/null +++ b/app/Services/DocsOutputCacheService.php @@ -0,0 +1,272 @@ + $segments + */ + public function key(string $namespace, array $segments = []): string + { + $normalizedSegments = array_map( + $this->normalizeSegment(...), + $segments, + ); + + $parts = array_values(array_filter([ + $this->version(), + trim($namespace), + ...$normalizedSegments, + ], static fn (string $part): bool => $part !== '')); + + return implode(':', $parts); + } + + public function get(string $key): mixed + { + if (! $this->enabled()) { + return null; + } + + $filePath = $this->cacheFilePath($key); + + if (! is_file($filePath)) { + return null; + } + + $contents = file_get_contents($filePath); + + if ($contents === false || $contents === '') { + return null; + } + + /** @var array{version?: string, key?: string, value?: mixed}|null $payload */ + $payload = json_decode($contents, true); + + if (! is_array($payload)) { + return null; + } + + if (($payload['version'] ?? null) !== $this->version()) { + return null; + } + + if (($payload['key'] ?? null) !== $key) { + return null; + } + + return $payload['value'] ?? null; + } + + public function put(string $key, mixed $value): void + { + if (! $this->enabled()) { + return; + } + + $filePath = $this->cacheFilePath($key); + $directory = dirname($filePath); + + $this->files->ensureDirectoryExists($directory); + + $payload = [ + 'version' => $this->version(), + 'created_at' => now()->toIso8601String(), + 'key' => $key, + 'value' => $value, + ]; + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if ($encoded === false) { + return; + } + + try { + $suffix = bin2hex(random_bytes(8)); + } catch (Throwable) { + $suffix = uniqid('', true); + } + + $tempFile = sprintf('%s.tmp.%s', $filePath, $suffix); + $bytes = file_put_contents($tempFile, $encoded, LOCK_EX); + + if ($bytes === false) { + return; + } + + if (! @rename($tempFile, $filePath)) { + @unlink($tempFile); + } + } + + public function remember(string $key, callable $resolver): mixed + { + if (! $this->enabled()) { + return $resolver(); + } + + $cached = $this->get($key); + + if ($cached !== null) { + return $cached; + } + + return $this->withLock($key, function () use ($key, $resolver): mixed { + $cachedAfterLock = $this->get($key); + + if ($cachedAfterLock !== null) { + return $cachedAfterLock; + } + + $value = $resolver(); + + if ($value !== null) { + $this->put($key, $value); + } + + return $value; + }); + } + + public function clear(): void + { + $path = $this->path(); + + if ($this->files->isDirectory($path)) { + $this->files->deleteDirectory($path); + } + + $this->files->ensureDirectoryExists($path); + } + + public function path(): string + { + $path = config('docs.cache.path'); + + if (is_string($path) && $path !== '') { + return $path; + } + + return storage_path('framework/cache/docs-output'); + } + + private function enabled(): bool + { + $enabled = config('docs.cache.enabled'); + + if (is_bool($enabled)) { + return $enabled; + } + + return ! app()->isLocal(); + } + + private function version(): string + { + $version = config('docs.cache.version'); + + if (is_string($version) && $version !== '') { + return $version; + } + + return 'v1'; + } + + private function lockTimeoutSeconds(): int + { + $timeout = config('docs.cache.lock_timeout_seconds'); + + if (is_int($timeout) && $timeout > 0) { + return $timeout; + } + + return 5; + } + + private function cacheFilePath(string $key): string + { + $hash = sha1($key); + $prefix = substr($hash, 0, 2); + + return sprintf('%s/%s/%s.json', $this->path(), $prefix, $hash); + } + + private function lockFilePath(string $key): string + { + $hash = sha1($key); + + return sprintf('%s/.locks/%s.lock', $this->path(), $hash); + } + + private function withLock(string $key, callable $callback): mixed + { + $lockFilePath = $this->lockFilePath($key); + $this->files->ensureDirectoryExists(dirname($lockFilePath)); + + $lockHandle = fopen($lockFilePath, 'c+'); + + if ($lockHandle === false) { + return $callback(); + } + + $timeoutAt = microtime(true) + $this->lockTimeoutSeconds(); + $acquired = false; + + do { + $acquired = flock($lockHandle, LOCK_EX | LOCK_NB); + + if ($acquired) { + break; + } + + usleep(50_000); + } while (microtime(true) < $timeoutAt); + + if (! $acquired) { + fclose($lockHandle); + + return $callback(); + } + + try { + return $callback(); + } finally { + flock($lockHandle, LOCK_UN); + fclose($lockHandle); + } + } + + private function normalizeSegment(mixed $segment): string + { + if (is_bool($segment)) { + return $segment ? 'true' : 'false'; + } + + if ($segment === null) { + return 'null'; + } + + if (is_scalar($segment)) { + return (string) $segment; + } + + $encoded = json_encode($segment, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if ($encoded !== false) { + return $encoded; + } + + return sha1(serialize($segment)); + } +} diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index f00dca3..ca4a788 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -10,6 +10,7 @@ public function __construct( private DocsPathService $docsPath, private MarkdownService $markdown, private HeadingExtractorService $headingExtractor, + private DocsOutputCacheService $docsOutputCache, ) {} /** @@ -25,25 +26,7 @@ public function load(string $page): ?array { $filePath = sprintf('%s/%s.md', $this->docsPath->path(), $page); - if (! file_exists($filePath)) { - return null; - } - - $content = file_get_contents($filePath); - - if ($content === false) { - return null; - } - - $html = $this->markdown->toHtml($content); - $headings = $this->headingExtractor->extract($html); - $title = $this->extractTitle($html, $page); - - return [ - 'title' => $title, - 'content' => $html, - 'headings' => $headings, - ]; + return $this->loadFromPath($filePath, "page:{$page}", $page); } /** @@ -59,10 +42,39 @@ public function loadReadme(): ?array { $filePath = dirname($this->docsPath->path()).'/README.md'; + return $this->loadFromPath($filePath, 'readme', 'README'); + } + + /** + * @return array{ + * title: string, + * content: string, + * headings: array + * }|null + */ + private function loadFromPath(string $filePath, string $documentIdentity, string $titleFallback): ?array + { if (! file_exists($filePath)) { return null; } + $cacheKey = $this->docsOutputCache->key('document', [ + $documentIdentity, + $this->fingerprint($filePath), + ]); + + $cached = $this->docsOutputCache->get($cacheKey); + + if (is_array($cached)) { + /** @var array{ + * title: string, + * content: string, + * headings: array + * } $cached + */ + return $cached; + } + $content = file_get_contents($filePath); if ($content === false) { @@ -71,13 +83,25 @@ public function loadReadme(): ?array $html = $this->markdown->toHtml($content); $headings = $this->headingExtractor->extract($html); - $title = $this->extractTitle($html, 'README'); + $title = $this->extractTitle($html, $titleFallback); - return [ + $document = [ 'title' => $title, 'content' => $html, 'headings' => $headings, ]; + + $this->docsOutputCache->put($cacheKey, $document); + + return $document; + } + + private function fingerprint(string $path): string + { + $modifiedAt = filemtime($path); + $size = filesize($path); + + return sprintf('%d:%d', $modifiedAt === false ? 0 : $modifiedAt, $size === false ? 0 : $size); } /** diff --git a/boost.json b/boost.json index 9f73e7b..5d6022f 100644 --- a/boost.json +++ b/boost.json @@ -1,9 +1,11 @@ { "agents": [ - "claude_code" + "claude_code", + "codex" ], "editors": [ - "claude_code" + "claude_code", + "codex" ], "guidelines": [] } diff --git a/config/docs.php b/config/docs.php index 36fccf9..5321b23 100644 --- a/config/docs.php +++ b/config/docs.php @@ -20,15 +20,19 @@ /* |-------------------------------------------------------------------------- - | Cheat Sheet + | Docs Output Cache |-------------------------------------------------------------------------- | - | Cache TTL for the command cheat sheet payload in seconds. + | Shared output cache for docs pages and command index payloads. + | This cache is file-backed and can be cleared via optimize:clear. | */ - 'cheat_sheet' => [ - 'cache_ttl_seconds' => (int) env('DOCS_CHEAT_SHEET_CACHE_TTL', 300), + 'cache' => [ + 'enabled' => (bool) env('DOCS_CACHE_ENABLED', env('APP_ENV', 'local') !== 'local'), + 'path' => env('DOCS_CACHE_PATH', storage_path('framework/cache/docs-output')), + 'version' => env('DOCS_CACHE_VERSION', 'v1'), + 'lock_timeout_seconds' => (int) env('DOCS_CACHE_LOCK_TIMEOUT_SECONDS', 5), ], /* diff --git a/resources/css/app.css b/resources/css/app.css index 72ddf44..3d50187 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -65,7 +65,7 @@ --tw-prose-headings: #18181b; /* zinc-900 */ --tw-prose-links: #2299dd; /* accent */ --tw-prose-code: #d946ef; /* fuchsia-600 */ - --tw-prose-pre-bg: #f4f4f5; /* zinc-100 */ + --tw-prose-pre-bg: #f4f4f590; /* zinc-100 */ --tw-prose-pre-code: #3f3f46; /* zinc-700 */ --tw-prose-counters: #71717a; /* zinc-500 */ --tw-prose-bullets: #a1a1aa; /* zinc-400 */ @@ -160,6 +160,7 @@ padding: 2rem 12px 12px; margin-top: 1.5rem; margin-bottom: 1.5rem; + line-height: 1.25; } .prose :where(pre code) { @@ -168,7 +169,6 @@ font-size: 0.875rem; font-weight: 400; color: inherit; - line-height: 1.2rem; } .prose :where(ul) { diff --git a/resources/css/highlight.css b/resources/css/highlight.css index 883231d..d6f565d 100644 --- a/resources/css/highlight.css +++ b/resources/css/highlight.css @@ -47,7 +47,7 @@ .hljs-comment, .hljs-quote, .hljs-deletion { - color: #71717a; /* zinc-500 */ + color: #a1a1aa; /* zinc-400 */ font-style: italic; } diff --git a/resources/views/cheat-sheet.blade.php b/resources/views/cheat-sheet.blade.php deleted file mode 100644 index 6465002..0000000 --- a/resources/views/cheat-sheet.blade.php +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - DeployerPHP - Command Cheat Sheet - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @fluxAppearance - - - {{-- Header (matches docs layout) --}} - - - - - DeployerPHP - - - -
- Cheat Sheet - GitHub - - - - - - - -
-
- - {{-- Main Content --}} - -
- {{-- Page Header --}} -
-

Command Cheat Sheet

-

- {{ $commandCount }} commands - - {{ $aliasCount }} aliases -

-
- - {{-- Global Options Hint --}} -
- Prefix commands with deployer (or your alias). - - Global options: - --env - --inventory - --quiet - -
- - {{-- Command Groups Grid --}} -
- @foreach ($groups as $group) -
-

- {{ $group['name'] }} - ({{ $group['count'] }}) -

- -
    - @foreach ($group['commands'] as $command) -
  • - {{ $command['primary'] }} - - @if ($command['links'] !== []) - @foreach ($command['links'] as $index => $link) - {{ $index + 1 }} - @endforeach - @endif - - @if ($command['aliases'] !== []) - - alias: - @foreach ($command['aliases'] as $alias) - {{ $alias }}@if (! $loop->last), @endif - @endforeach - - @endif -
  • - @endforeach -
-
- @endforeach -
-
- - {{-- Footer (matches docs layout) --}} - -
- - @fluxScripts - - diff --git a/resources/views/components/docs/footer.blade.php b/resources/views/components/docs/footer.blade.php new file mode 100644 index 0000000..5dee00c --- /dev/null +++ b/resources/views/components/docs/footer.blade.php @@ -0,0 +1,31 @@ + diff --git a/resources/views/components/docs/header.blade.php b/resources/views/components/docs/header.blade.php new file mode 100644 index 0000000..caf42a7 --- /dev/null +++ b/resources/views/components/docs/header.blade.php @@ -0,0 +1,29 @@ + + + + {{-- Colorful Logo --}} + + + + DeployerPHP + + + +
+ {{-- GitHub Button --}} + GitHub + + + + {{-- Dark Mode Toggle --}} + + + + +
+
diff --git a/resources/views/components/docs/toc.blade.php b/resources/views/components/docs/toc.blade.php index d9ca4d8..f65c13e 100644 --- a/resources/views/components/docs/toc.blade.php +++ b/resources/views/components/docs/toc.blade.php @@ -11,16 +11,10 @@ {{ $link['title'] }} @endforeach - - @if ($loop->last) - -
- Cheat Sheet - - -
-
- @endif @endforeach + + + Command Index + diff --git a/resources/views/components/layouts/command-index.blade.php b/resources/views/components/layouts/command-index.blade.php new file mode 100644 index 0000000..ce40c52 --- /dev/null +++ b/resources/views/components/layouts/command-index.blade.php @@ -0,0 +1,55 @@ + + + + + + DeployerPHP - Command Index + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + {{-- + // ---- + // Fixed Header + // ---- + --}} + + + {{-- + // ---- + // Mobile Sidebar + // ---- + --}} + + {{ $sidebar }} + + + {{-- + // ---- + // Main Layout + // ---- + --}} + +
+
+ + +
+ {{ $slot }} + + +
+
+
+
+ + @fluxScripts + + diff --git a/resources/views/components/layouts/docs.blade.php b/resources/views/components/layouts/docs.blade.php index c3188e8..f6697e6 100644 --- a/resources/views/components/layouts/docs.blade.php +++ b/resources/views/components/layouts/docs.blade.php @@ -16,35 +16,7 @@ // Fixed Header // ---- --}} - - - - {{-- Colorful Logo --}} - - - - DeployerPHP - - - -
- {{-- GitHub Button --}} - GitHub - - - - {{-- Dark Mode Toggle --}} - - - - -
-
+ {{-- // ---- @@ -77,37 +49,7 @@ {{-- See content.blade.php --}} {{ $slot }} - + {{-- Right Sidebar: Page Headings --}} diff --git a/resources/views/flux/icon/square-arrow-out-up-right.blade.php b/resources/views/flux/icon/square-arrow-out-up-right.blade.php deleted file mode 100644 index bb331eb..0000000 --- a/resources/views/flux/icon/square-arrow-out-up-right.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -@blaze - -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - diff --git a/resources/views/livewire/command-index.blade.php b/resources/views/livewire/command-index.blade.php new file mode 100644 index 0000000..b55f104 --- /dev/null +++ b/resources/views/livewire/command-index.blade.php @@ -0,0 +1,59 @@ + + + + +
+
+

Command Index

+

+ {{ $commandCount }} + commands organized by deployment workflow. +

+
+ + @if ($sections !== []) +
+ @foreach ($sections as $section) + @php + $sectionDescription = match ($section['name']) { + 'Server & Site Operations' => 'Core commands for provisioning hosts, managing sites, and handling routine server maintenance.', + 'Scheduling & Process Control' => 'Tools for defining scheduled jobs and managing long-running background workers.', + 'Web Runtime Services' => 'Commands for configuring and controlling the web stack, including PHP and Nginx services.', + 'Data Services' => 'Operational commands for databases and cache layers used by deployed applications.', + 'Scaffolding' => 'Generators and setup helpers that bootstrap common deployment resources and project structure.', + 'Cloud Providers' => 'Provider-specific commands for creating and managing infrastructure across supported cloud platforms.', + default => 'Supporting commands for specialized tasks that do not fit a primary operations category.', + }; + @endphp + +
+
+

{{ $section['name'] }}

+

{{ $sectionDescription }}

+
+ +
+ @foreach ($section['groups'] as $group) +
+

+ {{ $group['name'] }} + ({{ $group['count'] }}) +

+ +
    + @foreach ($group['commands'] as $command) +
  • + {{ $command['primary'] }} +
  • + @endforeach +
+
+ @endforeach +
+
+ @endforeach +
+ @else +

No commands were discovered for the configured docs path.

+ @endif +
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index b7355d7..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,277 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/web.php b/routes/web.php index 5d81a0a..509d5b6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,7 @@ name('home'); @@ -12,13 +11,4 @@ ->name('docs.show') ->where(['page' => '[a-z0-9-]+']); -Route::get('/cheat-sheet', function (CommandCheatSheetService $sheet) { - $cacheTtlSeconds = max(1, (int) config('docs.cheat_sheet.cache_ttl_seconds', 300)); - $cheatSheet = Cache::remember( - 'cheat_sheet:v1', - now()->addSeconds($cacheTtlSeconds), - fn (): array => $sheet->build(), - ); - - return view('cheat-sheet', $cheatSheet); -})->name('cheat-sheet'); +Route::get('/command-index', CommandIndex::class)->name('command-index'); diff --git a/tests/Arch/ArchTest.php b/tests/Arch/ArchTest.php index d0d2cf3..2a1c820 100644 --- a/tests/Arch/ArchTest.php +++ b/tests/Arch/ArchTest.php @@ -2,10 +2,6 @@ declare(strict_types=1); -// -// Global Debugging Prevention -// ---- - arch() ->expect('App') ->not->toUse([ @@ -17,129 +13,27 @@ 'exit', ]); -// -// Preset Architecture Rules -// ---- - arch()->preset()->php(); arch()->preset()->laravel(); -arch()->preset()->security(); - -// -// Strict Layered Architecture Enforcement -// ---- - -// Layer 1: Controllers must NOT directly use Models or Repositories -arch() - ->expect('App\Http\Controllers') - ->not->toUse([ - 'App\Models', - 'App\Repositories', - 'App\Services', - 'Illuminate\Database\Eloquent', - ]); - -// Layer 1: Livewire Components must NOT directly use Models or Repositories -arch() - ->expect('App\Livewire') - ->not->toUse([ - 'App\Models', - 'App\Repositories', - 'App\Services', - 'Illuminate\Database\Eloquent', - ]); - -// Layer 2: Actions must NOT directly use Models or Repositories -arch() - ->expect('App\Actions') - ->not->toUse([ - 'App\Models', - 'App\Repositories', - 'Illuminate\Database\Eloquent', - ]); - -// Layer 2: Services must NOT directly use Models (should use Repositories) or Actions -arch() - ->expect('App\Services') - ->not->toUse([ - 'App\Actions', - 'App\Models', - 'Illuminate\Database\Eloquent', - ]); - -// -// Directory Structure Enforcement -// ---- arch() - ->expect('App\Models') - ->toExtend(\Illuminate\Database\Eloquent\Model::class); + ->expect('App\\Http\\Controllers') + ->toHaveSuffix('Controller') + ->toExtend(\App\Http\Controllers\Controller::class); arch() - ->expect('App\Exceptions') - ->toExtend('Exception'); - -// -// Naming Conventions Enforcement -// ---- + ->expect('App\\Livewire') + ->toExtend(\Livewire\Component::class); arch() - ->expect('App\Http\Controllers') - ->toHaveSuffix('Controller'); - -arch() - ->expect('App\Actions') - ->toHaveSuffix('Action'); - -arch() - ->expect('App\Services') + ->expect('App\\Services') ->toHaveSuffix('Service'); arch() - ->expect('App\Repositories') - ->toHaveSuffix('Repository'); - -arch() - ->expect('App\Exceptions') - ->toHaveSuffix('Exception'); - -// -// Interface and Contract Enforcement -// ---- - -arch() - ->expect('App\Repositories') + ->expect('App\\Repositories') + ->toHaveSuffix('Repository') ->toImplement(\App\Contracts\RepositoryInterface::class); -// -// Conditional Architecture Tests -// ---- - -// For conditional tests that require complex logic, we keep traditional test format -it('controllers extend base controller when it exists', function (): void { - if (class_exists(\App\Http\Controllers\Controller::class)) { - arch() - ->expect('App\Http\Controllers') - ->toExtend(\App\Http\Controllers\Controller::class); - } else { - expect(true)->toBeTrue(); // Skip if base controller doesn't exist - } -}); - -it('livewire components extend base component when livewire is installed', function (): void { - if (class_exists(\Livewire\Component::class)) { - arch() - ->expect('App\Livewire') - ->toExtend(\Livewire\Component::class); - } else { - expect(true)->toBeTrue(); // Skip if Livewire not installed - } -}); - -it('services implement service contracts when defined', function (): void { - arch() - ->expect('App\Services') - ->classes() - ->when(fn (ReflectionClass $class): bool => interface_exists("App\\Contracts\\{$class->getShortName()}Interface")) - ->toImplement(fn (ReflectionClass $class): string => "App\\Contracts\\{$class->getShortName()}Interface"); -}); +arch() + ->expect('App\\Models') + ->toExtend(\Illuminate\Database\Eloquent\Model::class); diff --git a/tests/Concerns/UsesDocsFixtures.php b/tests/Concerns/UsesDocsFixtures.php new file mode 100644 index 0000000..13c8258 --- /dev/null +++ b/tests/Concerns/UsesDocsFixtures.php @@ -0,0 +1,38 @@ +docsFixtureRoot(); + $cachePath = storage_path('framework/cache/docs-output-tests/'.($cacheSuffix ?? uniqid('', true))); + + config()->set('docs.path', "{$root}/docs"); + config()->set('docs.cache.path', $cachePath); + config()->set('docs.cache.enabled', true); + config()->set('docs.cache.version', 'v1'); + + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(DocsOutputCacheService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(DocumentService::class); + app()->forgetInstance(CommandIndexService::class); + + app(DocsOutputCacheService::class)->clear(); + } + + protected function docsFixtureRoot(): string + { + return base_path('tests/Fixtures/docs'); + } +} diff --git a/tests/Feature/CheatSheetPageTest.php b/tests/Feature/CheatSheetPageTest.php deleted file mode 100644 index a21954b..0000000 --- a/tests/Feature/CheatSheetPageTest.php +++ /dev/null @@ -1,18 +0,0 @@ -forgetInstance(DocsPathService::class); - app()->forgetInstance(CommandCheatSheetService::class); - - $response = $this->get(route('cheat-sheet')); - - $response->assertOk() - ->assertSee('Command Cheat Sheet') - ->assertSee('server:add') - ->assertSee('site:deploy'); -}); diff --git a/tests/Feature/CommandIndexPageTest.php b/tests/Feature/CommandIndexPageTest.php new file mode 100644 index 0000000..bbb3a54 --- /dev/null +++ b/tests/Feature/CommandIndexPageTest.php @@ -0,0 +1,93 @@ +configureDocsFixtures(); +}); + +it('renders the command index using fixture commands', function (): void { + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(CommandIndexService::class); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSeeText('Command Index') + ->assertSeeText('server:add') + ->assertSeeText('aws:region') + ->assertSeeText('custom-task'); +}); + +it('uses cached command index payload when docs are unavailable', function (): void { + $docsOutputCache = app(DocsOutputCacheService::class); + + $docsOutputCache->put( + $docsOutputCache->key('command-index'), + [ + 'toc' => [ + [ + 'name' => 'Cached', + 'anchor' => 'cached', + 'links' => [ + ['title' => 'Cached Command Index', 'path' => 'command-index'], + ], + ], + ], + 'sections' => [ + [ + 'name' => 'Cached Section', + 'count' => 1, + 'namespaceCount' => 1, + 'groups' => [ + [ + 'name' => 'Cached Group', + 'count' => 1, + 'commands' => [ + [ + 'primary' => 'cached:run', + 'aliases' => [], + 'description' => 'Cached command', + ], + ], + ], + ], + ], + ], + 'groups' => [ + [ + 'name' => 'Cached Group', + 'count' => 1, + 'commands' => [ + [ + 'primary' => 'cached:run', + 'aliases' => [], + 'description' => 'Cached command', + ], + ], + ], + ], + 'commandCount' => 1, + 'aliasCount' => 0, + ], + ); + + config()->set('docs.path', storage_path('framework/testing/missing-docs-'.uniqid('', true))); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(CommandIndexService::class); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSeeText('Cached Section') + ->assertSeeText('cached:run'); +}); diff --git a/tests/Feature/DocsClearCommandTest.php b/tests/Feature/DocsClearCommandTest.php new file mode 100644 index 0000000..376b59d --- /dev/null +++ b/tests/Feature/DocsClearCommandTest.php @@ -0,0 +1,27 @@ +set('docs.cache.path', $cachePath); + + app()->forgetInstance(DocsOutputCacheService::class); + + $docsOutput = app(DocsOutputCacheService::class); + $docsOutput->put( + $docsOutput->key('docs-viewer', ['home']), + ['title' => 'Cached'], + ); + + expect(File::allFiles($cachePath))->not->toBeEmpty(); + + $this->artisan('optimize:clear')->assertSuccessful(); + + expect(File::isDirectory($cachePath))->toBeTrue(); + expect(File::allFiles($cachePath))->toBeEmpty(); +}); diff --git a/tests/Feature/DocsRoutesSmokeTest.php b/tests/Feature/DocsRoutesSmokeTest.php new file mode 100644 index 0000000..ee10a36 --- /dev/null +++ b/tests/Feature/DocsRoutesSmokeTest.php @@ -0,0 +1,32 @@ +configureDocsFixtures(); +}); + +it('exposes the docs home route', function (): void { + $this->get(route('home')) + ->assertOk() + ->assertSeeText('Fixture Documentation Home'); +}); + +it('exposes the command index route', function (): void { + $this->get(route('command-index')) + ->assertOk() + ->assertSeeText('Command Index'); +}); + +it('redirects docs index routes to home', function (string $uri): void { + $this->get($uri) + ->assertRedirect(route('home')) + ->assertMovedPermanently(); +})->with([ + '/docs', + '/docs/', +]); diff --git a/tests/Feature/DocsSidebarNavigationTest.php b/tests/Feature/DocsSidebarNavigationTest.php index 11a7aeb..812b9a9 100644 --- a/tests/Feature/DocsSidebarNavigationTest.php +++ b/tests/Feature/DocsSidebarNavigationTest.php @@ -5,31 +5,38 @@ use App\Services\DocsPathService; use App\Services\DocumentService; use App\Services\TocParserService; +use Tests\Concerns\UsesDocsFixtures; -it('renders grouped sidebar navigation from documentation toc', function (): void { +uses(UsesDocsFixtures::class); + +beforeEach(function (): void { + $this->configureDocsFixtures(); +}); + +it('renders fixture toc links on docs home', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(TocParserService::class); app()->forgetInstance(DocumentService::class); - $response = $this->get('/'); + $response = $this->get(route('home')); $response->assertOk() - ->assertSeeInOrder([ - 'Guides', - 'Introduction', - 'Installation', - 'Zero to Deploy', - 'References', - 'Managing Sites', - 'Managing Servers', - 'Managing Services', - 'Managing Databases', - 'Cloud Providers', - 'Automation & AI', - ]) - ->assertSee('href="'.route('home').'"', false) + ->assertSeeText('Guides') + ->assertSeeText('Installation') ->assertSee('href="'.route('docs.show', ['page' => 'installation']).'"', false) - ->assertSee('href="'.route('docs.show', ['page' => 'managing-services']).'"', false); + ->assertSee('href="'.route('command-index').'"', false); +}); + +it('renders a fixture docs page', function (): void { + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(DocumentService::class); + + $response = $this->get(route('docs.show', ['page' => 'installation'])); + + $response->assertOk() + ->assertSeeText('Installation') + ->assertSeeText('Requirements'); }); it('redirects missing docs pages to the docs home', function (): void { @@ -37,7 +44,7 @@ app()->forgetInstance(TocParserService::class); app()->forgetInstance(DocumentService::class); - $this->get('/docs/introduction') - ->assertRedirect('/') - ->assertStatus(301); + $this->get(route('docs.show', ['page' => 'does-not-exist'])) + ->assertRedirect(route('home')) + ->assertMovedPermanently(); }); diff --git a/tests/Feature/MarkdownServiceTest.php b/tests/Feature/MarkdownServiceTest.php deleted file mode 100644 index 96c3076..0000000 --- a/tests/Feature/MarkdownServiceTest.php +++ /dev/null @@ -1,40 +0,0 @@ - [!IMPORTANT] -> First important callout. -> -> - First item -> - Second item -> -> Another paragraph. -MARKDOWN; - - $html = app(MarkdownService::class)->toHtml($markdown); - - expect(substr_count($html, 'dark:border-l-amber-400'))->toBe(1) - ->and(substr_count($html, 'toBe(1) - ->and($html)->toContain('
    ') - ->toContain('
  • First item
  • ') - ->toContain('
  • Second item
  • '); -}); - -it('renders multiple important github alerts on the same page', function (): void { - $markdown = <<<'MARKDOWN' -> [!IMPORTANT] -> First important callout. - -> [!IMPORTANT] -> Second important callout. -MARKDOWN; - - $html = app(MarkdownService::class)->toHtml($markdown); - - expect(substr_count($html, 'dark:border-l-amber-400'))->toBe(2) - ->and(substr_count($html, 'toBe(2); -}); diff --git a/tests/Fixtures/docs/README.md b/tests/Fixtures/docs/README.md new file mode 100644 index 0000000..0af3dd4 --- /dev/null +++ b/tests/Fixtures/docs/README.md @@ -0,0 +1,5 @@ +# Fixture Documentation Home + +Welcome to the fixture-based docs home. + +See [Installation](/docs/installation) and [External Guide](guides/setup). diff --git a/tests/Fixtures/docs/app/Console/Commands/FakeCloudCommand.php b/tests/Fixtures/docs/app/Console/Commands/FakeCloudCommand.php new file mode 100644 index 0000000..c1efd8e --- /dev/null +++ b/tests/Fixtures/docs/app/Console/Commands/FakeCloudCommand.php @@ -0,0 +1,10 @@ + +## Guides +- [Introduction](README.md) +- [Installation](installation.md) +- [Link Behavior](link-behavior.md) + + +## References +- [Command Index](command-index.md) +- [Operations](operations/runbooks.md) diff --git a/tests/Fixtures/docs/docs/installation.md b/tests/Fixtures/docs/docs/installation.md new file mode 100644 index 0000000..ac3e7f0 --- /dev/null +++ b/tests/Fixtures/docs/docs/installation.md @@ -0,0 +1,10 @@ +# Installation + +Install the application. + +## Requirements +PHP 8.5+ + +## Steps +1. Copy env. +2. Run composer install. diff --git a/tests/Fixtures/docs/docs/link-behavior.md b/tests/Fixtures/docs/docs/link-behavior.md new file mode 100644 index 0000000..3fcf2e1 --- /dev/null +++ b/tests/Fixtures/docs/docs/link-behavior.md @@ -0,0 +1,12 @@ +# Link Behavior + +[Home Docs Link](/docs) +[Install Relative](docs/installation) +[GitHub Relative](guides/setup) +[GitHub Root](/CONTRIBUTING) +[Anchor](#local-anchor) +[External](https://example.com/docs) + +```bash nocopy +php artisan test +``` diff --git a/tests/Pest.php b/tests/Pest.php index 562545e..cb99f7b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,55 +2,11 @@ declare(strict_types=1); -/* -|-------------------------------------------------------------------------- -| Test Case -|-------------------------------------------------------------------------- -| -| The closure you provide to your test functions is always bound to a specific PHPUnit test -| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may -| need to change it using the "pest()" function to bind a different classes or traits. -| -*/ - -// Architecture Tests: Architecture rules and best practices pest()->extend(Tests\TestCase::class) ->in('Arch'); -// Feature Tests: Full HTTP stack end-to-end tests pest()->extend(Tests\FeatureTestCase::class) - ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); -// Unit Tests: Pure business logic, mocked dependencies, no database pest()->extend(Tests\TestCase::class) ->in('Unit'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', fn () => $this->toBe(1)); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() -{ - // .. -} diff --git a/tests/Unit/CommandIndexServiceTest.php b/tests/Unit/CommandIndexServiceTest.php new file mode 100644 index 0000000..66879cc --- /dev/null +++ b/tests/Unit/CommandIndexServiceTest.php @@ -0,0 +1,54 @@ +configureDocsFixtures(); +}); + +it('builds grouped command index data from fixture command classes', function (): void { + $index = app(CommandIndexService::class)->build(); + + expect($index['commandCount'])->toBe(3) + ->and($index['aliasCount'])->toBe(1) + ->and($index['groups'])->toHaveCount(3) + ->and($index['groups'][0]['name'])->toBe('AWS') + ->and($index['groups'][1]['name'])->toBe('Custom-task') + ->and($index['groups'][2]['name'])->toBe('Server'); + + $commands = collect($index['groups'])->pluck('commands')->flatten(1)->pluck('primary')->all(); + + expect($commands)->toBe([ + 'aws:region', + 'custom-task', + 'server:add', + ]); +}); + +it('maps namespaces into ordered related sections', function (): void { + $sections = app(CommandIndexService::class)->build()['sections']; + + expect(array_column($sections, 'name'))->toBe([ + 'Server & Site Operations', + 'Cloud Providers', + 'Other', + ]); +}); + +it('falls back to vendor console commands when docs console path is unavailable', function (): void { + config()->set('docs.path', storage_path('framework/testing/no-console-'.uniqid('', true))); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(CommandIndexService::class); + + $index = app(CommandIndexService::class)->build(); + + expect($index['commandCount'])->toBeGreaterThan(0) + ->and($index['sections'])->not->toBeEmpty() + ->and($index['groups'])->not->toBeEmpty(); +}); diff --git a/tests/Unit/DocsOutputCacheServiceTest.php b/tests/Unit/DocsOutputCacheServiceTest.php new file mode 100644 index 0000000..27fd9a1 --- /dev/null +++ b/tests/Unit/DocsOutputCacheServiceTest.php @@ -0,0 +1,145 @@ +set('docs.cache.enabled', true); + config()->set('docs.cache.version', 'v1'); + config()->set('docs.cache.path', storage_path('framework/cache/docs-output-tests/'.uniqid('', true))); + + app()->forgetInstance(DocsOutputCacheService::class); + + app(DocsOutputCacheService::class)->clear(); +}); + +it('builds stable cache keys from normalized segments', function (): void { + $service = app(DocsOutputCacheService::class); + + $key = $service->key('docs-viewer', [true, false, null, ['page' => 'home']]); + + expect($key) + ->toContain('v1:docs-viewer:true:false:null') + ->toContain('{"page":"home"}'); +}); + +it('persists and retrieves cache payloads', function (): void { + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + + $service->put($key, ['title' => 'Installation']); + + expect($service->get($key))->toBe(['title' => 'Installation']); +}); + +it('returns null for stale versioned payloads', function (): void { + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + + $service->put($key, ['title' => 'Installation']); + + config()->set('docs.cache.version', 'v2'); + app()->forgetInstance(DocsOutputCacheService::class); + + expect(app(DocsOutputCacheService::class)->get($key))->toBeNull(); +}); + +it('returns null for key mismatches and invalid payloads', function (): void { + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + + $hash = sha1($key); + $path = config('docs.cache.path').'/'.substr($hash, 0, 2)."/{$hash}.json"; + + File::ensureDirectoryExists(dirname($path)); + File::put($path, '{bad json'); + + expect($service->get($key))->toBeNull(); + + File::put($path, json_encode([ + 'version' => 'v1', + 'key' => 'different-key', + 'value' => ['title' => 'Wrong'], + ], JSON_UNESCAPED_SLASHES)); + + expect($service->get($key))->toBeNull(); +}); + +it('bypasses cache reads and writes when disabled', function (): void { + config()->set('docs.cache.enabled', false); + app()->forgetInstance(DocsOutputCacheService::class); + + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + + $service->put($key, ['title' => 'Installation']); + + expect($service->get($key))->toBeNull(); + + $calls = 0; + + $first = $service->remember($key, function () use (&$calls): array { + $calls++; + + return ['title' => 'A']; + }); + + $second = $service->remember($key, function () use (&$calls): array { + $calls++; + + return ['title' => 'B']; + }); + + expect($first)->toBe(['title' => 'A']) + ->and($second)->toBe(['title' => 'B']) + ->and($calls)->toBe(2); +}); + +it('memoizes resolver values with remember when enabled', function (): void { + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + $calls = 0; + + $first = $service->remember($key, function () use (&$calls): array { + $calls++; + + return ['title' => 'Installation']; + }); + + $second = $service->remember($key, function () use (&$calls): array { + $calls++; + + return ['title' => 'Changed']; + }); + + expect($first)->toBe(['title' => 'Installation']) + ->and($second)->toBe(['title' => 'Installation']) + ->and($calls)->toBe(1); +}); + +it('clears cache files while keeping the cache directory present', function (): void { + $service = app(DocsOutputCacheService::class); + $key = $service->key('document', ['page:installation']); + + $service->put($key, ['title' => 'Installation']); + + $cachePath = config('docs.cache.path'); + + expect(File::allFiles($cachePath))->not->toBeEmpty(); + + $service->clear(); + + expect(File::isDirectory($cachePath))->toBeTrue() + ->and(File::allFiles($cachePath))->toBeEmpty(); +}); + +it('falls back to default cache path when config path is invalid', function (): void { + config()->set('docs.cache.path', null); + app()->forgetInstance(DocsOutputCacheService::class); + + $service = app(DocsOutputCacheService::class); + + expect($service->path())->toBe(storage_path('framework/cache/docs-output')); +}); diff --git a/tests/Unit/DocumentServiceTest.php b/tests/Unit/DocumentServiceTest.php new file mode 100644 index 0000000..29df400 --- /dev/null +++ b/tests/Unit/DocumentServiceTest.php @@ -0,0 +1,86 @@ +configureDocsFixtures(); +}); + +it('loads and renders fixture documentation pages', function (): void { + $document = app(DocumentService::class)->load('installation'); + + expect($document)->not->toBeNull(); + expect($document) + ->toHaveKey('title', 'Installation') + ->and($document['content'])->toContain('

    Requirements

    ') + ->and($document['headings'])->toBe([ + ['level' => 2, 'text' => 'Requirements', 'id' => 'requirements'], + ['level' => 2, 'text' => 'Steps', 'id' => 'steps'], + ]); +}); + +it('loads the fixture readme from the docs parent directory', function (): void { + $document = app(DocumentService::class)->loadReadme(); + + expect($document)->not->toBeNull(); + expect($document) + ->toHaveKey('title', 'Fixture Documentation Home') + ->and($document['content'])->toContain('fixture-based docs home'); +}); + +it('returns null when requested docs page is missing', function (): void { + expect(app(DocumentService::class)->load('missing-page'))->toBeNull(); +}); + +it('falls back to title-cased slug when no h1 is present', function (): void { + $docsPath = storage_path('framework/testing/docs-no-title-'.uniqid('', true)); + + File::ensureDirectoryExists($docsPath); + File::put("{$docsPath}/no-title.md", "Paragraph only\n\n## Section"); + + config()->set('docs.path', $docsPath); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(DocumentService::class); + + $document = app(DocumentService::class)->load('no-title'); + + expect($document)->not->toBeNull(); + expect($document['title'])->toBe('No title'); + + File::deleteDirectory($docsPath); +}); + +it('uses cached document payloads keyed by file fingerprint', function (): void { + $service = app(DocumentService::class); + $cache = app(DocsOutputCacheService::class); + + $path = $this->docsFixtureRoot().'/docs/installation.md'; + $modifiedAt = filemtime($path); + $size = filesize($path); + $fingerprint = sprintf('%d:%d', $modifiedAt === false ? 0 : $modifiedAt, $size === false ? 0 : $size); + + $cache->put( + $cache->key('document', ['page:installation', $fingerprint]), + [ + 'title' => 'From Cache', + 'content' => '

    From Cache

    Cached.

    ', + 'headings' => [], + ], + ); + + $document = $service->load('installation'); + + expect($document)->toBe([ + 'title' => 'From Cache', + 'content' => '

    From Cache

    Cached.

    ', + 'headings' => [], + ]); +}); diff --git a/tests/Unit/HeadingExtractorServiceTest.php b/tests/Unit/HeadingExtractorServiceTest.php new file mode 100644 index 0000000..bcb18a9 --- /dev/null +++ b/tests/Unit/HeadingExtractorServiceTest.php @@ -0,0 +1,48 @@ +extract(' '); + + expect($headings)->toBe([]); +}); + +it('extracts h2 and h3 headings and ignores h1', function (): void { + $html = <<<'HTML' +

    Top

    +

    Requirements

    +

    Steps

    +HTML; + + $headings = app(HeadingExtractorService::class)->extract($html); + + expect($headings)->toBe([ + ['level' => 2, 'text' => 'Requirements', 'id' => 'requirements'], + ['level' => 3, 'text' => 'Steps', 'id' => 'steps'], + ]); +}); + +it('extracts heading ids from permalink anchors when no id exists on heading', function (): void { + $html = <<<'HTML' +

    #Heading Title

    +HTML; + + $headings = app(HeadingExtractorService::class)->extract($html); + + expect($headings)->toBe([ + ['level' => 2, 'text' => 'Heading Title', 'id' => 'linked-heading'], + ]); +}); + +it('skips headings that do not contain visible text', function (): void { + $html = <<<'HTML' +

    #

    +HTML; + + $headings = app(HeadingExtractorService::class)->extract($html); + + expect($headings)->toBe([]); +}); diff --git a/tests/Unit/Livewire/DocsViewerTest.php b/tests/Unit/Livewire/DocsViewerTest.php new file mode 100644 index 0000000..5c1cec6 --- /dev/null +++ b/tests/Unit/Livewire/DocsViewerTest.php @@ -0,0 +1,68 @@ +configureDocsFixtures(); +}); + +it('hydrates docs viewer state for the docs home', function (): void { + Livewire::test(DocsViewer::class) + ->assertSet('page', '') + ->assertSet('title', 'Fixture Documentation Home') + ->assertSet('toc.0.name', 'Guides'); +}); + +it('hydrates docs viewer state for a docs page', function (): void { + Livewire::test(DocsViewer::class, ['page' => 'installation']) + ->assertSet('page', 'installation') + ->assertSet('title', 'Installation') + ->assertSet('headings.0.text', 'Requirements'); +}); + +it('uses cached viewer payload when available', function (): void { + $cache = app(DocsOutputCacheService::class); + + $cache->put( + $cache->key('docs-viewer', ['home']), + [ + 'page' => '', + 'title' => 'Cached Home', + 'content' => '

    Cached Home

    ', + 'headings' => [], + 'toc' => [ + [ + 'name' => 'Cached', + 'anchor' => 'cached', + 'links' => [ + ['title' => 'Cached Link', 'path' => 'cached-link'], + ], + ], + ], + ], + ); + + config()->set('docs.path', storage_path('framework/testing/missing-docs-'.uniqid('', true))); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(DocumentService::class); + + Livewire::test(DocsViewer::class) + ->assertSet('title', 'Cached Home') + ->assertSet('toc.0.name', 'Cached'); +}); + +it('redirects to home when a docs page does not exist', function (): void { + Livewire::test(DocsViewer::class, ['page' => 'missing-page']) + ->assertRedirect(route('home')); +}); diff --git a/tests/Unit/MarkdownServiceTest.php b/tests/Unit/MarkdownServiceTest.php new file mode 100644 index 0000000..7e94925 --- /dev/null +++ b/tests/Unit/MarkdownServiceTest.php @@ -0,0 +1,118 @@ +set('docs.github.repo', 'acme/docs-repo'); + config()->set('docs.github.branch', 'main'); + config()->set('docs.github.dir', 'docs'); +}); + +it('strips markdown artifacts and content before the first h1', function (): void { + $markdown = <<<'MARKDOWN' +Intro paragraph that should be removed. + + +- fake toc + + + + +# Real Title + +Body +MARKDOWN; + + $html = app(MarkdownService::class)->toHtml($markdown); + + expect($html) + ->toContain('

    Real Title

    ') + ->toContain('

    Body

    ') + ->not->toContain('Intro paragraph that should be removed.') + ->not->toContain('legacy-anchor'); +}); + +it('converts docs links to internal routes and preserves fragments', function (): void { + $markdown = <<<'MARKDOWN' +# Links + +[Docs Home](/docs) +[Install](docs/installation#requirements) +MARKDOWN; + + $html = app(MarkdownService::class)->toHtml($markdown); + + expect($html) + ->toContain('Docs Home') + ->toContain('Install'); +}); + +it('converts relative links to github blob urls', function (): void { + $markdown = <<<'MARKDOWN' +# Links + +[Relative Section](setup) +[Relative Markdown](guides/setup.md#cli) +[Root Path](/CONTRIBUTING#policy) +[Anchor](#local) +[External](https://example.com) +MARKDOWN; + + $html = app(MarkdownService::class)->toHtml($markdown, 'deploy'); + + expect($html) + ->toContain('href="https://github.com/acme/docs-repo/blob/main/docs/deploy/setup.md" target="_blank" rel="noopener noreferrer"') + ->toContain('href="https://github.com/acme/docs-repo/blob/main/docs/guides/setup.md#cli" target="_blank" rel="noopener noreferrer"') + ->toContain('href="https://github.com/acme/docs-repo/blob/main/CONTRIBUTING#policy" target="_blank" rel="noopener noreferrer"') + ->toContain('Anchor') + ->toContain('External'); +}); + +it('wraps code blocks and respects nocopy modifiers', function (): void { + $markdown = <<<'MARKDOWN' +# Code + +```bash nocopy +php artisan test +``` + +```php +toHtml($markdown); + + expect(substr_count($html, 'aria-label="Copy code to clipboard"'))->toBe(1) + ->and($html)->toContain('language-bash') + ->toContain('language-php'); +}); + +it('renders important alerts with block content', function (): void { + $markdown = <<<'MARKDOWN' +> [!IMPORTANT] +> First important callout. +> +> - First item +> - Second item +MARKDOWN; + + $html = app(MarkdownService::class)->toHtml($markdown); + + expect(substr_count($html, 'dark:border-l-amber-400'))->toBe(1) + ->and($html) + ->toContain('
  • First item
  • ') + ->toContain('
  • Second item
  • '); +}); + +it('keeps empty github alert markers unchanged when there is no content', function (): void { + $markdown = <<<'MARKDOWN' +> [!IMPORTANT] +MARKDOWN; + + $html = app(MarkdownService::class)->toHtml($markdown); + + expect($html)->toContain('[!IMPORTANT]'); +}); diff --git a/tests/Unit/TocParserServiceTest.php b/tests/Unit/TocParserServiceTest.php index 66dc202..ad7b910 100644 --- a/tests/Unit/TocParserServiceTest.php +++ b/tests/Unit/TocParserServiceTest.php @@ -4,24 +4,91 @@ use App\Services\DocsPathService; use App\Services\TocParserService; +use Illuminate\Support\Facades\File; +use Tests\Concerns\UsesDocsFixtures; -it('parses grouped toc sections and maps readme links to root path', function (): void { - $toc = new TocParserService(new DocsPathService)->parse(); - - expect($toc)->toHaveCount(2) - ->and($toc[0]['name'])->toBe('Guides') - ->and($toc[0]['links'])->toBe([ - ['title' => 'Introduction', 'path' => ''], - ['title' => 'Installation', 'path' => 'installation'], - ['title' => 'Zero to Deploy', 'path' => 'zero-to-deploy'], - ]) - ->and($toc[1]['name'])->toBe('References') - ->and($toc[1]['links'])->toBe([ - ['title' => 'Managing Sites', 'path' => 'managing-sites'], - ['title' => 'Managing Servers', 'path' => 'managing-servers'], - ['title' => 'Managing Services', 'path' => 'managing-services'], - ['title' => 'Managing Databases', 'path' => 'managing-databases'], - ['title' => 'Cloud Providers', 'path' => 'cloud-providers'], - ['title' => 'Automation & AI', 'path' => 'automation'], - ]); +uses(UsesDocsFixtures::class); + +beforeEach(function (): void { + $this->configureDocsFixtures(); +}); + +it('parses grouped toc sections from fixture docs', function (): void { + $toc = app(TocParserService::class)->parse(); + + expect($toc)->toBe([ + [ + 'name' => 'Guides', + 'anchor' => 'guides', + 'links' => [ + ['title' => 'Introduction', 'path' => ''], + ['title' => 'Installation', 'path' => 'installation'], + ['title' => 'Link Behavior', 'path' => 'link-behavior'], + ], + ], + [ + 'name' => 'References', + 'anchor' => 'references', + 'links' => [ + ['title' => 'Command Index', 'path' => 'command-index'], + ['title' => 'Operations', 'path' => 'runbooks'], + ], + ], + ]); +}); + +it('returns an empty toc when documentation file is missing', function (): void { + config()->set('docs.path', storage_path('framework/testing/missing-docs-'.uniqid('', true))); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + + expect(app(TocParserService::class)->parse())->toBe([]); +}); + +it('parses flat toc links into the default documentation section', function (): void { + $docsPath = storage_path('framework/testing/docs-flat-'.uniqid('', true)); + + File::ensureDirectoryExists($docsPath); + File::put("{$docsPath}/documentation.md", "- [Readme](README.md)\n- [Guide](guides/guide.md)\n"); + + config()->set('docs.path', $docsPath); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + + $toc = app(TocParserService::class)->parse(); + + expect($toc)->toBe([ + [ + 'name' => 'Documentation', + 'anchor' => 'documentation', + 'links' => [ + ['title' => 'Readme', 'path' => ''], + ['title' => 'Guide', 'path' => 'guide'], + ], + ], + ]); + + File::deleteDirectory($docsPath); +}); + +it('memoizes parsed toc values per service instance', function (): void { + $docsPath = storage_path('framework/testing/docs-memoized-'.uniqid('', true)); + File::ensureDirectoryExists($docsPath); + File::put("{$docsPath}/documentation.md", "- [Original](original.md)\n"); + + config()->set('docs.path', $docsPath); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + + $service = app(TocParserService::class); + + $first = $service->parse(); + + File::put("{$docsPath}/documentation.md", "- [Changed](changed.md)\n"); + + $second = $service->parse(); + + expect($second)->toBe($first); + + File::deleteDirectory($docsPath); });