Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ auth.json

# storage
/storage/*.key
/storage/docs-screenshots

# vendor
/vendor
Expand Down
278 changes: 278 additions & 0 deletions app/Console/Commands/CaptureDocsScreenshots.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\DocsScreenshotCrop;
use App\Enums\DocsScreenshotPlatform;
use App\Services\DocsScreenshots\ScreenshotCapturer;
use App\Services\DocsScreenshots\ScreenshotPublisher;
use App\Support\DocsScreenshotManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

final class CaptureDocsScreenshots extends Command
{
/** Exclusive ceiling for `--crop-percent` — matches `native:screenshot`'s own single-edge ceiling. */
private const CROP_PERCENT_CEILING = 1.0;

protected $signature = 'docs:capture-screenshots
{--platform=both : ios, android, or both}
{--super-native-path= : Local checkout of NativePHP/super-native}
{--udid= : Specific simulator/emulator UDID — required whenever native:run would otherwise have more than one device to pick from}
{--only= : Comma-separated screen keys to limit to, e.g. top-bar,bottom-nav}
{--settle-ms=2000 : Milliseconds to wait after launch before capturing}
{--full : Keep the full, uncropped screenshot instead of the tight top/bottom crop most screens use}
{--crop-percent= : Override the configured crop_percent for this run (0-1)}
{--crop-offset= : Override the configured crop_offset for this run (0-1) — skips the OS status bar/nav bar before measuring crop-percent}
{--dry-run : Print what would be captured without running anything}
{--publish : Copy every staged screenshot into public/img/docs}';

protected $description = "Regenerate the mobile docs' Edge Component screenshots from a running super-native checkout";

public function handle(): int
{
$superNativePath = $this->resolveSuperNativePath();

if ($superNativePath === null) {
return self::FAILURE;
}

$platforms = DocsScreenshotPlatform::fromOption((string) $this->option('platform'));

if ($platforms === []) {
$this->error(sprintf("Invalid --platform '%s'. Use 'ios', 'android', or 'both'.", $this->option('platform')));

return self::FAILURE;
}

$keys = $this->resolveScreenKeys();

if ($keys === null) {
return self::FAILURE;
}

$cropPercent = $this->resolveCropPercent();

if ($cropPercent === null) {
return self::FAILURE;
}

$cropOffset = $this->resolveCropOffset($cropPercent);

if ($cropOffset === null) {
return self::FAILURE;
}

if ($this->option('dry-run')) {
$this->printDryRun($superNativePath, $keys, $platforms, $cropPercent, $cropOffset);

return self::SUCCESS;
}

$stagingPath = (string) config('docs.screenshots.staging_path');
File::ensureDirectoryExists($stagingPath);

$capturer = new ScreenshotCapturer($superNativePath, (int) config('docs.screenshots.process_timeout'));
$failures = [];

foreach ($keys as $key) {
foreach ($platforms as $platform) {
if (! $this->captureScreen($capturer, $stagingPath, $key, $platform, $cropPercent, $cropOffset)) {
$failures[] = sprintf('%s (%s)', $key, $platform->value);
}
}
}

if ($failures !== []) {
$this->error(sprintf('Failed to capture: %s', implode(', ', $failures)));

return self::FAILURE;
}

$this->info(sprintf('Captured %d screen(s) into %s', count($keys), $stagingPath));

if ($this->option('publish') && ! $this->publish($stagingPath, $keys, $platforms)) {
return self::FAILURE;
}

return self::SUCCESS;
}

private function resolveSuperNativePath(): ?string
{
$path = rtrim((string) $this->option('super-native-path'), '/');

if ($path === '') {
$this->error('--super-native-path is required — pass the local checkout of NativePHP/super-native, e.g. a sibling directory of this repo.');

return null;
}

if (! is_dir($path) || ! is_file($path.'/artisan') || ! is_file($path.'/routes/mobile.php')) {
$this->error(sprintf("'%s' doesn't look like a super-native checkout (missing artisan or routes/mobile.php).", $path));

return null;
}

return $path;
}

/**
* @return list<string>|null
*/
private function resolveScreenKeys(): ?array
{
$only = (string) $this->option('only');

if ($only === '') {
return DocsScreenshotManifest::keys();
}

$keys = array_filter(array_map('trim', explode(',', $only)));
$unknown = array_filter($keys, fn (string $key): bool => ! DocsScreenshotManifest::has($key));

if ($unknown !== []) {
$this->error(sprintf('Unknown screen key(s): %s', implode(', ', $unknown)));

return null;
}

return array_values($keys);
}

private function resolveCropPercent(): ?float
{
$given = (string) $this->option('crop-percent');
$percent = $given === '' ? (float) config('docs.screenshots.crop_percent') : (float) $given;

if ($percent <= 0 || $percent >= self::CROP_PERCENT_CEILING) {
$this->error(sprintf(
'--crop-percent must be between 0 and %s (exclusive), got %s.',
self::CROP_PERCENT_CEILING,
$given
));

return null;
}

return $percent;
}

private function resolveCropOffset(float $cropPercent): ?float
{
$given = (string) $this->option('crop-offset');
$offset = $given === '' ? (float) config('docs.screenshots.crop_offset') : (float) $given;

if ($offset < 0 || $offset + $cropPercent >= self::CROP_PERCENT_CEILING) {
$this->error(sprintf(
'--crop-offset (%s) plus --crop-percent (%s) must be less than %s.',
$given === '' ? $offset : $given,
$cropPercent,
self::CROP_PERCENT_CEILING
));

return null;
}

return $offset;
}

/**
* @param list<string> $keys
* @param list<DocsScreenshotPlatform> $platforms
*/
private function printDryRun(string $superNativePath, array $keys, array $platforms, float $cropPercent, float $cropOffset): void
{
$this->info(sprintf('Would use super-native checkout: %s', $superNativePath));

foreach ($keys as $key) {
$screen = DocsScreenshotManifest::get($key);

foreach ($platforms as $platform) {
$crop = $this->option('full') || $screen['crop'] === DocsScreenshotCrop::Full
? 'full'
: sprintf('%s %d%% (offset %d%%)', $screen['crop']->value, (int) round($cropPercent * 100), (int) round($cropOffset * 100));

$this->line(sprintf(
' %s (%s) — route %s — %s%s',
$key,
$platform->value,
$screen['route'],
$crop,
$screen['requires_drawer_open'] ? ' — needs a manual drawer-open step' : ''
));
}
}
}

private function captureScreen(
ScreenshotCapturer $capturer,
string $stagingPath,
string $key,
DocsScreenshotPlatform $platform,
float $cropPercent,
float $cropOffset,
): bool {
$screen = DocsScreenshotManifest::get($key);
$outputPath = sprintf('%s/%s', $stagingPath, $screen[$platform->value]);
$crop = $this->option('full') ? DocsScreenshotCrop::Full : $screen['crop'];

$failure = $capturer->capture(
key: $key,
platform: $platform,
route: $screen['route'],
requiresDrawerOpen: $screen['requires_drawer_open'],
udid: (string) $this->option('udid'),
settleMs: (int) $this->option('settle-ms'),
outputPath: $outputPath,
crop: $crop,
cropPercent: $cropPercent,
cropOffset: $cropOffset,
isInteractive: $this->input->isInteractive(),
confirmDrawerOpen: fn (string $message) => $this->ask($message),
);

if ($failure !== null) {
$this->error($failure);

return false;
}

$this->info($crop === DocsScreenshotCrop::Full
? sprintf('Captured %s (full, uncropped).', $outputPath)
: sprintf('Captured %s (cropped to %s).', $outputPath, $crop->value));

return true;
}

/**
* @param list<string> $keys
* @param list<DocsScreenshotPlatform> $platforms
*/
private function publish(string $stagingPath, array $keys, array $platforms): bool
{
$publishPath = (string) config('docs.screenshots.publish_path');

$filenames = [];

foreach ($keys as $key) {
foreach ($platforms as $platform) {
$filenames[] = DocsScreenshotManifest::get($key)[$platform->value];
}
}

$failures = (new ScreenshotPublisher)->publish($stagingPath, $publishPath, $filenames);

if ($failures !== []) {
$this->error(sprintf('Failed to publish: %s', implode(', ', $failures)));

return false;
}

$this->info(sprintf('Published %s.', $publishPath));

return true;
}
}
19 changes: 19 additions & 0 deletions app/Enums/DocsScreenshotCrop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Enums;

/**
* Which edge of a captured screenshot documents the component being shown.
* `Top`/`Bottom` keep only a strip nearest that edge — matching every
* existing top-bar/bottom-nav image in public/img/docs, which are cropped
* tight rather than full-screen. `Full` keeps the whole screenshot, for
* components (like the side nav) that need their full height visible.
*/
enum DocsScreenshotCrop: string
{
case Top = 'top';
case Bottom = 'bottom';
case Full = 'full';
}
27 changes: 27 additions & 0 deletions app/Enums/DocsScreenshotPlatform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum DocsScreenshotPlatform: string
{
case Ios = 'ios';
case Android = 'android';

/**
* Parse the `--platform` option ('ios', 'android', or 'both') into the
* concrete platforms to capture. Returns an empty array for anything else.
*
* @return list<self>
*/
public static function fromOption(string $value): array
{
return match ($value) {
'ios' => [self::Ios],
'android' => [self::Android],
'both' => [self::Ios, self::Android],
default => [],
};
}
}
Loading