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 dfb5cda0..9fcdbffb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -113,10 +113,28 @@ 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) { + this.current = name + }, + }) +}) + 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', @@ -146,7 +164,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
  • Documentation @@ -420,7 +420,7 @@ class="flex flex-col items-start text-sm text-gray-500 dark:text-gray-400" >
  • Documentation diff --git a/resources/views/components/home/announcements.blade.php b/resources/views/components/home/announcements.blade.php index 63cc4da6..1e531353 100644 --- a/resources/views/components/home/announcements.blade.php +++ b/resources/views/components/home/announcements.blade.php @@ -1,22 +1,22 @@ -{{-- Announcements Section: Plugins (full width) + Bifrost (50%) & Course/Jump (50% stacked) --}} +{{-- Announcements Section: Jump/Course (50% stacked) & Bifrost (50%) + Plugins (full width) --}}
    - {{-- Plugins Announcement (Full Width) --}} - - - {{-- Course & Jump + Bifrost Row --}} + {{-- Jump & Course + Bifrost Row --}}
    - {{-- Left Column - Course & Jump --}} + {{-- Left Column - Jump & Course --}}
    - {{-- Course Card --}} - - {{-- Jump Card --}} + + {{-- Course Card --}} +
    {{-- Bifrost Banner (Right - 50%) --}}
    + + {{-- Plugins Announcement (Full Width) --}} +
    \ No newline at end of file diff --git a/resources/views/components/home/bifrost-card.blade.php b/resources/views/components/home/bifrost-card.blade.php index 4c039ca1..2cac56e6 100644 --- a/resources/views/components/home/bifrost-card.blade.php +++ b/resources/views/components/home/bifrost-card.blade.php @@ -4,7 +4,7 @@ target="_blank" rel="noopener noreferrer" onclick="fathom.trackEvent('bifrost_card_click');" - class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-sky-500/20 via-indigo-500/10 to-purple-500/20 p-0.5 ring-1 ring-zinc-200/50 transition duration-300 hover:ring-sky-400/50 dark:ring-sky-500/30" + class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-sky-500/20 via-indigo-500/10 to-purple-500/20 p-0.5 ring-1 ring-zinc-200/50 transition-shadow duration-300 hover:ring-sky-400/50 dark:ring-sky-500/30" x-init=" () => { motion.inView($el, (element) => { @@ -15,7 +15,7 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br y: 0, autoAlpha: 1, duration: 0.6, - delay: 0.1, + delay: 0.3, ease: 'power2.out', }, ) @@ -23,7 +23,7 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br } " > -
    +
    {{-- Animated glow --}} @@ -86,8 +86,13 @@ class="pointer-events-none absolute -bottom-10 -left-10 size-32 rounded-full bg-
    + {{-- Price hint --}} +

    + Plans start from just $10/month +

    + {{-- CTA --}} -
    +
    Ship it!
    diff --git a/resources/views/components/home/explainer.blade.php b/resources/views/components/home/explainer.blade.php index 77fffd51..452f71f5 100644 --- a/resources/views/components/home/explainer.blade.php +++ b/resources/views/components/home/explainer.blade.php @@ -43,32 +43,44 @@ class="text-2xl font-bold text-gray-800 lg:text-3xl dark:text-white" How does it work?
    - {{-- Description --}} + {{-- Description: Mobile --}}

    NativePHP - bundles PHP with your app and lets it run inside a + bundles PHP with your app and runs it - Swift, + inside + the app process. No server. No network round-trip. +
    +
    + Your Blade renders straight to real - Kotlin + SwiftUI - (mobile) or + and - Electron + Jetpack Compose - (desktop) shell. It uses special + views over - bridges + shared memory. - to talk directly to the device and show your app in a + No web view. No JSON bridge. + +
    +
    + We call this - native web view. + SuperNative.
    @@ -83,136 +95,91 @@ class="text-pretty text-gray-600 sm:max-w-75 dark:text-zinc-400" but it’s just PHP... on your user's device!

    + + {{-- Version pinned deliberately: SuperNative is a v4 story. --}} +
    + Learn more about SuperNative + + + + {{-- Description: Desktop --}} +

    + + NativePHP + + ships a + + statically-compiled PHP binary + + inside your app, alongside an + + Electron + + shell. Your users install nothing else. + +
    +
    + The two talk to each other over + + authenticated HTTP + + on the device, so your Laravel routes drive a real + + Chromium window, + + rendering the same HTML, CSS and JavaScript you already + write. + +
    +
    + You still write PHP like you’re used to—just with a few + extra tools for windows, menus, notifications and the file + system. +
    +
    + That’s it. It feels like + + magic, + + but it’s just PHP... on your user's device! +

    {{-- Right side --}}
    -
    - {{-- Phone wireframe --}} -
    {{-- Grid illustration --}} @@ -254,7 +221,12 @@ class="h-px w-full [background-image:linear-gradient(to_right,currentColor_0_8px
    {{-- Right side --}} -
    + {{-- + Grow to fill rather than sizing to content: the Mobile track's + pill list is short, so a max-content width leaves a gap on the + right instead of lining up with the cards above. + --}} +
    {{-- Performance --}}
    - Tiny apps + + Tiny apps + + + One file +

    - Mobile apps under 50MB + + Mobile apps under 50MB + + + Ships as a single executable +

    @@ -361,42 +349,131 @@ class="text-2xl font-bold text-gray-800 dark:text-white" > Bring your favorite tools -

    - Use any Composer packages and front-end frameworks. +

    + Use (almost) any Composer package! +

    +

    + Use any Composer package and front-end framework you + like.

    + @php + // Work the same whether your UI is native or a web view. + $phpSkills = [ + ['name' => 'Laravel', 'link' => 'https://laravel.com/', 'icon' => 'icons.skills.laravel'], + ['name' => 'Pest', 'link' => 'https://pestphp.com/', 'icon' => 'icons.skills.pest'], + ['name' => 'PHPUnit', 'link' => 'https://phpunit.de/', 'icon' => 'icons.skills.phpunit'], + ]; + // Render HTML, so they belong to the web view story. + $webSkills = [ + ['name' => 'Livewire', 'link' => 'https://livewire.laravel.com', 'icon' => 'icons.skills.livewire'], + ['name' => 'FilamentPHP', 'link' => 'https://filamentphp.com/', 'icon' => 'icons.skills.filamentphp'], + ['name' => 'TailwindCSS', 'link' => 'https://tailwindcss.com/', 'icon' => 'icons.skills.tailwind-css'], + ['name' => 'Alpine.js', 'link' => 'https://alpinejs.dev/', 'icon' => 'icons.skills.alpinejs'], + ['name' => 'Inertia.js', 'link' => 'https://inertiajs.com/', 'icon' => 'icons.skills.inertiajs'], + ['name' => 'React', 'link' => 'https://reactjs.org/', 'icon' => 'icons.skills.reactjs'], + ['name' => 'Vue.js', 'link' => 'https://vuejs.org/', 'icon' => 'icons.skills.vuejs'], + ['name' => 'Nuxt', 'link' => 'https://nuxtjs.org/', 'icon' => 'icons.skills.nuxtjs'], + ['name' => 'Next.js', 'link' => 'https://nextjs.org/', 'icon' => 'icons.skills.nextjs'], + ['name' => 'TypeScript', 'link' => 'https://www.typescriptlang.org/', 'icon' => 'icons.skills.typescript'], + ['name' => 'JavaScript', 'link' => 'https://www.javascript.com/', 'icon' => 'icons.skills.javascript'], + ]; + @endphp + + {{-- + One list: the web front-ends drop out on the mobile path. + Each icon must render exactly once — several skill SVGs use + document-wide ids (Pest's gradient is plain id="a"), so a + duplicated pill silently loses its fill. + --}}
    - @php - $skills = [ - ['name' => 'Laravel', 'link' => 'https://laravel.com/', 'icon' => 'icons.skills.laravel'], - ['name' => 'React', 'link' => 'https://reactjs.org/', 'icon' => 'icons.skills.reactjs'], - ['name' => 'Vue.js', 'link' => 'https://vuejs.org/', 'icon' => 'icons.skills.vuejs'], - ['name' => 'Nuxt', 'link' => 'https://nuxtjs.org/', 'icon' => 'icons.skills.nuxtjs'], - ['name' => 'Next.js', 'link' => 'https://nextjs.org/', 'icon' => 'icons.skills.nextjs'], - ['name' => 'Livewire', 'link' => 'https://livewire.laravel.com', 'icon' => 'icons.skills.livewire'], - ['name' => 'FilamentPHP', 'link' => 'https://filamentphp.com/', 'icon' => 'icons.skills.filamentphp'], - ['name' => 'Alpine.js', 'link' => 'https://alpinejs.dev/', 'icon' => 'icons.skills.alpinejs'], - ['name' => 'Inertia.js', 'link' => 'https://inertiajs.com/', 'icon' => 'icons.skills.inertiajs'], - ['name' => 'TailwindCSS', 'link' => 'https://tailwindcss.com/', 'icon' => 'icons.skills.tailwind-css'], - ['name' => 'TypeScript', 'link' => 'https://www.typescriptlang.org/', 'icon' => 'icons.skills.typescript'], - ['name' => 'JavaScript', 'link' => 'https://www.javascript.com/', 'icon' => 'icons.skills.javascript'], - ['name' => 'Pest', 'link' => 'https://pestphp.com/', 'icon' => 'icons.skills.pest'], - ['name' => 'PHPUnit', 'link' => 'https://phpunit.de/', 'icon' => 'icons.skills.phpunit'], - ]; - @endphp + @foreach ($phpSkills as $skill) + + + + @endforeach - @foreach ($skills as $skill) + @foreach ($webSkills as $skill) @endforeach
    + + {{-- Render targets --}} +
    +

    + Blade templates feel like HTML and render to real native + views. + + No web view required + +

    + +
    + @php + $renderTargets = [ + ['name' => 'SwiftUI', 'link' => 'https://developer.apple.com/xcode/swiftui/', 'icon' => 'icons.home.apple'], + ['name' => 'Jetpack Compose', 'link' => 'https://developer.android.com/compose', 'icon' => 'icons.home.android'], + ]; + @endphp + + @foreach ($renderTargets as $renderTarget) + + + + @endforeach +
    + +

    + Your front-end renders in a + + Chromium window. + +

    +
    @@ -474,7 +551,20 @@ class="size-5" 2. - Install the package. + Install + + + nativephp/mobile + + + nativephp/desktop + +
  • - {{-- Demo app --}} -
    -
    -

    - Try our - Mobile - app: -

    - - - -
    -
    - {{-- Mockups --}}
    @@ -554,141 +478,192 @@ class="inline-block font-medium text-gray-900 transition duration-200 will-chang ) } " - class="mt-4 flex w-full max-w-md flex-col items-center gap-3 sm:flex-row sm:justify-center" + class="mt-4 flex w-full max-w-md flex-col items-center gap-3" > - {{-- Mobile button --}} -
    - - {{-- Label --}} - {{-- Introduction video for mobile viewport --}} diff --git a/resources/views/components/home/jump-card.blade.php b/resources/views/components/home/jump-card.blade.php index fc1f8c23..82c311f6 100644 --- a/resources/views/components/home/jump-card.blade.php +++ b/resources/views/components/home/jump-card.blade.php @@ -3,7 +3,7 @@ href="https://bifrost.nativephp.com/jump" target="_blank" rel="noopener noreferrer" - class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-blue-500/20 via-cyan-500/10 to-indigo-500/20 p-0.5 ring-1 ring-zinc-200/50 transition duration-300 hover:ring-blue-400/50 dark:ring-blue-500/30" + class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br from-blue-500/20 via-cyan-500/10 to-indigo-500/20 p-0.5 ring-1 ring-zinc-200/50 transition-shadow duration-300 hover:ring-blue-400/50 dark:ring-blue-500/30" x-init=" () => { motion.inView($el, (element) => { @@ -14,7 +14,6 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br y: 0, autoAlpha: 1, duration: 0.6, - delay: 0.4, ease: 'power2.out', }, ) @@ -37,7 +36,7 @@ class="group relative block h-full overflow-hidden rounded-2xl bg-gradient-to-br }) } " - class="pointer-events-none absolute -bottom-10 -left-10 size-32 rounded-full bg-blue-500/20 blur-[40px] transition duration-500 group-hover:bg-blue-500/30" + class="pointer-events-none absolute -bottom-10 -left-10 size-32 rounded-full bg-blue-500/20 blur-[40px] transition-colors duration-500 group-hover:bg-blue-500/30" aria-hidden="true" >
    @@ -80,19 +79,19 @@ class="pointer-events-none absolute -bottom-10 -left-10 size-32 rounded-full bg- - Works offline after download + Test our native plugins — even the premium ones
  • - No Xcode or Android Studio + No Xcode or Android Studio required
  • - Free for local development + Completely free!
  • diff --git a/resources/views/components/home/skill-pill.blade.php b/resources/views/components/home/skill-pill.blade.php index 5a6fbb77..e5c286af 100644 --- a/resources/views/components/home/skill-pill.blade.php +++ b/resources/views/components/home/skill-pill.blade.php @@ -6,7 +6,7 @@ href="{{ $link }}" target="_blank" rel="noopener noreferrer" - class="dark:bg-cloud/30 dark:hover:bg-cloud/50 inline-flex items-center gap-2 rounded-full bg-white/50 py-2 pr-4 pl-3.5 text-sm text-slate-500 ring-1 ring-slate-200 transition duration-200 will-change-transform hover:scale-105 hover:bg-white/70 hover:text-black hover:ring-slate-200 2xl:py-2.5 2xl:pr-5 2xl:pl-4 2xl:text-base dark:text-gray-400 dark:ring-slate-700/80 dark:hover:text-white dark:hover:ring-slate-700" + {{ $attributes->merge(['class' => 'dark:bg-cloud/30 dark:hover:bg-cloud/50 inline-flex items-center gap-2 rounded-full bg-white/50 py-2 pr-4 pl-3.5 text-sm text-slate-500 ring-1 ring-slate-200 transition duration-200 will-change-transform hover:scale-105 hover:bg-white/70 hover:text-black hover:ring-slate-200 2xl:py-2.5 2xl:pr-5 2xl:pl-4 2xl:text-base dark:text-gray-400 dark:ring-slate-700/80 dark:hover:text-white dark:hover:ring-slate-700']) }} >
    - - - - - - - - diff --git a/resources/views/components/icons/home/arc-fan.blade.php b/resources/views/components/icons/home/arc-fan.blade.php new file mode 100644 index 00000000..020e3a41 --- /dev/null +++ b/resources/views/components/icons/home/arc-fan.blade.php @@ -0,0 +1,43 @@ +{{-- + Two arms fanning from a single point out to ±32px, matching the 48px + platform tiles stacked with a 16px gap (centres sit 64px apart). +--}} +merge(['class' => '[--grad-stop-1:--alpha(var(--color-gray-300)/100%)] [--grad-stop-2:--alpha(var(--color-blue-300)/100%)] dark:[--grad-stop-1:--alpha(var(--color-blue-300)/20%)] dark:[--grad-stop-2:--alpha(var(--color-blue-500)/80%)]']) }} + xmlns="http://www.w3.org/2000/svg" + width="67" + height="68" + viewBox="0 0 67 68" + fill="none" +> + {{-- Upper arm --}} + + {{-- Lower arm --}} + + + + + + + + diff --git a/resources/views/components/illustrations/bifrost-diagram.blade.php b/resources/views/components/illustrations/bifrost-diagram.blade.php index 1030adc3..9a6af24b 100644 --- a/resources/views/components/illustrations/bifrost-diagram.blade.php +++ b/resources/views/components/illustrations/bifrost-diagram.blade.php @@ -1,30 +1,26 @@ +{{-- + Three columns with equal 1fr flanks so the Bifrost bubble stays centred + even below 2xs, where the source column is hidden. +--}}
    {{-- Source (NativePHP app) --}} -
    diff --git a/resources/views/components/illustrations/desktop-stack.blade.php b/resources/views/components/illustrations/desktop-stack.blade.php new file mode 100644 index 00000000..04ba57e1 --- /dev/null +++ b/resources/views/components/illustrations/desktop-stack.blade.php @@ -0,0 +1,118 @@ +{{-- How a NativePHP for Desktop app is put together: a bundled PHP binary talking to an Electron shell over authenticated HTTP. --}} +
    + {{-- Title bar --}} + + {{-- Schema --}} +
    + {{-- Header --}} +
    +
    + Electron +
    +
    Shell app
    +
    + {{-- Php runtime --}} +
    +
    + PHP Binary +
    +
    +
    + Static PHP Runtime +
    +
    +
    + {{-- Core --}} +
    + {{-- Left --}} +
    +
    +
    Native
    +
    desktop
    +
    functions
    +
    + {{-- Bottom arrow --}} + +
    + {{-- Right --}} +
    +
    +
    Authenticated
    +
    HTTP
    +
    bridge
    +
    + {{-- Top arrow --}} + + {{-- Bottom arrow --}} + +
    +
    + {{-- Chromium window --}} +
    +
    + Chromium Window +
    +
    + HTML/CSS + JavaScript +
    +
    +
    +
    diff --git a/resources/views/components/illustrations/mobile-stack.blade.php b/resources/views/components/illustrations/mobile-stack.blade.php new file mode 100644 index 00000000..e579b9ab --- /dev/null +++ b/resources/views/components/illustrations/mobile-stack.blade.php @@ -0,0 +1,120 @@ +{{-- How a NativePHP for Mobile app is put together: PHP in-process, rendering real native views. --}} +
    + {{-- Phone wireframe --}} +
    diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php index c970b328..f725a69c 100644 --- a/resources/views/components/layout.blade.php +++ b/resources/views/components/layout.blade.php @@ -118,7 +118,7 @@ " class="font-poppins min-h-screen overflow-x-clip bg-white antialiased selection:bg-black selection:text-[#b4a9ff] dark:bg-[#050714] dark:text-white" > - + diff --git a/resources/views/components/navbar/device-dropdowns.blade.php b/resources/views/components/navbar/device-dropdowns.blade.php index 37f2591a..1d55475c 100644 --- a/resources/views/components/navbar/device-dropdowns.blade.php +++ b/resources/views/components/navbar/device-dropdowns.blade.php @@ -10,7 +10,7 @@ id="mobile-dropdown" > + {{-- Label --}} +
    + {{-- Icon --}} +
    + + {{-- Arrow --}} +
    + +
    + diff --git a/resources/views/docs/mobile/3/getting-started/changelog.md b/resources/views/docs/mobile/3/getting-started/changelog.md index ec985749..ef690edf 100644 --- a/resources/views/docs/mobile/3/getting-started/changelog.md +++ b/resources/views/docs/mobile/3/getting-started/changelog.md @@ -5,7 +5,7 @@ order: 2 For changes prior to v3, see the [v2 documentation](/docs/mobile/2/getting-started/changelog). -@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('3.1.0') as $release) +@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('3.1.0', before: '4.0.0') as $release) ## {{ $release->name ?: $release->tag_name }} **Released: {{ \Carbon\Carbon::parse($release->published_at)->format('F j, Y') }}** diff --git a/resources/views/docs/mobile/4/getting-started/changelog.md b/resources/views/docs/mobile/4/getting-started/changelog.md index 5f3026d0..b4637ba6 100644 --- a/resources/views/docs/mobile/4/getting-started/changelog.md +++ b/resources/views/docs/mobile/4/getting-started/changelog.md @@ -5,32 +5,13 @@ order: 2 For changes prior to v4, see the [v3 documentation](/docs/mobile/3/getting-started/changelog). -@forelse (\App\Support\GitHub::mobileAir()->releasesAfter('4.0.0') as $release) +@forelse (\App\Support\GitHub::mobileAir()->releasesFrom('4.0.0') as $release) ## {{ $release->name ?: $release->tag_name }} **Released: {{ \Carbon\Carbon::parse($release->published_at)->format('F j, Y') }}** {{ $release->getBodyForMarkdown() }} --- @empty +Release notes for v4 will appear here as releases are published. In the +meantime, start with the [SuperNative introduction](../architecture/super-native). @endforelse - -## v4.0 — SuperNative (beta) - -### New Features - -- **[SuperNative](../architecture/super-native)** — fully native UI powered by SwiftUI on iOS and Jetpack Compose on Android, and the new default for v4 apps -- **Livewire-like native components** — each screen is a PHP `NativeComponent` class holding its state and behavior, re-rendering the native UI as your properties change -- **Shared memory between PHP and the native layer** — no serialization overhead or web view bridge between your code and the UI -- **[Web view as a component](../edge-components/web-view)** — the classic web-view-first approach is now opt-out: render a single native route with a full-screen web view to keep building the v3 way -- **[Layouts](../the-basics/layouts)** — declare shared chrome (nav bars, tab bars) once in a `NativeLayout` class and attach it to a route or group of routes -- **[Native navigation stack](../the-basics/routing)** — register screens with `Route::native()`, then push, pop, and replace them with native transitions -- **[Component testing suite](../testing/introduction)** — mount a `NativeComponent`, drive interactions, and assert on state and output entirely in-process, with no device or simulator - -### Breaking Changes - -- **Device, Dialog, File and System are now core built-ins** — the `nativephp/mobile-device`, `nativephp/mobile-dialog`, `nativephp/mobile-file`, and `nativephp/mobile-system` plugins have moved into `nativephp/mobile`. **Remove the standalone plugins before upgrading** (see the [Upgrade Guide](upgrade-guide)). The facades and events are unchanged, so no application code changes are needed. -- **The Vite dev server is now opt-in** — `native:run` / `native:watch` no longer start Vite automatically. Pass `--vite` to enable JS/CSS HMR. The `--no-vite` flag still works but is now redundant. See [Hot Reloading](development#hot-reloading). - -### For Plugin Developers - -- **No breaking changes** to the plugin architecture — add the `^4.0` constraint to your plugin's `nativephp/mobile` dependency and you're done 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 be18bc15..8ba6795c 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -212,7 +212,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 d2c51260..b4199b14 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,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; @@ -272,6 +273,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/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..8e024a0f --- /dev/null +++ b/tests/Feature/HomeExplainerNativeUiTest.php @@ -0,0 +1,268 @@ +blade('') + ->assertSee('SwiftUI') + ->assertSee('Jetpack Compose') + ->assertSee('shared memory.') + ->assertSee('No web view. No JSON bridge.') + ->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)); + } + + #[Test] + public function the_hero_offers_a_platform_chooser_defaulting_to_mobile() + { + $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_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([ + 'No web view. No JSON bridge.', + 'We call this', + '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/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', ]);