Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions src/Icons/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/Icons/config/services.php
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
39 changes: 39 additions & 0 deletions src/Icons/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,45 @@ Now, you can use the ``dots`` alias in your templates:
{# same as: #}
<twig:ux:icon name="clarity:ellipsis-horizontal-line" />

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
------

Expand Down
233 changes: 233 additions & 0 deletions src/Icons/src/Command/ManageIconsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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 <info>%command.name%</info> 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]) ? '<fg=red>Unused</>' : '<fg=green>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: <info>$set</info>");
foreach ($icons as $icon) {
$color = isset($unusedIcons[$icon]) ? 'red' : 'green';
$io->writeln(" - <fg={$color}>{$icon}</>");
}
$io->newLine();
}
} else {
foreach ($iconsToShow as $icon => $path) {
$color = isset($unusedIcons[$icon]) ? 'red' : 'green';
$io->writeln(" - <fg={$color}>{$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;
}
}
40 changes: 39 additions & 1 deletion src/Icons/src/Twig/IconFinder.php
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
* @internal
*/
final class IconFinder
final class IconFinder implements IconFinderInterface
{
public function __construct(
private Environment $twig,
Expand Down Expand Up @@ -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) <twig:ux:icon ... name="name" ...> or name='name'
$patternTag = '/<twig:ux:icon\b[^>]*\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[]
*/
Expand Down
25 changes: 25 additions & 0 deletions src/Icons/src/Twig/IconFinderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;
}
Loading
Loading