diff --git a/app/Http/Controllers/ShowDocumentationController.php b/app/Http/Controllers/ShowDocumentationController.php index 42d4f4b8..5936f4a8 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 00000000..97adc099 --- /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 5dd3a535..4b0f3ed5 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 9c2ada3a..92c545a1 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', '2.1', '2.2'], + ], + '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' => '3.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 00000000..13e5f644 --- /dev/null +++ b/resources/views/components/docs/badge.blade.php @@ -0,0 +1,42 @@ +@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'], + ]); + + $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/resources/views/components/docs/jump-badge.blade.php b/resources/views/components/docs/jump-badge.blade.php new file mode 100644 index 00000000..5d879a2c --- /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 00000000..fd67a3af --- /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 2e8aeb82..dec21853 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 dad099dd..82415a31 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 99c21f2b..7da8cb55 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 07640b03..7c4cea91 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 7a6011f9..550244bd 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 e284fd32..5ab784f2 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 00000000..e5a3cce7 --- /dev/null +++ b/tests/Feature/Docs/JumpBadgeTest.php @@ -0,0 +1,124 @@ + '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 00000000..d77710fe --- /dev/null +++ b/tests/Feature/Docs/VersionBadgeTest.php @@ -0,0 +1,170 @@ + '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_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') + ->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) + ); + } +}