From ab9308cf82d4d2b83f6f802e4eceec5688144f1c Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Thu, 19 Feb 2026 16:24:23 +0200 Subject: [PATCH 1/8] feat: refactor cheat sheet to Livewire with grouped sections Convert the cheat sheet from a closure route to a full Livewire component with cached data. Group commands into semantic sections (Server & Site Operations, Data Services, Cloud Providers, etc.) for better discoverability. Extract shared header and footer into reusable Blade components, replace target="_blank" with wire:navigate on the TOC cheat sheet link, improve code block styling, and add codex to boost config. --- app/Livewire/CheatSheet.php | 102 +++++++++ app/Services/CommandCheatSheetService.php | 206 +++++++++++------- boost.json | 6 +- config/docs.php | 1 + resources/css/app.css | 4 +- resources/css/highlight.css | 2 +- resources/views/cheat-sheet.blade.php | 153 +++++-------- .../views/components/docs/footer.blade.php | 31 +++ .../views/components/docs/header.blade.php | 29 +++ resources/views/components/docs/toc.blade.php | 2 +- .../components/layouts/cheat-sheet.blade.php | 22 ++ .../views/components/layouts/docs.blade.php | 62 +----- .../views/livewire/cheat-sheet.blade.php | 71 ++++++ routes/web.php | 14 +- tests/Feature/CheatSheetPageTest.php | 35 +++ tests/Feature/DocsSidebarNavigationTest.php | 7 + 16 files changed, 496 insertions(+), 251 deletions(-) create mode 100644 app/Livewire/CheatSheet.php create mode 100644 resources/views/components/docs/footer.blade.php create mode 100644 resources/views/components/docs/header.blade.php create mode 100644 resources/views/components/layouts/cheat-sheet.blade.php create mode 100644 resources/views/livewire/cheat-sheet.blade.php diff --git a/app/Livewire/CheatSheet.php b/app/Livewire/CheatSheet.php new file mode 100644 index 0000000..abd2a0a --- /dev/null +++ b/app/Livewire/CheatSheet.php @@ -0,0 +1,102 @@ +, + * description: string + * }> + * }> + * }> + */ + public array $sections = []; + + /** + * @var array, + * description: string + * }> + * }> + */ + public array $groups = []; + + public int $commandCount = 0; + + public int $aliasCount = 0; + + public function mount(CommandCheatSheetService $sheet): void + { + $cacheTtlSeconds = (int) config('docs.cheat_sheet.cache_ttl_seconds', 300); + $cacheEnabled = ! app()->isLocal() && $cacheTtlSeconds > 0; + + /** @var array{ + * sections: array, + * description: string + * }> + * }> + * }>, + * groups: array, + * description: string + * }> + * }>, + * commandCount: int, + * aliasCount: int + * } $cheatSheet + */ + $cheatSheet = $cacheEnabled + ? Cache::remember( + 'cheat_sheet:v2', + now()->addSeconds($cacheTtlSeconds), + fn (): array => $sheet->build(), + ) + : $sheet->build(); + + $this->sections = $cheatSheet['sections']; + $this->groups = $cheatSheet['groups']; + $this->commandCount = $cheatSheet['commandCount']; + $this->aliasCount = $cheatSheet['aliasCount']; + } + + public function render(): View + { + return view('livewire.cheat-sheet'); + } +} diff --git a/app/Services/CommandCheatSheetService.php b/app/Services/CommandCheatSheetService.php index 5456cc4..af98b83 100644 --- a/app/Services/CommandCheatSheetService.php +++ b/app/Services/CommandCheatSheetService.php @@ -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/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..e441d09 100644 --- a/config/docs.php +++ b/config/docs.php @@ -24,6 +24,7 @@ |-------------------------------------------------------------------------- | | Cache TTL for the command cheat sheet payload in seconds. + | Set this to 0 (or a negative value) to disable caching. | */ diff --git a/resources/css/app.css b/resources/css/app.css index 72ddf44..f516f06 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.1; } .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 index 6465002..7c6b057 100644 --- a/resources/views/cheat-sheet.blade.php +++ b/resources/views/cheat-sheet.blade.php @@ -12,124 +12,85 @@ {{-- Header (matches docs layout) --}} - - - - - DeployerPHP - - - -
- Cheat Sheet - GitHub - - - - - - - -
-
+ {{-- Main Content --}} -
+
{{-- Page Header --}} -
+

Command Cheat Sheet

-

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

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

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

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

+ {{-- Command Groups By Related Documentation Area --}} + @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 -
    - @foreach ($group['commands'] as $command) -
  • - {{ $command['primary'] }} +
    +
    +

    {{ $section['name'] }}

    +

    {{ $sectionDescription }}

    +
    - @if ($command['links'] !== []) - @foreach ($command['links'] as $index => $link) - {{ $index + 1 }} - @endforeach - @endif +
    + @foreach ($section['groups'] as $group) +
    +

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

    - @if ($command['aliases'] !== []) - - alias: - @foreach ($command['aliases'] as $alias) - {{ $alias }}@if (! $loop->last), @endif +
      + @foreach ($group['commands'] as $command) +
    • + {{ $command['primary'] }} +
    • @endforeach - - @endif - - @endforeach -
    -
    - @endforeach -
    +
+
+ @endforeach +
+ + @endforeach +
+ @else +

No commands were discovered for the configured docs path.

+ @endif {{-- 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..9da18e1 100644 --- a/resources/views/components/docs/toc.blade.php +++ b/resources/views/components/docs/toc.blade.php @@ -13,7 +13,7 @@ @endforeach @if ($loop->last) - +
Cheat Sheet diff --git a/resources/views/components/layouts/cheat-sheet.blade.php b/resources/views/components/layouts/cheat-sheet.blade.php new file mode 100644 index 0000000..a0f6123 --- /dev/null +++ b/resources/views/components/layouts/cheat-sheet.blade.php @@ -0,0 +1,22 @@ + + + + + + DeployerPHP - Command Cheat Sheet + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + + + + + {{ $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/livewire/cheat-sheet.blade.php b/resources/views/livewire/cheat-sheet.blade.php new file mode 100644 index 0000000..9286b9b --- /dev/null +++ b/resources/views/livewire/cheat-sheet.blade.php @@ -0,0 +1,71 @@ +
+
+

Command Cheat Sheet

+

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

+
+ +
+ + Prefix commands with + deployer + in your terminal. + + + Global options: + --env + --inventory + --quiet + +
+ + @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/routes/web.php b/routes/web.php index 5d81a0a..6bdfb9b 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('/cheat-sheet', CheatSheet::class)->name('cheat-sheet'); diff --git a/tests/Feature/CheatSheetPageTest.php b/tests/Feature/CheatSheetPageTest.php index a21954b..f667aab 100644 --- a/tests/Feature/CheatSheetPageTest.php +++ b/tests/Feature/CheatSheetPageTest.php @@ -4,15 +4,50 @@ use App\Services\CommandCheatSheetService; use App\Services\DocsPathService; +use Illuminate\Support\Facades\Cache; it('renders the command cheat sheet with discovered commands', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(CommandCheatSheetService::class); + Cache::forget('cheat_sheet:v2'); $response = $this->get(route('cheat-sheet')); $response->assertOk() ->assertSee('Command Cheat Sheet') + ->assertSeeText('Server & Site Operations') + ->assertSeeText('Scheduling & Process Control') + ->assertSeeText('Web Runtime Services') + ->assertSeeText('Data Services') + ->assertSeeText('Cloud Providers') ->assertSee('server:add') ->assertSee('site:deploy'); }); + +it('does not render docs reference links for commands', function (): void { + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(CommandCheatSheetService::class); + Cache::forget('cheat_sheet:v2'); + + $response = $this->get(route('cheat-sheet')); + + $response->assertOk() + ->assertSeeText('site:delete') + ->assertDontSee(route('docs.show', ['page' => 'managing-sites']).'#deleting-a-site', false); +}); + +it('renders the empty-state message when no commands are available', function (): void { + Cache::forget('cheat_sheet:v2'); + + Cache::put('cheat_sheet:v2', [ + 'sections' => [], + 'groups' => [], + 'commandCount' => 0, + 'aliasCount' => 0, + ], now()->addMinutes(5)); + + $response = $this->get(route('cheat-sheet')); + + $response->assertOk() + ->assertSeeText('No commands were discovered for the configured docs path.'); +}); diff --git a/tests/Feature/DocsSidebarNavigationTest.php b/tests/Feature/DocsSidebarNavigationTest.php index 11a7aeb..48a8c37 100644 --- a/tests/Feature/DocsSidebarNavigationTest.php +++ b/tests/Feature/DocsSidebarNavigationTest.php @@ -30,6 +30,13 @@ ->assertSee('href="'.route('home').'"', false) ->assertSee('href="'.route('docs.show', ['page' => 'installation']).'"', false) ->assertSee('href="'.route('docs.show', ['page' => 'managing-services']).'"', false); + + $content = $response->getContent(); + + expect($content)->not->toBeFalse(); + expect((string) $content) + ->toMatch('/]*href="'.preg_quote(route('cheat-sheet'), '/').'"[^>]*wire:navigate(?:="")?[^>]*>/') + ->not->toMatch('/]*href="'.preg_quote(route('cheat-sheet'), '/').'"[^>]*target="_blank"[^>]*>/'); }); it('redirects missing docs pages to the docs home', function (): void { From 33b2ee0d7bfe384987e1107c23ec5e504fbe9fd9 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Thu, 19 Feb 2026 19:48:34 +0200 Subject: [PATCH 2/8] refactor: rename cheat sheet to command index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the CheatSheet feature to CommandIndex across the entire codebase—Livewire component, service, views, layout, route, config key, env var, sidebar TOC link, and tests. --- .phpstorm.meta.php | 12 ++-- .../{CheatSheet.php => CommandIndex.php} | 38 ++++++---- ...eetService.php => CommandIndexService.php} | 2 +- config/docs.php | 8 +-- ...heet.blade.php => command-index.blade.php} | 4 +- resources/views/components/docs/toc.blade.php | 20 +++--- .../components/layouts/cheat-sheet.blade.php | 22 ------ .../layouts/command-index.blade.php | 55 +++++++++++++++ ...heet.blade.php => command-index.blade.php} | 18 ++--- routes/web.php | 4 +- tests/Feature/CheatSheetPageTest.php | 53 -------------- tests/Feature/CommandIndexPageTest.php | 69 +++++++++++++++++++ tests/Feature/DocsSidebarNavigationTest.php | 6 +- 13 files changed, 188 insertions(+), 123 deletions(-) rename app/Livewire/{CheatSheet.php => CommandIndex.php} (70%) rename app/Services/{CommandCheatSheetService.php => CommandIndexService.php} (99%) rename resources/views/{cheat-sheet.blade.php => command-index.blade.php} (98%) delete mode 100644 resources/views/components/layouts/cheat-sheet.blade.php create mode 100644 resources/views/components/layouts/command-index.blade.php rename resources/views/livewire/{cheat-sheet.blade.php => command-index.blade.php} (91%) delete mode 100644 tests/Feature/CheatSheetPageTest.php create mode 100644 tests/Feature/CommandIndexPageTest.php 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/Livewire/CheatSheet.php b/app/Livewire/CommandIndex.php similarity index 70% rename from app/Livewire/CheatSheet.php rename to app/Livewire/CommandIndex.php index abd2a0a..3ee16c4 100644 --- a/app/Livewire/CheatSheet.php +++ b/app/Livewire/CommandIndex.php @@ -4,14 +4,15 @@ namespace App\Livewire; -use App\Services\CommandCheatSheetService; +use App\Services\CommandIndexService; +use App\Services\TocParserService; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Layout; use Livewire\Component; -#[Layout('components.layouts.cheat-sheet')] -final class CheatSheet extends Component +#[Layout('components.layouts.command-index')] +final class CommandIndex extends Component { /** * @var array + * }> + */ + public array $toc = []; + + public function mount(TocParserService $tocParser, CommandIndexService $sheet): void { - $cacheTtlSeconds = (int) config('docs.cheat_sheet.cache_ttl_seconds', 300); + $this->toc = $tocParser->parse(); + + $cacheTtlSeconds = (int) config('docs.command_index.cache_ttl_seconds', 300); $cacheEnabled = ! app()->isLocal() && $cacheTtlSeconds > 0; /** @var array{ @@ -79,24 +91,24 @@ public function mount(CommandCheatSheetService $sheet): void * }>, * commandCount: int, * aliasCount: int - * } $cheatSheet + * } $commandIndex */ - $cheatSheet = $cacheEnabled + $commandIndex = $cacheEnabled ? Cache::remember( - 'cheat_sheet:v2', + 'command_index:v2', now()->addSeconds($cacheTtlSeconds), fn (): array => $sheet->build(), ) : $sheet->build(); - $this->sections = $cheatSheet['sections']; - $this->groups = $cheatSheet['groups']; - $this->commandCount = $cheatSheet['commandCount']; - $this->aliasCount = $cheatSheet['aliasCount']; + $this->sections = $commandIndex['sections']; + $this->groups = $commandIndex['groups']; + $this->commandCount = $commandIndex['commandCount']; + $this->aliasCount = $commandIndex['aliasCount']; } public function render(): View { - return view('livewire.cheat-sheet'); + return view('livewire.command-index'); } } diff --git a/app/Services/CommandCheatSheetService.php b/app/Services/CommandIndexService.php similarity index 99% rename from app/Services/CommandCheatSheetService.php rename to app/Services/CommandIndexService.php index af98b83..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, diff --git a/config/docs.php b/config/docs.php index e441d09..474fa05 100644 --- a/config/docs.php +++ b/config/docs.php @@ -20,16 +20,16 @@ /* |-------------------------------------------------------------------------- - | Cheat Sheet + | Command Index |-------------------------------------------------------------------------- | - | Cache TTL for the command cheat sheet payload in seconds. + | Cache TTL for the command index payload in seconds. | Set this to 0 (or a negative value) to disable caching. | */ - 'cheat_sheet' => [ - 'cache_ttl_seconds' => (int) env('DOCS_CHEAT_SHEET_CACHE_TTL', 300), + 'command_index' => [ + 'cache_ttl_seconds' => (int) env('DOCS_COMMAND_INDEX_CACHE_TTL', 300), ], /* diff --git a/resources/views/cheat-sheet.blade.php b/resources/views/command-index.blade.php similarity index 98% rename from resources/views/cheat-sheet.blade.php rename to resources/views/command-index.blade.php index 7c6b057..8fbf740 100644 --- a/resources/views/cheat-sheet.blade.php +++ b/resources/views/command-index.blade.php @@ -3,7 +3,7 @@ - DeployerPHP - Command Cheat Sheet + DeployerPHP - Command Index @@ -19,7 +19,7 @@
{{-- Page Header --}}
-

Command Cheat Sheet

+

Command Index

{{ $commandCount }} commands organized by deployment workflow. diff --git a/resources/views/components/docs/toc.blade.php b/resources/views/components/docs/toc.blade.php index 9da18e1..7a32f00 100644 --- a/resources/views/components/docs/toc.blade.php +++ b/resources/views/components/docs/toc.blade.php @@ -11,16 +11,16 @@ {{ $link['title'] }} @endforeach - - @if ($loop->last) - -

- Cheat Sheet - - -
- - @endif @endforeach + + + +
+ Command Index + + +
+
+
diff --git a/resources/views/components/layouts/cheat-sheet.blade.php b/resources/views/components/layouts/cheat-sheet.blade.php deleted file mode 100644 index a0f6123..0000000 --- a/resources/views/components/layouts/cheat-sheet.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - DeployerPHP - Command Cheat Sheet - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @fluxAppearance - - - - - - {{ $slot }} - - - @fluxScripts - - 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/livewire/cheat-sheet.blade.php b/resources/views/livewire/command-index.blade.php similarity index 91% rename from resources/views/livewire/cheat-sheet.blade.php rename to resources/views/livewire/command-index.blade.php index 9286b9b..887fc0a 100644 --- a/resources/views/livewire/cheat-sheet.blade.php +++ b/resources/views/livewire/command-index.blade.php @@ -1,11 +1,15 @@ -
-
-

Command Cheat Sheet

-

- {{ $commandCount }} + + + + +

+
+

Command Index

+

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

-
+
@@ -66,6 +70,4 @@ @else

No commands were discovered for the configured docs path.

@endif - -
diff --git a/routes/web.php b/routes/web.php index 6bdfb9b..509d5b6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,6 @@ name('docs.show') ->where(['page' => '[a-z0-9-]+']); -Route::get('/cheat-sheet', CheatSheet::class)->name('cheat-sheet'); +Route::get('/command-index', CommandIndex::class)->name('command-index'); diff --git a/tests/Feature/CheatSheetPageTest.php b/tests/Feature/CheatSheetPageTest.php deleted file mode 100644 index f667aab..0000000 --- a/tests/Feature/CheatSheetPageTest.php +++ /dev/null @@ -1,53 +0,0 @@ -forgetInstance(DocsPathService::class); - app()->forgetInstance(CommandCheatSheetService::class); - Cache::forget('cheat_sheet:v2'); - - $response = $this->get(route('cheat-sheet')); - - $response->assertOk() - ->assertSee('Command Cheat Sheet') - ->assertSeeText('Server & Site Operations') - ->assertSeeText('Scheduling & Process Control') - ->assertSeeText('Web Runtime Services') - ->assertSeeText('Data Services') - ->assertSeeText('Cloud Providers') - ->assertSee('server:add') - ->assertSee('site:deploy'); -}); - -it('does not render docs reference links for commands', function (): void { - app()->forgetInstance(DocsPathService::class); - app()->forgetInstance(CommandCheatSheetService::class); - Cache::forget('cheat_sheet:v2'); - - $response = $this->get(route('cheat-sheet')); - - $response->assertOk() - ->assertSeeText('site:delete') - ->assertDontSee(route('docs.show', ['page' => 'managing-sites']).'#deleting-a-site', false); -}); - -it('renders the empty-state message when no commands are available', function (): void { - Cache::forget('cheat_sheet:v2'); - - Cache::put('cheat_sheet:v2', [ - 'sections' => [], - 'groups' => [], - 'commandCount' => 0, - 'aliasCount' => 0, - ], now()->addMinutes(5)); - - $response = $this->get(route('cheat-sheet')); - - $response->assertOk() - ->assertSeeText('No commands were discovered for the configured docs path.'); -}); diff --git a/tests/Feature/CommandIndexPageTest.php b/tests/Feature/CommandIndexPageTest.php new file mode 100644 index 0000000..dd9874e --- /dev/null +++ b/tests/Feature/CommandIndexPageTest.php @@ -0,0 +1,69 @@ +forgetInstance(DocsPathService::class); + app()->forgetInstance(CommandIndexService::class); + Cache::forget('command_index:v2'); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSee('Command Index') + ->assertSeeText('Server & Site Operations') + ->assertSeeText('Scheduling & Process Control') + ->assertSeeText('Web Runtime Services') + ->assertSeeText('Data Services') + ->assertSeeText('Cloud Providers') + ->assertSee('server:add') + ->assertSee('site:deploy'); +}); + +it('does not render docs reference links for commands', function (): void { + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(CommandIndexService::class); + Cache::forget('command_index:v2'); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSeeText('site:delete') + ->assertDontSee(route('docs.show', ['page' => 'managing-sites']).'#deleting-a-site', false); +}); + +it('renders the empty-state message when no commands are available', function (): void { + Cache::forget('command_index:v2'); + + Cache::put('command_index:v2', [ + 'sections' => [], + 'groups' => [], + 'commandCount' => 0, + 'aliasCount' => 0, + ], now()->addMinutes(5)); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSeeText('No commands were discovered for the configured docs path.'); +}); + +it('renders the docs-style single sidebar layout', function (): void { + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(CommandIndexService::class); + Cache::forget('command_index:v2'); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSee('href="'.route('home').'"', false) + ->assertSeeText('Guides') + ->assertSee('lg:grid-cols-[280px_1fr]', false) + ->assertDontSee('lg:grid-cols-[280px_1fr_280px]', false); +}); diff --git a/tests/Feature/DocsSidebarNavigationTest.php b/tests/Feature/DocsSidebarNavigationTest.php index 48a8c37..0b824b3 100644 --- a/tests/Feature/DocsSidebarNavigationTest.php +++ b/tests/Feature/DocsSidebarNavigationTest.php @@ -26,6 +26,8 @@ 'Managing Databases', 'Cloud Providers', 'Automation & AI', + 'Index', + 'Command Index', ]) ->assertSee('href="'.route('home').'"', false) ->assertSee('href="'.route('docs.show', ['page' => 'installation']).'"', false) @@ -35,8 +37,8 @@ expect($content)->not->toBeFalse(); expect((string) $content) - ->toMatch('/]*href="'.preg_quote(route('cheat-sheet'), '/').'"[^>]*wire:navigate(?:="")?[^>]*>/') - ->not->toMatch('/]*href="'.preg_quote(route('cheat-sheet'), '/').'"[^>]*target="_blank"[^>]*>/'); + ->toMatch('/]*href="'.preg_quote(route('command-index'), '/').'"[^>]*wire:navigate(?:="")?[^>]*>/') + ->not->toMatch('/]*href="'.preg_quote(route('command-index'), '/').'"[^>]*target="_blank"[^>]*>/'); }); it('redirects missing docs pages to the docs home', function (): void { From 4eda191053fc1f6cacdbbef29057e095ab36a87e Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Thu, 19 Feb 2026 20:06:45 +0200 Subject: [PATCH 3/8] fixup --- resources/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/css/app.css b/resources/css/app.css index f516f06..3d50187 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -160,7 +160,7 @@ padding: 2rem 12px 12px; margin-top: 1.5rem; margin-bottom: 1.5rem; - line-height: 1.1; + line-height: 1.25; } .prose :where(pre code) { From 5bf77cd7a5e80055f240e899257812898cc7eb22 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Fri, 20 Feb 2026 11:40:26 +0200 Subject: [PATCH 4/8] feat: introduce file-backed DocsOutputCacheService Replaces the Laravel Cache facade approach with a dedicated file-backed cache service that integrates with optimize:clear. - Add DocsOutputCacheService with SHA1-keyed JSON files, atomic writes via temp-rename, and flock-based double-checked locking - Add DocsClearCommand registered via AppServiceProvider::optimizes() so optimize:clear wipes the docs output cache automatically - Refactor DocsViewer and CommandIndex to cache full page payloads (including TOC) through DocsOutputCacheService - Add DocumentService file fingerprinting (mtime+size) for per-document cache invalidation without TTLs - Replace command_index.cache_ttl_seconds config with unified docs.cache.* config block - Add optimize:clear before optimize in deploy script --- .deployer/scripts/deploy.sh | 2 +- app/Console/Commands/DocsClearCommand.php | 24 ++ app/Livewire/CommandIndex.php | 118 +++++++-- app/Livewire/DocsViewer.php | 100 ++++++-- app/Providers/AppServiceProvider.php | 4 +- app/Services/DocsOutputCacheService.php | 269 ++++++++++++++++++++ app/Services/DocumentService.php | 66 +++-- config/docs.php | 13 +- tests/Feature/CommandIndexPageTest.php | 97 ++++++- tests/Feature/DocsClearCommandTest.php | 27 ++ tests/Feature/DocsSidebarNavigationTest.php | 47 ++++ tests/Feature/DocumentServiceCacheTest.php | 56 ++++ 12 files changed, 747 insertions(+), 76 deletions(-) create mode 100644 app/Console/Commands/DocsClearCommand.php create mode 100644 app/Services/DocsOutputCacheService.php create mode 100644 tests/Feature/DocsClearCommandTest.php create mode 100644 tests/Feature/DocumentServiceCacheTest.php 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/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/Livewire/CommandIndex.php b/app/Livewire/CommandIndex.php index 3ee16c4..2e8e83a 100644 --- a/app/Livewire/CommandIndex.php +++ b/app/Livewire/CommandIndex.php @@ -5,9 +5,9 @@ namespace App\Livewire; use App\Services\CommandIndexService; +use App\Services\DocsOutputCacheService; use App\Services\TocParserService; use Illuminate\Contracts\View\View; -use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Layout; use Livewire\Component; @@ -58,14 +58,17 @@ final class CommandIndex extends Component */ public array $toc = []; - public function mount(TocParserService $tocParser, CommandIndexService $sheet): void - { - $this->toc = $tocParser->parse(); - - $cacheTtlSeconds = (int) config('docs.command_index.cache_ttl_seconds', 300); - $cacheEnabled = ! app()->isLocal() && $cacheTtlSeconds > 0; - + public function mount( + DocsOutputCacheService $docsOutputCache, + TocParserService $tocParser, + CommandIndexService $sheet, + ): void { /** @var array{ + * toc: array + * }>, * sections: array, * commandCount: int, * aliasCount: int - * } $commandIndex + * } $payload */ - $commandIndex = $cacheEnabled - ? Cache::remember( - 'command_index:v2', - now()->addSeconds($cacheTtlSeconds), - fn (): array => $sheet->build(), - ) - : $sheet->build(); + $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->sections = $commandIndex['sections']; - $this->groups = $commandIndex['groups']; - $this->commandCount = $commandIndex['commandCount']; - $this->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/DocsOutputCacheService.php b/app/Services/DocsOutputCacheService.php new file mode 100644 index 0000000..3429c5b --- /dev/null +++ b/app/Services/DocsOutputCacheService.php @@ -0,0 +1,269 @@ + $this->normalizeSegment($segment), + $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/config/docs.php b/config/docs.php index 474fa05..5321b23 100644 --- a/config/docs.php +++ b/config/docs.php @@ -20,16 +20,19 @@ /* |-------------------------------------------------------------------------- - | Command Index + | Docs Output Cache |-------------------------------------------------------------------------- | - | Cache TTL for the command index payload in seconds. - | Set this to 0 (or a negative value) to disable caching. + | Shared output cache for docs pages and command index payloads. + | This cache is file-backed and can be cleared via optimize:clear. | */ - 'command_index' => [ - 'cache_ttl_seconds' => (int) env('DOCS_COMMAND_INDEX_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/tests/Feature/CommandIndexPageTest.php b/tests/Feature/CommandIndexPageTest.php index dd9874e..1ac71be 100644 --- a/tests/Feature/CommandIndexPageTest.php +++ b/tests/Feature/CommandIndexPageTest.php @@ -3,14 +3,20 @@ declare(strict_types=1); use App\Services\CommandIndexService; +use App\Services\DocsOutputCacheService; use App\Services\DocsPathService; use App\Services\TocParserService; -use Illuminate\Support\Facades\Cache; + +beforeEach(function (): void { + config()->set('docs.cache.path', storage_path('framework/cache/docs-output-tests/'.uniqid('', true))); + + app()->forgetInstance(DocsOutputCacheService::class); + app(DocsOutputCacheService::class)->clear(); +}); it('renders the command index with discovered commands', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(CommandIndexService::class); - Cache::forget('command_index:v2'); $response = $this->get(route('command-index')); @@ -28,7 +34,6 @@ it('does not render docs reference links for commands', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(CommandIndexService::class); - Cache::forget('command_index:v2'); $response = $this->get(route('command-index')); @@ -38,14 +43,18 @@ }); it('renders the empty-state message when no commands are available', function (): void { - Cache::forget('command_index:v2'); + $docsOutputCache = app(DocsOutputCacheService::class); - Cache::put('command_index:v2', [ - 'sections' => [], - 'groups' => [], - 'commandCount' => 0, - 'aliasCount' => 0, - ], now()->addMinutes(5)); + $docsOutputCache->put( + $docsOutputCache->key('command-index'), + [ + 'toc' => [], + 'sections' => [], + 'groups' => [], + 'commandCount' => 0, + 'aliasCount' => 0, + ], + ); $response = $this->get(route('command-index')); @@ -57,7 +66,6 @@ app()->forgetInstance(DocsPathService::class); app()->forgetInstance(TocParserService::class); app()->forgetInstance(CommandIndexService::class); - Cache::forget('command_index:v2'); $response = $this->get(route('command-index')); @@ -67,3 +75,70 @@ ->assertSee('lg:grid-cols-[280px_1fr]', false) ->assertDontSee('lg:grid-cols-[280px_1fr_280px]', false); }); + +it('uses cached command index payload when available', function (): void { + $docsOutputCache = app(DocsOutputCacheService::class); + $unresolvableDocsPath = storage_path('framework/testing/non-existent-docs-'.uniqid('', true)); + + config()->set('docs.path', $unresolvableDocsPath); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(CommandIndexService::class); + + $docsOutputCache->put( + $docsOutputCache->key('command-index'), + [ + 'toc' => [ + [ + 'name' => 'Cached Nav', + 'anchor' => 'cached-nav', + 'links' => [ + ['title' => 'Cached Command Index', 'path' => 'command-index'], + ], + ], + ], + 'sections' => [ + [ + 'name' => 'Cached Section', + 'count' => 1, + 'namespaceCount' => 1, + 'groups' => [ + [ + 'name' => 'Cached', + 'count' => 1, + 'commands' => [ + [ + 'primary' => 'cached:run', + 'aliases' => [], + 'description' => 'Cached command description', + ], + ], + ], + ], + ], + ], + 'groups' => [ + [ + 'name' => 'Cached', + 'count' => 1, + 'commands' => [ + [ + 'primary' => 'cached:run', + 'aliases' => [], + 'description' => 'Cached command description', + ], + ], + ], + ], + 'commandCount' => 1, + 'aliasCount' => 0, + ], + ); + + $response = $this->get(route('command-index')); + + $response->assertOk() + ->assertSeeText('Cached Section') + ->assertSeeText('cached:run') + ->assertSeeText('Cached Nav'); +}); 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/DocsSidebarNavigationTest.php b/tests/Feature/DocsSidebarNavigationTest.php index 0b824b3..1f23d22 100644 --- a/tests/Feature/DocsSidebarNavigationTest.php +++ b/tests/Feature/DocsSidebarNavigationTest.php @@ -2,10 +2,18 @@ declare(strict_types=1); +use App\Services\DocsOutputCacheService; use App\Services\DocsPathService; use App\Services\DocumentService; use App\Services\TocParserService; +beforeEach(function (): void { + config()->set('docs.cache.path', storage_path('framework/cache/docs-output-tests/'.uniqid('', true))); + + app()->forgetInstance(DocsOutputCacheService::class); + app(DocsOutputCacheService::class)->clear(); +}); + it('renders grouped sidebar navigation from documentation toc', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(TocParserService::class); @@ -50,3 +58,42 @@ ->assertRedirect('/') ->assertStatus(301); }); + +it('uses cached docs viewer payload when available', function (): void { + $docsOutputCache = app(DocsOutputCacheService::class); + $unresolvableDocsPath = storage_path('framework/testing/non-existent-docs-'.uniqid('', true)); + + config()->set('docs.path', $unresolvableDocsPath); + app()->forgetInstance(DocsPathService::class); + app()->forgetInstance(TocParserService::class); + app()->forgetInstance(DocumentService::class); + + $docsOutputCache->put( + $docsOutputCache->key('docs-viewer', ['home']), + [ + 'page' => '', + 'title' => 'Cached Docs Title', + 'content' => '

Cached Docs Title

Cached body.

', + 'headings' => [], + 'toc' => [ + [ + 'name' => 'Cached Section', + 'anchor' => 'cached-section', + 'links' => [ + [ + 'title' => 'Cached Link', + 'path' => 'cached-link', + ], + ], + ], + ], + ], + ); + + $response = $this->get('/'); + + $response->assertOk() + ->assertSeeText('Cached Docs Title') + ->assertSeeText('Cached Section') + ->assertSeeText('Cached Link'); +}); diff --git a/tests/Feature/DocumentServiceCacheTest.php b/tests/Feature/DocumentServiceCacheTest.php new file mode 100644 index 0000000..66d8dc0 --- /dev/null +++ b/tests/Feature/DocumentServiceCacheTest.php @@ -0,0 +1,56 @@ +set('docs.path', $docsPath); + config()->set('docs.cache.path', $cachePath); + + app()->forgetInstance(DocsOutputCacheService::class); + app()->forgetInstance(DocumentService::class); + + $docsOutputCache = app(DocsOutputCacheService::class); + $documentService = app(DocumentService::class); + + $firstLoad = $documentService->load('cached-page'); + + expect($firstLoad)->not->toBeNull(); + + $modifiedAt = filemtime("{$docsPath}/cached-page.md"); + $size = filesize("{$docsPath}/cached-page.md"); + $fingerprint = sprintf( + '%d:%d', + $modifiedAt === false ? 0 : $modifiedAt, + $size === false ? 0 : $size, + ); + + $docsOutputCache->put( + $docsOutputCache->key('document', ['page:cached-page', $fingerprint]), + [ + 'title' => 'From Cache', + 'content' => '

From Cache

Cached document payload.

', + 'headings' => [], + ], + ); + + $secondLoad = $documentService->load('cached-page'); + + expect($secondLoad)->toMatchArray([ + 'title' => 'From Cache', + 'content' => '

From Cache

Cached document payload.

', + 'headings' => [], + ]); + + File::deleteDirectory($docsPath); + File::deleteDirectory($cachePath); +}); From 3d1b0778eb1a66442d1e92b27fb661dba13079d6 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Fri, 20 Feb 2026 12:08:03 +0200 Subject: [PATCH 5/8] test: add comprehensive fixture-based test suite Replaces real-docs-dependent tests with isolated fixture-based tests using a shared UsesDocsFixtures concern. Adds full unit coverage for all services (DocumentService, MarkdownService, HeadingExtractorService, CommandIndexService, DocsOutputCacheService, TocParserService) and a Livewire DocsViewer test. Removes superseded feature-level cache tests. Simplifies Pest.php and ArchTest to match actual project structure. --- tests/Arch/ArchTest.php | 128 ++-------------- tests/Concerns/UsesDocsFixtures.php | 38 +++++ tests/Feature/CommandIndexPageTest.php | 95 +++--------- tests/Feature/DocsRoutesSmokeTest.php | 32 ++++ tests/Feature/DocsSidebarNavigationTest.php | 87 +++-------- tests/Feature/DocumentServiceCacheTest.php | 56 ------- tests/Feature/MarkdownServiceTest.php | 40 ----- tests/Fixtures/docs/README.md | 5 + .../app/Console/Commands/FakeCloudCommand.php | 10 ++ .../app/Console/Commands/FakeOtherCommand.php | 10 ++ .../Console/Commands/FakeServerCommand.php | 10 ++ .../app/Console/Commands/InvalidCommand.php | 7 + tests/Fixtures/docs/docs/documentation.md | 10 ++ tests/Fixtures/docs/docs/installation.md | 10 ++ tests/Fixtures/docs/docs/link-behavior.md | 12 ++ tests/Pest.php | 44 ------ tests/Unit/CommandIndexServiceTest.php | 54 +++++++ tests/Unit/DocsOutputCacheServiceTest.php | 145 ++++++++++++++++++ tests/Unit/DocumentServiceTest.php | 86 +++++++++++ tests/Unit/HeadingExtractorServiceTest.php | 48 ++++++ tests/Unit/Livewire/DocsViewerTest.php | 68 ++++++++ tests/Unit/MarkdownServiceTest.php | 118 ++++++++++++++ tests/Unit/TocParserServiceTest.php | 105 ++++++++++--- 23 files changed, 801 insertions(+), 417 deletions(-) create mode 100644 tests/Concerns/UsesDocsFixtures.php create mode 100644 tests/Feature/DocsRoutesSmokeTest.php delete mode 100644 tests/Feature/DocumentServiceCacheTest.php delete mode 100644 tests/Feature/MarkdownServiceTest.php create mode 100644 tests/Fixtures/docs/README.md create mode 100644 tests/Fixtures/docs/app/Console/Commands/FakeCloudCommand.php create mode 100644 tests/Fixtures/docs/app/Console/Commands/FakeOtherCommand.php create mode 100644 tests/Fixtures/docs/app/Console/Commands/FakeServerCommand.php create mode 100644 tests/Fixtures/docs/app/Console/Commands/InvalidCommand.php create mode 100644 tests/Fixtures/docs/docs/documentation.md create mode 100644 tests/Fixtures/docs/docs/installation.md create mode 100644 tests/Fixtures/docs/docs/link-behavior.md create mode 100644 tests/Unit/CommandIndexServiceTest.php create mode 100644 tests/Unit/DocsOutputCacheServiceTest.php create mode 100644 tests/Unit/DocumentServiceTest.php create mode 100644 tests/Unit/HeadingExtractorServiceTest.php create mode 100644 tests/Unit/Livewire/DocsViewerTest.php create mode 100644 tests/Unit/MarkdownServiceTest.php 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/CommandIndexPageTest.php b/tests/Feature/CommandIndexPageTest.php index 1ac71be..bbb3a54 100644 --- a/tests/Feature/CommandIndexPageTest.php +++ b/tests/Feature/CommandIndexPageTest.php @@ -6,92 +6,37 @@ use App\Services\DocsOutputCacheService; use App\Services\DocsPathService; use App\Services\TocParserService; +use Tests\Concerns\UsesDocsFixtures; -beforeEach(function (): void { - config()->set('docs.cache.path', storage_path('framework/cache/docs-output-tests/'.uniqid('', true))); - - app()->forgetInstance(DocsOutputCacheService::class); - app(DocsOutputCacheService::class)->clear(); -}); - -it('renders the command index with discovered commands', function (): void { - app()->forgetInstance(DocsPathService::class); - app()->forgetInstance(CommandIndexService::class); - - $response = $this->get(route('command-index')); - - $response->assertOk() - ->assertSee('Command Index') - ->assertSeeText('Server & Site Operations') - ->assertSeeText('Scheduling & Process Control') - ->assertSeeText('Web Runtime Services') - ->assertSeeText('Data Services') - ->assertSeeText('Cloud Providers') - ->assertSee('server:add') - ->assertSee('site:deploy'); -}); +uses(UsesDocsFixtures::class); -it('does not render docs reference links for commands', function (): void { - app()->forgetInstance(DocsPathService::class); - app()->forgetInstance(CommandIndexService::class); - - $response = $this->get(route('command-index')); - - $response->assertOk() - ->assertSeeText('site:delete') - ->assertDontSee(route('docs.show', ['page' => 'managing-sites']).'#deleting-a-site', false); -}); - -it('renders the empty-state message when no commands are available', function (): void { - $docsOutputCache = app(DocsOutputCacheService::class); - - $docsOutputCache->put( - $docsOutputCache->key('command-index'), - [ - 'toc' => [], - 'sections' => [], - 'groups' => [], - 'commandCount' => 0, - 'aliasCount' => 0, - ], - ); - - $response = $this->get(route('command-index')); - - $response->assertOk() - ->assertSeeText('No commands were discovered for the configured docs path.'); +beforeEach(function (): void { + $this->configureDocsFixtures(); }); -it('renders the docs-style single sidebar layout', function (): void { +it('renders the command index using fixture commands', function (): void { app()->forgetInstance(DocsPathService::class); - app()->forgetInstance(TocParserService::class); app()->forgetInstance(CommandIndexService::class); $response = $this->get(route('command-index')); $response->assertOk() - ->assertSee('href="'.route('home').'"', false) - ->assertSeeText('Guides') - ->assertSee('lg:grid-cols-[280px_1fr]', false) - ->assertDontSee('lg:grid-cols-[280px_1fr_280px]', false); + ->assertSeeText('Command Index') + ->assertSeeText('server:add') + ->assertSeeText('aws:region') + ->assertSeeText('custom-task'); }); -it('uses cached command index payload when available', function (): void { +it('uses cached command index payload when docs are unavailable', function (): void { $docsOutputCache = app(DocsOutputCacheService::class); - $unresolvableDocsPath = storage_path('framework/testing/non-existent-docs-'.uniqid('', true)); - - config()->set('docs.path', $unresolvableDocsPath); - app()->forgetInstance(DocsPathService::class); - app()->forgetInstance(TocParserService::class); - app()->forgetInstance(CommandIndexService::class); $docsOutputCache->put( $docsOutputCache->key('command-index'), [ 'toc' => [ [ - 'name' => 'Cached Nav', - 'anchor' => 'cached-nav', + 'name' => 'Cached', + 'anchor' => 'cached', 'links' => [ ['title' => 'Cached Command Index', 'path' => 'command-index'], ], @@ -104,13 +49,13 @@ 'namespaceCount' => 1, 'groups' => [ [ - 'name' => 'Cached', + 'name' => 'Cached Group', 'count' => 1, 'commands' => [ [ 'primary' => 'cached:run', 'aliases' => [], - 'description' => 'Cached command description', + 'description' => 'Cached command', ], ], ], @@ -119,13 +64,13 @@ ], 'groups' => [ [ - 'name' => 'Cached', + 'name' => 'Cached Group', 'count' => 1, 'commands' => [ [ 'primary' => 'cached:run', 'aliases' => [], - 'description' => 'Cached command description', + 'description' => 'Cached command', ], ], ], @@ -135,10 +80,14 @@ ], ); + 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') - ->assertSeeText('Cached Nav'); + ->assertSeeText('cached:run'); }); 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 1f23d22..812b9a9 100644 --- a/tests/Feature/DocsSidebarNavigationTest.php +++ b/tests/Feature/DocsSidebarNavigationTest.php @@ -2,98 +2,49 @@ declare(strict_types=1); -use App\Services\DocsOutputCacheService; use App\Services\DocsPathService; use App\Services\DocumentService; use App\Services\TocParserService; +use Tests\Concerns\UsesDocsFixtures; -beforeEach(function (): void { - config()->set('docs.cache.path', storage_path('framework/cache/docs-output-tests/'.uniqid('', true))); +uses(UsesDocsFixtures::class); - app()->forgetInstance(DocsOutputCacheService::class); - app(DocsOutputCacheService::class)->clear(); +beforeEach(function (): void { + $this->configureDocsFixtures(); }); -it('renders grouped sidebar navigation from documentation toc', function (): void { +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', - 'Index', - 'Command Index', - ]) - ->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); - - $content = $response->getContent(); - - expect($content)->not->toBeFalse(); - expect((string) $content) - ->toMatch('/]*href="'.preg_quote(route('command-index'), '/').'"[^>]*wire:navigate(?:="")?[^>]*>/') - ->not->toMatch('/]*href="'.preg_quote(route('command-index'), '/').'"[^>]*target="_blank"[^>]*>/'); + ->assertSee('href="'.route('command-index').'"', false); }); -it('redirects missing docs pages to the docs home', function (): void { +it('renders a fixture docs page', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(TocParserService::class); app()->forgetInstance(DocumentService::class); - $this->get('/docs/introduction') - ->assertRedirect('/') - ->assertStatus(301); -}); + $response = $this->get(route('docs.show', ['page' => 'installation'])); -it('uses cached docs viewer payload when available', function (): void { - $docsOutputCache = app(DocsOutputCacheService::class); - $unresolvableDocsPath = storage_path('framework/testing/non-existent-docs-'.uniqid('', true)); + $response->assertOk() + ->assertSeeText('Installation') + ->assertSeeText('Requirements'); +}); - config()->set('docs.path', $unresolvableDocsPath); +it('redirects missing docs pages to the docs home', function (): void { app()->forgetInstance(DocsPathService::class); app()->forgetInstance(TocParserService::class); app()->forgetInstance(DocumentService::class); - $docsOutputCache->put( - $docsOutputCache->key('docs-viewer', ['home']), - [ - 'page' => '', - 'title' => 'Cached Docs Title', - 'content' => '

Cached Docs Title

Cached body.

', - 'headings' => [], - 'toc' => [ - [ - 'name' => 'Cached Section', - 'anchor' => 'cached-section', - 'links' => [ - [ - 'title' => 'Cached Link', - 'path' => 'cached-link', - ], - ], - ], - ], - ], - ); - - $response = $this->get('/'); - - $response->assertOk() - ->assertSeeText('Cached Docs Title') - ->assertSeeText('Cached Section') - ->assertSeeText('Cached Link'); + $this->get(route('docs.show', ['page' => 'does-not-exist'])) + ->assertRedirect(route('home')) + ->assertMovedPermanently(); }); diff --git a/tests/Feature/DocumentServiceCacheTest.php b/tests/Feature/DocumentServiceCacheTest.php deleted file mode 100644 index 66d8dc0..0000000 --- a/tests/Feature/DocumentServiceCacheTest.php +++ /dev/null @@ -1,56 +0,0 @@ -set('docs.path', $docsPath); - config()->set('docs.cache.path', $cachePath); - - app()->forgetInstance(DocsOutputCacheService::class); - app()->forgetInstance(DocumentService::class); - - $docsOutputCache = app(DocsOutputCacheService::class); - $documentService = app(DocumentService::class); - - $firstLoad = $documentService->load('cached-page'); - - expect($firstLoad)->not->toBeNull(); - - $modifiedAt = filemtime("{$docsPath}/cached-page.md"); - $size = filesize("{$docsPath}/cached-page.md"); - $fingerprint = sprintf( - '%d:%d', - $modifiedAt === false ? 0 : $modifiedAt, - $size === false ? 0 : $size, - ); - - $docsOutputCache->put( - $docsOutputCache->key('document', ['page:cached-page', $fingerprint]), - [ - 'title' => 'From Cache', - 'content' => '

From Cache

Cached document payload.

', - 'headings' => [], - ], - ); - - $secondLoad = $documentService->load('cached-page'); - - expect($secondLoad)->toMatchArray([ - 'title' => 'From Cache', - 'content' => '

From Cache

Cached document payload.

', - 'headings' => [], - ]); - - File::deleteDirectory($docsPath); - File::deleteDirectory($cachePath); -}); 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); }); From b6dcd6b2b176101ebf2198edc2140a3643aa0dfc Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Fri, 20 Feb 2026 12:27:29 +0200 Subject: [PATCH 6/8] chore: clean up stale files and polish command index view - Remove HomeController (broken redirect to self) and welcome.blade.php - Delete old non-Livewire command-index view - Fix x-slot closing tag in Livewire command-index view - Remove redundant prefix info bar from command-index view - Add PHPDoc array type annotation to DocsOutputCacheService::key() --- app/Http/Controllers/HomeController.php | 18 -- app/Services/DocsOutputCacheService.php | 3 + resources/views/command-index.blade.php | 98 ------- .../views/livewire/command-index.blade.php | 16 +- resources/views/welcome.blade.php | 277 ------------------ 5 files changed, 4 insertions(+), 408 deletions(-) delete mode 100644 app/Http/Controllers/HomeController.php delete mode 100644 resources/views/command-index.blade.php delete mode 100644 resources/views/welcome.blade.php 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 @@ - $segments + */ public function key(string $namespace, array $segments = []): string { $normalizedSegments = array_map( diff --git a/resources/views/command-index.blade.php b/resources/views/command-index.blade.php deleted file mode 100644 index 8fbf740..0000000 --- a/resources/views/command-index.blade.php +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - DeployerPHP - Command Index - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @fluxAppearance - - - {{-- Header (matches docs layout) --}} - - - {{-- Main Content --}} - -
    - {{-- Page Header --}} -
    -

    Command Index

    -

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

    -
    - - {{-- Global Options Hint --}} -
    - - Prefix commands with - deployer - in your terminal. - - - Global options: - --env - --inventory - --quiet - -
    - - {{-- Command Groups By Related Documentation Area --}} - @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 -
    - - {{-- Footer (matches docs layout) --}} - -
    - - @fluxScripts - - diff --git a/resources/views/livewire/command-index.blade.php b/resources/views/livewire/command-index.blade.php index 887fc0a..b55f104 100644 --- a/resources/views/livewire/command-index.blade.php +++ b/resources/views/livewire/command-index.blade.php @@ -1,6 +1,6 @@ - +
    @@ -11,20 +11,6 @@

    -
    - - Prefix commands with - deployer - in your terminal. - - - Global options: - --env - --inventory - --quiet - -
    - @if ($sections !== [])
    @foreach ($sections as $section) 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 - - From 0d62fe992f04aad3c4cb53b3b30f7071efb3e7ff Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Fri, 20 Feb 2026 12:30:23 +0200 Subject: [PATCH 7/8] fixup --- app/Services/DocsOutputCacheService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/DocsOutputCacheService.php b/app/Services/DocsOutputCacheService.php index 20349d7..7145264 100644 --- a/app/Services/DocsOutputCacheService.php +++ b/app/Services/DocsOutputCacheService.php @@ -19,7 +19,7 @@ public function __construct( public function key(string $namespace, array $segments = []): string { $normalizedSegments = array_map( - fn (mixed $segment): string => $this->normalizeSegment($segment), + $this->normalizeSegment(...), $segments, ); From 02f024fc53096c7ddd5c81e14e3a81399fa399f6 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Fri, 20 Feb 2026 12:37:57 +0200 Subject: [PATCH 8/8] fixup --- resources/views/components/docs/toc.blade.php | 8 +--- .../icon/square-arrow-out-up-right.blade.php | 45 ------------------- 2 files changed, 1 insertion(+), 52 deletions(-) delete mode 100644 resources/views/flux/icon/square-arrow-out-up-right.blade.php diff --git a/resources/views/components/docs/toc.blade.php b/resources/views/components/docs/toc.blade.php index 7a32f00..f65c13e 100644 --- a/resources/views/components/docs/toc.blade.php +++ b/resources/views/components/docs/toc.blade.php @@ -15,12 +15,6 @@ @endforeach - -
    - Command Index - - -
    -
    + Command Index
    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" -> - - - -