diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 06eabb9c7ee..c459142fca2 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,8 @@ # CHANGELOG +## 2.32 +- New console command `ux:icons:manage` to manage local icons. + ## 2.30 - Ensure compatibility with PHP 8.5 diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php old mode 100644 new mode 100755 index 4b29a381f7a..47e32a8c44a --- a/src/Icons/config/services.php +++ b/src/Icons/config/services.php @@ -15,6 +15,7 @@ use Symfony\UX\Icons\Command\LockIconsCommand; use Symfony\UX\Icons\Command\SearchIconCommand; use Symfony\UX\Icons\Command\WarmCacheCommand; +use Symfony\UX\Icons\Command\ManageIconsCommand; use Symfony\UX\Icons\IconCacheWarmer; use Symfony\UX\Icons\Iconify; use Symfony\UX\Icons\IconRenderer; @@ -119,6 +120,12 @@ service('.ux_icons.icon_finder'), ]) ->tag('console.command') + ->set('.ux_icons.command.cleanup', ManageIconsCommand::class) + ->args([ + service('.ux_icons.local_svg_icon_registry'), + service('.ux_icons.icon_finder'), + ]) + ->tag('console.command') ->set('.ux_icons.command.search', SearchIconCommand::class) ->args([ diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 8f7fc770e90..1029750c39a 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -443,6 +443,45 @@ Now, you can use the ``dots`` alias in your templates: {# same as: #} +Manage Icons +------------ + +The ``ux:icons:manage`` console command helps you list, group and remove local icons. +It is especially useful for cleaning up unused icons and managing icon sets. + +Usages +^^^^^^ + +.. code-block:: terminal + + # Lists all icons found in the local icon directory.Can be combined with `--table` or `--group` + $ php bin/console ux:icons:manage --list + + # List all unused Icons in templates. Can be combined with `--table` or `--group` + $ php bin/console ux:icons:manage --list --unused + + # Removes a specific icon by name (2 methods) + $ php bin/console ux:icons:manage --remove=flowbite:user-solid + $ php bin/console ux:icons:manage --remove user-profile + + # Deletes all icons that are not detected as used in templates. (Use with caution) + $ php bin/console ux:icons:manage --remove --unused + + # Deletes *all* local icons from the directory. (Use with extreme caution) + $ php bin/console ux:icons:manage --remove-all + +.. caution:: + + Dynamic icon usages like: + + .. code-block:: twig + + {{ ux_icon('flag-' ~ locale) }} + + cannot be automatically detected by the command. + These may appear as *unused* even though they are in use. + Always review before removing unused icons. + Errors ------ diff --git a/src/Icons/src/Command/ManageIconsCommand.php b/src/Icons/src/Command/ManageIconsCommand.php new file mode 100755 index 00000000000..3844b19f66b --- /dev/null +++ b/src/Icons/src/Command/ManageIconsCommand.php @@ -0,0 +1,233 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; +use Symfony\UX\Icons\Twig\IconFinderInterface; + +#[AsCommand( + name: 'ux:icons:manage', + description: 'List, group or remove icons from your local icon directory.', +)] +final class ManageIconsCommand extends Command +{ + public function __construct( + private readonly LocalSvgIconRegistry $localRegistry, + private readonly IconFinderInterface $iconFinder, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setHelp(<<<'HELP' + The %command.name% command helps you manage locally stored icons. + + Note: + Icons used dynamically, such as {{ ux_icon('flag-' ~ locale) }}, may appear as unused even if they are rendered at runtime. + + Usage: + php %command.full_name% [options] + + Options: + --list List all icons + --group Group icons by set (use with --list) + --unused Filter only unused icons (use with --list or --remove) + --table Show listing in table format (use with --list) + --remove[=ICON] Remove a specific icon by name, or use with --unused to delete all unused icons + --remove-all Delete all local icons from the directory (use with caution) + + Examples: + php %command.full_name% --list + php %command.full_name% --list --group + php %command.full_name% --list --unused + php %command.full_name% --list --group --unused + php %command.full_name% --list --table + php %command.full_name% --remove bi:clock + php %command.full_name% --remove --unused + php %command.full_name% --remove-all + HELP + ) + ->addOption('list', null, InputOption::VALUE_NONE, 'List icons.') + ->addOption('group', null, InputOption::VALUE_NONE, 'Group icons by set when listing.') + ->addOption('unused', null, InputOption::VALUE_NONE, 'Filter unused icons.') + ->addOption('table', null, InputOption::VALUE_NONE, 'Show listing in table format.') + ->addOption('remove', null, InputOption::VALUE_OPTIONAL, 'Remove a specific icon or use with --unused to delete all unused icons.') + ->addOption('remove-all', null, InputOption::VALUE_NONE, 'Delete all icons from the local directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // Get icon directory + $ref = new \ReflectionProperty($this->localRegistry, 'iconDir'); + $iconDir = str_replace('\\', '/', $ref->getValue($this->localRegistry)); + + if (!is_dir($iconDir)) { + $io->error("Icon directory not found: {$iconDir}"); + + return Command::FAILURE; + } + + $list = $input->getOption('list'); + $group = $input->getOption('group'); + $unusedOnly = $input->getOption('unused'); + $tableMode = $input->getOption('table'); + $remove = $input->getOption('remove'); + $removeAll = $input->getOption('remove-all'); + + // Scan local icons if needed + $localIcons = []; + if ($list || $removeAll || ($remove && $unusedOnly) || $remove) { + $finder = new Finder(); + $finder->files()->in($iconDir)->name('*.svg'); + foreach ($finder as $file) { + $relative = str_replace('\\', '/', $file->getRelativePathname()); + $iconName = str_replace('/', ':', preg_replace('#\.svg$#', '', $relative)); + $localIcons[$iconName] = $file->getRealPath(); + } + + if (empty($localIcons)) { + $io->warning('No local icons found.'); + + return Command::SUCCESS; + } + } + + // Find used icons if needed + $usedIcons = ($list || ($remove && $unusedOnly)) ? $this->iconFinder->iconsInTemplates() : []; + $unusedIcons = array_diff_key($localIcons, array_flip($usedIcons)); + + // Listing + if ($list) { + $title = $unusedOnly ? 'Unused Icons Listing' : 'All Icons Listing'; + $io->title($title); + + $iconsToShow = $unusedOnly ? $unusedIcons : $localIcons; + + if (empty($iconsToShow)) { + $io->success($unusedOnly ? 'No unused icons found.' : 'No icons found.'); + + return Command::SUCCESS; + } + + if ($tableMode) { + $table = new Table($output); + $table->setHeaders(['Set', 'Icon Name', 'Status']); + foreach ($iconsToShow as $icon => $path) { + $parts = explode(':', $icon, 2); + $set = isset($parts[1]) ? $parts[0] : '-'; + $status = isset($unusedIcons[$icon]) ? 'Unused' : 'Used'; + $table->addRow([$set, $icon, $status]); + } + $table->render(); + } else { + // Group + if ($group) { + $grouped = []; + foreach ($iconsToShow as $icon => $path) { + $parts = explode(':', $icon, 2); + $set = isset($parts[1]) ? $parts[0] : '-'; + $grouped[$set][] = $icon; + } + + foreach ($grouped as $set => $icons) { + $io->section("Set: $set"); + foreach ($icons as $icon) { + $color = isset($unusedIcons[$icon]) ? 'red' : 'green'; + $io->writeln(" - {$icon}"); + } + $io->newLine(); + } + } else { + foreach ($iconsToShow as $icon => $path) { + $color = isset($unusedIcons[$icon]) ? 'red' : 'green'; + $io->writeln(" - {$icon}"); + } + } + } + + $io->note(\sprintf('%d %s icons found.', \count($iconsToShow), $unusedOnly ? 'unused' : 'total')); + + return Command::SUCCESS; + } + + // Remove all + if ($removeAll) { + if ($io->confirm('Are you sure you want to delete ALL icons?', false)) { + foreach ($localIcons as $icon => $path) { + @unlink($path); + } + $io->success('All icons have been deleted.'); + } else { + $io->info('Operation cancelled.'); + } + + return Command::SUCCESS; + } + + // Remove unused + if ($remove && $unusedOnly) { + if (empty($unusedIcons)) { + $io->success('No unused icons to delete.'); + + return Command::SUCCESS; + } + + if ($io->confirm(\sprintf('Delete all %d unused icons?', \count($unusedIcons)), true)) { + foreach ($unusedIcons as $icon => $path) { + @unlink($path); + } + $io->success('All unused icons deleted.'); + } else { + $io->info('Operation cancelled.'); + } + + return Command::SUCCESS; + } + + // Remove single icon + if ($remove && !$unusedOnly) { + $target = str_replace(':', '/', $remove); + $iconPath = "{$iconDir}/{$target}.svg"; + + if (!file_exists($iconPath)) { + $io->warning("Icon not found: {$remove}"); + + return Command::SUCCESS; + } + + if ($io->confirm("Are you sure you want to delete '{$remove}'?", false)) { + @unlink($iconPath); + $io->success("Deleted icon: {$remove}"); + } else { + $io->info('Operation cancelled.'); + } + + return Command::SUCCESS; + } + + $io->note('Use --list, --remove, or --remove-all options to manage your icons.'); + + return Command::SUCCESS; + } +} diff --git a/src/Icons/src/Twig/IconFinder.php b/src/Icons/src/Twig/IconFinder.php old mode 100644 new mode 100755 index a2b6844221d..fb0ec263a66 --- a/src/Icons/src/Twig/IconFinder.php +++ b/src/Icons/src/Twig/IconFinder.php @@ -22,7 +22,7 @@ * * @internal */ -final class IconFinder +final class IconFinder implements IconFinderInterface { public function __construct( private Environment $twig, @@ -61,6 +61,44 @@ public function icons(): array return array_unique($found); } + /** + * @return string[] + */ + public function iconsInTemplates(): array + { + $found = []; + + // part: starts with alnum, may contain -, _, . thereafter + $part = '[A-Za-z0-9][A-Za-z0-9_\\-\\.]*'; + + // full token: either "part" or "part:part" (single optional colon) + $token = $part.'(?:\:'.$part.')?'; + + // 1) ux_icon('name' ...) or ux_icon("name" ...) + $patternFunction = '/ux_icon\(\s*[\'"]('.$token.')[\'"]/i'; + + // 2) or name='name' + $patternTag = '/]*\bname\s*=\s*[\'"]('.$token.')[\'"][^>]*>/i'; + + foreach ($this->templateFiles($this->twig->getLoader()) as $file) { + $contents = file_get_contents($file); + + if (preg_match_all($patternFunction, $contents, $matchesFunc) && !empty($matchesFunc[1])) { + foreach ($matchesFunc[1] as $m) { + $found[] = $m; + } + } + + if (preg_match_all($patternTag, $contents, $matchesTag) && !empty($matchesTag[1])) { + foreach ($matchesTag[1] as $m) { + $found[] = $m; + } + } + } + + return array_values(array_unique($found)); + } + /** * @return string[] */ diff --git a/src/Icons/src/Twig/IconFinderInterface.php b/src/Icons/src/Twig/IconFinderInterface.php new file mode 100644 index 00000000000..10613b7074d --- /dev/null +++ b/src/Icons/src/Twig/IconFinderInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Twig; + +interface IconFinderInterface +{ + /** + * @return array + */ + public function icons(): array; + + /** + * @return array + */ + public function iconsInTemplates(): array; +} diff --git a/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php b/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php new file mode 100755 index 00000000000..b2ccd2d27a0 --- /dev/null +++ b/src/Icons/tests/Integration/Command/ManageIconsCommandTest.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Tests\Integration\Command; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\UX\Icons\Command\ManageIconsCommand; +use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; +use Symfony\UX\Icons\Twig\IconFinderInterface; + +class ManageIconsCommandTest extends KernelTestCase +{ + private string $iconDir; + private LocalSvgIconRegistry $localRegistry; + private IconFinderInterface $iconFinder; + + protected function setUp(): void + { + $this->iconDir = sys_get_temp_dir().'/icons_test_'.uniqid(); + + if (!is_dir($this->iconDir.'/bi')) { + mkdir($this->iconDir.'/bi', 0o777, true); + } + if (!is_dir($this->iconDir.'/fa')) { + mkdir($this->iconDir.'/fa', 0o777, true); + } + + file_put_contents($this->iconDir.'/bi/clock.svg', ''); + file_put_contents($this->iconDir.'/fa/flag.svg', ''); + + // LocalSvgIconRegistry + $this->localRegistry = new LocalSvgIconRegistry($this->iconDir); + + $this->iconFinder = $this->createMock(IconFinderInterface::class); + $this->iconFinder->method('iconsInTemplates')->willReturn([ + 'bi:clock', + ]); + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->iconDir.'/*/*.svg')); + @rmdir($this->iconDir.'/bi'); + @rmdir($this->iconDir.'/fa'); + @rmdir($this->iconDir); + } + + public function testListIcons() + { + $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder); + $tester = new CommandTester($command); + + $tester->execute([ + '--list' => true, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('All Icons Listing', $output); + $this->assertStringContainsString('bi:clock', $output); + $this->assertStringContainsString('fa:flag', $output); + } + + public function testListUnusedIcons() + { + $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder); + $tester = new CommandTester($command); + + $tester->execute([ + '--list' => true, + '--unused' => true, + ]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Unused Icons Listing', $output); + $this->assertStringContainsString('fa:flag', $output); + $this->assertStringNotContainsString('bi:clock', $output); + } + + public function testRemoveUnusedIcons() + { + $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder); + $tester = new CommandTester($command); + + $this->assertFileExists($this->iconDir.'/fa/flag.svg'); + + $tester->setInputs(['yes']); + $tester->execute([ + '--remove' => true, + '--unused' => true, + ]); + + $this->assertStringContainsString('All unused icons deleted.', $tester->getDisplay()); + $this->assertFileDoesNotExist($this->iconDir.'/fa/flag.svg'); + } + + public function testRemoveSingleIcon() + { + $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder); + $tester = new CommandTester($command); + + $this->assertFileExists($this->iconDir.'/bi/clock.svg'); + + $tester->setInputs(['yes']); + $tester->execute([ + '--remove' => 'bi:clock', + ]); + + $this->assertStringContainsString('Deleted icon: bi:clock', $tester->getDisplay()); + $this->assertFileDoesNotExist($this->iconDir.'/bi/clock.svg'); + } + + public function testRemoveAllIcons() + { + $command = new ManageIconsCommand($this->localRegistry, $this->iconFinder); + $tester = new CommandTester($command); + + $tester->setInputs(['yes']); + $tester->execute([ + '--remove-all' => true, + ]); + + $this->assertStringContainsString('All icons have been deleted.', $tester->getDisplay()); + $this->assertFileDoesNotExist($this->iconDir.'/bi/clock.svg'); + $this->assertFileDoesNotExist($this->iconDir.'/fa/flag.svg'); + } +}