Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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}
{--demo-path= : Local checkout of NativePHP/edge-components-demo (defaults to docs.screenshots.demo_path)}
{--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 edge-components-demo checkout";

public function handle(): int
{
$demoPath = $this->resolveDemoPath();

if ($demoPath === 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($demoPath, $keys, $platforms, $cropPercent, $cropOffset);

return self::SUCCESS;
}

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

$capturer = new ScreenshotCapturer($demoPath, (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 resolveDemoPath(): ?string
{
$path = rtrim((string) ($this->option('demo-path') ?: config('docs.screenshots.demo_path')), '/');

if ($path === '') {
$this->error('--demo-path is required — pass the local checkout of NativePHP/edge-components-demo, e.g. a sibling directory of this repo, or set DOCS_SCREENSHOTS_DEMO_PATH.');

return null;
}

if (! is_dir($path) || ! is_file($path.'/artisan') || ! is_file($path.'/routes/mobile.php')) {
$this->error(sprintf("'%s' doesn't look like an edge-components-demo 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 $demoPath, array $keys, array $platforms, float $cropPercent, float $cropOffset): void
{
$this->info(sprintf('Would use edge-components-demo checkout: %s', $demoPath));

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['manual_step'] !== null ? sprintf(' — manual step: %s', $screen['manual_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'],
manualStep: $screen['manual_step'],
udid: (string) $this->option('udid'),
settleMs: (int) $this->option('settle-ms'),
outputPath: $outputPath,
crop: $crop,
cropPercent: $cropPercent,
cropOffset: $cropOffset,
isInteractive: $this->input->isInteractive(),
confirmManualStep: 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