From 77d7566a803cce0824482bff327ad4b22fa4976d Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 30 Sep 2026 12:48:31 +0100 Subject: [PATCH] Add mobile-ui version labels to the docs The EDGE components ship in nativephp/mobile-ui, which has its own release line, so the docs version badge had no way to say a feature needs a newer mobile-ui. The badge now takes a package attribute, backed by a docs.packages config entry, and 20 labels mark what arrived after 0.3, the release current when mobile 4.0.0 shipped. Co-Authored-By: Claude Opus 5.5 --- app/Support/DocsLabels.php | 4 +- config/docs.php | 27 ++++ .../components/docs/version-badge.blade.php | 16 ++- resources/views/docs/index.blade.php | 1 + .../mobile/4/edge-components/accordion.md | 2 + .../docs/mobile/4/edge-components/button.md | 6 +- .../docs/mobile/4/edge-components/chip.md | 2 +- .../docs/mobile/4/edge-components/pager.md | 2 + .../docs/mobile/4/edge-components/tab-row.md | 2 +- .../mobile/4/edge-components/text-input.md | 20 ++- .../docs/mobile/4/edge-components/text.md | 10 +- .../mobile/4/getting-started/versioning.md | 24 ++++ tests/Feature/Docs/VersionBadgeTest.php | 128 +++++++++++++++++- 13 files changed, 220 insertions(+), 24 deletions(-) diff --git a/app/Support/DocsLabels.php b/app/Support/DocsLabels.php index 97adc0996..ed1eab0d6 100644 --- a/app/Support/DocsLabels.php +++ b/app/Support/DocsLabels.php @@ -22,9 +22,9 @@ public static function productName(): string * Null when that version's tree has no versioning page — an unlinked * label beats one that 404s. */ - public static function versioningPolicyUrl(): ?string + public static function versioningPolicyUrl(string $fragment = 'version-labels'): ?string { - return self::pageUrl('getting-started/versioning', 'version-labels'); + return self::pageUrl('getting-started/versioning', $fragment); } public static function jumpUrl(): ?string diff --git a/config/docs.php b/config/docs.php index 022b2365c..5468e4447 100644 --- a/config/docs.php +++ b/config/docs.php @@ -63,6 +63,33 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Separately Versioned Packages + |-------------------------------------------------------------------------- + | + | Packages documented inside a platform's docs that ship on their own + | release line, keyed by the slug a label passes as `package`. Their labels + | name the package and are checked against the package's own released + | versions in the test suite, rather than the platform's. + | + | Labels at or below the baseline don't render. The baseline is whatever + | was current when the platform major shipped (mobile-ui 0.3.0 was current + | when nativephp/mobile 4.0.0 shipped), the same way x.0 never renders for + | the platform itself. + | + | Add the new entry here as part of shipping a release. + | + */ + + 'packages' => [ + 'mobile-ui' => [ + 'name' => 'nativephp/mobile-ui', + 'baseline' => '0.3', + 'released_versions' => ['0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7'], + ], + ], + /* |-------------------------------------------------------------------------- | Jump diff --git a/resources/views/components/docs/version-badge.blade.php b/resources/views/components/docs/version-badge.blade.php index fd67a3af2..c00e433de 100644 --- a/resources/views/components/docs/version-badge.blade.php +++ b/resources/views/components/docs/version-badge.blade.php @@ -7,6 +7,7 @@ 'changed' => null, 'deprecated' => null, 'removed' => null, + 'package' => null, ]) @php @@ -22,9 +23,22 @@ // 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); + + // A package on its own release line (config('docs.packages')) is named in + // its labels. Its baseline was current when the platform major shipped, so + // labels at or below it never render, just like x.0. Neither does a + // package missing from the config. + $release = filled($package) ? (config('docs.packages')[$package] ?? null) : null; @endphp -@if ($state && $minor > 0) +@if ($release && $state && version_compare($state['version'], $release['baseline']) > 0) + +@elseif (blank($package) && $state && $minor > 0) - Per-platform overrides for the leading icon: an [SF Symbol](icon#ios-sf-symbols) name on iOS, a [Material Icon](icon#android-material-icons) name on Android. `icon` is the fallback on whichever platform has no override — or skip it and declare only per-platform icons. `iosIcon` / `androidIcon` and the ``-style `ios` / `android` are accepted as aliases - `icon-trailing` - A trailing [icon](icon#icon-name-reference) name (optional) -- `ios-icon-trailing` / `android-icon-trailing` - Per-platform overrides for the trailing icon, mirroring +- `ios-icon-trailing` / `android-icon-trailing` - Per-platform overrides for the trailing icon, mirroring `ios-icon` / `android-icon` (camelCase aliases accepted; no `ios` / `android` shorthand for this slot) - `font` - Custom font for the label: a `resources/fonts/` file token or a config alias like `accent` (optional, string) — see [Text › Custom fonts](text#custom-fonts) - `line-height` - Label line height as a multiplier of the font size (optional, float) @@ -104,6 +104,8 @@ attributes are intentionally dropped before reaching the renderer. ### Platform icons + + When one shared name doesn't map well on both platforms, give each platform its own symbol — an [SF Symbol](icon#ios-sf-symbols) name on iOS, a [Material Icon](icon#android-material-icons) name on Android: diff --git a/resources/views/docs/mobile/4/edge-components/chip.md b/resources/views/docs/mobile/4/edge-components/chip.md index 56a3f80e5..36a4ea920 100644 --- a/resources/views/docs/mobile/4/edge-components/chip.md +++ b/resources/views/docs/mobile/4/edge-components/chip.md @@ -27,7 +27,7 @@ fully rounded and can be adjusted with `rounded-*` classes. - `label` - Chip text (optional, string). Can also be passed as the first argument to `make()` - `selected` / `value` - Whether the chip is active (optional, boolean, default: `false`) - `icon` - Leading [icon](icon#icon-name-reference) name (optional, string) -- `ios-icon` / `android-icon` - Per-platform overrides for the leading icon: an [SF Symbol](icon#ios-sf-symbols) +- `ios-icon` / `android-icon` - Per-platform overrides for the leading icon: an [SF Symbol](icon#ios-sf-symbols) name on iOS, a [Material Icon](icon#android-material-icons) name on Android. `icon` is the fallback on whichever platform has no override. Bound `:ios-icon` / `:android-icon` also accept [enum cases](icon#typed-icon-enums); `iosIcon` / `androidIcon` and `ios` / `android` are accepted as aliases diff --git a/resources/views/docs/mobile/4/edge-components/pager.md b/resources/views/docs/mobile/4/edge-components/pager.md index 348093f5e..149230bfd 100644 --- a/resources/views/docs/mobile/4/edge-components/pager.md +++ b/resources/views/docs/mobile/4/edge-components/pager.md @@ -1,6 +1,8 @@ --- title: Pager order: 312 +package: mobile-ui +since: "0.5" --- ## Overview diff --git a/resources/views/docs/mobile/4/edge-components/tab-row.md b/resources/views/docs/mobile/4/edge-components/tab-row.md index 99c3f7506..911590ec4 100644 --- a/resources/views/docs/mobile/4/edge-components/tab-row.md +++ b/resources/views/docs/mobile/4/edge-components/tab-row.md @@ -68,7 +68,7 @@ Here `currentTab` stands in for `public int $currentTab = 0;` on your component. - `label` - Tab label (required, string). Can also be passed as the first argument to `make()` - `icon` - Optional [icon](icon#icon-name-reference) name rendered above the label -- `ios-icon` / `android-icon` - Per-platform overrides for the tab icon: an [SF Symbol](icon#ios-sf-symbols) name +- `ios-icon` / `android-icon` - Per-platform overrides for the tab icon: an [SF Symbol](icon#ios-sf-symbols) name on iOS, a [Material Icon](icon#android-material-icons) name on Android. `icon` is the fallback on whichever platform has no override. Bound `:ios-icon` / `:android-icon` also accept [enum cases](icon#typed-icon-enums); `iosIcon` / `androidIcon` and `ios` / `android` are accepted as aliases diff --git a/resources/views/docs/mobile/4/edge-components/text-input.md b/resources/views/docs/mobile/4/edge-components/text-input.md index 59cb2130f..53613458d 100644 --- a/resources/views/docs/mobile/4/edge-components/text-input.md +++ b/resources/views/docs/mobile/4/edge-components/text-input.md @@ -61,7 +61,7 @@ All three variants accept the same shared prop set. The bare variant adds a `col - `keyboard` - Keyboard hint string: `text` (default), `number`, `email`, `phone`, `url`, `decimal`, `password`, `numberPassword`. On iOS `password` uses the standard keyboard; `secure` is the masking mechanism. The keyboard type also decides capitalization and autocorrect — see [Capitalization](#capitalization) -- `autocapitalize` - Override that capitalization: `none`, `sentences`, `words`, or `characters` (HTML's +- `autocapitalize` - Override that capitalization: `none`, `sentences`, `words`, or `characters` (HTML's vocabulary). Leave it unset to let `keyboard` decide (optional, string) - `secure` - Mask input for passwords (optional, boolean, default: `false`) - `multiline` - Allow multiple lines (optional, boolean, default: `false`) @@ -73,7 +73,7 @@ All three variants accept the same shared prop set. The bare variant adds a `col - `sync-mode` - How change events dispatch back to your component: `live` (default), `blur`, or `debounce`. Usually set via the `native:model` modifiers below, but accepted directly too - `debounce-ms` - Milliseconds of inactivity before a `debounce` sync fires (optional, int, default: `300`) -- `selection-debounce-ms` - Coalescing window for `@selectionChange` events (optional, int, default: `150`). `0` or +- `selection-debounce-ms` - Coalescing window for `@selectionChange` events (optional, int, default: `150`). `0` or less means the default; positive values are floored at one frame (16ms). See [Caret and selection reporting](#caret-and-selection-reporting) @@ -83,9 +83,9 @@ All three variants accept the same shared prop set. The bare variant adds a `col - `suffix` - Text rendered after the input (optional, string) - `leading-icon` - Icon name rendered at the start (optional, string) - `trailing-icon` - Icon name rendered at the end (optional, string) -- `ios-leading-icon` / `android-leading-icon` - Per-platform overrides for `leading-icon` (optional). See +- `ios-leading-icon` / `android-leading-icon` - Per-platform overrides for `leading-icon` (optional). See [Per-platform icons](#per-platform-icons) -- `ios-trailing-icon` / `android-trailing-icon` - Per-platform overrides for `trailing-icon` (optional) +- `ios-trailing-icon` / `android-trailing-icon` - Per-platform overrides for `trailing-icon` (optional) ### Typography @@ -101,6 +101,8 @@ All three variants accept the same shared prop set. The bare variant adds a `col ## Capitalization + + Declaring a `keyboard` type carries its typing behaviour with it, not just the key layout. Fields whose content is case-sensitive or non-alphabetic never capitalize, and never autocorrect: @@ -140,7 +142,7 @@ own default, left as-is. Set `autocapitalize` explicitly when you need the two t - `@change` - Component method called when the text changes. Receives the new value - `@submit` - Component method called when the user submits (e.g. presses return). Receives the current value -- `@selectionChange` - Component method called when the caret moves or the selection changes. Receives the full +- `@selectionChange` - Component method called when the caret moves or the selection changes. Receives the full current text plus the selection start and end offsets — see [Caret and selection reporting](#caret-and-selection-reporting) @@ -186,6 +188,8 @@ automatically, so the `@{{ $name }}` echo updates as you type. ## Caret and selection reporting + + `@selectionChange` reports caret position and text selection back to your component — for the cases where `@change` alone can't tell you *where* the user is typing. The handler receives the full current text plus the selection range: @@ -266,6 +270,8 @@ Caret and selection reporting requires `nativephp/mobile` 4.0+, which ships the ## Per-platform icons + + The shared `leading-icon` / `trailing-icon` names render the same icon on both platforms. When each platform should show its own symbol, prefix the attribute with the platform — the same convention as [``](button)'s `ios-icon`: @@ -471,9 +477,9 @@ All three elements share the same fluent API (defined on `BaseTextInput`): - `font(string $name)` - Custom font (file token or config alias) - `a11yLabel(string $value)`, `a11yHint(string $value)` - `syncMode(string $mode)`, `debounceMs(int $ms)` -- `selectionDebounceMs(int $ms)` - Coalescing window for `@selectionChange` events (150ms when unset; `0` or less +- `selectionDebounceMs(int $ms)` - Coalescing window for `@selectionChange` events (150ms when unset; `0` or less means the default, positive values floored at 16ms) -- `onChange(string $method)`, `onSubmit(string $method)`, `onSelectionChange(string $method)` +- `onChange(string $method)`, `onSubmit(string $method)`, `onSelectionChange(string $method)` `BareTextInput` adds one method on top of the shared API: diff --git a/resources/views/docs/mobile/4/edge-components/text.md b/resources/views/docs/mobile/4/edge-components/text.md index 1211bc0b7..134c81a3b 100644 --- a/resources/views/docs/mobile/4/edge-components/text.md +++ b/resources/views/docs/mobile/4/edge-components/text.md @@ -74,6 +74,8 @@ and is available fluently as `->font('Inter-Bold')`. ### Downloading from Google Fonts + + The `native:font` command downloads a [Google Fonts](https://fonts.google.com) family into `resources/fonts/` and registers it in your config — no API key needed: @@ -99,9 +101,9 @@ whether one style should become the app-wide `default`. Google Fonts are libre-licensed (OFL / Apache), so bundling them in your app is permitted. - `--default` — make the downloaded font the app-wide `default` alias without asking -- `--force` — overwrite files that already exist in `resources/fonts/` (and skip the `--reset` confirmation) -- `--reset` — delete every font file in `resources/fonts/` and reset the config to `'default' => 'System'` (asks first) -- `--clear-cache` — clear the cached catalog (the family list is cached for 24 hours) +- `--force` — overwrite files that already exist in `resources/fonts/` (and skip the `--reset` confirmation) +- `--reset` — delete every font file in `resources/fonts/` and reset the config to `'default' => 'System'` (asks first) +- `--clear-cache` — clear the cached catalog (the family list is cached for 24 hours) Non-interactive runs (`--no-interaction`, CI) require the family argument, download only Regular (400) — or the first listed style for families without @@ -140,7 +142,7 @@ explicit `font-serif` / `font-mono` classes still win over it. Swapping a font app-wide becomes a one-line config change; blades keep their semantic names. Each alias must point directly at a file token (no alias-to-alias chaining). -`native:font` maintains this array for you — every downloaded style gets an +`native:font` maintains this array for you — every downloaded style gets an entry, and the `--default` flag (or the prompt) sets `default`. Prefer aliases over the older `font-family` theme token (which `fonts.default` supersedes when both are set); `native:font --default` only falls back to writing `font-family` diff --git a/resources/views/docs/mobile/4/getting-started/versioning.md b/resources/views/docs/mobile/4/getting-started/versioning.md index 7c4cea919..ec9c87b16 100644 --- a/resources/views/docs/mobile/4/getting-started/versioning.md +++ b/resources/views/docs/mobile/4/getting-started/versioning.md @@ -85,6 +85,30 @@ render on your phone when you scan a QR code. Where that's the case, you'll see: 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. +### mobile-ui labels + +The [EDGE components](../edge-components/introduction) ship in `nativephp/mobile-ui`, which has its own version +numbers. Anything on those pages without a label has been there since mobile-ui 0.3, the release that shipped +alongside 4.0. Later additions and changes carry one of these labels: + +| Label | Meaning | +|-------|---------| +| | Added in that mobile-ui release. Upgrade mobile-ui 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 | + +Because mobile-ui is still on 0.x, Composer treats each minor release like a major one, so a `^0.5` constraint never +installs 0.6. Raise the constraint to pick up a newer release: + +```shell +composer require nativephp/mobile-ui:^0.6 +``` + +Then [rebuild your app](../plugins/using-plugins#rebuild-your-app), because the components' renderers are native code +compiled in at build time. Newer mobile-ui releases can need a newer `nativephp/mobile` too (0.5 and later need 4.5), +and Composer will say so. + ## 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/tests/Feature/Docs/VersionBadgeTest.php b/tests/Feature/Docs/VersionBadgeTest.php index d77710fe8..11b6e8513 100644 --- a/tests/Feature/Docs/VersionBadgeTest.php +++ b/tests/Feature/Docs/VersionBadgeTest.php @@ -58,6 +58,49 @@ public function test_removed_renders_with_prefix(): void ->assertSee('Removed 4.1'); } + public function test_package_since_renders_the_package_and_version(): void + { + $this->blade('') + ->assertSee('>mobile-ui 0.6<', false) + ->assertSee('title="Added in nativephp/mobile-ui 0.6"', false); + } + + public function test_package_changed_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('>Changed mobile-ui 0.6<', false) + ->assertSee('title="Changed in nativephp/mobile-ui 0.6"', false); + } + + public function test_package_deprecated_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('>Deprecated mobile-ui 0.6<', false); + } + + public function test_package_removed_renders_with_prefix(): void + { + $this->blade('') + ->assertSee('>Removed mobile-ui 0.6<', false); + } + + public function test_package_labels_at_or_below_the_baseline_render_nothing(): void + { + // mobile-ui 0.3 was current when Mobile 4.0 shipped, so like x.0 it's + // the starting point rather than something to label. + $this->blade('') + ->assertDontSee('0.3'); + + $this->blade('') + ->assertDontSee('0.1'); + } + + public function test_an_unknown_package_renders_nothing(): void + { + $this->blade('') + ->assertDontSee('0.6'); + } + public function test_a_badge_renders_on_one_line(): void { // Labels sit inline in markdown, including inside table cells, where a @@ -65,8 +108,9 @@ public function test_a_badge_renders_on_one_line(): void // rest of the table into paragraph text. $linked = (string) $this->blade(''); $bare = (string) $this->blade(''); + $package = (string) $this->blade(''); - foreach ([$linked, $bare] as $badge) { + foreach ([$linked, $bare, $package] as $badge) { $this->assertStringNotContainsString("\n", $badge); $this->assertSame(trim($badge), $badge); } @@ -94,6 +138,25 @@ public function test_layout_page_contains_the_4_2_pill(): void ->assertSee('4.2'); } + public function test_a_package_label_links_to_its_section_of_the_versioning_page(): void + { + $response = $this->get('/docs/mobile/4/getting-started/versioning') + ->assertStatus(200) + ->assertSee('id="mobile-ui-labels"', false); + + $this->assertMatchesRegularExpression( + '/]*>mobile-ui 0\.6<\/a>/', + $response->getContent() + ); + } + + public function test_accordion_page_carries_a_page_level_mobile_ui_label(): void + { + $this->get('/docs/mobile/4/edge-components/accordion') + ->assertStatus(200) + ->assertSee('>mobile-ui 0.4<', false); + } + public function test_section_label_does_not_change_the_heading_anchor_id(): void { $html = CommonMark::convertToHtml( @@ -122,6 +185,7 @@ public function test_search_index_content_contains_no_badge_markup(): void public function test_every_version_label_points_at_a_released_version(): void { $releasedVersions = config('docs.released_versions'); + $packages = config('docs.packages'); $finder = (new Finder)->files()->name('*.md')->in(resource_path('views/docs')); $violations = []; @@ -135,25 +199,62 @@ public function test_every_version_label_points_at_a_released_version(): void } [$platform, $major] = [$parts[0], (int) $parts[1]]; - $allowed = $releasedVersions[$platform][$major] ?? []; + + // A package label is checked against the package's own releases. + // Null means the package isn't in config('docs.packages'). + $releasedFor = fn (?string $package): ?array => $package === null + ? ($releasedVersions[$platform][$major] ?? []) + : ($packages[$package]['released_versions'] ?? null); $content = $file->getContents(); $document = YamlFrontMatter::parse($content); + $package = $document->matter('package'); + $packageNote = $package === null ? '' : " `package: {$package}`"; + $allowed = $releasedFor($package); + + if ($allowed === null) { + $violations[] = "{$relative} front matter `package: {$package}` isn't in config('docs.packages')"; + } + 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 ($value === null) { + continue; } + + // YAML reads an unquoted `since: 0.10` as the float 0.1, so + // the label would quietly show the wrong version. + if (! is_string($value)) { + $violations[] = "{$relative} front matter `{$key}: ".json_encode($value)."` must be quoted, e.g. `{$key}: \"0.10\"`, or YAML reads it as a number"; + } elseif ($allowed !== null && ! in_array($value, $allowed, true)) { + $violations[] = "{$relative} front matter{$packageNote} `{$key}: {$value}`"; + } + } + + $jump = $document->matter('jump'); + + if ($jump !== null && $jump !== false && ! is_string($jump)) { + $violations[] = "{$relative} front matter `jump: ".json_encode($jump).'` must be a quoted version, e.g. `jump: "3.10"`, or false'; } if (preg_match_all('/]*)\/>/s', $content, $tagMatches)) { foreach ($tagMatches[1] as $attrs) { + $package = preg_match('/package="([^"]+)"/', $attrs, $m) ? $m[1] : null; + $packageAttribute = $package === null ? '' : " package=\"{$package}\""; + $allowed = $releasedFor($package); + + if ($allowed === null) { + $violations[] = "{$relative} isn't in config('docs.packages')"; + + continue; + } + foreach (['since', 'changed', 'deprecated', 'removed'] as $key) { if (preg_match('/'.$key.'="([^"]+)"/', $attrs, $m)) { if (! in_array($m[1], $allowed, true)) { - $violations[] = "{$relative} "; + $violations[] = "{$relative} "; } break; } @@ -164,7 +265,22 @@ public function test_every_version_label_points_at_a_released_version(): void $this->assertEmpty( $violations, - "Version labels pointing at an unreleased or mismatched version:\n".implode("\n", $violations) + "Version labels that are unquoted, name an unknown package, or point at an unreleased or mismatched version:\n".implode("\n", $violations) ); } + + public function test_every_package_baseline_is_one_of_its_released_versions(): void + { + $packages = config('docs.packages'); + + $this->assertNotEmpty($packages); + + foreach ($packages as $slug => $package) { + $this->assertContains( + $package['baseline'], + $package['released_versions'], + "config('docs.packages.{$slug}.baseline') isn't one of its released_versions" + ); + } + } }