diff --git a/app/Livewire/Customer/Course/Index.php b/app/Livewire/Customer/Course/Index.php index 308cabcd..df71fb1f 100644 --- a/app/Livewire/Customer/Course/Index.php +++ b/app/Livewire/Customer/Course/Index.php @@ -122,10 +122,13 @@ public function completedCount(): int } /** - * Published modules with at least one published lesson. + * Every published module, including those still awaiting their first + * released lesson. * * Drives the pre-purchase curriculum outline, which lists every lesson — - * locked or not — so non-purchasers can see the whole course. + * locked or not — so non-purchasers can see the whole course. Modules with + * nothing released yet are listed as coming soon rather than hidden, so the + * outline reflects the full shape of the course. * * Keys are preserved so the view can number each module by its real position * in the course rather than its position within this filtered subset. @@ -140,8 +143,17 @@ public function outlineModules(): Collection } return $this->course->modules - ->filter(fn (CourseModule $module): bool => $module->is_published - && $module->lessons->contains(fn (CourseLesson $lesson): bool => $lesson->is_published)); + ->filter(fn (CourseModule $module): bool => $module->is_published); + } + + /** + * A published module whose lessons are all still unreleased. + */ + public function isComingSoonModule(CourseModule $module): bool + { + return ! $module->lessons->contains( + fn (CourseLesson $lesson): bool => $lesson->is_published + ); } #[Computed] diff --git a/app/Notifications/PluginReviewChecksIncomplete.php b/app/Notifications/PluginReviewChecksIncomplete.php index fa95d078..1d7a6928 100644 --- a/app/Notifications/PluginReviewChecksIncomplete.php +++ b/app/Notifications/PluginReviewChecksIncomplete.php @@ -12,7 +12,7 @@ class PluginReviewChecksIncomplete extends Notification implements ShouldQueue { use Queueable; - private const DOCS_BASE = 'https://nativephp.com/docs/mobile/3/plugins'; + private const DOCS_BASE = 'https://nativephp.com/docs/mobile/plugins'; /** * @var array diff --git a/app/Support/GitHub.php b/app/Support/GitHub.php index 3daf6382..639c107b 100644 --- a/app/Support/GitHub.php +++ b/app/Support/GitHub.php @@ -70,12 +70,38 @@ public function releases(): Collection ) ?? collect(); } - public function releasesAfter(string $version): Collection + /** + * Releases strictly after the given version, optionally capped below + * another version and its prereleases — without a cap, a versioned + * changelog would list the next major's releases too (4.0.0-rc.1 + * compares greater than any 3.x tag). + */ + public function releasesAfter(string $version, ?string $before = null): Collection { $version = ltrim($version, 'v'); + $ceiling = $before === null ? null : ltrim($before, 'v').'-dev'; + + return $this->releases()->filter(function (Release $release) use ($version, $ceiling) { + $tag = ltrim((string) $release->tag_name, 'v'); + + return version_compare($tag, $version, '>') + && ($ceiling === null || version_compare($tag, $ceiling, '<')); + })->values(); + } + + /** + * Releases for the given version and everything later, including that + * prefix version itself and its prereleases. releasesAfter("4.0.0") + * would exclude 4.0.0-rc.1 because version_compare ranks prereleases + * below the release; "dev" ranks below every other prerelease marker, + * so a "-dev" floor admits alphas, betas, RCs and the release itself. + */ + public function releasesFrom(string $version): Collection + { + $floor = ltrim($version, 'v').'-dev'; return $this->releases()->filter( - fn (Release $release) => version_compare(ltrim((string) $release->tag_name, 'v'), $version, '>') + fn (Release $release) => version_compare(ltrim((string) $release->tag_name, 'v'), $floor, '>=') )->values(); } diff --git a/app/Support/GitHub/Release.php b/app/Support/GitHub/Release.php index 11522465..82a22f2a 100644 --- a/app/Support/GitHub/Release.php +++ b/app/Support/GitHub/Release.php @@ -49,25 +49,31 @@ public function getBodyForMarkdown(): string { $body = $this->body; - // Convert any URLs to Markdown links + // Convert bare URLs to Markdown links. Release bodies can already + // contain Markdown links, so URLs preceded by "(" or "<" are left + // alone — wrapping those again nests link syntax and the page ends + // up rendering both the text and the URL. Closing ")" and "]" are + // excluded from the URL so a link's own delimiters are never + // swallowed into it. if ($this->convertLinks) { $body = preg_replace( - '/https?:\/\/[^\s]+\/pull\/(\d+)/', + '/(?withUserLinks) { $body = preg_replace( - '/@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)/', + '/(? [ 'desktop' => 2, - 'mobile' => 3, + 'mobile' => 4, ], /* @@ -32,7 +32,7 @@ 'prerelease_versions' => [ 'desktop' => [], - 'mobile' => [4], + 'mobile' => [], ], /* @@ -53,6 +53,7 @@ 4 => [ 'the-basics/native-components' => 'the-basics/native-ui', 'the-basics/dialog' => 'the-basics/dialogs', + 'the-basics/web-view' => 'edge-components/web-view', 'getting-started/deployment' => 'publishing/introduction', 'plugins/vibe' => 'digging-deeper/websockets', diff --git a/resources/css/app.css b/resources/css/app.css index 10bb7bda..1d207711 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,6 +8,15 @@ @source '../../storage/framework/views/*.php'; @source '../../vendor/filament/**/*.blade.php'; +/* + * flux.css pulls in the flux-pro stubs, and the carousel slide stub declares + * `[snap="mandatory"_&]:snap-always` — one bracket short, so it compiles to the + * invalid selector `snap="mandatory" &` and Lightning CSS warns on every build. + * We don't use , so skip that stub. Drop this once Flux fixes it + * upstream; remove it first if we ever adopt the carousel. + */ +@source not '../../vendor/livewire/flux-pro/stubs/resources/views/flux/carousel'; + /* Safelist classes used in dynamic content (blog posts from database) */ @source inline("grid-cols-1 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6"); diff --git a/resources/js/app.js b/resources/js/app.js index 12430840..293fc949 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -115,10 +115,58 @@ Alpine.data('countdown', (iso) => ({ }, // tidy up })) +// Which platform path the homepage is showing: 'mobile' | 'desktop'. +// Shared across sections (hero, explainer) and remembered between visits. +// Registered on alpine:init because $persist only exists once Livewire has +// registered Alpine's plugins. +document.addEventListener('alpine:init', () => { + Alpine.store('platform', { + current: Alpine.$persist('mobile').as('nativephpPlatform'), + is(name) { + return this.current === name + }, + select(name) { + if (this.current === name) return + + this.current = name + this.revealExplainer() + }, + // Switching tracks rewrites the explainer, so bring it into view. + // Two frames: one for Alpine to apply the change, one for the + // browser to lay it out, so the target offset is the final one. + revealExplainer() { + requestAnimationFrame(() => + requestAnimationFrame(() => { + const target = document.getElementById('platform-explainer') + if (!target) return + + const nav = document.querySelector('[data-site-nav]') + const offset = (nav?.offsetHeight ?? 0) + 16 + const top = + target.getBoundingClientRect().top + + window.scrollY - + offset + + window.scrollTo({ + top: Math.max(0, top), + behavior: window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches + ? 'auto' + : 'smooth', + }) + }), + ) + }, + }) +}) + Livewire.start() // Docsearch -const docsPathMatch = window.location.pathname.match(/^\/docs\/(desktop|mobile)\/(\d+)/) +const docsPathMatch = window.location.pathname.match( + /^\/docs\/(desktop|mobile)\/(\d+)/, +) const docsearchOptions = { appId: 'ZNII9QZ8WI', apiKey: '9be495a1aaf367b47c873d30a8e7ccf5', @@ -148,7 +196,9 @@ docsearch({ // pressing Cmd+K only registers one handler (avoiding duplicate modals). const mobileContainer = document.getElementById('docsearch-mobile') if (mobileContainer) { - const desktopButton = document.querySelector('#docsearch-desktop .DocSearch-Button') + const desktopButton = document.querySelector( + '#docsearch-desktop .DocSearch-Button', + ) if (desktopButton) { const mobileButton = desktopButton.cloneNode(true) mobileContainer.appendChild(mobileButton) diff --git a/resources/views/components/blog/ad-rotation.blade.php b/resources/views/components/blog/ad-rotation.blade.php index 023572c9..375478c9 100644 --- a/resources/views/components/blog/ad-rotation.blade.php +++ b/resources/views/components/blog/ad-rotation.blade.php @@ -180,7 +180,7 @@ class="group relative z-0 grid place-items-center overflow-hidden rounded-2xl bg {{-- Price hint --}}
- Plans from $19/mo + Plans from $10/mo
{{-- Decorative stars --}} diff --git a/resources/views/components/docs/platform-switcher.blade.php b/resources/views/components/docs/platform-switcher.blade.php index 8e4f3d5d..7474154d 100644 --- a/resources/views/components/docs/platform-switcher.blade.php +++ b/resources/views/components/docs/platform-switcher.blade.php @@ -1,7 +1,7 @@ @php $isMobile = request()->is('docs/mobile/*'); - $mobileHref = '/docs/mobile/3'; - $desktopHref = '/docs/desktop/2'; + $mobileHref = '/docs/mobile/'.config('docs.latest_versions.mobile'); + $desktopHref = '/docs/desktop/'.config('docs.latest_versions.desktop'); @endphp
@@ -181,15 +185,17 @@ class="size-4 shrink-0"
{{-- Swag Link --}} - {{--
+ {{-- +
-
Swag
+
Swag
-
--}} +
+ --}}
Consulting - New
@@ -281,10 +286,7 @@ class="size-4 shrink-0" /> @endif -
- Build - New -
+
Build
@@ -309,7 +311,11 @@ class="size-4 shrink-0"
Learn - New + + New +
@@ -335,7 +341,6 @@ class="size-4 shrink-0"
Support - New
@@ -351,7 +356,9 @@ class="flex w-full items-center justify-between py-3 opacity-70 transition durat >
Dashboard - {{ auth()->user()->email }} + + {{ auth()->user()->email }} +
@else @@ -378,7 +385,10 @@ class="size-4 shrink-0" @auth
-
+ @csrf
-
+
diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 248453b4..314d5d46 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -1,4 +1,5 @@
@endforeach diff --git a/resources/views/livewire/customer/developer/onboarding.blade.php b/resources/views/livewire/customer/developer/onboarding.blade.php index 005c7183..ac86e285 100644 --- a/resources/views/livewire/customer/developer/onboarding.blade.php +++ b/resources/views/livewire/customer/developer/onboarding.blade.php @@ -211,7 +211,7 @@ class="mt-0.5 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-5
What do I need to get started? - A new Stripe account will be created for you as part of the onboarding process. You'll also need a GitHub account with a private repository for your plugin. Check out the plugin documentation to start building. + A new Stripe account will be created for you as part of the onboarding process. You'll also need a GitHub account with a private repository for your plugin. Check out the plugin documentation to start building.
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 38045a0a..2cbc402d 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -223,7 +223,7 @@ class="size-4 shrink-0 text-indigo-500 transition-transform duration-200 dark:te

- Full walkthrough in the Using Plugins guide → + Full walkthrough in the Using Plugins guide →

diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index bd286457..4e434f5c 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -8,8 +8,17 @@ {{-- Explainer --}} - {{-- Announcements: Plugins, Bifrost, Mimi, Jump --}} - + {{-- + Announcements: Plugins, Masterclass, Jump, Bifrost. All mobile- + specific, so they drop out on the Desktop track. No x-cloak: mobile is + the default, so the first paint is already correct for most visitors. + --}} +
+ +
{{-- Partners --}} diff --git a/routes/web.php b/routes/web.php index 9c42f000..d3e8caaf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -44,6 +44,7 @@ use App\Models\Course; use App\Models\Product; use App\Services\CartService; +use App\Services\DocsVersionService; use Illuminate\Http\Request; use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Support\Facades\Route; @@ -273,6 +274,11 @@ ->last() ?? '1'; + // Map renamed pages to their slug in the latest version up front, so a + // stale unversioned link 301s straight to its new home instead of + // chaining through a second redirect. + $page = app(DocsVersionService::class)->resolvePageForVersion($platform, $latestVersion, $page); + return redirect("/docs/{$platform}/{$latestVersion}/{$page}", 301); }) ->where('platform', 'desktop|mobile') diff --git a/tests/Feature/CourseContentTest.php b/tests/Feature/CourseContentTest.php index 40c326ca..62b5be45 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -271,7 +271,7 @@ public function free_lessons_are_not_labelled_with_a_free_pill(): void } #[Test] - public function course_dashboard_excludes_unpublished_lessons_from_the_curriculum(): void + public function course_dashboard_lists_unpublished_lessons_as_coming_soon(): void { $user = User::factory()->create(); @@ -293,18 +293,22 @@ public function course_dashboard_excludes_unpublished_lessons_from_the_curriculu ->test(Index::class) ->assertSee('Partly Released Module') ->assertSee('Released Lesson') - ->assertDontSee('Unreleased Lesson'); + ->assertSeeInOrder(['Unreleased Lesson', 'Coming Soon']); } + /** + * A published module with nothing released yet still belongs in the + * outline — free users should see the shape of the whole course. + */ #[Test] - public function course_dashboard_excludes_modules_with_no_published_lessons(): void + public function course_dashboard_lists_modules_with_no_published_lessons_as_coming_soon(): void { $user = User::factory()->create(); $course = Course::factory()->published()->create(); $module = CourseModule::factory()->published()->free()->create([ 'course_id' => $course->id, - 'title' => 'Empty Module', + 'title' => 'Upcoming Module', ]); CourseLesson::factory()->free()->create([ 'course_module_id' => $module->id, @@ -313,9 +317,95 @@ public function course_dashboard_excludes_modules_with_no_published_lessons(): v Livewire::actingAs($user) ->test(Index::class) - ->assertDontSee('Course curriculum') - ->assertDontSee('Empty Module') - ->assertDontSee('Unreleased Lesson'); + ->assertSee('Course curriculum') + ->assertSee('Upcoming Module') + ->assertSee('Coming Soon') + ->assertSee('Unreleased Lesson'); + } + + #[Test] + public function modules_with_a_released_lesson_are_not_marked_coming_soon(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Released Module', + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Released Lesson', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Released Module') + ->assertSee('Released Lesson') + ->assertDontSee('Coming Soon'); + } + + /** + * An unreleased lesson isn't gated by purchase, so it gets a Coming Soon + * badge rather than the "buy to unlock" lock. + */ + #[Test] + public function unreleased_lessons_are_not_shown_as_locked(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Partly Released Module', + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Released Lesson', + ]); + $unreleased = CourseLesson::factory()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Unreleased Lesson', + ]); + + $html = Livewire::actingAs($user)->test(Index::class)->html(); + + $row = $this->outlineRowFor($html, $unreleased->id); + + $this->assertStringContainsString('Coming Soon', $row); + $this->assertStringNotContainsString('unlock all lessons', $row); + } + + /** + * The markup for a single lesson row in the pre-purchase outline. + */ + private function outlineRowFor(string $html, int $lessonId): string + { + $start = strpos($html, 'wire:key="outline-lesson-'.$lessonId.'"'); + + $this->assertNotFalse($start, "Lesson {$lessonId} is missing from the outline."); + + // Up to the next row (or the end of the module's lesson list). + $next = strpos($html, 'wire:key="outline-', $start + 1); + + return $next === false ? substr($html, $start) : substr($html, $start, $next - $start); + } + + #[Test] + public function a_module_with_no_lessons_at_all_shows_as_coming_soon(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Planned Module', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Planned Module') + ->assertSee('Coming Soon'); } #[Test] diff --git a/tests/Feature/DocsPrereleaseVersionTest.php b/tests/Feature/DocsPrereleaseVersionTest.php index de211842..4b108e08 100644 --- a/tests/Feature/DocsPrereleaseVersionTest.php +++ b/tests/Feature/DocsPrereleaseVersionTest.php @@ -25,8 +25,23 @@ protected function setUp(): void ]); } + /** + * v4 is promoted to stable, so these tests pin the config back to the + * pre-promotion state to keep the prerelease machinery covered for the + * next major. + */ + private function pretendVersionFourIsStillPrerelease(): void + { + config([ + 'docs.latest_versions.mobile' => 3, + 'docs.prerelease_versions.mobile' => [4], + ]); + } + public function test_prerelease_pages_show_the_beta_notice(): void { + $this->pretendVersionFourIsStillPrerelease(); + $response = $this->get('/docs/mobile/4/edge-components/button'); $response->assertOk(); @@ -36,6 +51,8 @@ public function test_prerelease_pages_show_the_beta_notice(): void public function test_beta_notice_links_to_the_same_page_when_it_exists_on_stable(): void { + $this->pretendVersionFourIsStillPrerelease(); + // top-bar exists in v3 edge-components, so the notice should deep-link to it. $this->get('/docs/mobile/4/edge-components/top-bar') ->assertOk() @@ -49,22 +66,36 @@ public function test_beta_notice_links_to_the_same_page_when_it_exists_on_stable public function test_stable_pages_do_not_show_the_beta_notice(): void { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertOk() + ->assertDontSee('pre-release documentation'); + $this->get('/docs/mobile/3/getting-started/introduction') ->assertOk() ->assertDontSee('pre-release documentation'); } + public function test_old_versions_point_to_the_promoted_version(): void + { + $this->get('/docs/mobile/3/getting-started/introduction') + ->assertOk() + ->assertSee('/docs/mobile/4/getting-started/introduction'); + } + public function test_unversioned_docs_urls_redirect_to_the_stable_version(): void { $this->get('/docs/mobile') - ->assertRedirect('/docs/mobile/3/getting-started/introduction'); + ->assertRedirect('/docs/mobile/4/getting-started/introduction'); + // the-basics/web-view became the web-view EDGE component in v4. $this->get('/docs/mobile/the-basics/web-view') - ->assertRedirect('/docs/mobile/3/the-basics/web-view'); + ->assertRedirect('/docs/mobile/4/edge-components/web-view'); } public function test_version_switcher_lists_newest_first_and_labels_beta(): void { + $this->pretendVersionFourIsStillPrerelease(); + $response = $this->get('/docs/mobile/4/getting-started/introduction'); $content = $response->getContent(); @@ -78,6 +109,14 @@ public function test_version_switcher_lists_newest_first_and_labels_beta(): void ); } + public function test_version_switcher_no_longer_labels_the_promoted_version_beta(): void + { + $this->get('/docs/mobile/4/getting-started/introduction') + ->assertOk() + ->assertDontSee('Version 4.x (beta)') + ->assertSee('Version 4.x'); + } + public function test_supernative_shield_badges_are_removed_from_navigation(): void { $this->get('/docs/mobile/4/edge-components/button') @@ -99,6 +138,8 @@ public function test_renamed_pages_redirect_to_their_equivalent_within_a_version public function test_beta_notice_maps_renamed_pages_to_their_stable_equivalent(): void { + $this->pretendVersionFourIsStillPrerelease(); + $this->get('/docs/mobile/4/the-basics/native-ui') ->assertOk() ->assertSee('/docs/mobile/3/the-basics/native-components'); diff --git a/tests/Feature/HomeCourseCardTest.php b/tests/Feature/HomeCourseCardTest.php index 5d933ad2..ed008242 100644 --- a/tests/Feature/HomeCourseCardTest.php +++ b/tests/Feature/HomeCourseCardTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use DOMDocument; +use DOMXPath; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -26,4 +28,50 @@ public function the_homepage_shows_the_video_course_badge() ->assertSee('Video Course') ->assertDontSee('Early Bird'); } + + /** + * The announcement cards (Plugins, Masterclass, Jump, Bifrost) are all + * mobile-specific, so they show on the Mobile track and drop out on + * Desktop. Asserting on the wrapper rather than the page text, because the + * cards stay in the DOM either way — only their visibility changes. + */ + #[Test] + public function the_announcement_cards_are_scoped_to_the_mobile_track() + { + $dom = new DOMDocument; + libxml_use_internal_errors(true); + $dom->loadHTML(''.$this->get('/')->getContent()); + libxml_clear_errors(); + + $wrapper = (new DOMXPath($dom)) + ->query('//*[@data-platform-section="announcements"]') + ->item(0); + + $this->assertNotNull($wrapper, 'Announcements wrapper is missing.'); + + $this->assertSame( + "\$store.platform.is('mobile')", + $wrapper->getAttribute('x-show'), + 'Announcements should only show on the Mobile track.', + ); + + // Guards against the wrapper being correctly bound but empty. + $contents = $dom->saveHTML($wrapper); + + foreach (['Video Course', 'Cloud Platform', 'Jump'] as $card) { + $this->assertStringContainsString($card, $contents); + } + } + + #[Test] + public function the_announcement_cards_lead_with_jump_then_masterclass_then_plugins(): void + { + $this->get('/') + ->assertOk() + ->assertSeeInOrder([ + 'Code here. Jump there.', + 'The Masterclass', + 'Plugins', + ]); + } } diff --git a/tests/Feature/HomeExplainerNativeUiTest.php b/tests/Feature/HomeExplainerNativeUiTest.php new file mode 100644 index 00000000..cc977f34 --- /dev/null +++ b/tests/Feature/HomeExplainerNativeUiTest.php @@ -0,0 +1,292 @@ +blade('') + ->assertSee('SwiftUI') + ->assertSee('Jetpack Compose') + ->assertSee('real SwiftUI') + ->assertSee('No web view.') + ->assertDontSee('Native WebView') + ->assertDontSee('native web view'); + } + + #[Test] + public function the_mobile_diagram_shows_native_views_with_the_web_view_as_optional() + { + $this->blade('') + ->assertSee('SwiftUI & Compose', escape: false) + ->assertSee('Real native views') + ->assertSee('Web view (optional)') + ->assertDontSee('HTML/CSS + JavaScript'); + } + + #[Test] + public function the_desktop_diagram_shows_the_electron_and_chromium_stack() + { + $this->blade('') + ->assertSee('Electron') + ->assertSee('Static PHP Runtime') + ->assertSee('Chromium Window') + ->assertSee('HTML/CSS + JavaScript') + ->assertDontSee('SwiftUI'); + } + + #[Test] + public function the_explainer_renders_both_platform_paths_for_the_toggle() + { + $this->blade('') + // Mobile path + ->assertSee('Real native views') + ->assertSee('Tiny apps') + ->assertSee('nativephp/mobile') + // Desktop path + ->assertSee('Chromium Window') + ->assertSee('statically-compiled PHP binary') + ->assertSee('authenticated HTTP') + ->assertSee('One file') + ->assertSee('nativephp/desktop'); + } + + #[Test] + public function the_mobile_tools_card_reads_as_written() + { + $this->blade('') + ->assertSee('Use (almost) any Composer package!') + ->assertSeeInOrder([ + 'Blade templates feel like HTML and render to real native', + 'views.', + 'No web view required', + ]); + } + + #[Test] + public function the_explainer_blocks_are_bound_to_the_platform_store() + { + $this->blade('') + ->assertSee("\$store.platform.is('mobile')", escape: false) + ->assertSee("\$store.platform.is('desktop')", escape: false); + } + + /** + * The mobile path renders native UI, so anything that renders HTML would + * imply a web view. Those tools belong to the desktop path only. + */ + #[Test] + public function the_html_rendering_tools_are_scoped_to_the_desktop_path() + { + $scopes = $this->toolPillScopes(); + + $webOnly = [ + 'Livewire', 'FilamentPHP', 'TailwindCSS', 'Alpine.js', 'Inertia.js', + 'React', 'Vue.js', 'Nuxt', 'Next.js', 'TypeScript', 'JavaScript', + ]; + + foreach ($webOnly as $name) { + $this->assertSame('desktop', $scopes[$name] ?? null, "{$name} should be desktop-only."); + } + } + + #[Test] + public function the_platform_agnostic_php_tools_show_on_both_paths() + { + $scopes = $this->toolPillScopes(); + + foreach (['Laravel', 'Pest', 'PHPUnit'] as $name) { + $this->assertSame('all', $scopes[$name] ?? null, "{$name} should show on both paths."); + } + } + + /** + * Several skill SVGs use document-wide ids (Pest's gradient is a bare + * id="a"), so a pill rendered twice loses its fill in the second copy. + */ + #[Test] + public function each_tool_pill_is_rendered_exactly_once() + { + $names = array_map( + fn (DOMElement $pill) => $this->pillName($pill), + iterator_to_array($this->toolPills()), + ); + + $this->assertSame( + array_unique($names), + $names, + 'A tool pill is rendered more than once, which breaks SVGs with shared ids.', + ); + } + + /** + * @return array pill name => the path it appears on + */ + private function toolPillScopes(): array + { + $scopes = []; + + foreach ($this->toolPills() as $pill) { + $scopes[$this->pillName($pill)] = $pill->getAttribute('data-tools'); + } + + return $scopes; + } + + private function toolPills(): DOMNodeList + { + $dom = new DOMDocument; + libxml_use_internal_errors(true); + $dom->loadHTML(''.$this->blade('')); + libxml_clear_errors(); + + return (new DOMXPath($dom))->query('//a[@data-tools]'); + } + + private function pillName(DOMElement $pill): string + { + return trim(preg_replace('/\s+/', ' ', $pill->textContent)); + } + + /** + * The chooser drives the explainer, so it lives with it rather than in + * the hero — one instance on the page, directly above Under the hood. + */ + #[Test] + public function the_explainer_owns_the_platform_chooser() + { + $this->blade('') + ->assertSee('I want to build a', escape: false) + ->assertSee('role="tablist"', escape: false) + ->assertSee("\$store.platform.select('mobile')", escape: false) + ->assertSee("\$store.platform.select('desktop')", escape: false) + ->assertSee('Mobile app') + ->assertSee('Desktop app') + // No-JS fallback points at the Mobile docs + ->assertSee('href="/docs/mobile/getting-started/introduction"', escape: false) + ->assertSee('Read the Mobile docs'); + } + + #[Test] + public function the_hero_no_longer_holds_a_platform_chooser() + { + $this->blade('') + ->assertDontSee('I want to build a', escape: false) + ->assertDontSee('role="tablist"', escape: false) + ->assertDontSee('$store.platform.select(', escape: false); + } + + #[Test] + public function the_homepage_renders_exactly_one_platform_chooser() + { + $html = $this->get('/')->assertOk()->getContent(); + + $this->assertSame(1, substr_count($html, 'I want to build a')); + $this->assertSame(1, substr_count($html, 'aria-label="Choose a platform"')); + } + + #[Test] + public function the_hero_no_longer_promotes_the_demo_app_downloads() + { + $this->blade('') + ->assertDontSee('Try our') + ->assertDontSee('TestFlight') + ->assertDontSee('Play Store') + ->assertDontSee('testflight.apple.com', escape: false); + } + + /** + * The version is pinned deliberately — SuperNative is a v4 story, so the + * link should keep working even after a future major becomes the latest. + */ + #[Test] + public function the_under_the_hood_section_links_to_the_supernative_intro() + { + $this->blade('') + ->assertSee('Learn more about SuperNative') + ->assertSee('/docs/mobile/4/architecture/super-native', escape: false); + } + + #[Test] + public function the_supernative_page_the_homepage_links_to_exists() + { + // The page contains fenced code blocks. Torchlight throws outside + // production when no token is configured (as in CI), so give it a + // token and fake the API to force its offline fallback. + config(['torchlight.token' => 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + + $this->get('/docs/mobile/4/architecture/super-native') + ->assertOk(); + } + + #[Test] + public function the_mobile_path_names_supernative() + { + $this->blade('') + ->assertSeeInOrder([ + 'bundles PHP inside a native Swift/Kotlin shell', + 'No web view.', + '240fps', + 'We call it', + 'SuperNative.', + ]); + } + + #[Test] + public function the_desktop_render_target_note_drops_the_gui_framework_line() + { + $this->blade('') + ->assertSee('Chromium window.') + ->assertDontSee("isn't a GUI framework", escape: false); + } + + #[Test] + public function the_bifrost_diagram_no_longer_advertises_a_windows_track() + { + $this->blade('') + ->assertSee('Apple (macOS)') + ->assertSee('Android') + ->assertDontSee('Windows'); + } + + #[Test] + public function the_homepage_renders_both_paths() + { + $this->get('/') + ->assertOk() + ->assertSee('Real native views') + ->assertSee('Jetpack Compose') + ->assertSee('Chromium Window') + ->assertDontSee('Native WebView'); + } +} diff --git a/tests/Feature/MobileChangelogTest.php b/tests/Feature/MobileChangelogTest.php new file mode 100644 index 00000000..6f597f48 --- /dev/null +++ b/tests/Feature/MobileChangelogTest.php @@ -0,0 +1,114 @@ + Http::response([ + [ + 'id' => 1, + 'tag_name' => 'v4.0.0-rc.2', + 'name' => 'v4.0.0-rc.2', + 'body' => 'Fixed gesture handling in the SuperNative renderer by @simonhamp in https://github.com/NativePHP/mobile-air/pull/123', + 'published_at' => '2026-07-15T00:00:00Z', + ], + [ + 'id' => 2, + 'tag_name' => 'v4.0.0-rc.1', + 'name' => 'v4.0.0-rc.1', + 'body' => 'First release candidate. See [SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native) for the full story.', + 'published_at' => '2026-07-01T00:00:00Z', + ], + [ + 'id' => 3, + 'tag_name' => 'v3.2.0', + 'name' => 'v3.2.0', + 'body' => 'A v3 release that must not appear on the v4 page', + 'published_at' => '2026-05-01T00:00:00Z', + ], + ]), + // The page contains fenced code blocks; Torchlight throws outside + // production without a token (as in CI), so fake its API too. + '*' => Http::response(['blocks' => []], 200), + ]); + + config(['torchlight.token' => 'test-token']); + } + + public function test_the_v4_changelog_lists_release_candidates_from_github(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSeeInOrder(['v4.0.0-rc.2', 'v4.0.0-rc.1']) + ->assertSee('Released: July 15, 2026') + ->assertSee('First release candidate') + // The hand-written v4.0 section was replaced by the automated feed. + ->assertDontSee('v4.0 — SuperNative (beta)'); + } + + public function test_the_v4_changelog_shows_a_fallback_when_no_releases_exist(): void + { + // Pre-seed the releases cache so the page renders the @empty branch + // without fighting the Http fakes registered in setUp. + Cache::put('nativephp/mobile-air-releases', collect()); + + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('Release notes for v4 will appear here') + ->assertDontSee('v4.0.0-rc.2'); + } + + public function test_the_v4_changelog_excludes_v3_releases(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertDontSee('A v3 release that must not appear'); + } + + public function test_release_bodies_link_pull_requests_and_authors(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('https://github.com/NativePHP/mobile-air/pull/123', false) + ->assertSee('https://github.com/simonhamp', false); + } + + /** + * Hand-written release notes already contain Markdown links; re-wrapping + * their URLs nests the link syntax and the page renders both the text + * and the URL. + */ + public function test_markdown_links_in_release_bodies_render_as_single_links(): void + { + $this->get('/docs/mobile/4/getting-started/changelog') + ->assertOk() + ->assertSee('href="https://nativephp.com/docs/mobile/4/architecture/super-native"', false) + ->assertDontSee('[SuperNative]', false) + ->assertDontSee('](https://nativephp.com', false); + } + + public function test_the_v3_changelog_does_not_list_v4_prereleases(): void + { + $this->get('/docs/mobile/3/getting-started/changelog') + ->assertOk() + ->assertSee('v3.2.0') + ->assertDontSee('4.0.0-rc'); + } +} diff --git a/tests/Feature/PluginShowInstallCredentialsTest.php b/tests/Feature/PluginShowInstallCredentialsTest.php index ec68b7c5..61569a82 100644 --- a/tests/Feature/PluginShowInstallCredentialsTest.php +++ b/tests/Feature/PluginShowInstallCredentialsTest.php @@ -84,7 +84,7 @@ public function test_install_box_links_to_the_using_plugins_guide(): void $this->get(route('plugins.show', $plugin->routeParams())) ->assertStatus(200) - ->assertSee(url('docs/mobile/3/plugins/using-plugins'), false) + ->assertSee(url('docs/mobile/plugins/using-plugins'), false) ->assertSee('Using Plugins guide'); } } diff --git a/tests/Feature/SupernativeBannerTest.php b/tests/Feature/SupernativeBannerTest.php new file mode 100644 index 00000000..13ac023e --- /dev/null +++ b/tests/Feature/SupernativeBannerTest.php @@ -0,0 +1,24 @@ +get('/') + ->assertOk() + ->assertSee('NativePHP for Mobile v4') + ->assertSee('/docs/mobile/4/architecture/super-native', escape: false) + ->assertSee('supernative_banner_click', escape: false) + // The Vibes event banner is replaced. + ->assertDontSee('Get your ticket'); + } +} diff --git a/tests/Feature/Support/GitHub/ReleaseTest.php b/tests/Feature/Support/GitHub/ReleaseTest.php index 593931f1..b516c2ab 100644 --- a/tests/Feature/Support/GitHub/ReleaseTest.php +++ b/tests/Feature/Support/GitHub/ReleaseTest.php @@ -39,4 +39,82 @@ public function test_it_does_not_match_trailing_hyphens_in_at_mentions(): void $this->assertStringContainsString('[@foo](https://github.com/foo)- bar', $release->getBodyForMarkdown()); } + + public function test_it_leaves_existing_markdown_links_untouched(): void + { + $release = new Release([ + 'body' => '* [SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native) — fully native UI', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString( + '[SuperNative](https://nativephp.com/docs/mobile/4/architecture/super-native)', + $output, + ); + $this->assertStringNotContainsString('[[', $output); + $this->assertStringNotContainsString('([', $output); + $this->assertStringNotContainsString(')]', $output); + } + + public function test_it_leaves_pull_request_urls_inside_markdown_links_untouched(): void + { + $release = new Release([ + 'body' => 'See [the PR](https://github.com/NativePHP/mobile-air/pull/158) for details.', + ]); + + $this->assertStringContainsString( + '[the PR](https://github.com/NativePHP/mobile-air/pull/158)', + $release->getBodyForMarkdown(), + ); + } + + public function test_it_converts_bare_pull_request_urls(): void + { + $release = new Release([ + 'body' => '* Fix: Remove stray tag by @martin-ro in https://github.com/NativePHP/mobile-air/pull/158', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString('[#158](https://github.com/NativePHP/mobile-air/pull/158)', $output); + $this->assertStringContainsString('[@martin-ro](https://github.com/martin-ro)', $output); + } + + public function test_it_converts_other_bare_urls(): void + { + $release = new Release([ + 'body' => '**Full Changelog**: https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1', + ]); + + $this->assertStringContainsString( + '[https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1](https://github.com/NativePHP/mobile-air/compare/3.3.6...4.0.0-rc.1)', + $release->getBodyForMarkdown(), + ); + } + + public function test_it_does_not_relink_usernames_that_are_already_link_text(): void + { + $release = new Release([ + 'body' => 'Thanks to [@simonhamp](https://github.com/simonhamp) for shipping this.', + ]); + + $output = $release->getBodyForMarkdown(); + + $this->assertStringContainsString('[@simonhamp](https://github.com/simonhamp)', $output); + $this->assertStringNotContainsString('[[', $output); + } + + public function test_it_does_not_convert_email_addresses_to_user_links(): void + { + $release = new Release([ + 'body' => 'Questions? Email support@nativephp.com any time.', + ]); + + $this->assertStringContainsString( + 'support@nativephp.com', + $release->getBodyForMarkdown(), + ); + $this->assertStringNotContainsString('[@nativephp]', $release->getBodyForMarkdown()); + } } diff --git a/tests/Feature/Support/GitHubTest.php b/tests/Feature/Support/GitHubTest.php index de171aa5..5b9b0f0e 100644 --- a/tests/Feature/Support/GitHubTest.php +++ b/tests/Feature/Support/GitHubTest.php @@ -98,6 +98,59 @@ public function test_releases_after_handles_tag_names_without_a_leading_v(): voi $this->assertEquals('3.2.0', $releases->first()->tag_name); } + public function test_releases_after_caps_below_the_next_major_including_its_prereleases(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => 'v4.0.0', 'name' => 'v4.0.0', 'body' => '', 'published_at' => '2026-08-01T00:00:00Z'], + ['id' => 2, 'tag_name' => 'v4.0.0-rc.1', 'name' => 'v4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 3, 'tag_name' => 'v3.2.0', 'name' => 'v3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ['id' => 4, 'tag_name' => 'v3.1.0', 'name' => 'v3.1.0', 'body' => '', 'published_at' => '2026-04-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesAfter('3.1.0', before: '4.0.0'); + + $this->assertEquals(['v3.2.0'], $releases->pluck('tag_name')->all()); + } + + public function test_releases_from_includes_prereleases_of_the_boundary_version(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => 'v4.0.1', 'name' => 'v4.0.1', 'body' => '', 'published_at' => '2026-08-01T00:00:00Z'], + ['id' => 2, 'tag_name' => 'v4.0.0', 'name' => 'v4.0.0', 'body' => '', 'published_at' => '2026-07-20T00:00:00Z'], + ['id' => 3, 'tag_name' => 'v4.0.0-rc.2', 'name' => 'v4.0.0-rc.2', 'body' => '', 'published_at' => '2026-07-10T00:00:00Z'], + ['id' => 4, 'tag_name' => 'v4.0.0-rc.1', 'name' => 'v4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 5, 'tag_name' => 'v4.0.0-beta.1', 'name' => 'v4.0.0-beta.1', 'body' => '', 'published_at' => '2026-06-15T00:00:00Z'], + ['id' => 6, 'tag_name' => 'v3.2.9', 'name' => 'v3.2.9', 'body' => '', 'published_at' => '2026-06-01T00:00:00Z'], + ['id' => 7, 'tag_name' => 'v3.2.0', 'name' => 'v3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesFrom('4.0.0'); + + $this->assertEquals( + ['v4.0.1', 'v4.0.0', 'v4.0.0-rc.2', 'v4.0.0-rc.1', 'v4.0.0-beta.1'], + $releases->pluck('tag_name')->all() + ); + } + + public function test_releases_from_handles_tag_names_without_a_leading_v(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile-air/releases*' => Http::response([ + ['id' => 1, 'tag_name' => '4.0.0-rc.1', 'name' => '4.0.0-rc.1', 'body' => '', 'published_at' => '2026-07-01T00:00:00Z'], + ['id' => 2, 'tag_name' => '3.2.0', 'name' => '3.2.0', 'body' => '', 'published_at' => '2026-05-01T00:00:00Z'], + ]), + ]); + + $releases = GitHub::mobileAir()->releasesFrom('v4.0.0'); + + $this->assertCount(1, $releases); + $this->assertEquals('4.0.0-rc.1', $releases->first()->tag_name); + } + public function test_releases_after_returns_empty_collection_when_request_fails(): void { Http::fake([ diff --git a/tests/Feature/TheVibesBannerTest.php b/tests/Feature/TheVibesBannerTest.php index 980bdf5b..b8080320 100644 --- a/tests/Feature/TheVibesBannerTest.php +++ b/tests/Feature/TheVibesBannerTest.php @@ -31,23 +31,20 @@ public function the_banner_disappears_once_ticket_sales_close() ->assertDontSee('Get your ticket'); } + /** + * The site banner slot now announces Mobile v4 and SuperNative, so the + * Vibes banner is no longer mounted on the page — even while tickets are + * still on sale. Its countdown behaviour stays covered above, and the + * replacement is covered by SupernativeBannerTest. + */ #[Test] - public function the_homepage_shows_the_countdown_while_tickets_are_on_sale() + public function the_homepage_no_longer_carries_the_vibes_banner() { $this->travelTo(Carbon::parse('2026-07-23T12:00:00-04:00')); $this->get('/') ->assertOk() - ->assertSee('2026-07-30T00:00:00-04:00', false); - } - - #[Test] - public function the_homepage_hides_the_banner_once_ticket_sales_close() - { - $this->travelTo(Carbon::parse('2026-07-30T04:00:00Z')); - - $this->get('/') - ->assertOk() - ->assertDontSee('2026-07-30T00:00:00-04:00', false); + ->assertDontSee('2026-07-30T00:00:00-04:00', false) + ->assertDontSee('Get your ticket'); } } diff --git a/tests/Unit/DocsVersionServiceTest.php b/tests/Unit/DocsVersionServiceTest.php index 8bbd279e..669e39c8 100644 --- a/tests/Unit/DocsVersionServiceTest.php +++ b/tests/Unit/DocsVersionServiceTest.php @@ -78,7 +78,7 @@ public function it_points_to_the_latest_version_of_getting_started_introduction_ $expected = route('docs.show', [ 'platform' => 'mobile', - 'version' => '3', + 'version' => config('docs.latest_versions.mobile'), 'page' => 'getting-started/introduction', ]);