Skip to content

Commit 5928843

Browse files
authored
Merge pull request #2062 from hydephp/interactive-component-publisher-command
[2.x] Interactive component publisher command
2 parents 88d0ea4 + 68aec1e commit 5928843

File tree

11 files changed

+924
-67
lines changed

11 files changed

+924
-67
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This serves two purposes:
3636
- Added a Vite HMR support for the realtime compiler in https://github.com/hydephp/develop/pull/2016
3737
- Added Vite facade in https://github.com/hydephp/develop/pull/2016
3838
- Added a custom Blade-based heading renderer for Markdown conversions in https://github.com/hydephp/develop/pull/2047
39+
- The `publish:views` command is now interactive for Unix-like systems in https://github.com/hydephp/develop/pull/2062
3940

4041
### Changed
4142

docs/digging-deeper/customization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ php hyde publish:views
370370

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

373+
>info **Tip:** If you use Linux/macOS or Windows with WSL you will be able to interactively select individual files to publish.
374+
373375
## Frontend Styles
374376

375377
Hyde is designed to not only serve as a framework but a whole starter kit and comes with a Tailwind starter template

packages/framework/src/Console/Commands/PublishViewsCommand.php

Lines changed: 113 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@
44

55
namespace Hyde\Console\Commands;
66

7+
use Closure;
78
use Hyde\Console\Concerns\Command;
8-
use Illuminate\Support\Facades\Artisan;
9-
9+
use Hyde\Console\Helpers\ConsoleHelper;
10+
use Hyde\Console\Helpers\InteractivePublishCommandHelper;
11+
use Hyde\Console\Helpers\ViewPublishGroup;
12+
use Illuminate\Support\Str;
13+
use Laravel\Prompts\Key;
14+
use Laravel\Prompts\MultiSelectPrompt;
15+
use Laravel\Prompts\SelectPrompt;
16+
17+
use function Laravel\Prompts\select;
1018
use function str_replace;
1119
use function sprintf;
1220
use function strstr;
@@ -20,78 +28,131 @@ class PublishViewsCommand extends Command
2028
protected $signature = 'publish:views {category? : The category to publish}';
2129

2230
/** @var string */
23-
protected $description = 'Publish the hyde components for customization. Note that existing files will be overwritten';
24-
25-
/** @var array<string, array<string, string>> */
26-
protected array $options = [
27-
'layouts' => [
28-
'name' => 'Blade Layouts',
29-
'description' => 'Shared layout views, such as the app layout, navigation menu, and Markdown page templates',
30-
'group' => 'hyde-layouts',
31-
],
32-
'components' => [
33-
'name' => 'Blade Components',
34-
'description' => 'More or less self contained components, extracted for customizability and DRY code',
35-
'group' => 'hyde-components',
36-
],
37-
'page-404' => [
38-
'name' => '404 Page',
39-
'description' => 'A beautiful 404 error page by the Laravel Collective',
40-
'group' => 'hyde-page-404',
41-
],
42-
];
31+
protected $description = 'Publish the Hyde components for customization. Note that existing files will be overwritten';
32+
33+
/** @var array<string, \Hyde\Console\Helpers\ViewPublishGroup> */
34+
protected array $options;
4335

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

48-
if ($selected === 'all' || $selected === '') {
49-
foreach ($this->options as $key => $_ignored) {
50-
$this->publishOption($key);
51-
}
52-
} else {
53-
$this->publishOption($selected);
43+
$selected = ($this->argument('category') ?? $this->promptForCategory()) ?: 'all';
44+
45+
if ($selected !== 'all' && (bool) $this->argument('category') === false && ConsoleHelper::canUseLaravelPrompts($this->input)) {
46+
$this->infoComment(sprintf('Selected category [%s]', $selected));
5447
}
5548

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

59-
protected function publishOption(string $selected): void
60-
{
61-
Artisan::call('vendor:publish', [
62-
'--tag' => $this->options[$selected]['group'] ?? $selected,
63-
'--force' => true,
64-
], $this->output);
53+
return Command::FAILURE;
54+
}
55+
56+
$files = $selected === 'all'
57+
? collect($this->options)->flatMap(fn (ViewPublishGroup $option): array => $option->publishableFilesMap())->all()
58+
: $this->options[$selected]->publishableFilesMap();
59+
60+
$publisher = $this->publishSelectedFiles($files, $selected === 'all');
61+
62+
$this->infoComment($publisher->formatOutput($selected));
63+
64+
return Command::SUCCESS;
6565
}
6666

6767
protected function promptForCategory(): string
6868
{
69-
/** @var string $choice */
70-
$choice = $this->choice(
71-
'Which category do you want to publish?',
72-
$this->formatPublishableChoices(),
73-
0
69+
SelectPrompt::fallbackUsing(function (SelectPrompt $prompt): string {
70+
return $this->choice($prompt->label, $prompt->options, $prompt->default);
71+
});
72+
73+
return $this->parseChoiceIntoKey(
74+
select('Which category do you want to publish?', $this->formatPublishableChoices(), 0) ?: 'all'
7475
);
76+
}
7577

76-
$selection = $this->parseChoiceIntoKey($choice);
78+
protected function formatPublishableChoices(): array
79+
{
80+
return collect($this->options)
81+
->map(fn (ViewPublishGroup $option, string $key): string => sprintf('<comment>%s</comment>: %s', $key, $option->description))
82+
->prepend('Publish all categories listed below')
83+
->values()
84+
->all();
85+
}
7786

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

80-
return $selection;
92+
/**
93+
* @param array<string, \Hyde\Console\Helpers\ViewPublishGroup> $groups
94+
* @return array<string, \Hyde\Console\Helpers\ViewPublishGroup>
95+
*/
96+
protected static function mapToKeys(array $groups): array
97+
{
98+
return collect($groups)->mapWithKeys(function (ViewPublishGroup $group): array {
99+
return [Str::after($group->group, 'hyde-') => $group];
100+
})->all();
81101
}
82102

83-
protected function formatPublishableChoices(): array
103+
/** @param array<string, string> $files */
104+
protected function publishSelectedFiles(array $files, bool $isPublishingAll): InteractivePublishCommandHelper
84105
{
85-
$keys = ['Publish all categories listed below'];
86-
foreach ($this->options as $key => $option) {
87-
$keys[] = "<comment>$key</comment>: {$option['description']}";
106+
$publisher = new InteractivePublishCommandHelper($files);
107+
108+
if (! $isPublishingAll && ConsoleHelper::canUseLaravelPrompts($this->input)) {
109+
$publisher->only($this->promptUserForWhichFilesToPublish($publisher->getFileChoices()));
88110
}
89111

90-
return $keys;
112+
$publisher->publishFiles();
113+
114+
return $publisher;
91115
}
92116

93-
protected function parseChoiceIntoKey(string $choice): string
117+
/**
118+
* @param array<string, string> $files
119+
* @return array<string>
120+
*/
121+
protected function promptUserForWhichFilesToPublish(array $files): array
94122
{
95-
return strstr(str_replace(['<comment>', '</comment>'], '', $choice), ':', true) ?: '';
123+
$choices = array_merge(['all' => '<comment>All files</comment>'], $files);
124+
125+
$prompt = new MultiSelectPrompt('Select the files you want to publish', $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.');
126+
127+
$prompt->on('key', static::supportTogglingAll($prompt));
128+
129+
return (array) $prompt->prompt();
130+
}
131+
132+
protected static function supportTogglingAll(MultiSelectPrompt $prompt): Closure
133+
{
134+
return function (string $key) use ($prompt): void {
135+
static $isToggled = false;
136+
137+
if ($prompt->isHighlighted('all')) {
138+
if ($key === Key::SPACE) {
139+
$prompt->emit('key', Key::CTRL_A);
140+
141+
if ($isToggled) {
142+
// We need to emit CTRL+A twice to deselect all for some reason
143+
$prompt->emit('key', Key::CTRL_A);
144+
$isToggled = false;
145+
} else {
146+
$isToggled = true;
147+
}
148+
} elseif ($key === Key::ENTER) {
149+
if (! $isToggled) {
150+
$prompt->emit('key', Key::CTRL_A);
151+
}
152+
153+
$prompt->state = 'submit';
154+
}
155+
}
156+
};
96157
}
97158
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\Console\Helpers;
6+
7+
use Laravel\Prompts\Prompt;
8+
use Symfony\Component\Console\Input\InputInterface;
9+
10+
/**
11+
* @internal This class contains internal helpers for interacting with the console, and for easier testing.
12+
*
13+
* @codeCoverageIgnore This class provides internal testing helpers and does not need to be tested.
14+
*/
15+
class ConsoleHelper
16+
{
17+
/** Allows for mocking the Windows OS check. Remember to clear the mock after the test. */
18+
protected static ?bool $enableLaravelPrompts = null;
19+
20+
public static function clearMocks(): void
21+
{
22+
static::$enableLaravelPrompts = null;
23+
}
24+
25+
public static function disableLaravelPrompts(): void
26+
{
27+
static::$enableLaravelPrompts = false;
28+
}
29+
30+
public static function mockWindowsOs(bool $isWindowsOs): void
31+
{
32+
static::$enableLaravelPrompts = ! $isWindowsOs;
33+
}
34+
35+
public static function canUseLaravelPrompts(InputInterface $input): bool
36+
{
37+
if (static::$enableLaravelPrompts !== null) {
38+
return static::$enableLaravelPrompts;
39+
}
40+
41+
return $input->isInteractive() && windows_os() === false && Prompt::shouldFallback() === false;
42+
}
43+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\Console\Helpers;
6+
7+
use Hyde\Facades\Filesystem;
8+
use Illuminate\Support\Arr;
9+
use Illuminate\Support\Str;
10+
11+
/**
12+
* @internal This class offloads logic from the PublishViewsCommand class and should not be used elsewhere.
13+
*/
14+
class InteractivePublishCommandHelper
15+
{
16+
/** @var array<string, string> Map of source files to target files */
17+
protected array $publishableFilesMap;
18+
19+
protected readonly int $originalFileCount;
20+
21+
/** @param array<string, string> $publishableFilesMap */
22+
public function __construct(array $publishableFilesMap)
23+
{
24+
$this->publishableFilesMap = $publishableFilesMap;
25+
$this->originalFileCount = count($publishableFilesMap);
26+
}
27+
28+
/** @return array<string, string> */
29+
public function getFileChoices(): array
30+
{
31+
return Arr::mapWithKeys($this->publishableFilesMap, /** @return array<string, string> */ function (string $target, string $source): array {
32+
return [$source => $this->pathRelativeToDirectory($source, $this->getBaseDirectory())];
33+
});
34+
}
35+
36+
/**
37+
* Only publish the selected files.
38+
*
39+
* @param array<string> $selectedFiles Array of selected file paths, matching the keys of the publishableFilesMap.
40+
*/
41+
public function only(array $selectedFiles): void
42+
{
43+
$this->publishableFilesMap = Arr::only($this->publishableFilesMap, $selectedFiles);
44+
}
45+
46+
/** Find the most specific common parent directory path for the files, trimming as much as possible whilst keeping specificity and uniqueness. */
47+
public function getBaseDirectory(): string
48+
{
49+
$partsMap = collect($this->publishableFilesMap)->map(function (string $file): array {
50+
return explode('/', $file);
51+
});
52+
53+
$commonParts = $partsMap->reduce(function (array $carry, array $parts): array {
54+
return array_intersect($carry, $parts);
55+
}, $partsMap->first());
56+
57+
return implode('/', $commonParts);
58+
}
59+
60+
public function publishFiles(): void
61+
{
62+
foreach ($this->publishableFilesMap as $source => $target) {
63+
Filesystem::ensureDirectoryExists(dirname($target));
64+
Filesystem::copy($source, $target);
65+
}
66+
}
67+
68+
public function formatOutput(string $group): string
69+
{
70+
$fileCount = count($this->publishableFilesMap);
71+
$publishedOneFile = $fileCount === 1;
72+
$publishedAllGroups = $group === 'all';
73+
$publishedAllFiles = $fileCount === $this->originalFileCount;
74+
$selectedFilesModifier = $publishedAllFiles ? 'all' : 'selected';
75+
76+
return match (true) {
77+
$publishedAllGroups => sprintf('Published all %d files to [%s]', $fileCount, $this->getBaseDirectory()),
78+
$publishedOneFile => sprintf('Published selected file to [%s]', reset($this->publishableFilesMap)),
79+
default => sprintf('Published %s [%s] files to [%s]', $selectedFilesModifier, Str::singular($group), $this->getBaseDirectory())
80+
};
81+
}
82+
83+
protected function pathRelativeToDirectory(string $source, string $directory): string
84+
{
85+
return Str::after($source, basename($directory).'/');
86+
}
87+
}

0 commit comments

Comments
 (0)