From db42d5d16384c387cbaebebbaf205a303d92d115 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 23:20:49 +0200 Subject: [PATCH 1/9] refactor(docs): introduce DocsPlatform enum and DocsVersionRegistry Replaces bare 'desktop'/'mobile' strings scattered across DocsSearchService, DocsLabels, and the docs controller with a typed DocsPlatform enum, and collapses the four hand-copied version-label arrays in index.blade.php into DocsVersionRegistry::switcherLabels(), sourced from config('docs') instead of being duplicated per platform per render location. Co-Authored-By: Claude Sonnet 5 --- app/Enums/DocsPlatform.php | 24 ++++++ .../ShowDocumentationController.php | 7 ++ app/Services/DocsSearchService.php | 20 +++-- app/Services/DocsVersionRegistry.php | 48 ++++++++++++ app/Services/DocsVersionService.php | 8 +- app/Support/DocsLabels.php | 8 +- resources/views/docs/index.blade.php | 28 +------ tests/Unit/DocsVersionRegistryTest.php | 73 +++++++++++++++++++ 8 files changed, 175 insertions(+), 41 deletions(-) create mode 100644 app/Enums/DocsPlatform.php create mode 100644 app/Services/DocsVersionRegistry.php create mode 100644 tests/Unit/DocsVersionRegistryTest.php diff --git a/app/Enums/DocsPlatform.php b/app/Enums/DocsPlatform.php new file mode 100644 index 000000000..4cb38b764 --- /dev/null +++ b/app/Enums/DocsPlatform.php @@ -0,0 +1,24 @@ + 'NativePHP for Desktop', + self::Mobile => 'NativePHP for Mobile', + }; + } + + public static function tryFromRoute(): ?self + { + return self::tryFrom((string) request()->route('platform')); + } +} diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index 50a0c5482..2509a0c29 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\DocsPlatform; +use App\Services\DocsVersionRegistry; use App\Services\DocsVersionService; use App\Support\CommonMark\CommonMark; use Artesaos\SEOTools\Facades\SEOMeta; @@ -170,6 +172,11 @@ protected function getPageProperties($platform, $version, $page = null): array $pageProperties['version'] = $version; $pageProperties['pagePath'] = request()->path(); + $platformEnum = DocsPlatform::tryFrom($platform); + $pageProperties['docsVersionLabels'] = $platformEnum + ? app(DocsVersionRegistry::class)->switcherLabels($platformEnum) + : []; + $pageProperties['content'] = CommonMark::convertToHtml($document->body(), [ 'user' => auth()->user(), ]); diff --git a/app/Services/DocsSearchService.php b/app/Services/DocsSearchService.php index 249f7f4c5..d32cac9f4 100644 --- a/app/Services/DocsSearchService.php +++ b/app/Services/DocsSearchService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Enums\DocsPlatform; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Spatie\YamlFrontMatter\YamlFrontMatter; @@ -168,7 +169,7 @@ protected function sectionRanks(string $platform, string $version): array public function getPlatforms(): array { - return ['desktop', 'mobile']; + return array_map(fn (DocsPlatform $platform): string => $platform->value, DocsPlatform::cases()); } public function getVersions(?string $platform = null): array @@ -196,10 +197,15 @@ public function getLatestVersions(): array { $versions = $this->getVersions(); - return [ - 'desktop' => (string) (config('docs.latest_versions.desktop') ?? collect($versions['desktop'] ?? [])->sort()->last() ?? '2'), - 'mobile' => (string) (config('docs.latest_versions.mobile') ?? collect($versions['mobile'] ?? [])->sort()->last() ?? '3'), - ]; + return collect(DocsPlatform::cases()) + ->mapWithKeys(function (DocsPlatform $platform) use ($versions): array { + $fallback = $platform === DocsPlatform::Mobile ? '3' : '2'; + $configured = config("docs.latest_versions.{$platform->value}"); + $detected = collect($versions[$platform->value] ?? [])->sort()->last(); + + return [$platform->value => (string) ($configured ?? $detected ?? $fallback)]; + }) + ->all(); } protected function getAllPages(?string $platform = null, ?string $version = null): array @@ -393,9 +399,7 @@ protected function sanitizePlatform(?string $platform): ?string return null; } - $allowed = ['desktop', 'mobile']; - - return in_array($platform, $allowed, true) ? $platform : null; + return DocsPlatform::tryFrom($platform)?->value; } protected function sanitizeVersion(?string $version): ?string diff --git a/app/Services/DocsVersionRegistry.php b/app/Services/DocsVersionRegistry.php new file mode 100644 index 000000000..95001bb25 --- /dev/null +++ b/app/Services/DocsVersionRegistry.php @@ -0,0 +1,48 @@ +value}"); + } + + public function isPrerelease(DocsPlatform $platform, int $version): bool + { + return in_array($version, config("docs.prerelease_versions.{$platform->value}", []), true); + } + + /** + * @return array minor versions released for one major, e.g. ['4.0', '4.1', '4.2'] + */ + public function releasedMinors(DocsPlatform $platform, int $major): array + { + return config("docs.released_versions.{$platform->value}.{$major}", []); + } + + /** + * Version-switcher labels for every released major, e.g. [1 => '1.x', 2 => '2.x']. + * + * @return array + */ + public function switcherLabels(DocsPlatform $platform): array + { + $majors = array_keys(config("docs.released_versions.{$platform->value}", [])); + + return array_combine($majors, array_map(fn (int $major): string => "{$major}.x", $majors)); + } + + /** + * @return array> renames keyed by the version the rename happened in + */ + public function renames(DocsPlatform $platform): array + { + return config("docs.renamed_pages.{$platform->value}", []); + } +} diff --git a/app/Services/DocsVersionService.php b/app/Services/DocsVersionService.php index 1cd9902de..d78a4757f 100644 --- a/app/Services/DocsVersionService.php +++ b/app/Services/DocsVersionService.php @@ -2,11 +2,15 @@ namespace App\Services; +use App\Enums\DocsPlatform; + class DocsVersionService { + public function __construct(private DocsVersionRegistry $registry) {} + public function determineCanonicalUrl(string $platform, string $page): string { - $latestVersion = $platform === 'mobile' ? config('docs.latest_versions.mobile') : config('docs.latest_versions.desktop'); + $latestVersion = (string) $this->registry->latest(DocsPlatform::from($platform)); $page = $this->resolveOldVersionApisWithPluginsCorePage($platform, $latestVersion, $page); $page = $this->resolvePageForVersion($platform, $latestVersion, $page); @@ -30,7 +34,7 @@ public function determineCanonicalUrl(string $platform, string $page): string */ public function resolvePageForVersion(string $platform, int|string $targetVersion, string $page): string { - $renames = config("docs.renamed_pages.{$platform}", []); + $renames = $this->registry->renames(DocsPlatform::from($platform)); ksort($renames); diff --git a/app/Support/DocsLabels.php b/app/Support/DocsLabels.php index 97adc0996..d2e73ae82 100644 --- a/app/Support/DocsLabels.php +++ b/app/Support/DocsLabels.php @@ -2,6 +2,8 @@ namespace App\Support; +use App\Enums\DocsPlatform; + /** * Platform/version come from the route rather than being passed in, since * BladeMarkdownPreprocessor hands page markdown to Blade with no view data @@ -11,11 +13,7 @@ final class DocsLabels { public static function productName(): string { - return match (request()->route('platform')) { - 'mobile' => 'NativePHP for Mobile', - 'desktop' => 'NativePHP for Desktop', - default => 'NativePHP', - }; + return DocsPlatform::tryFromRoute()?->label() ?? 'NativePHP'; } /** diff --git a/resources/views/docs/index.blade.php b/resources/views/docs/index.blade.php index 6bb564dc9..18391d4e6 100644 --- a/resources/views/docs/index.blade.php +++ b/resources/views/docs/index.blade.php @@ -23,19 +23,7 @@ {{-- Version switcher --}} - @if($platform === 'desktop') - - @elseif($platform === 'mobile') - - @endif + {{-- EDGE component pages can be previewed live in the Jump app --}} @@ -94,19 +82,7 @@
{{-- Version switcher --}} - @if($platform === 'desktop') - - @elseif($platform === 'mobile') - - @endif + {{-- Copy as Markdown Button --}} diff --git a/tests/Unit/DocsVersionRegistryTest.php b/tests/Unit/DocsVersionRegistryTest.php new file mode 100644 index 000000000..07ece7ab6 --- /dev/null +++ b/tests/Unit/DocsVersionRegistryTest.php @@ -0,0 +1,73 @@ +registry = app(DocsVersionRegistry::class); + } + + #[Test] + public function it_reads_the_latest_version_per_platform(): void + { + $this->assertSame(config('docs.latest_versions.mobile'), $this->registry->latest(DocsPlatform::Mobile)); + $this->assertSame(config('docs.latest_versions.desktop'), $this->registry->latest(DocsPlatform::Desktop)); + } + + #[Test] + public function it_reports_prerelease_status_from_config(): void + { + config(['docs.prerelease_versions.mobile' => [5]]); + + $this->assertTrue($this->registry->isPrerelease(DocsPlatform::Mobile, 5)); + $this->assertFalse($this->registry->isPrerelease(DocsPlatform::Mobile, 4)); + } + + #[Test] + public function it_derives_switcher_labels_from_released_versions(): void + { + $this->assertSame( + [1 => '1.x', 2 => '2.x', 3 => '3.x', 4 => '4.x'], + $this->registry->switcherLabels(DocsPlatform::Mobile), + ); + + $this->assertSame( + [1 => '1.x', 2 => '2.x'], + $this->registry->switcherLabels(DocsPlatform::Desktop), + ); + } + + #[Test] + public function it_returns_released_minors_for_a_major(): void + { + $this->assertSame( + ['4.0', '4.1', '4.2'], + $this->registry->releasedMinors(DocsPlatform::Mobile, 4), + ); + + $this->assertSame([], $this->registry->releasedMinors(DocsPlatform::Mobile, 99)); + } + + #[Test] + public function it_returns_renamed_pages_for_a_platform(): void + { + $renames = $this->registry->renames(DocsPlatform::Mobile); + + $this->assertArrayHasKey(4, $renames); + $this->assertSame('the-basics/dialogs', $renames[4]['the-basics/dialog']); + } +} From b8f9a0ab759fce956d44e55821375d0c5c945e9d Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 23:33:15 +0200 Subject: [PATCH 2/9] refactor(docs): unify navigation-tree building into DocsNavigationService ShowDocumentationController and DocsSearchService each walked the docs filesystem with their own Finder-based tree builder and their own ordering math (a nested tree vs. flat order*10000 ranks). Extracts that into a single DocsNavigationService::build()/sectionRanks(), consumed by both, so there's one place that defines "what's the sidebar order." Co-Authored-By: Claude Sonnet 5 --- .../ShowDocumentationController.php | 148 +------------- app/Services/DocsNavigationService.php | 187 ++++++++++++++++++ app/Services/DocsSearchService.php | 42 +--- app/Services/DocsVersionRegistry.php | 4 +- tests/Unit/DocsNavigationServiceTest.php | 73 +++++++ 5 files changed, 273 insertions(+), 181 deletions(-) create mode 100644 app/Services/DocsNavigationService.php create mode 100644 tests/Unit/DocsNavigationServiceTest.php diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index 2509a0c29..b2e965263 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Enums\DocsPlatform; +use App\Services\DocsNavigationService; use App\Services\DocsVersionRegistry; use App\Services\DocsVersionService; use App\Support\CommonMark\CommonMark; @@ -28,6 +29,9 @@ class ShowDocumentationController extends Controller public function __invoke(Request $request, string $platform, string $version, ?string $page = null) { abort_unless(is_dir(resource_path('views/docs/'.$platform.'/'.$version)), 404); + // The route only constrains {platform} to [a-z]+, so a real docs + // directory isn't proof it's one DocsPlatform::from() below can resolve. + abort_unless(DocsPlatform::tryFrom($platform) !== null, 404); session(['viewing_docs_version' => $version]); session(['viewing_docs_platform' => $platform]); @@ -35,7 +39,7 @@ public function __invoke(Request $request, string $platform, string $version, ?s $fingerprint = $this->fingerprint($platform, $version); $navigation = $this->cacheOrCompute("docs_nav_{$platform}_{$version}", $fingerprint, - fn () => $this->getNavigation($platform, $version) + fn () => app(DocsNavigationService::class)->build(DocsPlatform::from($platform), (int) $version) ); if (is_null($page)) { @@ -182,7 +186,7 @@ protected function getPageProperties($platform, $version, $page = null): array ]); $pageProperties['tableOfContents'] = $this->extractTableOfContents($pageProperties['content']); - $navigation = $this->getNavigation($platform, $version); + $navigation = app(DocsNavigationService::class)->build(DocsPlatform::from($platform), (int) $version); $pageProperties['navigation'] = Menu::build($navigation, function (Menu $menu, $nav): void { if (array_key_exists('path', $nav)) { $menu->link($nav['path'], $nav['title']); @@ -286,137 +290,6 @@ protected function getPageProperties($platform, $version, $page = null): array return $pageProperties; } - protected function getNavigation(string $platform, string $version): array - { - $basePath = resource_path('views'); - $path = "$basePath/docs/$platform/$version"; - - $mainNavigation = (new Finder) - ->files() - ->name('_index.md') - ->depth(1) - ->in($path); - - $navigation = collect(); - - $mainPages = (new Finder) - ->files() - ->notName('_index.md') - ->name('*.md') - ->depth(0) - ->in($path); - - /** @var SplFileInfo $mainPage */ - foreach ($mainPages as $mainPage) { - $parsedSection = YamlFrontMatter::parse($mainPage->getContents()); - - $path = Str::after($mainPage->getPath(), $basePath).'/'.$mainPage->getBasename('.md'); - - $navigation->push([ - 'path' => $path, - 'title' => $parsedSection->matter('title', ''), - 'order' => $parsedSection->matter('order', 0), - ]); - } - - /** @var SplFileInfo $section */ - foreach ($mainNavigation as $section) { - $parsedSection = YamlFrontMatter::parse($section->getContents()); - $navigationEntry = [ - 'relative_path' => $section->getRelativePath(), - 'title' => $parsedSection->matter('title', ''), - 'order' => $parsedSection->matter('order', 0), - ]; - - // Get direct child pages (depth 0) - $subSections = (new Finder) - ->files() - ->notName('_index.md') - ->name('*.md') - ->depth(0) - ->in($section->getPath()); - - $children = collect(); - - /** @var SplFileInfo $subSection */ - foreach ($subSections as $subSection) { - $parsedSection = YamlFrontMatter::parse($subSection->getContents()); - - $path = Str::after($subSection->getPath(), $basePath).'/'.$subSection->getBasename('.md'); - - $title = $parsedSection->matter('title', ''); - - if ($title === '') { - $content = CommonMark::convertToHtml($subSection->getContents()); - $title = $this->extractTitle($content); - } - - $children->push([ - 'path' => $path, - 'title' => $title, - 'order' => $parsedSection->matter('order', 0), - ]); - } - - // Check for nested subsections (3rd tier) - $nestedSections = (new Finder) - ->files() - ->name('_index.md') - ->depth(1) - ->in($section->getPath()); - - /** @var SplFileInfo $nestedSection */ - foreach ($nestedSections as $nestedSection) { - $parsedNested = YamlFrontMatter::parse($nestedSection->getContents()); - - $nestedEntry = [ - 'title' => $parsedNested->matter('title', ''), - 'order' => $parsedNested->matter('order', 0), - 'is_subsection' => true, - ]; - - // Get pages within this nested section - $nestedPages = (new Finder) - ->files() - ->notName('_index.md') - ->name('*.md') - ->depth(0) - ->in($nestedSection->getPath()); - - $nestedChildren = collect(); - - /** @var SplFileInfo $nestedPage */ - foreach ($nestedPages as $nestedPage) { - $parsedPage = YamlFrontMatter::parse($nestedPage->getContents()); - - $path = Str::after($nestedPage->getPath(), $basePath).'/'.$nestedPage->getBasename('.md'); - - $title = $parsedPage->matter('title', ''); - - if ($title === '') { - $content = CommonMark::convertToHtml($nestedPage->getContents()); - $title = $this->extractTitle($content); - } - - $nestedChildren->push([ - 'path' => $path, - 'title' => $title, - 'order' => $parsedPage->matter('order', 0), - ]); - } - - $nestedEntry['children'] = $nestedChildren->sortBy('order')->values()->toArray(); - $children->push($nestedEntry); - } - - $navigationEntry['children'] = $children->sortBy('order')->values()->toArray(); - - $navigation->push($navigationEntry); - } - - return $navigation->sortBy('order')->values()->toArray(); - } - protected function findFirstPath(array $children): string { foreach ($children as $child) { @@ -480,15 +353,6 @@ protected function extractTableOfContents(string $html): array ->toArray(); } - protected function extractTitle(string $document): string - { - $matches = []; - - preg_match('/

([^<]+)/', $document, $matches); - - return $matches[1] ?? ''; - } - protected function markdownViewExists($platform, $version, $page): bool { $markdownFileName = $platform.'.'.$version.'.'.($page ?? 'index'); diff --git a/app/Services/DocsNavigationService.php b/app/Services/DocsNavigationService.php new file mode 100644 index 000000000..affa75b63 --- /dev/null +++ b/app/Services/DocsNavigationService.php @@ -0,0 +1,187 @@ +> + */ + public function build(DocsPlatform $platform, int $version): array + { + $basePath = resource_path('views'); + $path = sprintf('%s/docs/%s/%d', $basePath, $platform->value, $version); + + $navigation = collect(); + + $mainPages = (new Finder) + ->files() + ->notName('_index.md') + ->name('*.md') + ->depth(0) + ->in($path); + + /** @var SplFileInfo $mainPage */ + foreach ($mainPages as $mainPage) { + $parsedSection = YamlFrontMatter::parse($mainPage->getContents()); + + $navigation->push([ + 'path' => $this->pagePath($basePath, $mainPage), + 'title' => $parsedSection->matter('title', ''), + 'order' => $parsedSection->matter('order', 0), + ]); + } + + $mainNavigation = (new Finder) + ->files() + ->name('_index.md') + ->depth(1) + ->in($path); + + /** @var SplFileInfo $section */ + foreach ($mainNavigation as $section) { + $parsedSection = YamlFrontMatter::parse($section->getContents()); + + $navigation->push([ + 'relative_path' => $section->getRelativePath(), + 'title' => $parsedSection->matter('title', ''), + // Sections without an explicit order trail rather than lead — + // sectionRanks() (below) relies on this default for a section + // that has no order of its own. + 'order' => $parsedSection->matter('order', 9999), + 'children' => $this->buildSectionChildren($basePath, $section->getPath()), + ]); + } + + return $navigation->sortBy('order')->values()->toArray(); + } + + /** + * Scaled by 10000 so a nested subsection's own order can slot in right + * after its parent without colliding with the next top-level section. + * + * @return array + */ + public function sectionRanks(DocsPlatform $platform, int $version): array + { + $rank = []; + + foreach ($this->build($platform, $version) as $entry) { + if (! isset($entry['relative_path'])) { + continue; + } + + $slug = $entry['relative_path']; + $rank[$slug] = $entry['order'] * 10000; + + foreach ($entry['children'] ?? [] as $child) { + if (! ($child['is_subsection'] ?? false)) { + continue; + } + + $rank[sprintf('%s/%s', $slug, $child['slug'])] = $rank[$slug] + 1 + min($child['order'], 9998); + } + } + + return $rank; + } + + /** + * @return array> + */ + private function buildSectionChildren(string $basePath, string $sectionPath): array + { + $children = collect(); + + $subSections = (new Finder) + ->files() + ->notName('_index.md') + ->name('*.md') + ->depth(0) + ->in($sectionPath); + + /** @var SplFileInfo $subSection */ + foreach ($subSections as $subSection) { + $parsedSection = YamlFrontMatter::parse($subSection->getContents()); + + $children->push([ + 'path' => $this->pagePath($basePath, $subSection), + 'title' => $this->resolveTitle($parsedSection, $subSection->getContents()), + 'order' => $parsedSection->matter('order', 0), + ]); + } + + $nestedSections = (new Finder) + ->files() + ->name('_index.md') + ->depth(1) + ->in($sectionPath); + + /** @var SplFileInfo $nestedSection */ + foreach ($nestedSections as $nestedSection) { + $parsedNested = YamlFrontMatter::parse($nestedSection->getContents()); + + $nestedChildren = collect(); + + $nestedPages = (new Finder) + ->files() + ->notName('_index.md') + ->name('*.md') + ->depth(0) + ->in($nestedSection->getPath()); + + /** @var SplFileInfo $nestedPage */ + foreach ($nestedPages as $nestedPage) { + $parsedPage = YamlFrontMatter::parse($nestedPage->getContents()); + + $nestedChildren->push([ + 'path' => $this->pagePath($basePath, $nestedPage), + 'title' => $this->resolveTitle($parsedPage, $nestedPage->getContents()), + 'order' => $parsedPage->matter('order', 0), + ]); + } + + $children->push([ + // The directory name, e.g. "core" for plugins/core — the key + // sectionRanks() needs to match DocsSearchService's section + // paths, which come straight from the filesystem too. + 'slug' => $nestedSection->getRelativePath(), + 'title' => $parsedNested->matter('title', ''), + 'order' => $parsedNested->matter('order', 9999), + 'is_subsection' => true, + 'children' => $nestedChildren->sortBy('order')->values()->toArray(), + ]); + } + + return $children->sortBy('order')->values()->toArray(); + } + + public function pagePath(string $basePath, SplFileInfo $file): string + { + return sprintf('%s/%s', Str::after($file->getPath(), $basePath), $file->getBasename('.md')); + } + + private function resolveTitle(mixed $parsedPage, string $rawContents): string + { + $title = $parsedPage->matter('title', ''); + + if ($title !== '') { + return $title; + } + + $html = CommonMark::convertToHtml($rawContents); + + preg_match('/

([^<]+)/', $html, $matches); + + return $matches[1] ?? ''; + } +} diff --git a/app/Services/DocsSearchService.php b/app/Services/DocsSearchService.php index d32cac9f4..c4c432005 100644 --- a/app/Services/DocsSearchService.php +++ b/app/Services/DocsSearchService.php @@ -120,13 +120,12 @@ public function getNavigation(string $platform, string $version): array usort($sections[$section], fn ($a, $b) => $a['order'] <=> $b['order']); } - // Order the sections themselves to match the sidebar (each section - // directory's `_index.md` front-matter `order` — the same source - // ShowDocumentationController sorts by). Nested subsections (e.g. - // plugins/core) rank right after their parent. JSON objects keep key - // order, so API consumers get the sidebar order for free. Unknown - // sections trail in their original grouping order (stable sort). - $rank = $this->sectionRanks($platform, $version); + // Order the sections themselves to match the sidebar — same tree + // DocsNavigationService gives ShowDocumentationController. JSON + // objects keep key order, so API consumers get the sidebar order for + // free. Unknown sections trail in their original grouping order + // (stable sort). + $rank = app(DocsNavigationService::class)->sectionRanks(DocsPlatform::from($platform), (int) $version); $slugs = array_keys($sections); usort($slugs, fn ($a, $b) => ($rank[$a] ?? PHP_INT_MAX) <=> ($rank[$b] ?? PHP_INT_MAX)); @@ -138,35 +137,6 @@ public function getNavigation(string $platform, string $version): array return $ordered; } - /** - * Sidebar rank per section slug for one platform/version: top-level - * sections rank by their `_index.md` front-matter `order` (scaled so - * children can interleave); a nested subsection ranks just after its - * parent, offset by its own `order`. Sections without an `_index.md` - * get no rank (callers push them to the end). - * - * @return array - */ - protected function sectionRanks(string $platform, string $version): array - { - $base = "{$this->docsPath}/{$platform}/{$version}"; - $rank = []; - - foreach (glob("{$base}/*/_index.md") ?: [] as $index) { - $slug = basename(dirname($index)); - $order = YamlFrontMatter::parse(file_get_contents($index))->matter('order') ?? 9999; - $rank[$slug] = $order * 10000; - - foreach (glob("{$base}/{$slug}/*/_index.md") ?: [] as $nested) { - $nestedSlug = basename(dirname($nested)); - $nestedOrder = YamlFrontMatter::parse(file_get_contents($nested))->matter('order') ?? 9999; - $rank["{$slug}/{$nestedSlug}"] = $rank[$slug] + 1 + min($nestedOrder, 9998); - } - } - - return $rank; - } - public function getPlatforms(): array { return array_map(fn (DocsPlatform $platform): string => $platform->value, DocsPlatform::cases()); diff --git a/app/Services/DocsVersionRegistry.php b/app/Services/DocsVersionRegistry.php index 95001bb25..2e9e671b1 100644 --- a/app/Services/DocsVersionRegistry.php +++ b/app/Services/DocsVersionRegistry.php @@ -27,9 +27,7 @@ public function releasedMinors(DocsPlatform $platform, int $major): array } /** - * Version-switcher labels for every released major, e.g. [1 => '1.x', 2 => '2.x']. - * - * @return array + * @return array version-switcher labels, e.g. [1 => '1.x', 2 => '2.x'] */ public function switcherLabels(DocsPlatform $platform): array { diff --git a/tests/Unit/DocsNavigationServiceTest.php b/tests/Unit/DocsNavigationServiceTest.php new file mode 100644 index 000000000..6b5eba29c --- /dev/null +++ b/tests/Unit/DocsNavigationServiceTest.php @@ -0,0 +1,73 @@ +service = app(DocsNavigationService::class); + } + + #[Test] + public function it_sorts_top_level_entries_by_order(): void + { + $navigation = $this->service->build(DocsPlatform::Mobile, 4); + + $orders = array_column($navigation, 'order'); + + $this->assertSame($orders, collect($orders)->sort()->values()->all()); + } + + #[Test] + public function it_builds_a_third_tier_subsection_for_plugins_core(): void + { + $navigation = $this->service->build(DocsPlatform::Mobile, 4); + + $plugins = collect($navigation)->firstWhere('relative_path', 'plugins'); + $this->assertNotNull($plugins); + + $core = collect($plugins['children'])->first(fn (array $child) => ($child['is_subsection'] ?? false) && $child['slug'] === 'core'); + $this->assertNotNull($core, 'Expected a "core" subsection under plugins'); + $this->assertSame('Core Plugins', $core['title']); + $this->assertNotEmpty($core['children']); + $this->assertContains('Camera', array_column($core['children'], 'title')); + } + + #[Test] + public function it_ranks_a_nested_subsection_right_after_its_parent(): void + { + $ranks = $this->service->sectionRanks(DocsPlatform::Mobile, 4); + + $this->assertArrayHasKey('plugins', $ranks); + $this->assertArrayHasKey('plugins/core', $ranks); + $this->assertGreaterThan($ranks['plugins'], $ranks['plugins/core']); + + // Nothing else should rank between a section and its own subsection. + $otherRanks = collect($ranks)->except(['plugins', 'plugins/core']); + $this->assertTrue($otherRanks->every( + fn (int $rank) => $rank < $ranks['plugins'] || $rank > $ranks['plugins/core'] + )); + } + + #[Test] + public function it_returns_no_rank_for_a_section_without_an_index_file(): void + { + $ranks = $this->service->sectionRanks(DocsPlatform::Desktop, 2); + + // Desktop v2 has no nested (3rd-tier) subsections today. + $this->assertTrue(collect($ranks)->keys()->every(fn (string $slug) => ! str_contains($slug, '/'))); + } +} From 16a98a07a65678b6bb4dbf4f1eb1d8ce8b4f78cb Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 23:38:22 +0200 Subject: [PATCH 3/9] refactor(docs): consolidate ad-hoc redirects into config('docs.renamed_pages') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the mobile v4 Route::redirect() calls for renamed pages were redundant with config('docs.renamed_pages') + DocsVersionService, which ShowDocumentationController's 404 catch-block already resolves. Fold those into config and drop the routes. The remaining four (device/file/system/dialog under plugins/core, plus the super-native/architecture-overview URL aliases) stay as explicit routes — the first four need route-registration precedence over the generic core-plugin-to-marketplace redirect below them, and the latter two were never real pages, so adding them to the renamed_pages map would collide with the existing super-native/introduction entry's reverse (older-version) lookup and silently resolve to the wrong page. Co-Authored-By: Claude Sonnet 5 --- routes/web.php | 25 ++++++++--- .../Docs/ConsolidatedRedirectsTest.php | 44 +++++++++++++++++++ tests/Unit/DocsVersionServiceTest.php | 23 ++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Docs/ConsolidatedRedirectsTest.php diff --git a/routes/web.php b/routes/web.php index 8d281a6e7..db014d8ee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -76,7 +76,11 @@ Route::redirect('tshirt', 'blog/nativephp-for-mobile-is-now-free'); // v4: Device/Dialog/File/System moved from plugins into core built-ins; their docs -// now live in The Basics (must precede the generic core-plugin redirect below). +// now live in The Basics. These can't be folded into config('docs.renamed_pages') +// like the other v4 renames below — they must be registered (and win route-matching +// precedence) *before* the generic core-plugin redirect immediately after, otherwise +// that catch-all intercepts the URL before docs.show (and its config-driven +// resolution) ever runs. foreach (['device', 'file', 'system'] as $corePage) { Route::redirect("docs/mobile/4/plugins/core/{$corePage}", "/docs/mobile/4/the-basics/{$corePage}", 301); } @@ -94,17 +98,24 @@ return redirect("/plugins/nativephp/mobile-{$page}", 301); })->where('version', '[0-9]+')->where('page', '[a-z-]+'); -// v4: the SuperNative section was flattened; its old section root now points at -// the SuperNative overview page, which lives in the Architecture section. +// None of these can move into config('docs.renamed_pages') like the other v4 +// renames above: each would make an *existing* config entry's reverse +// (older-version) lookup collide, since DocsVersionService::resolvePageForVersion() +// builds that reverse map with array_flip(), which silently keeps only the last +// of several old paths mapping to the same new one. +// +// - 'super-native' and 'architecture/overview' were never real pages (a bare +// section-root URL, and a page removed without a v3 equivalent) — both would +// collide with 'super-native/introduction', which already redirects here. Route::redirect('docs/mobile/4/super-native', '/docs/mobile/4/architecture/super-native', 301); - -// v4: the Architecture Overview page was removed; the SuperNative page now leads the section. Route::redirect('docs/mobile/4/architecture/overview', '/docs/mobile/4/architecture/super-native', 301); -// v4: the old the-basics/navigation slug is now the-basics/routing (Layouts lives at the-basics/layouts directly). +// - 'the-basics/navigation' would collide with the existing +// 'super-native/navigation' => 'the-basics/routing' entry above. Route::redirect('docs/mobile/4/the-basics/navigation', '/docs/mobile/4/the-basics/routing', 301); -// v4: Screen & Card components were removed — theme surfaces with bg-theme-* classes instead +// - 'edge-components/screen' and 'edge-components/card' would collide with +// each other (both target 'edge-components/layout'). Route::redirect('docs/mobile/4/edge-components/screen', '/docs/mobile/4/edge-components/layout', 301); Route::redirect('docs/mobile/4/edge-components/card', '/docs/mobile/4/edge-components/layout', 301); diff --git a/tests/Feature/Docs/ConsolidatedRedirectsTest.php b/tests/Feature/Docs/ConsolidatedRedirectsTest.php new file mode 100644 index 000000000..f512c1299 --- /dev/null +++ b/tests/Feature/Docs/ConsolidatedRedirectsTest.php @@ -0,0 +1,44 @@ + ['docs/mobile/4/super-native', '/docs/mobile/4/architecture/super-native'], + 'architecture overview' => ['docs/mobile/4/architecture/overview', '/docs/mobile/4/architecture/super-native'], + 'the-basics navigation' => ['docs/mobile/4/the-basics/navigation', '/docs/mobile/4/the-basics/routing'], + 'edge-components screen' => ['docs/mobile/4/edge-components/screen', '/docs/mobile/4/edge-components/layout'], + 'edge-components card' => ['docs/mobile/4/edge-components/card', '/docs/mobile/4/edge-components/layout'], + ]; + } + + #[Test] + #[DataProvider('renamedPageProvider')] + public function it_redirects_a_renamed_v4_page_via_config_driven_resolution(string $from, string $to): void + { + $this->get($from)->assertRedirect($to)->assertStatus(301); + } + + #[Test] + public function core_plugin_pages_still_redirect_to_the_basics_ahead_of_the_marketplace_catch_all(): void + { + $this->get('docs/mobile/4/plugins/core/device')->assertRedirect('/docs/mobile/4/the-basics/device'); + $this->get('docs/mobile/4/plugins/core/dialog')->assertRedirect('/docs/mobile/4/the-basics/dialogs'); + } + + #[Test] + public function other_core_plugin_pages_still_fall_through_to_the_marketplace(): void + { + $this->get('docs/mobile/4/plugins/core/camera')->assertRedirect('/plugins/nativephp/mobile-camera'); + } +} diff --git a/tests/Unit/DocsVersionServiceTest.php b/tests/Unit/DocsVersionServiceTest.php index 669e39c89..68db83644 100644 --- a/tests/Unit/DocsVersionServiceTest.php +++ b/tests/Unit/DocsVersionServiceTest.php @@ -138,6 +138,29 @@ public function it_maps_deployment_to_the_publishing_section_across_versions(): ); } + #[Test] + #[DataProvider('platformDataProvider')] + public function no_two_renamed_pages_entries_in_the_same_version_share_a_target(string $platform): void + { + // resolvePageForVersion()'s reverse (older-version) lookup builds its map + // with array_flip(), which silently keeps only the last of several old + // paths mapping to the same new one — so two entries sharing a target + // isn't just untidy config, it's a real collision. + $collisions = collect(config("docs.renamed_pages.{$platform}", [])) + ->flatMap(function (array $map, int $version) { + return collect($map) + ->countBy() + ->filter(fn (int $count) => $count > 1) + ->keys() + ->map(fn (string $target) => "v{$version}: {$target}"); + }); + + $this->assertTrue( + $collisions->isEmpty(), + "renamed_pages.{$platform} has colliding targets: ".$collisions->implode(', '), + ); + } + #[Test] public function it_flattens_super_native_pages_into_the_digging_deeper_section_in_v4(): void { From ce8150e50c1162aadf9fd8e3f2f7da7b388ac251 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 23:48:26 +0200 Subject: [PATCH 4/9] feat(docs): add per-page feedback widget with admin-only visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thumbs up/down + optional comment widget under every docs page's content, backed by a new docs_feedback table. Rate-limited by IP (5/min, mirroring LeadSubmissionForm's pattern) to discourage spam. Results are visible only in Filament (DocsFeedbackResource) — there is no public display of aggregate votes or comments anywhere on the site. Co-Authored-By: Claude Sonnet 5 --- .../Resources/DocsFeedbackResource.php | 117 ++++++++++++++++++ .../Pages/ListDocsFeedback.php | 13 ++ .../Pages/ViewDocsFeedback.php | 13 ++ app/Livewire/DocsFeedbackWidget.php | 75 +++++++++++ app/Models/DocsFeedback.php | 40 ++++++ database/factories/DocsFeedbackFactory.php | 29 +++++ ...9_04_214310_create_docs_feedback_table.php | 35 ++++++ resources/views/docs/index.blade.php | 6 + .../livewire/docs-feedback-widget.blade.php | 35 ++++++ tests/Feature/DocsFeedbackWidgetTest.php | 95 ++++++++++++++ .../Filament/DocsFeedbackResourceTest.php | 62 ++++++++++ 11 files changed, 520 insertions(+) create mode 100644 app/Filament/Resources/DocsFeedbackResource.php create mode 100644 app/Filament/Resources/DocsFeedbackResource/Pages/ListDocsFeedback.php create mode 100644 app/Filament/Resources/DocsFeedbackResource/Pages/ViewDocsFeedback.php create mode 100644 app/Livewire/DocsFeedbackWidget.php create mode 100644 app/Models/DocsFeedback.php create mode 100644 database/factories/DocsFeedbackFactory.php create mode 100644 database/migrations/2026_09_04_214310_create_docs_feedback_table.php create mode 100644 resources/views/livewire/docs-feedback-widget.blade.php create mode 100644 tests/Feature/DocsFeedbackWidgetTest.php create mode 100644 tests/Feature/Filament/DocsFeedbackResourceTest.php diff --git a/app/Filament/Resources/DocsFeedbackResource.php b/app/Filament/Resources/DocsFeedbackResource.php new file mode 100644 index 000000000..2dd243f10 --- /dev/null +++ b/app/Filament/Resources/DocsFeedbackResource.php @@ -0,0 +1,117 @@ +inlineLabel() + ->columns(1) + ->schema([ + Schemas\Components\Section::make('Feedback') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('page') + ->label('Page'), + Infolists\Components\TextEntry::make('platform'), + Infolists\Components\TextEntry::make('version'), + Infolists\Components\IconEntry::make('helpful') + ->boolean(), + Infolists\Components\TextEntry::make('comment') + ->placeholder('—'), + Infolists\Components\TextEntry::make('created_at') + ->label('Submitted') + ->dateTime(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('page') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('platform') + ->sortable(), + + Tables\Columns\TextColumn::make('version') + ->sortable(), + + Tables\Columns\IconColumn::make('helpful') + ->boolean() + ->sortable(), + + Tables\Columns\TextColumn::make('comment') + ->limit(60) + ->placeholder('—'), + + Tables\Columns\TextColumn::make('created_at') + ->label('Submitted') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('platform') + ->options(collect(DocsPlatform::cases())->mapWithKeys( + fn (DocsPlatform $platform) => [$platform->value => ucfirst($platform->value)] + )), + Tables\Filters\TernaryFilter::make('helpful'), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDocsFeedback::route('/'), + 'view' => Pages\ViewDocsFeedback::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/DocsFeedbackResource/Pages/ListDocsFeedback.php b/app/Filament/Resources/DocsFeedbackResource/Pages/ListDocsFeedback.php new file mode 100644 index 000000000..c884f4fc0 --- /dev/null +++ b/app/Filament/Resources/DocsFeedbackResource/Pages/ListDocsFeedback.php @@ -0,0 +1,13 @@ +platform = $platform; + $this->version = $version; + $this->page = $page; + } + + public function vote(bool $helpful): void + { + $this->helpful = $helpful; + } + + public function submit(): void + { + $key = 'docs-feedback:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($key, 5)) { + $seconds = RateLimiter::availableIn($key); + $this->addError('form', "You've submitted a lot of feedback recently. Please try again in {$seconds} seconds."); + + return; + } + + $this->validate([ + 'helpful' => 'required|boolean', + 'comment' => 'nullable|string|max:1000', + ]); + + RateLimiter::hit($key, 60); + + DocsFeedback::create([ + 'platform' => $this->platform, + 'version' => $this->version, + 'page' => $this->page, + 'helpful' => $this->helpful, + 'comment' => $this->comment !== '' ? $this->comment : null, + 'ip_hash' => hash('sha256', (string) request()->ip()), + ]); + + $this->submitted = true; + } + + public function render() + { + return view('livewire.docs-feedback-widget'); + } +} diff --git a/app/Models/DocsFeedback.php b/app/Models/DocsFeedback.php new file mode 100644 index 000000000..c99a69522 --- /dev/null +++ b/app/Models/DocsFeedback.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $fillable = [ + 'platform', 'version', 'page', 'helpful', 'comment', 'ip_hash', + ]; + + #[Scope] + protected function helpful(Builder $query): Builder + { + return $query->where('helpful', true); + } + + #[Scope] + protected function unhelpful(Builder $query): Builder + { + return $query->where('helpful', false); + } + + protected function casts(): array + { + return [ + 'helpful' => 'boolean', + ]; + } +} diff --git a/database/factories/DocsFeedbackFactory.php b/database/factories/DocsFeedbackFactory.php new file mode 100644 index 000000000..aab1632a0 --- /dev/null +++ b/database/factories/DocsFeedbackFactory.php @@ -0,0 +1,29 @@ + + */ +class DocsFeedbackFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'platform' => 'mobile', + 'version' => '4', + 'page' => 'getting-started/introduction', + 'helpful' => $this->faker->boolean(), + 'comment' => $this->faker->optional()->sentence(), + 'ip_hash' => hash('sha256', $this->faker->ipv4()), + ]; + } +} diff --git a/database/migrations/2026_09_04_214310_create_docs_feedback_table.php b/database/migrations/2026_09_04_214310_create_docs_feedback_table.php new file mode 100644 index 000000000..590fe1fbc --- /dev/null +++ b/database/migrations/2026_09_04_214310_create_docs_feedback_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('platform'); + $table->string('version'); + $table->string('page'); + $table->boolean('helpful'); + $table->text('comment')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamps(); + + $table->index(['platform', 'version', 'page']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('docs_feedback'); + } +}; diff --git a/resources/views/docs/index.blade.php b/resources/views/docs/index.blade.php index 18391d4e6..21e5a2a17 100644 --- a/resources/views/docs/index.blade.php +++ b/resources/views/docs/index.blade.php @@ -126,6 +126,12 @@ class="prose dark:prose-invert prose-headings:scroll-mt-20 prose-headings:text-g {!! $content !!}

+ + @php diff --git a/resources/views/livewire/docs-feedback-widget.blade.php b/resources/views/livewire/docs-feedback-widget.blade.php new file mode 100644 index 000000000..9e7e721e4 --- /dev/null +++ b/resources/views/livewire/docs-feedback-widget.blade.php @@ -0,0 +1,35 @@ +
+ @if ($submitted) +

Thanks for the feedback!

+ @else +
+ Was this page helpful? + + + Yes + + + + No + +
+ + + + @if (! is_null($helpful)) +
+ + What were you looking for? (optional) + + + + + + + + Send feedback + +
+ @endif + @endif +
diff --git a/tests/Feature/DocsFeedbackWidgetTest.php b/tests/Feature/DocsFeedbackWidgetTest.php new file mode 100644 index 000000000..1fbac7373 --- /dev/null +++ b/tests/Feature/DocsFeedbackWidgetTest.php @@ -0,0 +1,95 @@ + 'mobile', + 'version' => '4', + 'page' => 'docs/mobile/4/getting-started/introduction', + ]) + ->call('vote', true) + ->call('submit') + ->assertHasNoErrors() + ->assertSet('submitted', true); + + $feedback = DocsFeedback::first(); + $this->assertNotNull($feedback); + $this->assertTrue($feedback->helpful); + $this->assertNull($feedback->comment); + $this->assertSame('mobile', $feedback->platform); + } + + #[Test] + public function it_submits_unhelpful_feedback_with_a_comment(): void + { + Livewire::test(DocsFeedbackWidget::class, [ + 'platform' => 'mobile', + 'version' => '4', + 'page' => 'docs/mobile/4/getting-started/introduction', + ]) + ->call('vote', false) + ->set('comment', 'Could not find how to configure push notifications.') + ->call('submit') + ->assertHasNoErrors(); + + $feedback = DocsFeedback::first(); + $this->assertFalse($feedback->helpful); + $this->assertSame('Could not find how to configure push notifications.', $feedback->comment); + } + + #[Test] + public function it_stores_a_hashed_ip_not_the_raw_address(): void + { + Livewire::test(DocsFeedbackWidget::class, [ + 'platform' => 'mobile', + 'version' => '4', + 'page' => 'docs/mobile/4/getting-started/introduction', + ]) + ->call('vote', true) + ->call('submit'); + + $feedback = DocsFeedback::first(); + $this->assertNotNull($feedback->ip_hash); + $this->assertSame(64, strlen($feedback->ip_hash)); + } + + #[Test] + public function it_rate_limits_repeated_submissions_from_the_same_ip(): void + { + for ($i = 0; $i < 5; $i++) { + Livewire::test(DocsFeedbackWidget::class, [ + 'platform' => 'mobile', + 'version' => '4', + 'page' => 'docs/mobile/4/getting-started/introduction', + ]) + ->call('vote', true) + ->call('submit') + ->assertHasNoErrors(); + } + + Livewire::test(DocsFeedbackWidget::class, [ + 'platform' => 'mobile', + 'version' => '4', + 'page' => 'docs/mobile/4/getting-started/introduction', + ]) + ->call('vote', true) + ->call('submit') + ->assertHasErrors('form'); + + $this->assertSame(5, DocsFeedback::count()); + } +} diff --git a/tests/Feature/Filament/DocsFeedbackResourceTest.php b/tests/Feature/Filament/DocsFeedbackResourceTest.php new file mode 100644 index 000000000..a2edf3690 --- /dev/null +++ b/tests/Feature/Filament/DocsFeedbackResourceTest.php @@ -0,0 +1,62 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + #[Test] + public function admin_can_view_the_feedback_list(): void + { + DocsFeedback::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(ListDocsFeedback::class) + ->assertSuccessful(); + } + + #[Test] + public function admin_can_view_a_single_feedback_entry_including_its_comment(): void + { + $feedback = DocsFeedback::factory()->create(['comment' => 'Needed a Windows example.']); + + Livewire::actingAs($this->admin) + ->test(ViewDocsFeedback::class, ['record' => $feedback->getRouteKey()]) + ->assertSuccessful() + ->assertSee('Needed a Windows example.'); + } + + #[Test] + public function a_non_admin_cannot_access_the_feedback_list(): void + { + $user = User::factory()->create(['email' => 'not-an-admin@test.com']); + + $this->actingAs($user)->get('/admin/docs-feedback')->assertForbidden(); + } + + #[Test] + public function a_guest_is_redirected_away_from_the_feedback_list(): void + { + $this->get('/admin/docs-feedback')->assertRedirect(); + } +} From 68134b03bb444cf90949f4955094c8341beca562 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 23:54:46 +0200 Subject: [PATCH 5/9] feat(docs): show docs search scope and let visitors broaden it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a docs page, Algolia DocSearch silently filters results to the current platform/version via transformItems — a reader searching for something that only exists elsewhere gets no results and no explanation. Adds a visible "Searching {Platform} v{X} docs" label next to the search box with a "Search everywhere" action that re-initializes DocSearch without the scope filter (destroy + re-init, since docsearch() has no API to update an existing instance's options), preserving whatever the visitor had already typed. Co-Authored-By: Claude Sonnet 5 --- resources/js/app.js | 83 +++++++++++++------ .../components/navbar/mobile-menu.blade.php | 20 +++++ 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/resources/js/app.js b/resources/js/app.js index 293fc9494..0e6395a42 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -164,44 +164,75 @@ document.addEventListener('alpine:init', () => { Livewire.start() // Docsearch +const docsearchContainerSelector = '#docsearch-desktop' const docsPathMatch = window.location.pathname.match( /^\/docs\/(desktop|mobile)\/(\d+)/, ) -const docsearchOptions = { - appId: 'ZNII9QZ8WI', - apiKey: '9be495a1aaf367b47c873d30a8e7ccf5', - indexName: 'nativephp', - insights: true, - debug: false, - ...(docsPathMatch && { - transformItems(items) { - const prefix = `/docs/${docsPathMatch[1]}/${docsPathMatch[2]}/` - return items.filter((item) => { - try { - return new URL(item.url).pathname.startsWith(prefix) - } catch { - return item.url.includes(prefix) - } - }) - }, - }), + +function scopedTransformItems(items) { + const prefix = `/docs/${docsPathMatch[1]}/${docsPathMatch[2]}/` + return items.filter((item) => { + try { + return new URL(item.url).pathname.startsWith(prefix) + } catch { + return item.url.includes(prefix) + } + }) } -docsearch({ - ...docsearchOptions, - container: '#docsearch-desktop', -}) +function getDesktopSearchButton() { + return document.querySelector( + `${docsearchContainerSelector} .DocSearch-Button`, + ) +} + +// Re-created on demand when "Search everywhere" is clicked, since docsearch() +// has no API to update an already-initialized instance's transformItems — +// only to destroy() and re-initialize it (see docsearch()'s DocSearchInstance +// return value in the @docsearch/js API reference). +function initDocsearch(broadenScope, initialQuery) { + return docsearch({ + appId: 'ZNII9QZ8WI', + apiKey: '9be495a1aaf367b47c873d30a8e7ccf5', + indexName: 'nativephp', + insights: true, + debug: false, + container: docsearchContainerSelector, + initialQuery, + ...(docsPathMatch && ! broadenScope && { transformItems: scopedTransformItems }), + }) +} + +let docsearchInstance = initDocsearch(false) + +const broadenButton = document.getElementById('docsearch-broaden') +if (broadenButton && docsPathMatch) { + broadenButton.addEventListener('click', () => { + const query = + document.querySelector( + `${docsearchContainerSelector} .DocSearch-Input`, + )?.value ?? '' + + docsearchInstance.destroy() + docsearchInstance = initDocsearch(true, query) + docsearchInstance.open() + + document.getElementById('docsearch-scope-label')?.remove() + }) +} // Mirror the desktop DocSearch button into the mobile container so that // pressing Cmd+K only registers one handler (avoiding duplicate modals). +// Looks the button up fresh on click rather than caching a reference, since +// initDocsearch() re-renders it whenever the search scope is broadened. const mobileContainer = document.getElementById('docsearch-mobile') if (mobileContainer) { - const desktopButton = document.querySelector( - '#docsearch-desktop .DocSearch-Button', - ) + const desktopButton = getDesktopSearchButton() if (desktopButton) { const mobileButton = desktopButton.cloneNode(true) mobileContainer.appendChild(mobileButton) - mobileButton.addEventListener('click', () => desktopButton.click()) + mobileButton.addEventListener('click', () => + getDesktopSearchButton()?.click(), + ) } } diff --git a/resources/views/components/navbar/mobile-menu.blade.php b/resources/views/components/navbar/mobile-menu.blade.php index 5ca02d30b..7c049d472 100644 --- a/resources/views/components/navbar/mobile-menu.blade.php +++ b/resources/views/components/navbar/mobile-menu.blade.php @@ -423,6 +423,26 @@ class="-mr-0.5 transition-all duration-200 ease-in-out will-change-transform lg: aria-label="Search documentation" > + + @if ($docsPlatform = request()->route('platform')) + {{-- Search is scoped to the current platform/version by app.js's + transformItems; this makes that scoping visible instead of + silently hiding results from the other platform/version. --}} +

+ Searching {{ ucfirst($docsPlatform) }} v{{ request()->route('version') }} docs + · + +

+ @endif {{--
usage and groups it by minor version and type (added/changed/deprecated/removed), excluding the versioning-policy page's own illustrative examples. - Adds a docs/{platform}/{version}/whats-new page rendering that aggregate, linked from both versioning.md pages. - Points 's link at the new What's New page instead of the generic versioning-policy page, since a badge's own minor version is far more useful context than the policy blurb. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/DocsWhatsNewController.php | 40 +++++++ app/Services/DocsChangelogService.php | 106 ++++++++++++++++++ app/Support/DocsLabels.php | 24 ++-- .../components/docs/version-badge.blade.php | 2 +- .../desktop/2/getting-started/versioning.md | 45 ++++++++ .../mobile/4/getting-started/versioning.md | 2 + resources/views/docs/whats-new.blade.php | 47 ++++++++ routes/web.php | 8 ++ tests/Feature/Docs/WhatsNewPageTest.php | 52 +++++++++ tests/Unit/DocsChangelogServiceTest.php | 77 +++++++++++++ 10 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/DocsWhatsNewController.php create mode 100644 app/Services/DocsChangelogService.php create mode 100644 resources/views/docs/desktop/2/getting-started/versioning.md create mode 100644 resources/views/docs/whats-new.blade.php create mode 100644 tests/Feature/Docs/WhatsNewPageTest.php create mode 100644 tests/Unit/DocsChangelogServiceTest.php diff --git a/app/Http/Controllers/DocsWhatsNewController.php b/app/Http/Controllers/DocsWhatsNewController.php new file mode 100644 index 000000000..9ead20275 --- /dev/null +++ b/app/Http/Controllers/DocsWhatsNewController.php @@ -0,0 +1,40 @@ + $platform, + 'version' => $version, + 'badges' => $this->badges($platform, (int) $version), + ]); + } + + /** + * @return array>>> + */ + private function badges(string $platform, int $version): array + { + $compute = fn () => app(DocsChangelogService::class)->badgesForVersion(DocsPlatform::from($platform), $version); + + if (config('app.env') === 'local') { + return $compute(); + } + + return Cache::remember("docs_whats_new_{$platform}_{$version}", now()->addDay(), $compute); + } +} diff --git a/app/Services/DocsChangelogService.php b/app/Services/DocsChangelogService.php new file mode 100644 index 000000000..e101d2465 --- /dev/null +++ b/app/Services/DocsChangelogService.php @@ -0,0 +1,106 @@ + across a platform's pages into a + * per-minor-version "what's new" listing, so a badge marking a single line + * on a single page becomes discoverable without already knowing which page + * to look on. The versioning-policy page itself is excluded — its badges + * are illustrative examples of the labelling system, not real feature history. + */ +final class DocsChangelogService +{ + public const TYPE_LABELS = [ + 'added' => 'Added', + 'changed' => 'Changed', + 'deprecated' => 'Deprecated', + 'removed' => 'Removed', + ]; + + private const ATTRIBUTE_TYPES = [ + 'since' => 'added', + 'changed' => 'changed', + 'deprecated' => 'deprecated', + 'removed' => 'removed', + ]; + + public function __construct(private DocsNavigationService $navigation) {} + + /** + * @return array>>> minor version, desc order => badge type => [{title, path}] + */ + public function badgesForVersion(DocsPlatform $platform, int $major): array + { + $basePath = resource_path('views'); + $versionPath = sprintf('%s/docs/%s/%d', $basePath, $platform->value, $major); + + $entries = []; + + $files = (new Finder) + ->files() + ->name('*.md') + ->notName('_index.md') + ->notName('versioning.md') + ->in($versionPath); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + $document = YamlFrontMatter::parse($file->getContents()); + $title = $document->matter('title') ?: pathinfo($file->getFilename(), PATHINFO_FILENAME); + $path = $this->navigation->pagePath($basePath, $file); + + foreach ($this->badgesInBody($document->body()) as [$type, $minor]) { + $entries[$minor][$type][$path] = ['title' => $title, 'path' => $path]; + } + } + + krsort($entries, SORT_NATURAL); + + foreach ($entries as $minor => $types) { + foreach ($types as $type => $pages) { + $entries[$minor][$type] = collect($pages)->sortBy('title')->values()->all(); + } + } + + return $entries; + } + + /** + * @return array [type, minor version] pairs + */ + private function badgesInBody(string $body): array + { + if (! preg_match_all('/]*\/>/', $body, $tags)) { + return []; + } + + $badges = []; + + foreach ($tags[0] as $tag) { + foreach (self::ATTRIBUTE_TYPES as $attribute => $type) { + if (! preg_match('/\b'.$attribute.'="([0-9]+)\.([0-9]+)"/', $tag, $match)) { + continue; + } + + // x.0 never renders on the page itself (see version-badge.blade.php's + // "everything in a major's tree was there at x.0" rule) — keeping it + // out of the aggregate too avoids listing a badge nobody can see. + if ((int) $match[2] === 0) { + continue; + } + + $badges[] = [$type, "{$match[1]}.{$match[2]}"]; + } + } + + return $badges; + } +} diff --git a/app/Support/DocsLabels.php b/app/Support/DocsLabels.php index d2e73ae82..19885df6f 100644 --- a/app/Support/DocsLabels.php +++ b/app/Support/DocsLabels.php @@ -16,18 +16,26 @@ public static function productName(): string return DocsPlatform::tryFromRoute()?->label() ?? 'NativePHP'; } - /** - * Null when that version's tree has no versioning page — an unlinked - * label beats one that 404s. - */ - public static function versioningPolicyUrl(): ?string + public static function jumpUrl(): ?string { - return self::pageUrl('getting-started/versioning', 'version-labels'); + return self::pageUrl('the-basics/jump'); } - public static function jumpUrl(): ?string + /** + * The current platform/version is always a real directory when a badge + * is rendering (it's rendering on a live page inside it), so unlike + * pageUrl() this never needs a file_exists guard. + */ + public static function whatsNewUrl(): ?string { - return self::pageUrl('the-basics/jump'); + $platform = request()->route('platform'); + $version = request()->route('version'); + + if (blank($platform) || blank($version)) { + return null; + } + + return route('docs.whats-new', ['platform' => $platform, 'version' => $version]); } private static function pageUrl(string $page, ?string $fragment = null): ?string diff --git a/resources/views/components/docs/version-badge.blade.php b/resources/views/components/docs/version-badge.blade.php index fd67a3af2..df266de07 100644 --- a/resources/views/components/docs/version-badge.blade.php +++ b/resources/views/components/docs/version-badge.blade.php @@ -29,6 +29,6 @@ :label="$state['prefix'].$state['version']" :variant="$state['variant']" :tooltip="$state['verb'].' '.\App\Support\DocsLabels::productName().' '.$state['version']" - :href="\App\Support\DocsLabels::versioningPolicyUrl()" + :href="\App\Support\DocsLabels::whatsNewUrl()" /> @endif diff --git a/resources/views/docs/desktop/2/getting-started/versioning.md b/resources/views/docs/desktop/2/getting-started/versioning.md new file mode 100644 index 000000000..a51f2c122 --- /dev/null +++ b/resources/views/docs/desktop/2/getting-started/versioning.md @@ -0,0 +1,45 @@ +--- +title: Versioning Policy +order: 600 +--- + +NativePHP for Desktop follows [semantic versioning](https://semver.org) for the `nativephp/desktop` package itself. + +Unlike distributing through an app store, updating a NativePHP desktop app doesn't require your users to +manually download and reinstall anything — the [built-in updater](../publishing/updating) checks your configured +provider (GitHub Releases, S3, or DigitalOcean Spaces) for a newer build and installs it automatically. This means +there's no separate "requires a rebuild" release tier the way there is for app-store-distributed platforms: any new +`php artisan native:publish` you push out reaches your users the same way, regardless of what changed. + +Your app's own version — set in `config/nativephp.php` — is separate from the `nativephp/desktop` package version. +It's what the [updater](../publishing/updating) and your app's database migrations key off, and you're free to use +whatever scheme suits you. + +## Version constraints + +We recommend using the [tilde range operator](https://getcomposer.org/doc/articles/versions.md#tilde-version-range-) +with a full minimum patch release defined in your `composer.json`: + +```json +{ + "require": { + "nativephp/desktop": "~2.0.0" + } +} +``` + +This automatically receives patch updates while giving you control over minor releases. + +## Version labels + +Anything documented in this version of the docs has been here since 2.0 unless it carries a label. Labels appear +next to a page title, under a section heading, or beside the individual prop or class they describe: + +| Label | Meaning | +|-------|---------| +| | Added in that minor release | +| | Behaviour or signature changed in that release — check it against what your app relies on before upgrading | +| | Still works, but slated for removal. Move off it when convenient | +| | Gone as of that release. Documented only so you know what replaced it | + +See everything labelled across these docs, grouped by release, on the [What's New]({{ \App\Support\DocsLabels::whatsNewUrl() }}) page. diff --git a/resources/views/docs/mobile/4/getting-started/versioning.md b/resources/views/docs/mobile/4/getting-started/versioning.md index 7c4cea919..324331fc5 100644 --- a/resources/views/docs/mobile/4/getting-started/versioning.md +++ b/resources/views/docs/mobile/4/getting-started/versioning.md @@ -72,6 +72,8 @@ to a page title, under a section heading, or beside the individual prop or class Remember that a minor release [may contain native code changes](#minor-releases), so picking up a labelled feature means rebuilding with `php artisan native:install --force` rather than a `composer update` alone. +See everything labelled across these docs, grouped by release, on the [What's New]({{ \App\Support\DocsLabels::whatsNewUrl() }}) page. + ### Jump labels [Jump](../the-basics/jump) ships on its own release cadence, so a feature can be released in NativePHP and still not diff --git a/resources/views/docs/whats-new.blade.php b/resources/views/docs/whats-new.blade.php new file mode 100644 index 000000000..91a663ab3 --- /dev/null +++ b/resources/views/docs/whats-new.blade.php @@ -0,0 +1,47 @@ + +
+
+

What's New

+

+ {{ \App\Support\DocsLabels::productName() }} v{{ $version }} — every documented change, + gathered from the version labels across these docs. +

+
+ + @if (empty($badges)) +

+ Nothing labelled yet for this version. As pages pick up + <x-docs.version-badge> labels, they'll show up here. +

+ @else +
+ @foreach ($badges as $minor => $types) +
+

{{ $minor }}

+ +
+ @foreach (\App\Services\DocsChangelogService::TYPE_LABELS as $type => $label) + @continue(empty($types[$type])) +
+

{{ $label }}

+ +
+ @endforeach +
+
+ @endforeach +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index db014d8ee..892d2b6be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Http\Controllers\CustomerSubLicenseController; use App\Http\Controllers\DeveloperOnboardingController; use App\Http\Controllers\DiscordIntegrationController; +use App\Http\Controllers\DocsWhatsNewController; use App\Http\Controllers\GitHubAuthController; use App\Http\Controllers\GitHubIntegrationController; use App\Http\Controllers\LicenseRenewalController; @@ -270,6 +271,13 @@ ->where('version', '[0-9]+') ->name('docs.raw'); +// Must precede docs.show below, which would otherwise treat "whats-new" as a +// literal page slug and 404. +Route::get('docs/{platform}/{version}/whats-new', DocsWhatsNewController::class) + ->where('platform', '[a-z]+') + ->where('version', '[0-9]+') + ->name('docs.whats-new'); + Route::get('docs/{platform}/{version}/{page?}', ShowDocumentationController::class) ->where('page', '(.*)') ->where('platform', '[a-z]+') diff --git a/tests/Feature/Docs/WhatsNewPageTest.php b/tests/Feature/Docs/WhatsNewPageTest.php new file mode 100644 index 000000000..0fb9055fc --- /dev/null +++ b/tests/Feature/Docs/WhatsNewPageTest.php @@ -0,0 +1,52 @@ +get('/docs/mobile/4/whats-new') + ->assertStatus(200) + ->assertSee('4.2') + ->assertSee('Layout'); + } + + #[Test] + public function it_renders_for_desktop_v2_with_no_labelled_pages_yet(): void + { + $this->get('/docs/desktop/2/whats-new') + ->assertStatus(200) + ->assertSee('Nothing labelled yet'); + } + + #[Test] + public function it_404s_for_an_unknown_platform_version(): void + { + $this->get('/docs/mobile/99/whats-new')->assertNotFound(); + } + + #[Test] + public function docs_labels_resolves_the_whats_new_url_from_the_current_route(): void + { + $request = Request::create('/docs/mobile/4/getting-started/introduction'); + $route = app('router')->getRoutes()->match($request); + $route->bind($request); + $request->setRouteResolver(fn () => $route); + app()->instance('request', $request); + + $this->assertSame( + route('docs.whats-new', ['platform' => 'mobile', 'version' => '4']), + DocsLabels::whatsNewUrl(), + ); + } +} diff --git a/tests/Unit/DocsChangelogServiceTest.php b/tests/Unit/DocsChangelogServiceTest.php new file mode 100644 index 000000000..9e22da4f7 --- /dev/null +++ b/tests/Unit/DocsChangelogServiceTest.php @@ -0,0 +1,77 @@ +service = app(DocsChangelogService::class); + } + + #[Test] + public function it_finds_the_known_since_badge_on_the_layout_page(): void + { + $badges = $this->service->badgesForVersion(DocsPlatform::Mobile, 4); + + $this->assertArrayHasKey('4.2', $badges); + $this->assertArrayHasKey('added', $badges['4.2']); + + $titles = array_column($badges['4.2']['added'], 'title'); + $this->assertContains('Layout & Styling', $titles); + } + + #[Test] + public function it_finds_the_known_removed_badge_on_the_system_page(): void + { + $badges = $this->service->badgesForVersion(DocsPlatform::Mobile, 4); + + $this->assertArrayHasKey('4.1', $badges); + $titles = array_column($badges['4.1']['removed'] ?? [], 'title'); + $this->assertContains('System', $titles); + } + + #[Test] + public function it_excludes_the_versioning_policy_pages_own_example_badges(): void + { + $badges = $this->service->badgesForVersion(DocsPlatform::Mobile, 4); + + foreach ($badges as $types) { + foreach ($types as $pages) { + $this->assertNotContains('Versioning Policy', array_column($pages, 'title')); + } + } + } + + #[Test] + public function it_returns_minor_versions_sorted_descending(): void + { + $badges = $this->service->badgesForVersion(DocsPlatform::Mobile, 4); + + $minors = array_keys($badges); + $sorted = $minors; + rsort($sorted, SORT_NATURAL); + + $this->assertSame($sorted, $minors); + } + + #[Test] + public function it_returns_an_empty_array_when_nothing_is_labelled(): void + { + $badges = $this->service->badgesForVersion(DocsPlatform::Desktop, 1); + + $this->assertSame([], $badges); + } +} From 77abb4fdfc8716e1a3dc657805324614527033e7 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 5 Sep 2026 02:05:22 +0200 Subject: [PATCH 7/9] feat(docs): audit config keys and console commands for coverage gaps Adds docs:audit-coverage, which cross-references each platform's config keys and console commands (fetched from the package's GitHub repo tree) against the docs markdown, flagging anything never mentioned by name. Results persist to a docs_coverage_gaps table, surfaced to maintainers via an admin-only Filament resource, and the command runs weekly. Extends App\Support\GitHub with tree()/blob() (Git Trees/Blobs API) and authenticates its requests when a GitHub token is configured, since the per-file blob fetches this adds can otherwise exhaust the 60/hour unauthenticated rate limit in a single run. Co-Authored-By: Claude Sonnet 5 --- app/Console/Commands/AuditDocsCoverage.php | 93 +++++++++++++ app/Console/Kernel.php | 6 + app/Enums/DocsCoverageCategory.php | 19 +++ .../Resources/DocsCoverageGapResource.php | 96 +++++++++++++ .../Pages/ListDocsCoverageGaps.php | 13 ++ app/Models/DocsCoverageGap.php | 39 ++++++ app/Services/DocsCoverageAuditor.php | 114 +++++++++++++++ app/Services/DocsVersionRegistry.php | 8 ++ app/Support/GitHub.php | 113 ++++++++++++++- config/docs.php | 25 ++++ database/factories/DocsCoverageGapFactory.php | 31 +++++ ...234024_create_docs_coverage_gaps_table.php | 35 +++++ .../Console/AuditDocsCoverageCommandTest.php | 91 ++++++++++++ .../Filament/DocsCoverageGapResourceTest.php | 65 +++++++++ tests/Feature/Support/GitHubTest.php | 88 ++++++++++++ tests/Unit/DocsCoverageAuditorTest.php | 131 ++++++++++++++++++ 16 files changed, 961 insertions(+), 6 deletions(-) create mode 100644 app/Console/Commands/AuditDocsCoverage.php create mode 100644 app/Enums/DocsCoverageCategory.php create mode 100644 app/Filament/Resources/DocsCoverageGapResource.php create mode 100644 app/Filament/Resources/DocsCoverageGapResource/Pages/ListDocsCoverageGaps.php create mode 100644 app/Models/DocsCoverageGap.php create mode 100644 app/Services/DocsCoverageAuditor.php create mode 100644 database/factories/DocsCoverageGapFactory.php create mode 100644 database/migrations/2026_09_04_234024_create_docs_coverage_gaps_table.php create mode 100644 tests/Feature/Console/AuditDocsCoverageCommandTest.php create mode 100644 tests/Feature/Filament/DocsCoverageGapResourceTest.php create mode 100644 tests/Unit/DocsCoverageAuditorTest.php diff --git a/app/Console/Commands/AuditDocsCoverage.php b/app/Console/Commands/AuditDocsCoverage.php new file mode 100644 index 000000000..c837f7e3a --- /dev/null +++ b/app/Console/Commands/AuditDocsCoverage.php @@ -0,0 +1,93 @@ +argument('platform')) { + $platform = DocsPlatform::tryFrom($this->argument('platform')); + + if ($platform === null) { + $this->error("Unknown platform: {$this->argument('platform')}"); + + return self::FAILURE; + } + + $platforms = [$platform]; + } else { + $platforms = DocsPlatform::cases(); + } + + foreach ($platforms as $platform) { + $this->auditPlatform($auditor, $platform); + } + + return self::SUCCESS; + } + + private function auditPlatform(DocsCoverageAuditor $auditor, DocsPlatform $platform): void + { + $results = $auditor->audit($platform); + + if ($results->isEmpty()) { + $this->error("{$platform->label()}: audit returned nothing (a fetch likely failed) — leaving stored gaps untouched."); + + return; + } + + $checkedAt = now(); + + foreach ($results as $result) { + DocsCoverageGap::query()->updateOrCreate( + [ + 'platform' => $platform, + 'category' => $result['category'], + 'identifier' => $result['identifier'], + ], + [ + 'documented' => $result['documented'], + 'source_path' => $result['source_path'], + 'checked_at' => $checkedAt, + ] + ); + } + + // Stale identifiers (renamed, removed) are dropped per category — + // scoped this way so a category the auditor didn't touch this run + // (e.g. no command files matched) can't have its untouched rows + // wiped by another category's identifier list. + foreach ($results->groupBy(fn (array $result) => $result['category']->value) as $category => $categoryResults) { + DocsCoverageGap::query() + ->where('platform', $platform) + ->where('category', $category) + ->whereNotIn('identifier', $categoryResults->pluck('identifier')) + ->delete(); + } + + $undocumented = $results->reject(fn (array $result) => $result['documented']); + + $this->info(sprintf( + '%s: %d checked, %d undocumented', + $platform->label(), + $results->count(), + $undocumented->count() + )); + + foreach ($undocumented as $gap) { + $this->line(sprintf(' - [%s] %s', $gap['category']->label(), $gap['identifier'])); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 6600f5e4d..ff304df45 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -28,6 +28,12 @@ protected function schedule(Schedule $schedule): void $schedule->command('payouts:process-eligible') ->dailyAt('11:00') ->onOneServer(); + + // Flag docs that have drifted from the packages' config keys and console commands + $schedule->command('docs:audit-coverage') + ->weekly() + ->onOneServer() + ->runInBackground(); } /** diff --git a/app/Enums/DocsCoverageCategory.php b/app/Enums/DocsCoverageCategory.php new file mode 100644 index 000000000..ecc2dcaf7 --- /dev/null +++ b/app/Enums/DocsCoverageCategory.php @@ -0,0 +1,19 @@ + 'Config key', + self::ConsoleCommand => 'Console command', + }; + } +} diff --git a/app/Filament/Resources/DocsCoverageGapResource.php b/app/Filament/Resources/DocsCoverageGapResource.php new file mode 100644 index 000000000..ebfb5b243 --- /dev/null +++ b/app/Filament/Resources/DocsCoverageGapResource.php @@ -0,0 +1,96 @@ +columns([ + Tables\Columns\TextColumn::make('platform') + ->formatStateUsing(fn (DocsPlatform $state): string => $state->label()) + ->sortable(), + + Tables\Columns\TextColumn::make('category') + ->formatStateUsing(fn (DocsCoverageCategory $state): string => $state->label()) + ->badge() + ->sortable(), + + Tables\Columns\TextColumn::make('identifier') + ->searchable() + ->sortable(), + + Tables\Columns\IconColumn::make('documented') + ->boolean() + ->sortable(), + + Tables\Columns\TextColumn::make('source_path') + ->label('Source') + ->toggleable(), + + Tables\Columns\TextColumn::make('checked_at') + ->label('Last checked') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('platform') + ->options(collect(DocsPlatform::cases())->mapWithKeys( + fn (DocsPlatform $platform) => [$platform->value => $platform->label()] + )), + Tables\Filters\SelectFilter::make('category') + ->options(collect(DocsCoverageCategory::cases())->mapWithKeys( + fn (DocsCoverageCategory $category) => [$category->value => $category->label()] + )), + Tables\Filters\TernaryFilter::make('documented'), + ]) + ->actions([ + Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('documented'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDocsCoverageGaps::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/DocsCoverageGapResource/Pages/ListDocsCoverageGaps.php b/app/Filament/Resources/DocsCoverageGapResource/Pages/ListDocsCoverageGaps.php new file mode 100644 index 000000000..15529fb39 --- /dev/null +++ b/app/Filament/Resources/DocsCoverageGapResource/Pages/ListDocsCoverageGaps.php @@ -0,0 +1,13 @@ + */ + use HasFactory; + + protected $fillable = [ + 'platform', 'category', 'identifier', 'documented', 'source_path', 'checked_at', + ]; + + #[Scope] + protected function undocumented(Builder $query): Builder + { + return $query->where('documented', false); + } + + protected function casts(): array + { + return [ + 'platform' => DocsPlatform::class, + 'category' => DocsCoverageCategory::class, + 'documented' => 'boolean', + 'checked_at' => 'datetime', + ]; + } +} diff --git a/app/Services/DocsCoverageAuditor.php b/app/Services/DocsCoverageAuditor.php new file mode 100644 index 000000000..af0991c9c --- /dev/null +++ b/app/Services/DocsCoverageAuditor.php @@ -0,0 +1,114 @@ + + */ + public function audit(DocsPlatform $platform): Collection + { + $repo = $this->registry->sourceRepo($platform); + $github = new GitHub($repo['package']); + $tree = $github->tree(); + $docsText = $this->docsText($platform); + + return $this->auditConfigKeys($github, $tree, $docsText, $repo['config_path']) + ->concat($this->auditConsoleCommands($github, $tree, $docsText)) + ->values(); + } + + /** + * @param Collection $tree + * @return Collection + */ + private function auditConfigKeys(GitHub $github, Collection $tree, string $docsText, string $configPath): Collection + { + $entry = $tree->firstWhere('path', $configPath); + + if ($entry === null) { + return collect(); + } + + $contents = $github->blob($entry['sha']); + + if ($contents === null) { + return collect(); + } + + // Matches keys indented 4 (top-level) or 8 spaces (one level nested, + // e.g. inside 'android' => [...]) — config/nativephp.php groups most + // platform-specific options that way. Deeper nesting is skipped to + // avoid false positives from array values that happen to look like + // 'key' => in unrelated data (e.g. permission description strings). + preg_match_all("/^ {4,8}'([a-z_]+)'\s*=>/m", $contents, $matches); + + return collect(array_unique($matches[1])) + ->map(fn (string $key): array => [ + 'category' => DocsCoverageCategory::ConfigKey, + 'identifier' => $key, + 'documented' => (bool) preg_match('/\b'.preg_quote($key, '/').'\b/', $docsText), + 'source_path' => $configPath, + ]); + } + + /** + * @param Collection $tree + * @return Collection + */ + private function auditConsoleCommands(GitHub $github, Collection $tree, string $docsText): Collection + { + return $tree + ->filter(fn (array $entry): bool => $entry['type'] === 'blob' && (bool) preg_match('#/Commands/[^/]+\.php$#', $entry['path'])) + ->map(function (array $entry) use ($github): ?array { + $contents = $github->blob($entry['sha']); + + if ($contents === null || ! preg_match('/protected \$signature\s*=\s*[\'"]([a-z0-9:_-]+)/i', $contents, $match)) { + return null; + } + + return ['identifier' => $match[1], 'source_path' => $entry['path']]; + }) + ->filter() + ->unique('identifier') + ->map(fn (array $command): array => [ + 'category' => DocsCoverageCategory::ConsoleCommand, + 'identifier' => $command['identifier'], + 'documented' => (bool) preg_match('/\b'.preg_quote($command['identifier'], '/').'\b/', $docsText), + 'source_path' => $command['source_path'], + ]) + ->values(); + } + + protected function docsText(DocsPlatform $platform): string + { + $directory = resource_path('views/docs/'.$platform->value); + + if (! is_dir($directory)) { + return ''; + } + + $finder = (new Finder)->files()->in($directory)->name('*.md'); + + $text = ''; + + foreach ($finder as $file) { + $text .= $file->getContents()."\n"; + } + + return $text; + } +} diff --git a/app/Services/DocsVersionRegistry.php b/app/Services/DocsVersionRegistry.php index 2e9e671b1..8f2d87200 100644 --- a/app/Services/DocsVersionRegistry.php +++ b/app/Services/DocsVersionRegistry.php @@ -43,4 +43,12 @@ public function renames(DocsPlatform $platform): array { return config("docs.renamed_pages.{$platform->value}", []); } + + /** + * @return array{package: string, config_path: string} + */ + public function sourceRepo(DocsPlatform $platform): array + { + return config("docs.source_repos.{$platform->value}"); + } } diff --git a/app/Support/GitHub.php b/app/Support/GitHub.php index 639c107b9..3382a1c18 100644 --- a/app/Support/GitHub.php +++ b/app/Support/GitHub.php @@ -3,9 +3,11 @@ namespace App\Support; use App\Support\GitHub\Release; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class GitHub { @@ -19,6 +21,16 @@ class GitHub public const PACKAGE_MOBILE_AIR = 'nativephp/mobile-air'; + private const CACHE_LATEST_VERSION = 'latest-version'; + + private const CACHE_RELEASES = 'releases'; + + private const CACHE_TREE = 'tree'; + + private const CACHE_DEFAULT_BRANCH = 'default-branch'; + + private const CACHE_BLOB_PREFIX = 'blob-'; + public function __construct( private string $package ) {} @@ -53,7 +65,7 @@ public static function mobileAir(): static public function latestVersion() { $release = Cache::remember( - $this->getCacheKey('latest-version'), + $this->getCacheKey(self::CACHE_LATEST_VERSION), now()->addHour(), fn () => $this->fetchLatestVersion() ); @@ -64,12 +76,38 @@ public function latestVersion() public function releases(): Collection { return Cache::remember( - $this->getCacheKey('releases'), + $this->getCacheKey(self::CACHE_RELEASES), now()->addHour(), fn () => $this->fetchReleases() ) ?? collect(); } + /** + * @return Collection + */ + public function tree(): Collection + { + return Cache::remember( + $this->getCacheKey(self::CACHE_TREE), + now()->addHour(), + fn () => $this->fetchTree() + ) ?? collect(); + } + + /** + * The decoded contents of a single blob, addressed by the sha reported + * for it in tree(). Cached separately from the tree itself since blobs + * are large and most only need to be fetched once per commit. + */ + public function blob(string $sha): ?string + { + return Cache::remember( + $this->getCacheKey(self::CACHE_BLOB_PREFIX.$sha), + now()->addDay(), + fn () => $this->fetchBlob($sha) + ); + } + /** * Releases strictly after the given version, optionally capped below * another version and its prereleases — without a cap, a versioned @@ -107,10 +145,8 @@ public function releasesFrom(string $version): Collection private function fetchLatestVersion(): ?Release { - // Make a request to GitHub - $response = Http::get('https://api.github.com/repos/'.$this->package.'/releases/latest'); + $response = $this->request()->get('/releases/latest'); - // Check if the request was successful if ($response->failed()) { return null; } @@ -123,13 +159,64 @@ private function getCacheKey(string $string): string return sprintf('%s-%s', $this->package, $string); } + private function defaultBranch(): string + { + return Cache::remember( + $this->getCacheKey(self::CACHE_DEFAULT_BRANCH), + now()->addDay(), + fn () => $this->fetchDefaultBranch() + ) ?? 'main'; + } + + private function fetchDefaultBranch(): ?string + { + $response = $this->request()->get('/'); + + if ($response->failed()) { + return null; + } + + return $response->json('default_branch'); + } + + private function fetchTree(): Collection + { + $response = $this->request()->get('/git/trees/'.$this->defaultBranch(), ['recursive' => 1]); + + if ($response->failed()) { + return collect(); + } + + // GitHub silently truncates trees over its internal size limit rather + // than erroring or paginating, so a truncated result would otherwise + // look like a repo that simply doesn't have the missing files. + if ($response->json('truncated') === true) { + Log::warning("GitHub tree for {$this->package} was truncated; some paths may be missing from the audit."); + } + + return collect($response->json('tree')); + } + + private function fetchBlob(string $sha): ?string + { + $response = $this->request()->get('/git/blobs/'.$sha); + + if ($response->failed()) { + return null; + } + + $content = $response->json('content'); + + return $content === null ? null : base64_decode($content); + } + private function fetchReleases(): ?Collection { $releases = collect(); $page = 1; do { - $response = Http::get('https://api.github.com/repos/'.$this->package.'/releases', [ + $response = $this->request()->get('/releases', [ 'per_page' => 100, 'page' => $page, ]); @@ -145,4 +232,18 @@ private function fetchReleases(): ?Collection return $releases->map(fn (array $release) => new Release($release)); } + + /** + * A request pre-scoped to this repo, authenticated when a token is + * configured — GitHub's REST API allows only 60 unauthenticated + * requests/hour, which the per-file blob fetches in tree-walking + * callers (e.g. DocsCoverageAuditor) can exhaust in a single run. + */ + private function request(): PendingRequest + { + $request = Http::baseUrl('https://api.github.com/repos/'.$this->package); + $token = config('services.github.token'); + + return $token ? $request->withToken($token) : $request; + } } diff --git a/config/docs.php b/config/docs.php index 92c545a15..bd8e56efb 100644 --- a/config/docs.php +++ b/config/docs.php @@ -1,5 +1,7 @@ [ + 'desktop' => [ + 'package' => GitHub::PACKAGE_DESKTOP, + 'config_path' => 'config/nativephp.php', + ], + 'mobile' => [ + 'package' => GitHub::PACKAGE_MOBILE_AIR, + 'config_path' => 'config/nativephp.php', + ], + ], + ]; diff --git a/database/factories/DocsCoverageGapFactory.php b/database/factories/DocsCoverageGapFactory.php new file mode 100644 index 000000000..0f69a5133 --- /dev/null +++ b/database/factories/DocsCoverageGapFactory.php @@ -0,0 +1,31 @@ + + */ +class DocsCoverageGapFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'platform' => DocsPlatform::Mobile, + 'category' => DocsCoverageCategory::ConfigKey, + 'identifier' => $this->faker->unique()->word(), + 'documented' => $this->faker->boolean(), + 'source_path' => 'config/nativephp.php', + 'checked_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_09_04_234024_create_docs_coverage_gaps_table.php b/database/migrations/2026_09_04_234024_create_docs_coverage_gaps_table.php new file mode 100644 index 000000000..56c3f7e08 --- /dev/null +++ b/database/migrations/2026_09_04_234024_create_docs_coverage_gaps_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('platform'); + $table->string('category'); + $table->string('identifier'); + $table->boolean('documented'); + $table->string('source_path')->nullable(); + $table->timestamp('checked_at'); + $table->timestamps(); + + $table->unique(['platform', 'category', 'identifier']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('docs_coverage_gaps'); + } +}; diff --git a/tests/Feature/Console/AuditDocsCoverageCommandTest.php b/tests/Feature/Console/AuditDocsCoverageCommandTest.php new file mode 100644 index 000000000..094f75d91 --- /dev/null +++ b/tests/Feature/Console/AuditDocsCoverageCommandTest.php @@ -0,0 +1,91 @@ +fakeRepo('nativephp/mobile-air', " true,\n];\n"); + $this->fakeRepo('nativephp/desktop', " true,\n];\n"); + + $this->artisan('docs:audit-coverage')->assertSuccessful(); + + $this->assertSame( + [DocsPlatform::Desktop, DocsPlatform::Mobile], + DocsCoverageGap::query()->orderBy('platform')->pluck('platform')->all() + ); + } + + #[Test] + public function it_only_audits_the_given_platform(): void + { + $this->fakeRepo('nativephp/mobile-air', " true,\n];\n"); + + $this->artisan('docs:audit-coverage', ['platform' => 'mobile'])->assertSuccessful(); + + $this->assertSame(1, DocsCoverageGap::query()->count()); + $this->assertSame(DocsPlatform::Mobile, DocsCoverageGap::query()->sole()->platform); + } + + #[Test] + public function it_fails_for_an_unknown_platform(): void + { + $this->artisan('docs:audit-coverage', ['platform' => 'blackberry'])->assertFailed(); + } + + #[Test] + public function it_removes_gaps_for_identifiers_no_longer_present_in_source(): void + { + DocsCoverageGap::factory()->create([ + 'platform' => DocsPlatform::Mobile, + 'category' => DocsCoverageCategory::ConfigKey, + 'identifier' => 'removed_key', + ]); + + $this->fakeRepo('nativephp/mobile-air', " true,\n];\n"); + + $this->artisan('docs:audit-coverage', ['platform' => 'mobile'])->assertSuccessful(); + + $this->assertNull(DocsCoverageGap::query()->where('identifier', 'removed_key')->first()); + $this->assertNotNull(DocsCoverageGap::query()->where('identifier', 'current_key')->first()); + } + + #[Test] + public function rerunning_it_updates_an_existing_gap_instead_of_duplicating_it(): void + { + $this->fakeRepo('nativephp/mobile-air', " true,\n];\n"); + + $this->artisan('docs:audit-coverage', ['platform' => 'mobile'])->assertSuccessful(); + $this->artisan('docs:audit-coverage', ['platform' => 'mobile'])->assertSuccessful(); + + $this->assertSame(1, DocsCoverageGap::query()->where('identifier', 'a_key')->count()); + } + + private function fakeRepo(string $package, string $configPhp): void + { + Http::fake([ + "api.github.com/repos/{$package}" => Http::response(['default_branch' => 'main']), + "api.github.com/repos/{$package}/git/trees/main*" => Http::response([ + 'tree' => [ + ['path' => 'config/nativephp.php', 'type' => 'blob', 'sha' => 'sha-config'], + ], + 'truncated' => false, + ]), + "api.github.com/repos/{$package}/git/blobs/sha-config" => Http::response([ + 'content' => base64_encode($configPhp), + ]), + ]); + } +} diff --git a/tests/Feature/Filament/DocsCoverageGapResourceTest.php b/tests/Feature/Filament/DocsCoverageGapResourceTest.php new file mode 100644 index 000000000..68fd9a2f0 --- /dev/null +++ b/tests/Feature/Filament/DocsCoverageGapResourceTest.php @@ -0,0 +1,65 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + #[Test] + public function admin_can_view_the_coverage_gap_list(): void + { + DocsCoverageGap::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(ListDocsCoverageGaps::class) + ->assertSuccessful(); + } + + #[Test] + public function admin_can_filter_the_list_to_undocumented_entries(): void + { + DocsCoverageGap::factory()->create(['identifier' => 'documented_key', 'documented' => true]); + DocsCoverageGap::factory()->create(['identifier' => 'missing_key', 'documented' => false]); + + Livewire::actingAs($this->admin) + ->test(ListDocsCoverageGaps::class) + ->assertSuccessful() + ->assertCanSeeTableRecords(DocsCoverageGap::query()->where('documented', false)->get()) + ->filterTable('documented', false) + ->assertCanSeeTableRecords(DocsCoverageGap::query()->where('documented', false)->get()) + ->assertCanNotSeeTableRecords(DocsCoverageGap::query()->where('documented', true)->get()); + } + + #[Test] + public function a_non_admin_cannot_access_the_coverage_gap_list(): void + { + $user = User::factory()->create(['email' => 'not-an-admin@test.com']); + + $this->actingAs($user)->get('/admin/docs-coverage-gaps')->assertForbidden(); + } + + #[Test] + public function a_guest_is_redirected_away_from_the_coverage_gap_list(): void + { + $this->get('/admin/docs-coverage-gaps')->assertRedirect(); + } +} diff --git a/tests/Feature/Support/GitHubTest.php b/tests/Feature/Support/GitHubTest.php index 5b9b0f0ec..4428beb5e 100644 --- a/tests/Feature/Support/GitHubTest.php +++ b/tests/Feature/Support/GitHubTest.php @@ -159,4 +159,92 @@ public function test_releases_after_returns_empty_collection_when_request_fails( $this->assertTrue(GitHub::mobileAir()->releasesAfter('3.1.0')->isEmpty()); } + + public function test_tree_fetches_the_recursive_tree_of_the_default_branch(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/' => Http::response(['default_branch' => 'develop']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/develop*' => Http::response([ + 'tree' => [ + ['path' => 'config/nativephp.php', 'type' => 'blob', 'sha' => 'abc123'], + ], + 'truncated' => false, + ]), + ]); + + $tree = GitHub::mobileAir()->tree(); + + $this->assertCount(1, $tree); + $this->assertSame('config/nativephp.php', $tree->first()['path']); + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.github.com/repos/nativephp/mobile-air/git/trees/develop?')); + } + + public function test_tree_falls_back_to_main_when_the_default_branch_lookup_fails(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/' => Http::response(null, 404), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response(['tree' => [], 'truncated' => false]), + ]); + + GitHub::mobileAir()->tree(); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.github.com/repos/nativephp/mobile-air/git/trees/main?')); + } + + public function test_tree_returns_an_empty_collection_when_the_request_fails(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/' => Http::response(['default_branch' => 'main']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response(null, 500), + ]); + + $this->assertTrue(GitHub::mobileAir()->tree()->isEmpty()); + } + + public function test_blob_decodes_the_base64_content(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/git/blobs/abc123' => Http::response([ + 'content' => base64_encode(" 'bar'];\n"), + 'encoding' => 'base64', + ]), + ]); + + $this->assertSame(" 'bar'];\n", GitHub::mobileAir()->blob('abc123')); + } + + public function test_blob_returns_null_when_the_request_fails(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/git/blobs/missing' => Http::response(null, 404), + ]); + + $this->assertNull(GitHub::mobileAir()->blob('missing')); + } + + public function test_requests_are_authenticated_when_a_github_token_is_configured(): void + { + config(['services.github.token' => 'test-token']); + + Http::fake([ + 'api.github.com/*' => Http::response(['default_branch' => 'main']), + ]); + + GitHub::mobileAir()->tree(); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer test-token')); + } + + public function test_requests_carry_no_authorization_header_without_a_configured_token(): void + { + config(['services.github.token' => null]); + + Http::fake([ + 'api.github.com/*' => Http::response(['default_branch' => 'main']), + ]); + + GitHub::mobileAir()->tree(); + + Http::assertSent(fn ($request) => ! $request->hasHeader('Authorization')); + } } diff --git a/tests/Unit/DocsCoverageAuditorTest.php b/tests/Unit/DocsCoverageAuditorTest.php new file mode 100644 index 000000000..c8c04155e --- /dev/null +++ b/tests/Unit/DocsCoverageAuditorTest.php @@ -0,0 +1,131 @@ +fakeMobileRepo( + configPhp: " env('NATIVEPHP_DEEPLINK_SCHEME'),\n 'undocumented_key' => true,\n];\n" + ); + + $results = $this->auditor()->audit(DocsPlatform::Mobile); + + $documented = $results->firstWhere('identifier', 'deeplink_scheme'); + $undocumented = $results->firstWhere('identifier', 'undocumented_key'); + + $this->assertTrue($documented['documented']); + $this->assertFalse($undocumented['documented']); + } + + #[Test] + public function it_only_matches_nested_config_keys_up_to_one_level_deep(): void + { + $this->fakeMobileRepo( + configPhp: " [\n 'permissions' => [\n 'too_deep_to_flag' => 'a description',\n ],\n ],\n];\n" + ); + + $results = $this->auditor()->audit(DocsPlatform::Mobile); + + $this->assertNull($results->firstWhere('identifier', 'too_deep_to_flag')); + } + + #[Test] + public function it_extracts_the_command_name_from_either_quote_style(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air' => Http::response(['default_branch' => 'main']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response([ + 'tree' => [ + ['path' => 'src/Commands/ValidateCommand.php', 'type' => 'blob', 'sha' => 'sha-single'], + ['path' => 'src/Commands/WatchCommand.php', 'type' => 'blob', 'sha' => 'sha-double'], + ], + 'truncated' => false, + ]), + 'api.github.com/repos/nativephp/mobile-air/git/blobs/sha-single' => Http::response([ + 'content' => base64_encode(" Http::response([ + 'content' => base64_encode('auditor()->audit(DocsPlatform::Mobile); + + $this->assertNotNull($results->firstWhere('identifier', 'native:validate')); + $this->assertNotNull($results->firstWhere('identifier', 'native:watch')); + } + + #[Test] + public function it_ignores_files_outside_a_commands_directory(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air' => Http::response(['default_branch' => 'main']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response([ + 'tree' => [ + ['path' => 'src/Support/Helper.php', 'type' => 'blob', 'sha' => 'sha-helper'], + ], + 'truncated' => false, + ]), + ]); + + $results = $this->auditor()->audit(DocsPlatform::Mobile); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'sha-helper')); + $this->assertTrue($results->isEmpty()); + } + + #[Test] + public function it_returns_an_empty_collection_when_the_config_file_is_missing_from_the_tree(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air' => Http::response(['default_branch' => 'main']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response(['tree' => [], 'truncated' => false]), + ]); + + $this->assertTrue($this->auditor()->audit(DocsPlatform::Mobile)->isEmpty()); + } + + private function fakeMobileRepo(string $configPhp): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air' => Http::response(['default_branch' => 'main']), + 'api.github.com/repos/nativephp/mobile-air/git/trees/main*' => Http::response([ + 'tree' => [ + ['path' => 'config/nativephp.php', 'type' => 'blob', 'sha' => 'sha-config'], + ], + 'truncated' => false, + ]), + 'api.github.com/repos/nativephp/mobile-air/git/blobs/sha-config' => Http::response([ + 'content' => base64_encode($configPhp), + ]), + ]); + } + + private function auditor(string $docsText = self::FAKE_DOCS_TEXT): DocsCoverageAuditor + { + return new class(app(DocsVersionRegistry::class), $docsText) extends DocsCoverageAuditor + { + public function __construct(DocsVersionRegistry $registry, private readonly string $fakeDocsText) + { + parent::__construct($registry); + } + + protected function docsText(DocsPlatform $platform): string + { + return $this->fakeDocsText; + } + }; + } +} From 7aa869b4bb7d7c548a319bbc03e94b9105d0b6d4 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 5 Sep 2026 18:31:48 +0100 Subject: [PATCH 8/9] feat(docs): link each What's New minor to its latest patch in the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The What's New page grouped changes under a bare minor version heading, so readers had no way to get from "4.2" to what actually shipped in it. Each heading now carries a link to that minor's latest patch release on the platform's changelog page. config('docs.changelog') records, per platform and major, which page holds the changelog, what it's called, and the GitHub repo whose releases it renders. Mobile 1 and 2 are left out on purpose: their changelogs are hand-written, so there's no release feed to line a link up with, and a link would just land on the top of the page. The anchor is slugged from the release name rather than the tag, because the name is what the changelog prints in its heading and what HeadingRenderer slugs into the id. Note Str::slug drops the dots, so 4.2.3 anchors as #423. The release lookup deliberately sits outside the page's day-long badge cache — GitHub::releases() already caches for an hour, and releases move faster than docs pages. If GitHub is unreachable the headings render on their own. One rough edge: mobile v3's changelog only lists releases after 3.1.0, so a badge labelled 3.1 whose latest patch is exactly 3.1.0 would point at an anchor that isn't on the page. The link still lands on the changelog, just at the top. Nothing is labelled in mobile 3 today. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A8RHq357KfTFsniMevuEkE --- .../Controllers/DocsWhatsNewController.php | 11 ++- app/Services/DocsChangelogService.php | 68 +++++++++++++++++++ config/docs.php | 26 +++++++ resources/views/docs/whats-new.blade.php | 13 +++- tests/Feature/Docs/WhatsNewPageTest.php | 29 ++++++++ tests/Unit/DocsChangelogServiceTest.php | 54 +++++++++++++++ 6 files changed, 199 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/DocsWhatsNewController.php b/app/Http/Controllers/DocsWhatsNewController.php index 9ead20275..95091010d 100644 --- a/app/Http/Controllers/DocsWhatsNewController.php +++ b/app/Http/Controllers/DocsWhatsNewController.php @@ -17,10 +17,19 @@ public function __invoke(Request $request, string $platform, string $version): V abort_unless(is_dir(resource_path("views/docs/{$platform}/{$version}")), 404); abort_unless(DocsPlatform::tryFrom($platform) !== null, 404); + $badges = $this->badges($platform, (int) $version); + return view('docs.whats-new', [ 'platform' => $platform, 'version' => $version, - 'badges' => $this->badges($platform, (int) $version), + 'badges' => $badges, + // Not folded into the day-long badge cache: releases move faster + // than docs pages, and GitHub::releases() already caches hourly. + 'changelogLinks' => app(DocsChangelogService::class)->changelogLinks( + DocsPlatform::from($platform), + (int) $version, + array_keys($badges), + ), ]); } diff --git a/app/Services/DocsChangelogService.php b/app/Services/DocsChangelogService.php index e101d2465..ab424a82d 100644 --- a/app/Services/DocsChangelogService.php +++ b/app/Services/DocsChangelogService.php @@ -5,6 +5,10 @@ namespace App\Services; use App\Enums\DocsPlatform; +use App\Support\GitHub; +use App\Support\GitHub\Release; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Spatie\YamlFrontMatter\YamlFrontMatter; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; @@ -73,6 +77,70 @@ public function badgesForVersion(DocsPlatform $platform, int $major): array return $entries; } + /** + * Links each minor version to the latest patch release listed for it on the + * platform's changelog, so "4.2" on the What's New page lands on 4.2.3's + * entry rather than the top of a long page. + * + * Majors whose changelog isn't built from a GitHub release feed, and minors + * with no release to point at, get no link — the heading stands on its own. + * + * @param array $minors + * @return array + */ + public function changelogLinks(DocsPlatform $platform, int $major, array $minors): array + { + $changelog = config("docs.changelog.{$platform->value}.{$major}"); + + if ($changelog === null || ! file_exists(resource_path("views/docs/{$platform->value}/{$major}/{$changelog['page']}.md"))) { + return []; + } + + $releases = $this->releaseNames($changelog['repository']); + + $changelogUrl = route('docs.show', [ + 'platform' => $platform->value, + 'version' => $major, + 'page' => $changelog['page'], + ]); + + $links = []; + + foreach ($minors as $minor) { + $latest = $releases + ->filter(fn (string $name) => str_starts_with(ltrim($name, 'v'), "{$minor}.")) + ->sort(fn (string $a, string $b) => version_compare(ltrim($a, 'v'), ltrim($b, 'v'))) + ->last(); + + if ($latest === null) { + continue; + } + + $links[$minor] = [ + 'version' => $latest, + 'url' => $changelogUrl.'#'.Str::slug($latest), + 'label' => $changelog['label'], + ]; + } + + return $links; + } + + /** + * The release names as the changelog page prints them in its headings — + * HeadingRenderer slugs that heading text into the anchor, so the link has + * to be built from the name rather than the tag. + * + * @return Collection + */ + private function releaseNames(string $repository): Collection + { + return (new GitHub($repository))->releases() + ->map(fn (Release $release) => (string) ($release->name ?: $release->tag_name)) + ->filter() + ->values(); + } + /** * @return array [type, minor version] pairs */ diff --git a/config/docs.php b/config/docs.php index 92c545a15..67582c520 100644 --- a/config/docs.php +++ b/config/docs.php @@ -63,6 +63,32 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Changelog Sources + |-------------------------------------------------------------------------- + | + | Which page carries the changelog for a platform's major version, what that + | page is called, and the GitHub repository whose releases it renders. The + | What's New page uses this to link each minor version at the latest patch + | release listed for it on the changelog. + | + | Majors whose changelog is hand-written are left out — there's no release + | feed behind them to line a link up with. + | + */ + + 'changelog' => [ + 'desktop' => [ + 1 => ['page' => 'getting-started/releasenotes', 'label' => 'release notes', 'repository' => 'nativephp/electron'], + 2 => ['page' => 'getting-started/releasenotes', 'label' => 'release notes', 'repository' => 'nativephp/desktop'], + ], + 'mobile' => [ + 3 => ['page' => 'getting-started/changelog', 'label' => 'changelog', 'repository' => 'nativephp/mobile-air'], + 4 => ['page' => 'getting-started/changelog', 'label' => 'changelog', 'repository' => 'nativephp/mobile-air'], + ], + ], + /* |-------------------------------------------------------------------------- | Jump diff --git a/resources/views/docs/whats-new.blade.php b/resources/views/docs/whats-new.blade.php index 91a663ab3..f2d84c360 100644 --- a/resources/views/docs/whats-new.blade.php +++ b/resources/views/docs/whats-new.blade.php @@ -17,7 +17,18 @@
@foreach ($badges as $minor => $types)
-

{{ $minor }}

+
+

{{ $minor }}

+ + @if ($link = $changelogLinks[$minor] ?? null) + + {{ $link['version'] }} in the {{ $link['label'] }} + + @endif +
@foreach (\App\Services\DocsChangelogService::TYPE_LABELS as $type => $label) diff --git a/tests/Feature/Docs/WhatsNewPageTest.php b/tests/Feature/Docs/WhatsNewPageTest.php index 0fb9055fc..7aa2f4212 100644 --- a/tests/Feature/Docs/WhatsNewPageTest.php +++ b/tests/Feature/Docs/WhatsNewPageTest.php @@ -3,8 +3,10 @@ namespace Tests\Feature\Docs; use App\Support\DocsLabels; +use App\Support\GitHub\Release; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -49,4 +51,31 @@ public function docs_labels_resolves_the_whats_new_url_from_the_current_route(): DocsLabels::whatsNewUrl(), ); } + + #[Test] + public function it_links_each_minor_version_at_its_latest_patch_in_the_changelog(): void + { + Cache::put('nativephp/mobile-air-releases', collect(['4.2.0', '4.2.3', '4.1.1'])->map( + fn (string $name) => new Release(['tag_name' => $name, 'name' => $name]), + )); + + $changelog = route('docs.show', ['platform' => 'mobile', 'version' => 4, 'page' => 'getting-started/changelog']); + + $this->get('/docs/mobile/4/whats-new') + ->assertStatus(200) + ->assertSee($changelog.'#423') + ->assertSee('4.2.3 in the changelog') + ->assertDontSee($changelog.'#420'); + } + + #[Test] + public function it_renders_minor_headings_without_a_link_when_github_is_unreachable(): void + { + Cache::put('nativephp/mobile-air-releases', collect()); + + $this->get('/docs/mobile/4/whats-new') + ->assertStatus(200) + ->assertSee('4.2') + ->assertDontSee('in the changelog'); + } } diff --git a/tests/Unit/DocsChangelogServiceTest.php b/tests/Unit/DocsChangelogServiceTest.php index 9e22da4f7..2f6f8c94e 100644 --- a/tests/Unit/DocsChangelogServiceTest.php +++ b/tests/Unit/DocsChangelogServiceTest.php @@ -4,7 +4,9 @@ use App\Enums\DocsPlatform; use App\Services\DocsChangelogService; +use App\Support\GitHub\Release; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -74,4 +76,56 @@ public function it_returns_an_empty_array_when_nothing_is_labelled(): void $this->assertSame([], $badges); } + + #[Test] + public function it_links_a_minor_version_at_its_latest_patch_release(): void + { + $this->seedReleases(['4.2.0', '4.2.10', '4.2.9', '4.1.0']); + + $links = $this->service->changelogLinks(DocsPlatform::Mobile, 4, ['4.2', '4.1']); + + $this->assertSame('4.2.10', $links['4.2']['version']); + $this->assertSame( + route('docs.show', ['platform' => 'mobile', 'version' => 4, 'page' => 'getting-started/changelog']).'#4210', + $links['4.2']['url'], + ); + $this->assertSame('4.1.0', $links['4.1']['version']); + } + + #[Test] + public function it_anchors_on_the_release_name_as_the_changelog_prints_it(): void + { + $this->seedReleases(['v4.2.1']); + + $links = $this->service->changelogLinks(DocsPlatform::Mobile, 4, ['4.2']); + + $this->assertStringEndsWith('#v421', $links['4.2']['url']); + $this->assertSame('v4.2.1', $links['4.2']['version']); + } + + #[Test] + public function it_skips_minor_versions_with_no_release_to_point_at(): void + { + $this->seedReleases(['4.1.0']); + + $this->assertSame([], $this->service->changelogLinks(DocsPlatform::Mobile, 4, ['4.9'])); + } + + #[Test] + public function it_returns_no_links_for_a_major_whose_changelog_is_hand_written(): void + { + $this->seedReleases(['2.1.0']); + + $this->assertSame([], $this->service->changelogLinks(DocsPlatform::Mobile, 2, ['2.1'])); + } + + /** + * @param array $names + */ + private function seedReleases(array $names, string $repository = 'nativephp/mobile-air'): void + { + Cache::put("{$repository}-releases", collect($names)->map( + fn (string $name) => new Release(['tag_name' => $name, 'name' => $name]), + )); + } } From 9da7ec39c697f8b66300cf62679a90c2eb288a9b Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 5 Sep 2026 23:49:58 +0200 Subject: [PATCH 9/9] fix(docs): make the search scope broaden button actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docsearch() from @docsearch/js renders into its container and returns undefined — there's no instance object, so the destroy()/open() calls this relied on threw immediately, and initialQuery only seeds state on first mount so it can't restore the query on a later re-render either. Re-init in place instead, and restore the typed query by writing to the (reused, same) input element directly. Co-Authored-By: Claude Sonnet 5 --- resources/js/app.js | 47 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/resources/js/app.js b/resources/js/app.js index 0e6395a42..6e066a8b0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -186,36 +186,51 @@ function getDesktopSearchButton() { ) } -// Re-created on demand when "Search everywhere" is clicked, since docsearch() -// has no API to update an already-initialized instance's transformItems — -// only to destroy() and re-initialize it (see docsearch()'s DocSearchInstance -// return value in the @docsearch/js API reference). -function initDocsearch(broadenScope, initialQuery) { - return docsearch({ +// docsearch() doesn't return a controllable instance (its @docsearch/js +// wrapper just renders into the container and returns undefined) — there's +// no destroy()/open() to call. Re-initializing means calling it again with +// new options: it re-renders into the same container, which preact reconciles +// against the previous tree in place rather than remounting it, so an +// already-open modal stays open across the props change. +function initDocsearch(broadenScope) { + docsearch({ appId: 'ZNII9QZ8WI', apiKey: '9be495a1aaf367b47c873d30a8e7ccf5', indexName: 'nativephp', insights: true, debug: false, container: docsearchContainerSelector, - initialQuery, ...(docsPathMatch && ! broadenScope && { transformItems: scopedTransformItems }), }) } -let docsearchInstance = initDocsearch(false) +initDocsearch(false) const broadenButton = document.getElementById('docsearch-broaden') if (broadenButton && docsPathMatch) { broadenButton.addEventListener('click', () => { - const query = - document.querySelector( - `${docsearchContainerSelector} .DocSearch-Input`, - )?.value ?? '' - - docsearchInstance.destroy() - docsearchInstance = initDocsearch(true, query) - docsearchInstance.open() + // DocSearch portals its modal onto rather than nesting it + // under the container it was initialized with, so the input can't + // be found by scoping the selector to docsearchContainerSelector. + const query = document.querySelector('.DocSearch-Input')?.value ?? '' + const wasOpen = document.querySelector('.DocSearch-Modal') !== null + + // initialQuery only seeds DocSearch's internal state on first mount — + // passing it again here would be silently ignored, since re-render + // reconciles the existing instance rather than remounting it. Restore + // the query by writing straight to the live (same, reused) input and + // dispatching the event its controlled input listens for instead. + initDocsearch(true) + + if (! wasOpen) { + getDesktopSearchButton()?.click() + } else if (query) { + const input = document.querySelector('.DocSearch-Input') + if (input) { + input.value = query + input.dispatchEvent(new Event('input', { bubbles: true })) + } + } document.getElementById('docsearch-scope-label')?.remove() })