Skip to content

Commit

Permalink
Merge pull request #2062 from hydephp/interactive-component-publisher…
Browse files Browse the repository at this point in the history
…-command

[2.x] Interactive component publisher command
  • Loading branch information
caendesilva authored Dec 29, 2024
2 parents 88d0ea4 + 68aec1e commit 5928843
Show file tree
Hide file tree
Showing 11 changed files with 924 additions and 67 deletions.
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This serves two purposes:
- Added a Vite HMR support for the realtime compiler in https://github.com/hydephp/develop/pull/2016
- Added Vite facade in https://github.com/hydephp/develop/pull/2016
- Added a custom Blade-based heading renderer for Markdown conversions in https://github.com/hydephp/develop/pull/2047
- The `publish:views` command is now interactive for Unix-like systems in https://github.com/hydephp/develop/pull/2062

### Changed

Expand Down
2 changes: 2 additions & 0 deletions docs/digging-deeper/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ php hyde publish:views

The files will then be available in the `resources/views/vendor/hyde` directory.

>info **Tip:** If you use Linux/macOS or Windows with WSL you will be able to interactively select individual files to publish.

## Frontend Styles

Hyde is designed to not only serve as a framework but a whole starter kit and comes with a Tailwind starter template
Expand Down
165 changes: 113 additions & 52 deletions packages/framework/src/Console/Commands/PublishViewsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@

namespace Hyde\Console\Commands;

use Closure;
use Hyde\Console\Concerns\Command;
use Illuminate\Support\Facades\Artisan;

use Hyde\Console\Helpers\ConsoleHelper;
use Hyde\Console\Helpers\InteractivePublishCommandHelper;
use Hyde\Console\Helpers\ViewPublishGroup;
use Illuminate\Support\Str;
use Laravel\Prompts\Key;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\SelectPrompt;

use function Laravel\Prompts\select;
use function str_replace;
use function sprintf;
use function strstr;
Expand All @@ -20,78 +28,131 @@ class PublishViewsCommand extends Command
protected $signature = 'publish:views {category? : The category to publish}';

/** @var string */
protected $description = 'Publish the hyde components for customization. Note that existing files will be overwritten';

/** @var array<string, array<string, string>> */
protected array $options = [
'layouts' => [
'name' => 'Blade Layouts',
'description' => 'Shared layout views, such as the app layout, navigation menu, and Markdown page templates',
'group' => 'hyde-layouts',
],
'components' => [
'name' => 'Blade Components',
'description' => 'More or less self contained components, extracted for customizability and DRY code',
'group' => 'hyde-components',
],
'page-404' => [
'name' => '404 Page',
'description' => 'A beautiful 404 error page by the Laravel Collective',
'group' => 'hyde-page-404',
],
];
protected $description = 'Publish the Hyde components for customization. Note that existing files will be overwritten';

/** @var array<string, \Hyde\Console\Helpers\ViewPublishGroup> */
protected array $options;

public function handle(): int
{
$selected = (string) ($this->argument('category') ?? $this->promptForCategory());
$this->options = static::mapToKeys([
ViewPublishGroup::fromGroup('hyde-layouts', 'Blade Layouts', 'Shared layout views, such as the app layout, navigation menu, and Markdown page templates'),
ViewPublishGroup::fromGroup('hyde-components', 'Blade Components', 'More or less self contained components, extracted for customizability and DRY code'),
]);

if ($selected === 'all' || $selected === '') {
foreach ($this->options as $key => $_ignored) {
$this->publishOption($key);
}
} else {
$this->publishOption($selected);
$selected = ($this->argument('category') ?? $this->promptForCategory()) ?: 'all';

if ($selected !== 'all' && (bool) $this->argument('category') === false && ConsoleHelper::canUseLaravelPrompts($this->input)) {
$this->infoComment(sprintf('Selected category [%s]', $selected));
}

return Command::SUCCESS;
}
if (! in_array($selected, $allowed = array_merge(['all'], array_keys($this->options)), true)) {
$this->error("Invalid selection: '$selected'");
$this->infoComment('Allowed values are: ['.implode(', ', $allowed).']');

protected function publishOption(string $selected): void
{
Artisan::call('vendor:publish', [
'--tag' => $this->options[$selected]['group'] ?? $selected,
'--force' => true,
], $this->output);
return Command::FAILURE;
}

$files = $selected === 'all'
? collect($this->options)->flatMap(fn (ViewPublishGroup $option): array => $option->publishableFilesMap())->all()
: $this->options[$selected]->publishableFilesMap();

$publisher = $this->publishSelectedFiles($files, $selected === 'all');

$this->infoComment($publisher->formatOutput($selected));

return Command::SUCCESS;
}

protected function promptForCategory(): string
{
/** @var string $choice */
$choice = $this->choice(
'Which category do you want to publish?',
$this->formatPublishableChoices(),
0
SelectPrompt::fallbackUsing(function (SelectPrompt $prompt): string {
return $this->choice($prompt->label, $prompt->options, $prompt->default);
});

return $this->parseChoiceIntoKey(
select('Which category do you want to publish?', $this->formatPublishableChoices(), 0) ?: 'all'
);
}

$selection = $this->parseChoiceIntoKey($choice);
protected function formatPublishableChoices(): array
{
return collect($this->options)
->map(fn (ViewPublishGroup $option, string $key): string => sprintf('<comment>%s</comment>: %s', $key, $option->description))
->prepend('Publish all categories listed below')
->values()
->all();
}

$this->infoComment(sprintf("Selected category [%s]\n", $selection ?: 'all'));
protected function parseChoiceIntoKey(string $choice): string
{
return strstr(str_replace(['<comment>', '</comment>'], '', $choice), ':', true) ?: '';
}

return $selection;
/**
* @param array<string, \Hyde\Console\Helpers\ViewPublishGroup> $groups
* @return array<string, \Hyde\Console\Helpers\ViewPublishGroup>
*/
protected static function mapToKeys(array $groups): array
{
return collect($groups)->mapWithKeys(function (ViewPublishGroup $group): array {
return [Str::after($group->group, 'hyde-') => $group];
})->all();
}

protected function formatPublishableChoices(): array
/** @param array<string, string> $files */
protected function publishSelectedFiles(array $files, bool $isPublishingAll): InteractivePublishCommandHelper
{
$keys = ['Publish all categories listed below'];
foreach ($this->options as $key => $option) {
$keys[] = "<comment>$key</comment>: {$option['description']}";
$publisher = new InteractivePublishCommandHelper($files);

if (! $isPublishingAll && ConsoleHelper::canUseLaravelPrompts($this->input)) {
$publisher->only($this->promptUserForWhichFilesToPublish($publisher->getFileChoices()));
}

return $keys;
$publisher->publishFiles();

return $publisher;
}

protected function parseChoiceIntoKey(string $choice): string
/**
* @param array<string, string> $files
* @return array<string>
*/
protected function promptUserForWhichFilesToPublish(array $files): array
{
return strstr(str_replace(['<comment>', '</comment>'], '', $choice), ':', true) ?: '';
$choices = array_merge(['all' => '<comment>All files</comment>'], $files);

$prompt = new MultiSelectPrompt('Select the files you want to publish', $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.');

$prompt->on('key', static::supportTogglingAll($prompt));

return (array) $prompt->prompt();
}

protected static function supportTogglingAll(MultiSelectPrompt $prompt): Closure
{
return function (string $key) use ($prompt): void {
static $isToggled = false;

if ($prompt->isHighlighted('all')) {
if ($key === Key::SPACE) {
$prompt->emit('key', Key::CTRL_A);

if ($isToggled) {
// We need to emit CTRL+A twice to deselect all for some reason
$prompt->emit('key', Key::CTRL_A);
$isToggled = false;
} else {
$isToggled = true;
}
} elseif ($key === Key::ENTER) {
if (! $isToggled) {
$prompt->emit('key', Key::CTRL_A);
}

$prompt->state = 'submit';
}
}
};
}
}
43 changes: 43 additions & 0 deletions packages/framework/src/Console/Helpers/ConsoleHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Hyde\Console\Helpers;

use Laravel\Prompts\Prompt;
use Symfony\Component\Console\Input\InputInterface;

/**
* @internal This class contains internal helpers for interacting with the console, and for easier testing.
*
* @codeCoverageIgnore This class provides internal testing helpers and does not need to be tested.
*/
class ConsoleHelper
{
/** Allows for mocking the Windows OS check. Remember to clear the mock after the test. */
protected static ?bool $enableLaravelPrompts = null;

public static function clearMocks(): void
{
static::$enableLaravelPrompts = null;
}

public static function disableLaravelPrompts(): void
{
static::$enableLaravelPrompts = false;
}

public static function mockWindowsOs(bool $isWindowsOs): void
{
static::$enableLaravelPrompts = ! $isWindowsOs;
}

public static function canUseLaravelPrompts(InputInterface $input): bool
{
if (static::$enableLaravelPrompts !== null) {
return static::$enableLaravelPrompts;
}

return $input->isInteractive() && windows_os() === false && Prompt::shouldFallback() === false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace Hyde\Console\Helpers;

use Hyde\Facades\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

/**
* @internal This class offloads logic from the PublishViewsCommand class and should not be used elsewhere.
*/
class InteractivePublishCommandHelper
{
/** @var array<string, string> Map of source files to target files */
protected array $publishableFilesMap;

protected readonly int $originalFileCount;

/** @param array<string, string> $publishableFilesMap */
public function __construct(array $publishableFilesMap)
{
$this->publishableFilesMap = $publishableFilesMap;
$this->originalFileCount = count($publishableFilesMap);
}

/** @return array<string, string> */
public function getFileChoices(): array
{
return Arr::mapWithKeys($this->publishableFilesMap, /** @return array<string, string> */ function (string $target, string $source): array {
return [$source => $this->pathRelativeToDirectory($source, $this->getBaseDirectory())];
});
}

/**
* Only publish the selected files.
*
* @param array<string> $selectedFiles Array of selected file paths, matching the keys of the publishableFilesMap.
*/
public function only(array $selectedFiles): void
{
$this->publishableFilesMap = Arr::only($this->publishableFilesMap, $selectedFiles);
}

/** Find the most specific common parent directory path for the files, trimming as much as possible whilst keeping specificity and uniqueness. */
public function getBaseDirectory(): string
{
$partsMap = collect($this->publishableFilesMap)->map(function (string $file): array {
return explode('/', $file);
});

$commonParts = $partsMap->reduce(function (array $carry, array $parts): array {
return array_intersect($carry, $parts);
}, $partsMap->first());

return implode('/', $commonParts);
}

public function publishFiles(): void
{
foreach ($this->publishableFilesMap as $source => $target) {
Filesystem::ensureDirectoryExists(dirname($target));
Filesystem::copy($source, $target);
}
}

public function formatOutput(string $group): string
{
$fileCount = count($this->publishableFilesMap);
$publishedOneFile = $fileCount === 1;
$publishedAllGroups = $group === 'all';
$publishedAllFiles = $fileCount === $this->originalFileCount;
$selectedFilesModifier = $publishedAllFiles ? 'all' : 'selected';

return match (true) {
$publishedAllGroups => sprintf('Published all %d files to [%s]', $fileCount, $this->getBaseDirectory()),
$publishedOneFile => sprintf('Published selected file to [%s]', reset($this->publishableFilesMap)),
default => sprintf('Published %s [%s] files to [%s]', $selectedFilesModifier, Str::singular($group), $this->getBaseDirectory())
};
}

protected function pathRelativeToDirectory(string $source, string $directory): string
{
return Str::after($source, basename($directory).'/');
}
}
Loading

0 comments on commit 5928843

Please sign in to comment.