Skip to content

Commit 9b3eb2d

Browse files
committed
New console command to manage local icons.
1 parent 9045a17 commit 9b3eb2d

File tree

7 files changed

+481
-1
lines changed

7 files changed

+481
-1
lines changed

src/Icons/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# CHANGELOG
22

3+
## 2.32
4+
- New console command `ux:icons:manage` to manage local icons.
5+
36
## 2.30
47

58
- Ensure compatibility with PHP 8.5

src/Icons/config/services.php

100644100755
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\UX\Icons\Command\LockIconsCommand;
1616
use Symfony\UX\Icons\Command\SearchIconCommand;
1717
use Symfony\UX\Icons\Command\WarmCacheCommand;
18+
use Symfony\UX\Icons\Command\ManageIconsCommand;
1819
use Symfony\UX\Icons\IconCacheWarmer;
1920
use Symfony\UX\Icons\Iconify;
2021
use Symfony\UX\Icons\IconRenderer;
@@ -119,6 +120,12 @@
119120
service('.ux_icons.icon_finder'),
120121
])
121122
->tag('console.command')
123+
->set('.ux_icons.command.cleanup', ManageIconsCommand::class)
124+
->args([
125+
service('.ux_icons.local_svg_icon_registry'),
126+
service('.ux_icons.icon_finder'),
127+
])
128+
->tag('console.command')
122129

123130
->set('.ux_icons.command.search', SearchIconCommand::class)
124131
->args([

src/Icons/doc/index.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,45 @@ Now, you can use the ``dots`` alias in your templates:
443443
{# same as: #}
444444
<twig:ux:icon name="clarity:ellipsis-horizontal-line" />
445445

446+
Manage Icons
447+
------------
448+
449+
The ``ux:icons:manage`` console command helps you list, group and remove local icons.
450+
It is especially useful for cleaning up unused icons and managing icon sets.
451+
452+
Usages
453+
^^^^^^
454+
455+
.. code-block:: terminal
456+
457+
# Lists all icons found in the local icon directory.Can be combined with `--table` or `--group`
458+
$ php bin/console ux:icons:manage --list
459+
460+
# List all unused Icons in templates. Can be combined with `--table` or `--group`
461+
$ php bin/console ux:icons:manage --list --unused
462+
463+
# Removes a specific icon by name (2 methods)
464+
$ php bin/console ux:icons:manage --remove=flowbite:user-solid
465+
$ php bin/console ux:icons:manage --remove user-profile
466+
467+
# Deletes all icons that are not detected as used in templates. (Use with caution)
468+
$ php bin/console ux:icons:manage --remove --unused
469+
470+
# Deletes *all* local icons from the directory. (Use with extreme caution)
471+
$ php bin/console ux:icons:manage --remove-all
472+
473+
.. caution::
474+
475+
Dynamic icon usages like:
476+
477+
.. code-block:: twig
478+
479+
{{ ux_icon('flag-' ~ locale) }}
480+
481+
cannot be automatically detected by the command.
482+
These may appear as *unused* even though they are in use.
483+
Always review before removing unused icons.
484+
446485
Errors
447486
------
448487

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Icons\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Helper\Table;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\Component\Finder\Finder;
22+
use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
23+
use Symfony\UX\Icons\Twig\IconFinderInterface;
24+
25+
#[AsCommand(
26+
name: 'ux:icons:manage',
27+
description: 'List, group or remove icons from your local icon directory.',
28+
)]
29+
final class ManageIconsCommand extends Command
30+
{
31+
public function __construct(
32+
private readonly LocalSvgIconRegistry $localRegistry,
33+
private readonly IconFinderInterface $iconFinder,
34+
) {
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this
41+
->setHelp(<<<'HELP'
42+
The <info>%command.name%</info> command helps you manage locally stored icons.
43+
44+
Note:
45+
Icons used dynamically, such as {{ ux_icon('flag-' ~ locale) }}, may appear as unused even if they are rendered at runtime.
46+
47+
Usage:
48+
php %command.full_name% [options]
49+
50+
Options:
51+
--list List all icons
52+
--group Group icons by set (use with --list)
53+
--unused Filter only unused icons (use with --list or --remove)
54+
--table Show listing in table format (use with --list)
55+
--remove[=ICON] Remove a specific icon by name, or use with --unused to delete all unused icons
56+
--remove-all Delete all local icons from the directory (use with caution)
57+
58+
Examples:
59+
php %command.full_name% --list
60+
php %command.full_name% --list --group
61+
php %command.full_name% --list --unused
62+
php %command.full_name% --list --group --unused
63+
php %command.full_name% --list --table
64+
php %command.full_name% --remove bi:clock
65+
php %command.full_name% --remove --unused
66+
php %command.full_name% --remove-all
67+
HELP
68+
)
69+
->addOption('list', null, InputOption::VALUE_NONE, 'List icons.')
70+
->addOption('group', null, InputOption::VALUE_NONE, 'Group icons by set when listing.')
71+
->addOption('unused', null, InputOption::VALUE_NONE, 'Filter unused icons.')
72+
->addOption('table', null, InputOption::VALUE_NONE, 'Show listing in table format.')
73+
->addOption('remove', null, InputOption::VALUE_OPTIONAL, 'Remove a specific icon or use with --unused to delete all unused icons.')
74+
->addOption('remove-all', null, InputOption::VALUE_NONE, 'Delete all icons from the local directory.');
75+
}
76+
77+
protected function execute(InputInterface $input, OutputInterface $output): int
78+
{
79+
$io = new SymfonyStyle($input, $output);
80+
81+
// Get icon directory
82+
$ref = new \ReflectionProperty($this->localRegistry, 'iconDir');
83+
$iconDir = str_replace('\\', '/', $ref->getValue($this->localRegistry));
84+
85+
if (!is_dir($iconDir)) {
86+
$io->error("Icon directory not found: {$iconDir}");
87+
88+
return Command::FAILURE;
89+
}
90+
91+
$list = $input->getOption('list');
92+
$group = $input->getOption('group');
93+
$unusedOnly = $input->getOption('unused');
94+
$tableMode = $input->getOption('table');
95+
$remove = $input->getOption('remove');
96+
$removeAll = $input->getOption('remove-all');
97+
98+
// Scan local icons if needed
99+
$localIcons = [];
100+
if ($list || $removeAll || ($remove && $unusedOnly) || $remove) {
101+
$finder = new Finder();
102+
$finder->files()->in($iconDir)->name('*.svg');
103+
foreach ($finder as $file) {
104+
$relative = str_replace('\\', '/', $file->getRelativePathname());
105+
$iconName = str_replace('/', ':', preg_replace('#\.svg$#', '', $relative));
106+
$localIcons[$iconName] = $file->getRealPath();
107+
}
108+
109+
if (empty($localIcons)) {
110+
$io->warning('No local icons found.');
111+
112+
return Command::SUCCESS;
113+
}
114+
}
115+
116+
// Find used icons if needed
117+
$usedIcons = ($list || ($remove && $unusedOnly)) ? $this->iconFinder->iconsInTemplates() : [];
118+
$unusedIcons = array_diff_key($localIcons, array_flip($usedIcons));
119+
120+
// Listing
121+
if ($list) {
122+
$title = $unusedOnly ? 'Unused Icons Listing' : 'All Icons Listing';
123+
$io->title($title);
124+
125+
$iconsToShow = $unusedOnly ? $unusedIcons : $localIcons;
126+
127+
if (empty($iconsToShow)) {
128+
$io->success($unusedOnly ? 'No unused icons found.' : 'No icons found.');
129+
130+
return Command::SUCCESS;
131+
}
132+
133+
if ($tableMode) {
134+
$table = new Table($output);
135+
$table->setHeaders(['Set', 'Icon Name', 'Status']);
136+
foreach ($iconsToShow as $icon => $path) {
137+
$parts = explode(':', $icon, 2);
138+
$set = isset($parts[1]) ? $parts[0] : '-';
139+
$status = isset($unusedIcons[$icon]) ? '<fg=red>Unused</>' : '<fg=green>Used</>';
140+
$table->addRow([$set, $icon, $status]);
141+
}
142+
$table->render();
143+
} else {
144+
// Group
145+
if ($group) {
146+
$grouped = [];
147+
foreach ($iconsToShow as $icon => $path) {
148+
$parts = explode(':', $icon, 2);
149+
$set = isset($parts[1]) ? $parts[0] : '-';
150+
$grouped[$set][] = $icon;
151+
}
152+
153+
foreach ($grouped as $set => $icons) {
154+
$io->section("Set: <info>$set</info>");
155+
foreach ($icons as $icon) {
156+
$color = isset($unusedIcons[$icon]) ? 'red' : 'green';
157+
$io->writeln(" - <fg={$color}>{$icon}</>");
158+
}
159+
$io->newLine();
160+
}
161+
} else {
162+
foreach ($iconsToShow as $icon => $path) {
163+
$color = isset($unusedIcons[$icon]) ? 'red' : 'green';
164+
$io->writeln(" - <fg={$color}>{$icon}</>");
165+
}
166+
}
167+
}
168+
169+
$io->note(\sprintf('%d %s icons found.', \count($iconsToShow), $unusedOnly ? 'unused' : 'total'));
170+
171+
return Command::SUCCESS;
172+
}
173+
174+
// Remove all
175+
if ($removeAll) {
176+
if ($io->confirm('Are you sure you want to delete ALL icons?', false)) {
177+
foreach ($localIcons as $icon => $path) {
178+
@unlink($path);
179+
}
180+
$io->success('All icons have been deleted.');
181+
} else {
182+
$io->info('Operation cancelled.');
183+
}
184+
185+
return Command::SUCCESS;
186+
}
187+
188+
// Remove unused
189+
if ($remove && $unusedOnly) {
190+
if (empty($unusedIcons)) {
191+
$io->success('No unused icons to delete.');
192+
193+
return Command::SUCCESS;
194+
}
195+
196+
if ($io->confirm(\sprintf('Delete all %d unused icons?', \count($unusedIcons)), true)) {
197+
foreach ($unusedIcons as $icon => $path) {
198+
@unlink($path);
199+
}
200+
$io->success('All unused icons deleted.');
201+
} else {
202+
$io->info('Operation cancelled.');
203+
}
204+
205+
return Command::SUCCESS;
206+
}
207+
208+
// Remove single icon
209+
if ($remove && !$unusedOnly) {
210+
$target = str_replace(':', '/', $remove);
211+
$iconPath = "{$iconDir}/{$target}.svg";
212+
213+
if (!file_exists($iconPath)) {
214+
$io->warning("Icon not found: {$remove}");
215+
216+
return Command::SUCCESS;
217+
}
218+
219+
if ($io->confirm("Are you sure you want to delete '{$remove}'?", false)) {
220+
@unlink($iconPath);
221+
$io->success("Deleted icon: {$remove}");
222+
} else {
223+
$io->info('Operation cancelled.');
224+
}
225+
226+
return Command::SUCCESS;
227+
}
228+
229+
$io->note('Use --list, --remove, or --remove-all options to manage your icons.');
230+
231+
return Command::SUCCESS;
232+
}
233+
}

src/Icons/src/Twig/IconFinder.php

100644100755
Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
* @internal
2424
*/
25-
final class IconFinder
25+
final class IconFinder implements IconFinderInterface
2626
{
2727
public function __construct(
2828
private Environment $twig,
@@ -61,6 +61,44 @@ public function icons(): array
6161
return array_unique($found);
6262
}
6363

64+
/**
65+
* @return string[]
66+
*/
67+
public function iconsInTemplates(): array
68+
{
69+
$found = [];
70+
71+
// part: starts with alnum, may contain -, _, . thereafter
72+
$part = '[A-Za-z0-9][A-Za-z0-9_\\-\\.]*';
73+
74+
// full token: either "part" or "part:part" (single optional colon)
75+
$token = $part.'(?:\:'.$part.')?';
76+
77+
// 1) ux_icon('name' ...) or ux_icon("name" ...)
78+
$patternFunction = '/ux_icon\(\s*[\'"]('.$token.')[\'"]/i';
79+
80+
// 2) <twig:ux:icon ... name="name" ...> or name='name'
81+
$patternTag = '/<twig:ux:icon\b[^>]*\bname\s*=\s*[\'"]('.$token.')[\'"][^>]*>/i';
82+
83+
foreach ($this->templateFiles($this->twig->getLoader()) as $file) {
84+
$contents = file_get_contents($file);
85+
86+
if (preg_match_all($patternFunction, $contents, $matchesFunc) && !empty($matchesFunc[1])) {
87+
foreach ($matchesFunc[1] as $m) {
88+
$found[] = $m;
89+
}
90+
}
91+
92+
if (preg_match_all($patternTag, $contents, $matchesTag) && !empty($matchesTag[1])) {
93+
foreach ($matchesTag[1] as $m) {
94+
$found[] = $m;
95+
}
96+
}
97+
}
98+
99+
return array_values(array_unique($found));
100+
}
101+
64102
/**
65103
* @return string[]
66104
*/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Icons\Twig;
13+
14+
interface IconFinderInterface
15+
{
16+
/**
17+
* @return array
18+
*/
19+
public function icons(): array;
20+
21+
/**
22+
* @return array
23+
*/
24+
public function iconsInTemplates(): array;
25+
}

0 commit comments

Comments
 (0)