From ef62b38c23f022b36df058b9731bac5e726951f1 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 22 Aug 2026 16:48:04 +0200 Subject: [PATCH 1/4] Add docs version labels and Jump availability labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by Simon in #website: a pill in the docs indicating which minor version a feature is available from, plus an "Available in Jump" / "Not in Jump yet" label since some features don't reach Jump immediately. Two independent labels sharing one visual primitive (): - — since/changed/deprecated/removed, usable at page level (front matter, beside the

), section level (under a ## heading), or inline in a bullet or table cell. x.0 never renders, since everything in a major's tree was there at x.0 unless stated otherwise. `since` renders bare; the other three carry a one-word prefix so a bare number on a removal doesn't read like the version that introduced it. - — "Jump 2.2+" when a feature needs a newer Jump than currently ships, or "Not in Jump yet" when no build has it. `config('docs.jump.current_version')` records what Jump ships, so bumping one value retires every label it now satisfies. A page whose Jump requirement isn't met loses its "Preview in Jump" QR card. Labels are validated in tests against config('docs.released_versions') so one can't point at an unreleased version or one belonging to a different major after a page is copied forward. Section labels sit on the line after a heading rather than inside it: HeadingRenderer slugs the heading's rendered contents to build the anchor id, so injected markup there would change the id and break existing deep links. Seeds three real annotations traced against NativePHP/mobile-air tags: per-side/per-corner rounded utilities (4.2), the ScreenMounted/ ScreenResumed/ScreenUnmounted lifecycle events (4.1), and a docs correctness fix — System::flashlight() was documented as merely deprecated but was actually removed in 4.1, not just deprecated. docs.jump.current_version is currently a guess ('2.0', going by "Jump v2 or later" on the v4 Jump page) — needs confirming with Simon before merge. released_versions for the older majors is likewise a best-effort reconstruction from the mobile-air tag list, worth a maintainer's eye; it's only consumed by the guard test, so being wrong there just means false failures, not a live bug. Bulk annotation of the rest of the v4 tree is intentionally left for a follow-up PR, to keep this one to mechanism + a few worked examples. Co-Authored-By: Claude Sonnet 5 --- .../ShowDocumentationController.php | 8 +- app/Support/DocsLabels.php | 56 +++++++ app/Support/JumpApp.php | 22 +++ config/docs.php | 46 ++++++ .../views/components/docs/badge.blade.php | 43 ++++++ .../components/docs/jump-badge.blade.php | 19 +++ .../components/docs/version-badge.blade.php | 34 +++++ resources/views/docs/index.blade.php | 30 +++- .../4/digging-deeper/lifecycle-hooks.md | 2 + .../docs/mobile/4/edge-components/layout.md | 4 +- .../mobile/4/getting-started/versioning.md | 28 ++++ .../views/docs/mobile/4/the-basics/system.md | 3 +- tests/Feature/Docs/DocsCachingTest.php | 6 +- tests/Feature/Docs/JumpBadgeTest.php | 121 +++++++++++++++ tests/Feature/Docs/VersionBadgeTest.php | 138 ++++++++++++++++++ 15 files changed, 550 insertions(+), 10 deletions(-) create mode 100644 app/Support/DocsLabels.php create mode 100644 resources/views/components/docs/badge.blade.php create mode 100644 resources/views/components/docs/jump-badge.blade.php create mode 100644 resources/views/components/docs/version-badge.blade.php create mode 100644 tests/Feature/Docs/JumpBadgeTest.php create mode 100644 tests/Feature/Docs/VersionBadgeTest.php diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index 42d4f4b83..5936f4a8f 100644 --- a/app/Http/Controllers/ShowDocumentationController.php +++ b/app/Http/Controllers/ShowDocumentationController.php @@ -83,6 +83,8 @@ public function __invoke(Request $request, string $platform, string $version, ?s /** * Cache the callback's result for a day, or compute it fresh in local so * docs edits show up immediately without clearing (or racing on) the cache. + * The key folds in `config('docs')` so a Jump version bump invalidates + * rendered pages instead of trailing by up to a day. */ private function cacheOrCompute(string $key, Closure $callback): mixed { @@ -90,7 +92,11 @@ private function cacheOrCompute(string $key, Closure $callback): mixed return $callback(); } - return Cache::remember($key, now()->addDay(), $callback); + return Cache::remember( + $key.'_'.substr(md5(serialize(config('docs'))), 0, 8), + now()->addDay(), + $callback + ); } public function serveRawMarkdown(Request $request, string $platform, string $version, string $page) diff --git a/app/Support/DocsLabels.php b/app/Support/DocsLabels.php new file mode 100644 index 000000000..97adc0996 --- /dev/null +++ b/app/Support/DocsLabels.php @@ -0,0 +1,56 @@ +route('platform')) { + 'mobile' => 'NativePHP for Mobile', + 'desktop' => 'NativePHP for Desktop', + default => 'NativePHP', + }; + } + + /** + * Null when that version's tree has no versioning page — an unlinked + * label beats one that 404s. + */ + public static function versioningPolicyUrl(): ?string + { + return self::pageUrl('getting-started/versioning', 'version-labels'); + } + + public static function jumpUrl(): ?string + { + return self::pageUrl('the-basics/jump'); + } + + private static function pageUrl(string $page, ?string $fragment = null): ?string + { + $platform = request()->route('platform'); + $version = request()->route('version'); + + if (blank($platform) || blank($version)) { + return null; + } + + if (! file_exists(resource_path("views/docs/{$platform}/{$version}/{$page}.md"))) { + return null; + } + + $url = route('docs.show', [ + 'platform' => $platform, + 'version' => $version, + 'page' => $page, + ]); + + return $fragment ? "{$url}#{$fragment}" : $url; + } +} diff --git a/app/Support/JumpApp.php b/app/Support/JumpApp.php index 5dd3a535c..4b0f3ed58 100644 --- a/app/Support/JumpApp.php +++ b/app/Support/JumpApp.php @@ -51,4 +51,26 @@ public static function docsDeepLink(string $path): string { return self::CANONICAL_DOMAIN.'/'.ltrim($path, '/').'?'.self::QR_PARAM; } + + public static function currentVersion(): string + { + return (string) config('docs.jump.current_version'); + } + + /** + * `null`/`true` = no requirement, `false` = no Jump build has it, a + * version string = the minimum Jump version needed. + */ + public static function supports(string|bool|null $requirement): bool + { + if ($requirement === null || $requirement === true) { + return true; + } + + if ($requirement === false) { + return false; + } + + return version_compare(self::currentVersion(), $requirement, '>='); + } } diff --git a/config/docs.php b/config/docs.php index 9c2ada3a3..a77e64823 100644 --- a/config/docs.php +++ b/config/docs.php @@ -35,6 +35,52 @@ 'mobile' => [], ], + /* + |-------------------------------------------------------------------------- + | Released Minor Versions + |-------------------------------------------------------------------------- + | + | Every minor release that exists, keyed by platform and then by major. + | The version labels rendered by are checked against + | this list in the test suite, so a label can't quietly point at a version + | that was never released — or at one belonging to a different major after + | a page has been copied forward into a new version's tree. + | + | Add the new entry here as part of shipping a release. + | + */ + + 'released_versions' => [ + 'desktop' => [ + 1 => ['1.0'], + 2 => ['2.0'], + ], + 'mobile' => [ + 1 => ['1.0', '1.1'], + 2 => ['2.0'], + 3 => ['3.0', '3.1', '3.2', '3.3'], + 4 => ['4.0', '4.1', '4.2'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Jump + |-------------------------------------------------------------------------- + | + | Jump ships on its own cadence, so a feature can be released in NativePHP + | and still not render when someone scans the QR code on a docs page. + | + | Pages and sections declare the Jump version they need; this value records + | what Jump currently ships. Bump it when Jump catches up and every label it + | now satisfies disappears on its own — no docs edits required. + | + */ + + 'jump' => [ + 'current_version' => '2.0', + ], + /* |-------------------------------------------------------------------------- | Renamed Documentation Pages diff --git a/resources/views/components/docs/badge.blade.php b/resources/views/components/docs/badge.blade.php new file mode 100644 index 000000000..9401cea26 --- /dev/null +++ b/resources/views/components/docs/badge.blade.php @@ -0,0 +1,43 @@ +@props([ + 'label', + 'tooltip' => null, + 'href' => null, + 'variant' => 'neutral', +]) + +@php + $palettes = [ + 'neutral' => 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/15', + 'info' => 'bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/25', + 'warning' => 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/25', + 'danger' => 'bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-400/10 dark:text-rose-300 dark:ring-rose-400/25', + 'jump' => 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/25', + ]; + + // not-prose: keeps the typography plugin from restyling the pill in markdown. + $classes = implode(' ', [ + 'not-prose inline-flex select-none items-center whitespace-nowrap rounded-full', + 'px-1.5 py-0.5 align-middle text-[11px] font-medium leading-4 no-underline', + 'ring-1 ring-inset transition', + $palettes[$variant] ?? $palettes['neutral'], + ]); +@endphp + +@if (filled($href)) + {{ $label }} +@else + {{ $label }} +@endif diff --git a/resources/views/components/docs/jump-badge.blade.php b/resources/views/components/docs/jump-badge.blade.php new file mode 100644 index 000000000..5d879a2ca --- /dev/null +++ b/resources/views/components/docs/jump-badge.blade.php @@ -0,0 +1,19 @@ +@props([ + 'since' => null, + 'unavailable' => false, +]) + +@php + $requirement = $unavailable ? false : $since; +@endphp + +@unless (\App\Support\JumpApp::supports($requirement)) + +@endunless diff --git a/resources/views/components/docs/version-badge.blade.php b/resources/views/components/docs/version-badge.blade.php new file mode 100644 index 000000000..fd67a3af2 --- /dev/null +++ b/resources/views/components/docs/version-badge.blade.php @@ -0,0 +1,34 @@ +{{-- Never place inside a heading — HeadingRenderer slugs the heading's + rendered contents into the anchor id, so injected markup would change + existing deep links. --}} + +@props([ + 'since' => null, + 'changed' => null, + 'deprecated' => null, + 'removed' => null, +]) + +@php + $states = [ + ['version' => $since, 'variant' => 'neutral', 'prefix' => '', 'verb' => 'Added in'], + ['version' => $changed, 'variant' => 'info', 'prefix' => 'Changed ', 'verb' => 'Changed in'], + ['version' => $deprecated, 'variant' => 'warning', 'prefix' => 'Deprecated ', 'verb' => 'Deprecated in'], + ['version' => $removed, 'variant' => 'danger', 'prefix' => 'Removed ', 'verb' => 'Removed in'], + ]; + + $state = collect($states)->firstWhere(fn (array $state) => filled($state['version'])); + + // x.0 never renders — everything in a major's tree was there at x.0 + // unless stated otherwise. + $minor = (int) (explode('.', (string) ($state['version'] ?? ''))[1] ?? 0); +@endphp + +@if ($state && $minor > 0) + +@endif diff --git a/resources/views/docs/index.blade.php b/resources/views/docs/index.blade.php index 2e8aeb823..dec218531 100644 --- a/resources/views/docs/index.blade.php +++ b/resources/views/docs/index.blade.php @@ -4,10 +4,16 @@ @endpush @php - // Jump previews EDGE components, which only exist in the Mobile v4 docs. + // Front matter `jump`: a version string, or false if no Jump build has it. + $jumpRequirement = $jump ?? null; + + // Jump previews EDGE components (Mobile v4 only), and only where the + // shipping Jump can render the page — a QR to a blank screen is worse + // than no QR. $showJumpPreview = $platform === 'mobile' && (string) $version === '4' - && str_starts_with((string) request()->route('page'), 'edge-components/'); + && str_starts_with((string) request()->route('page'), 'edge-components/') + && \App\Support\JumpApp::supports($jumpRequirement); @endphp @@ -58,9 +64,23 @@ :page="request()->route('page')" /> -

- {{ $title }} -

+
+

+ {{ $title }} +

+ + + + +
diff --git a/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md b/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md index dad099dd7..82415a31c 100644 --- a/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md +++ b/resources/views/docs/mobile/4/digging-deeper/lifecycle-hooks.md @@ -145,6 +145,8 @@ the screen's layout chrome. ## Observing the lifecycle from outside + + The hooks above are yours to override, which makes them the wrong place for anything cross-cutting. Put analytics, telemetry, or crash breadcrumbs in a base class's `mount()` and any screen that defines its own `mount()` silently replaces it — so the observer goes quiet on exactly the screens with the most logic in them. diff --git a/resources/views/docs/mobile/4/edge-components/layout.md b/resources/views/docs/mobile/4/edge-components/layout.md index 99c21f2bd..7da8cb55c 100644 --- a/resources/views/docs/mobile/4/edge-components/layout.md +++ b/resources/views/docs/mobile/4/edge-components/layout.md @@ -329,8 +329,8 @@ The parser recognizes the classes listed below. | Border color | `border-{palette}-{shade}`, `border-white`, `border-black`, `border-transparent`, `border-[#hex]`, `border-theme-{token}` | | Border width | `border` (1dp), `border-2`, `border-4`, `border-8` | | Rounded | `rounded` (4dp), `rounded-sm`, `rounded-md`, `rounded-lg`, `rounded-xl`, `rounded-2xl`, `rounded-3xl`, `rounded-full`, `rounded-[N]` | -| Rounded (per side) | `rounded-t-*`, `rounded-r-*`, `rounded-b-*`, `rounded-l-*` — each rounds that side's two corners. A bare side (`rounded-b`) uses the same 4dp default as `rounded` | -| Rounded (per corner) | `rounded-tl-*`, `rounded-tr-*`, `rounded-br-*`, `rounded-bl-*`, including arbitrary values (`rounded-br-[4]`) | +| Rounded (per side) | `rounded-t-*`, `rounded-r-*`, `rounded-b-*`, `rounded-l-*` — each rounds that side's two corners. A bare side (`rounded-b`) uses the same 4dp default as `rounded` | +| Rounded (per corner) | `rounded-tl-*`, `rounded-tr-*`, `rounded-br-*`, `rounded-bl-*`, including arbitrary values (`rounded-br-[4]`) | | Shadow | `shadow`, `shadow-sm`, `shadow-md`, `shadow-lg`, `shadow-xl`, `shadow-2xl`, `shadow-inner`, `shadow-none` | | Opacity | `opacity-{0..100}`, arbitrary `opacity-[0.5]` | | Text size | `text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`, `text-4xl`, `text-5xl`, `text-6xl`, arbitrary `text-[N]` | diff --git a/resources/views/docs/mobile/4/getting-started/versioning.md b/resources/views/docs/mobile/4/getting-started/versioning.md index 07640b03f..7c4cea919 100644 --- a/resources/views/docs/mobile/4/getting-started/versioning.md +++ b/resources/views/docs/mobile/4/getting-started/versioning.md @@ -57,6 +57,34 @@ with a full minimum patch release defined in your `composer.json`: This automatically receives patch updates while giving you control over minor releases. +## Version labels + +Anything documented in this version of the docs has been here since 4.0 unless it carries a label. Labels appear next +to a page title, under a section heading, or beside the individual prop or class they describe: + +| Label | Meaning | +|-------|---------| +| | Added in that minor release. Upgrade to at least that version to use it | +| | Behaviour or signature changed in that release — check it against what your app relies on before upgrading | +| | Still works, but slated for removal. Move off it when convenient | +| | Gone as of that release. Documented only so you know what replaced it | + +Remember that a minor release [may contain native code changes](#minor-releases), so picking up a labelled feature +means rebuilding with `php artisan native:install --force` rather than a `composer update` alone. + +### Jump labels + +[Jump](../the-basics/jump) ships on its own release cadence, so a feature can be released in NativePHP and still not +render on your phone when you scan a QR code. Where that's the case, you'll see: + +| Label | Meaning | +|-------|---------| +| | Needs a newer Jump than the one on the stores. Build to a simulator or device to try it today | +| | No Jump build supports it. It'll work in a packaged build of your app | + +These disappear on their own as Jump catches up. Pages carrying one don't offer the "Preview in Jump" QR code, since +scanning it wouldn't show you the component. + ## Your application versioning Just because we're using semantic versioning for the `nativephp/mobile` package, doesn't mean your app must follow that diff --git a/resources/views/docs/mobile/4/the-basics/system.md b/resources/views/docs/mobile/4/the-basics/system.md index 7a6011f98..550244bdf 100644 --- a/resources/views/docs/mobile/4/the-basics/system.md +++ b/resources/views/docs/mobile/4/the-basics/system.md @@ -73,6 +73,7 @@ Reading the current appearance and reacting to theme changes lives with the rest diff --git a/tests/Feature/Docs/DocsCachingTest.php b/tests/Feature/Docs/DocsCachingTest.php index e284fd322..5ab784f2e 100644 --- a/tests/Feature/Docs/DocsCachingTest.php +++ b/tests/Feature/Docs/DocsCachingTest.php @@ -54,6 +54,10 @@ public function test_non_local_docs_request_caches_page_properties(): void $this->get('/docs/mobile/4/edge-components/stack')->assertStatus(200); - $this->assertTrue(Cache::has('docs_mobile_4_edge-components/stack')); + // The key is suffixed with a hash of config('docs') so a Jump version + // bump invalidates rendered pages instead of trailing by up to a day. + $key = 'docs_mobile_4_edge-components/stack_'.substr(md5(serialize(config('docs'))), 0, 8); + + $this->assertTrue(Cache::has($key)); } } diff --git a/tests/Feature/Docs/JumpBadgeTest.php b/tests/Feature/Docs/JumpBadgeTest.php new file mode 100644 index 000000000..aa1aee7b8 --- /dev/null +++ b/tests/Feature/Docs/JumpBadgeTest.php @@ -0,0 +1,121 @@ + 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + + // testing runs with CACHE_DRIVER=array, but cacheOrCompute() still + // caches (only `local` bypasses it) — flush so a fixture written by + // one test can't be served stale to the next. + Cache::flush(); + + $this->fixturesDir = resource_path('views/docs/mobile/4/edge-components'); + } + + protected function tearDown(): void + { + foreach (['unavailable', 'unshipped', 'shipped'] as $name) { + @unlink("{$this->fixturesDir}/_test-jump-badge-{$name}.md"); + } + + Cache::flush(); + + parent::tearDown(); + } + + protected function writeFixture(string $name, string $frontMatterExtra): void + { + file_put_contents( + "{$this->fixturesDir}/_test-jump-badge-{$name}.md", + <<assertTrue(JumpApp::supports(null)); + } + + public function test_supports_when_no_build_has_it(): void + { + $this->assertFalse(JumpApp::supports(false)); + } + + public function test_supports_an_older_or_equal_version(): void + { + config(['docs.jump.current_version' => '2.2']); + + $this->assertTrue(JumpApp::supports('2.0')); + $this->assertTrue(JumpApp::supports('2.2')); + } + + public function test_does_not_support_a_newer_version(): void + { + config(['docs.jump.current_version' => '2.0']); + + $this->assertFalse(JumpApp::supports('2.2')); + } + + public function test_page_with_jump_false_shows_not_in_jump_yet_and_no_qr(): void + { + $this->writeFixture('unavailable', 'jump: false'); + + $this->get('/docs/mobile/4/edge-components/_test-jump-badge-unavailable') + ->assertStatus(200) + ->assertSee('Not in Jump yet') + ->assertDontSee('Preview in Jump'); + } + + public function test_page_with_unshipped_jump_version_shows_pill_and_no_qr(): void + { + config(['docs.jump.current_version' => '2.0']); + $this->writeFixture('unshipped', 'jump: "99.0"'); + + $this->get('/docs/mobile/4/edge-components/_test-jump-badge-unshipped') + ->assertStatus(200) + ->assertSee('Jump 99.0+') + ->assertDontSee('Preview in Jump'); + } + + public function test_page_with_shipped_jump_version_shows_no_pill_and_the_qr(): void + { + config(['docs.jump.current_version' => '2.0']); + $this->writeFixture('shipped', 'jump: "1.0"'); + + $response = $this->get('/docs/mobile/4/edge-components/_test-jump-badge-shipped') + ->assertStatus(200) + ->assertDontSee('Not in Jump yet') + ->assertDontSee('Jump 1.0+') + ->assertSee('Preview in Jump'); + + $response->assertOk(); + } +} diff --git a/tests/Feature/Docs/VersionBadgeTest.php b/tests/Feature/Docs/VersionBadgeTest.php new file mode 100644 index 000000000..aa698912a --- /dev/null +++ b/tests/Feature/Docs/VersionBadgeTest.php @@ -0,0 +1,138 @@ + 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + } + + public function test_since_renders_bare(): void + { + // The tooltip legitimately says "Added in ..." — it's the visible + // label, sandwiched between tags with no prefix, that must be bare. + $this->blade('') + ->assertSee('>4.2<', false); + } + + public function test_x_dot_zero_renders_nothing(): void + { + $this->blade('') + ->assertDontSee('4.0'); + } + + public function test_changed_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('Changed 4.2'); + } + + public function test_deprecated_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('Deprecated 4.1'); + } + + public function test_removed_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('Removed 4.1'); + } + + public function test_layout_page_contains_the_4_2_pill(): void + { + $this->get('/docs/mobile/4/edge-components/layout') + ->assertStatus(200) + ->assertSee('4.2'); + } + + public function test_section_label_does_not_change_the_heading_anchor_id(): void + { + $html = CommonMark::convertToHtml( + "## Observing the lifecycle from outside\n\n\n\nBody text." + ); + + $this->assertStringContainsString('id="observing-the-lifecycle-from-outside"', $html); + } + + public function test_lifecycle_hooks_page_keeps_its_heading_anchor(): void + { + $this->get('/docs/mobile/4/digging-deeper/lifecycle-hooks') + ->assertStatus(200) + ->assertSee('id="observing-the-lifecycle-from-outside"', false); + } + + public function test_search_index_content_contains_no_badge_markup(): void + { + $page = app(DocsSearchService::class)->getPage('mobile', '4', 'edge-components', 'layout'); + + $this->assertNotNull($page); + $this->assertStringNotContainsString('assertStringNotContainsString('version-badge', $page['content']); + } + + public function test_every_version_label_points_at_a_released_version(): void + { + $releasedVersions = config('docs.released_versions'); + $finder = (new Finder)->files()->name('*.md')->in(resource_path('views/docs')); + + $violations = []; + + foreach ($finder as $file) { + $relative = $file->getRelativePathname(); + $parts = explode(DIRECTORY_SEPARATOR, $relative); + + if (count($parts) < 2 || ! is_numeric($parts[1])) { + continue; + } + + [$platform, $major] = [$parts[0], (int) $parts[1]]; + $allowed = $releasedVersions[$platform][$major] ?? []; + + $content = $file->getContents(); + $document = YamlFrontMatter::parse($content); + + foreach (['since', 'changed', 'deprecated', 'removed'] as $key) { + $value = $document->matter($key); + + if ($value !== null && ! in_array((string) $value, $allowed, true)) { + $violations[] = "{$relative} front matter `{$key}: {$value}`"; + } + } + + if (preg_match_all('/]*)\/>/s', $content, $tagMatches)) { + foreach ($tagMatches[1] as $attrs) { + foreach (['since', 'changed', 'deprecated', 'removed'] as $key) { + if (preg_match('/'.$key.'="([^"]+)"/', $attrs, $m)) { + if (! in_array($m[1], $allowed, true)) { + $violations[] = "{$relative} "; + } + break; + } + } + } + } + } + + $this->assertEmpty( + $violations, + "Version labels pointing at an unreleased or mismatched version:\n".implode("\n", $violations) + ); + } +} From 22ebbb5f3ae9ec2636c995ffd6220cd8f926327a Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 22 Aug 2026 17:00:27 +0200 Subject: [PATCH 2/4] Update jump.current_version from a guess to the verified live version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the live App Store listing directly: Jump is on v3.0.0 as of Jul 30 ("Rebuilt from the ground up with SuperNative!"). Play Store doesn't expose a numeric version publicly, but was last updated Jul 25 with no indication of lagging behind — assuming parity at 3.0. Co-Authored-By: Claude Sonnet 5 --- config/docs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/docs.php b/config/docs.php index a77e64823..389b27187 100644 --- a/config/docs.php +++ b/config/docs.php @@ -78,7 +78,7 @@ */ 'jump' => [ - 'current_version' => '2.0', + 'current_version' => '3.0', ], /* From 2af590cb6420e9f764f9b3585989544d50591402 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Sat, 22 Aug 2026 17:02:49 +0200 Subject: [PATCH 3/4] Add missing 2.1 and 2.2 to desktop's released_versions Checked against real NativePHP/desktop git tags: 2.0.0/.1/.2, 2.1.0/.1, 2.2.0/.1 all exist, so only listing 2.0 was incomplete. Mobile's v4 entries (4.0, 4.1, 4.2) already matched the real mobile-air tags exactly, so no change needed there. Co-Authored-By: Claude Sonnet 5 --- config/docs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/docs.php b/config/docs.php index 389b27187..92c545a15 100644 --- a/config/docs.php +++ b/config/docs.php @@ -53,7 +53,7 @@ 'released_versions' => [ 'desktop' => [ 1 => ['1.0'], - 2 => ['2.0'], + 2 => ['2.0', '2.1', '2.2'], ], 'mobile' => [ 1 => ['1.0', '1.1'], From cd9ebe866576d8c83c8f45aafffd00baab34df1f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 24 Aug 2026 19:43:43 +0100 Subject: [PATCH 4/4] Render docs badges on one line so they survive markdown table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitted its / across several indented lines. Blade runs before markdown parsing, so a label used inline in a table cell — | Rounded (per side) | ... | ended the row mid-cell: the leftover attribute lines were indented far enough to become a code block, and the rest of the table collapsed into paragraph text. The edge-components/layout page showed this. The tag is now built and echoed from the @php block as a single string, so it is newline-free with no surrounding whitespace. Building it in PHP rather than writing single-line Blade markup also keeps prettier-plugin-blade's singleAttributePerLine from re-exploding it, which is how the multi-line markup arose in the first place. Both badge test files were missing RefreshDatabase, so all five of their full-page render tests errored on Pennant's missing `features` table — which is why test_layout_page_contains_the_4_2_pill never caught this. Adds two regression tests: a badge renders on one line with no surrounding whitespace, and an inline label leaves both s of its row intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VcwpxauFn4kvnmzgbt9FWd --- .../views/components/docs/badge.blade.php | 37 +++++++++---------- tests/Feature/Docs/JumpBadgeTest.php | 3 ++ tests/Feature/Docs/VersionBadgeTest.php | 32 ++++++++++++++++ 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/resources/views/components/docs/badge.blade.php b/resources/views/components/docs/badge.blade.php index 9401cea26..13e5f6445 100644 --- a/resources/views/components/docs/badge.blade.php +++ b/resources/views/components/docs/badge.blade.php @@ -21,23 +21,22 @@ 'ring-1 ring-inset transition', $palettes[$variant] ?? $palettes['neutral'], ]); -@endphp -@if (filled($href)) - {{ $label }} -@else - {{ $label }} -@endif + $tag = filled($href) ? 'a' : 'span'; + + $htmlAttributes = collect([ + 'href' => filled($href) ? $href : null, + 'class' => filled($href) ? $classes.' hover:ring-2' : $classes, + 'title' => $tooltip, + 'aria-label' => $tooltip, + ]) + ->filter(fn (?string $value): bool => filled($value)) + ->map(fn (string $value, string $name): string => $name.'="'.e($value).'"') + ->implode(' '); + + // Built and echoed from PHP, rather than written as Blade markup, so the + // pill is guaranteed to render as a single line with no surrounding + // whitespace: badges sit inline in markdown, and one newline inside a + // table cell ends the row and collapses the rest of the table. + echo '<'.$tag.' '.$htmlAttributes.'>'.e($label).''; +@endphp diff --git a/tests/Feature/Docs/JumpBadgeTest.php b/tests/Feature/Docs/JumpBadgeTest.php index aa1aee7b8..e5a3cce7d 100644 --- a/tests/Feature/Docs/JumpBadgeTest.php +++ b/tests/Feature/Docs/JumpBadgeTest.php @@ -4,6 +4,7 @@ use App\Features\ShowPlugins; use App\Support\JumpApp; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Laravel\Pennant\Feature; @@ -11,6 +12,8 @@ class JumpBadgeTest extends TestCase { + use RefreshDatabase; + protected string $fixturesDir; protected function setUp(): void diff --git a/tests/Feature/Docs/VersionBadgeTest.php b/tests/Feature/Docs/VersionBadgeTest.php index aa698912a..d77710fe8 100644 --- a/tests/Feature/Docs/VersionBadgeTest.php +++ b/tests/Feature/Docs/VersionBadgeTest.php @@ -4,6 +4,7 @@ use App\Services\DocsSearchService; use App\Support\CommonMark\CommonMark; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Spatie\YamlFrontMatter\YamlFrontMatter; use Symfony\Component\Finder\Finder; @@ -11,6 +12,8 @@ class VersionBadgeTest extends TestCase { + use RefreshDatabase; + protected function setUp(): void { parent::setUp(); @@ -55,6 +58,35 @@ public function test_removed_renders_with_prefix(): void ->assertSee('Removed 4.1'); } + public function test_a_badge_renders_on_one_line(): void + { + // Labels sit inline in markdown, including inside table cells, where a + // single newline in the rendered HTML ends the row and collapses the + // rest of the table into paragraph text. + $linked = (string) $this->blade(''); + $bare = (string) $this->blade(''); + + foreach ([$linked, $bare] as $badge) { + $this->assertStringNotContainsString("\n", $badge); + $this->assertSame(trim($badge), $badge); + } + } + + public function test_an_inline_label_leaves_its_table_row_intact(): void + { + $html = CommonMark::convertToHtml( + "| Utility | Classes |\n| --- | --- |\n| Rounded | `rounded-full` |\n" + ); + + $this->assertSame(2, substr_count($html, '')); + $this->assertStringContainsString('rounded-full', $html); + + preg_match('/(.*?)<\/td>/s', $html, $firstCell); + + $this->assertStringContainsString('4.2', $firstCell[1]); + $this->assertStringNotContainsString("\n", $firstCell[1]); + } + public function test_layout_page_contains_the_4_2_pill(): void { $this->get('/docs/mobile/4/edge-components/layout')