Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 187 additions & 183 deletions .phpstorm.meta.php

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions app/Livewire/DocsViewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Http\Exceptions\HttpResponseException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Symfony\Component\HttpFoundation\RedirectResponse;

#[Layout('components.layouts.docs')]
final class DocsViewer extends Component
Expand Down Expand Up @@ -52,14 +53,14 @@ public function mount(
return;
}

throw new HttpResponseException(redirect('/', 301));
throw new HttpResponseException(new RedirectResponse('/', 301));
}

// Load the requested document
$document = $documentService->load($page);

if ($document === null) {
throw new HttpResponseException(redirect('/', 301));
throw new HttpResponseException(new RedirectResponse('/', 301));
}

$this->page = $page;
Expand Down
1 change: 1 addition & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class User extends Authenticatable
*
* @return array<string, string>
*/
#[\Override]
protected function casts(): array
{
return [
Expand Down
279 changes: 279 additions & 0 deletions app/Services/CommandCheatSheetService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
<?php

declare(strict_types=1);

namespace App\Services;

use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

final readonly class CommandCheatSheetService
{
public function __construct(
private DocsPathService $docsPath,
) {}

/**
* @return array{
* groups: array<int, array{
* name: string,
* count: int,
* commands: array<int, array{
* primary: string,
* aliases: array<int, string>,
* description: string,
* links: array<int, array{page: string, anchor: string}>
* }>
* }>,
* commandCount: int,
* aliasCount: int
* }
*/
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),
'count' => 0,
'commands' => [],
];
}

$groups[$namespace]['commands'][] = $command;
$groups[$namespace]['count']++;
$aliasCount += count($command['aliases']);
}

ksort($groups);

return [
'groups' => array_values($groups),
'commandCount' => count($commands),
'aliasCount' => $aliasCount,
];
}

/**
* @return array<int, array{
* primary: string,
* aliases: array<int, string>,
* description: string,
* links?: array<int, array{page: string, anchor: string}>
* }>
*/
private function discoverCommands(): array
{
$consolePath = $this->resolveConsolePath();

if ($consolePath === null) {
return [];
}

$commands = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($consolePath));

/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}

if (! str_ends_with($file->getFilename(), 'Command.php')) {
continue;
}

$realPath = $file->getRealPath();

if ($realPath === false) {
continue;
}

$metadata = $this->parseCommandMetadata($realPath);

if ($metadata === null) {
continue;
}

$commands[] = $metadata;
}

usort(
$commands,
static fn (array $left, array $right): int => $left['primary'] <=> $right['primary'],
);

return $commands;
}

private function resolveConsolePath(): ?string
{
$docsPath = $this->docsPath->path();

$candidates = [
dirname($docsPath).'/app/Console',
base_path('vendor/loadinglucian/deployer-php/app/Console'),
];

foreach ($candidates as $candidate) {
if (is_dir($candidate)) {
return $candidate;
}
}

return null;
}

/**
* @return array{primary: string, aliases: array<int, string>, description: string}|null
*/
private function parseCommandMetadata(string $path): ?array
{
$content = file_get_contents($path);

if ($content === false) {
return null;
}

if (preg_match('/#\[AsCommand\((.*?)\)\]/s', $content, $attributeMatches) !== 1) {
return null;
}

$attribute = $attributeMatches[1];

if (preg_match('/name:\s*(?:\'([^\']+)\'|"([^"]+)")/', $attribute, $nameMatches) !== 1) {
return null;
}

$commandNames = $nameMatches[1] !== '' ? $nameMatches[1] : $nameMatches[2];
$description = '';

if (preg_match('/description:\s*(?:\'([^\']+)\'|"([^"]+)")/', $attribute, $descriptionMatches) === 1) {
$description = $descriptionMatches[1] !== '' ? $descriptionMatches[1] : $descriptionMatches[2];
}

$names = array_values(
array_filter(
array_map(
trim(...),
explode('|', $commandNames)
),
static fn (string $name): bool => $name !== '',
),
);

$primary = array_shift($names);

if ($primary === null) {
return null;
}

return [
'primary' => $primary,
'aliases' => $names,
'description' => $description,
];
}

/**
* Scan documentation markdown files to map commands to their doc sections.
*
* @return array<string, array<int, array{page: string, anchor: string}>>
*/
private function resolveCommandLinks(): array
{
$docsPath = $this->docsPath->path();

if (! is_dir($docsPath)) {
return [];
}

$files = glob($docsPath.'/*.md');

if ($files === false) {
return [];
}

sort($files);

/** @var array<string, array<string, array{page: string, anchor: string}>> $seen */
$seen = [];

foreach ($files as $file) {
$page = basename($file, '.md');

if ($page === 'documentation' || $page === 'README') {
continue;
}

$content = file_get_contents($file);

if ($content === false) {
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,
];
}
}
}
}

$links = [];

foreach ($seen as $commandName => $references) {
$links[$commandName] = array_values($references);
}

return $links;
}

private function slugify(string $text): string
{
$slug = mb_strtolower($text);
$slug = (string) preg_replace('/[^a-z0-9\s-]/', '', $slug);
$slug = (string) preg_replace('/[\s-]+/', '-', $slug);

return trim($slug, '-');
}

private function extractNamespace(string $command): string
{
$segments = explode(':', $command, 2);

if ($segments[0] === '') {
return 'general';
}

return $segments[0];
}
}
36 changes: 22 additions & 14 deletions app/Services/TocParserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,6 @@ public function parse(): array

$this->cachedToc = $this->parseContent($content);

// Prepend Home entry for README
$homeSection = [
'name' => '',
'anchor' => 'home',
'links' => [
['title' => 'Home', 'path' => ''],
],
];

$this->cachedToc = [$homeSection, ...$this->cachedToc];

return $this->cachedToc;
}

Expand All @@ -84,7 +73,11 @@ private function parseContent(string $content): array
foreach ($lines as $line) {
// Match section anchor: <a name="section-name"></a>
if (preg_match('/<a\s+name="([^"]+)"><\/a>/', $line, $anchorMatch) === 1) {
if ($currentSection !== null) {
if ($currentSection !== null && $currentSection['links'] !== []) {
if ($currentSection['name'] === '') {
$currentSection['name'] = 'Documentation';
}

$sections[] = $currentSection;
}

Expand All @@ -98,7 +91,11 @@ private function parseContent(string $content): array
}

// Match section header: ## Section Name
if ($currentSection !== null && preg_match('/^##\s+(.+)$/', $line, $headerMatch) === 1) {
if (
$currentSection !== null
&& $currentSection['name'] === ''
&& preg_match('/^##\s+(.+)$/', $line, $headerMatch) === 1
) {
$currentSection['name'] = trim($headerMatch[1]);

continue;
Expand All @@ -123,7 +120,11 @@ private function parseContent(string $content): array
}

// Don't forget the last section
if ($currentSection !== null) {
if ($currentSection !== null && $currentSection['links'] !== []) {
if ($currentSection['name'] === '') {
$currentSection['name'] = 'Documentation';
}

$sections[] = $currentSection;
}

Expand All @@ -137,11 +138,18 @@ private function parseContent(string $content): array
*/
private function normalizePath(string $path): string
{
$path = explode('#', $path, 2)[0];
$path = explode('?', $path, 2)[0];

// Remove .md extension
if (str_ends_with($path, '.md')) {
$path = substr($path, 0, -3);
}

if (strtolower(basename($path)) === 'readme') {
return '';
}

// Extract just the filename (flatten nested paths)
return basename($path);
}
Expand Down
Loading