Skip to content

Commit d083168

Browse files
committed
add ux:icons:import command
1 parent e2a3e24 commit d083168

File tree

9 files changed

+242
-0
lines changed

9 files changed

+242
-0
lines changed

src/Icons/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ composer require symfony/ux-icons
1111
No icons are provided by this package. Add your svg icons to the `assets/icons/` directory and commit them.
1212
The name of the file is used as the name of the icon (`name.svg` will be named `name`).
1313

14+
### Import Command
15+
16+
The [Iconify Design](https://iconify.design/) has a huge searchable repository of icons from
17+
many different icon sets. This package provides a command to locally install icons from this
18+
site.
19+
20+
1. Visit [Iconify Design](https://icon-sets.iconify.design/) and search for an icon
21+
you'd like to use. Once you find one you like, visit the icon's profile page and use the widget
22+
to copy its name. For instance, https://icon-sets.iconify.design/flowbite/user-solid/ has the name
23+
`flowbite:user-solid`.
24+
2. Run the following command, replacing `flowbite:user-solid` with the name of the icon you'd like
25+
to install:
26+
27+
```bash
28+
bin/console ux:icons:import flowbite:user-solid # saved as `user-solid.svg` and name is `user-solid`
29+
30+
# adjust the local name
31+
bin/console ux:icons:import flowbite:user-solid@user # saved as `user.svg` and name is `user`
32+
33+
# import several at a time
34+
bin/console ux:icons:import flowbite:user-solid flowbite:home-solid
35+
```
36+
1437
## Usage
1538

1639
```twig

src/Icons/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
"require-dev": {
3535
"symfony/console": "^6.4|^7.0",
36+
"symfony/http-client": "6.4|^7.0",
3637
"symfony/phpunit-bridge": "^6.3|^7.0",
3738
"symfony/ux-twig-component": "^2.14",
3839
"zenstruck/console-test": "^1.5"

src/Icons/config/services.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

14+
use Symfony\UX\Icons\Command\ImportIconCommand;
15+
use Symfony\UX\Icons\Iconify;
1416
use Symfony\UX\Icons\IconRenderer;
1517
use Symfony\UX\Icons\Registry\CacheIconRegistry;
1618
use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
@@ -54,5 +56,17 @@
5456

5557
->set('.ux_icons.twig_component.icon', UXIconComponent::class)
5658
->tag('twig.component', ['key' => 'UX:Icon'])
59+
60+
->set('.ux_icons.iconify', Iconify::class)
61+
->args([
62+
service('http_client')->nullOnInvalid(),
63+
])
64+
65+
->set('.ux_icons.command.import', ImportIconCommand::class)
66+
->args([
67+
service('.ux_icons.iconify'),
68+
service('.ux_icons.local_svg_icon_registry'),
69+
])
70+
->tag('console.command')
5771
;
5872
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace Symfony\UX\Icons\Command;
4+
5+
use Symfony\Component\Console\Attribute\AsCommand;
6+
use Symfony\Component\Console\Command\Command;
7+
use Symfony\Component\Console\Input\InputArgument;
8+
use Symfony\Component\Console\Input\InputInterface;
9+
use Symfony\Component\Console\Output\OutputInterface;
10+
use Symfony\Component\Console\Style\SymfonyStyle;
11+
use Symfony\UX\Icons\Exception\IconNotFoundException;
12+
use Symfony\UX\Icons\Iconify;
13+
use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
14+
15+
/**
16+
* @author Kevin Bond <[email protected]>
17+
*
18+
* @internal
19+
*/
20+
#[AsCommand(
21+
name: 'ux:icons:import',
22+
description: 'Import icon(s) from iconify.design',
23+
)]
24+
final class ImportIconCommand extends Command
25+
{
26+
public function __construct(private Iconify $iconify, private LocalSvgIconRegistry $registry)
27+
{
28+
parent::__construct();
29+
}
30+
31+
protected function configure(): void
32+
{
33+
$this
34+
->addArgument(
35+
'names',
36+
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
37+
'Icon name from iconify.design (suffix with "@<name>" to rename locally)',
38+
)
39+
;
40+
}
41+
42+
protected function execute(InputInterface $input, OutputInterface $output): int
43+
{
44+
$io = new SymfonyStyle($input, $output);
45+
$names = $input->getArgument('names');
46+
$result = Command::SUCCESS;
47+
48+
foreach ($names as $name) {
49+
if (!preg_match('#^(([\w-]+):([\w-]+))(@([\w-]+))?$#', $name, $matches)) {
50+
$io->error(sprintf('Invalid icon name "%s".', $name));
51+
$result = Command::FAILURE;
52+
53+
continue;
54+
}
55+
56+
[,,$prefix, $name] = $matches;
57+
$localName = $matches[5] ?? $name;
58+
59+
$io->comment(sprintf('Importing <info>%s:%s</info> as <info>%s</info>...', $prefix, $name, $localName));
60+
61+
try {
62+
$svg = $this->iconify->fetchSvg($prefix, $name);
63+
} catch (IconNotFoundException $e) {
64+
$io->error($e->getMessage());
65+
$result = Command::FAILURE;
66+
67+
continue;
68+
}
69+
70+
$this->registry->add($localName, $svg);
71+
72+
$io->text(sprintf("<info>Imported Icon</info>, render with <comment>{{ ux_icon('%s') }}</comment>.", $localName));
73+
$io->newLine();
74+
}
75+
76+
return $result;
77+
}
78+
}

src/Icons/src/DependencyInjection/UXIconsExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,9 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
6565
$container->getDefinition('.ux_icons.icon_renderer')
6666
->setArgument(1, $mergedConfig['default_icon_attributes'])
6767
;
68+
69+
if (!$container->getParameter('kernel.debug')) {
70+
$container->removeDefinition('.ux_icons.command.import');
71+
}
6872
}
6973
}

src/Icons/src/Iconify.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Symfony\UX\Icons;
4+
5+
use Symfony\Contracts\HttpClient\HttpClientInterface;
6+
use Symfony\UX\Icons\Exception\IconNotFoundException;
7+
8+
/**
9+
* @author Kevin Bond <[email protected]>
10+
*
11+
* @internal
12+
*/
13+
final class Iconify
14+
{
15+
public function __construct(private ?HttpClientInterface $httpClient = null)
16+
{
17+
}
18+
19+
public function fetchSvg(string $prefix, string $name): string
20+
{
21+
$content = $this->http()
22+
->request('GET', sprintf('https://api.iconify.design/%s/%s.svg', $prefix, $name))
23+
->getContent()
24+
;
25+
26+
if (!str_starts_with($content, '<svg')) {
27+
throw new IconNotFoundException(sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name));
28+
}
29+
30+
return $content;
31+
}
32+
33+
private function http(): HttpClientInterface
34+
{
35+
return $this->httpClient ?? throw new \LogicException('You must install "symfony/http-client" to import icons. Try running "composer require symfony/http-client".');
36+
}
37+
}

src/Icons/src/Registry/LocalSvgIconRegistry.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\UX\Icons\Registry;
1313

14+
use Symfony\Component\Filesystem\Filesystem;
1415
use Symfony\Component\Finder\Finder;
1516
use Symfony\UX\Icons\Exception\IconNotFoundException;
1617
use Symfony\UX\Icons\IconRegistryInterface;
@@ -75,6 +76,13 @@ public function get(string $name): Icon
7576
return new Icon($innerSvg, $attributes);
7677
}
7778

79+
public function add(string $name, string $svg): void
80+
{
81+
$filename = sprintf('%s/%s.svg', $this->iconDir, $name);
82+
83+
(new Filesystem())->dumpFile($filename, $svg);
84+
}
85+
7886
public function getIterator(): \Traversable
7987
{
8088
foreach ($this->finder()->sortByName() as $file) {

src/Icons/tests/Fixtures/TestKernel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ protected function configureContainer(ContainerConfigurator $c): void
3636
'http_method_override' => false,
3737
'php_errors' => ['log' => true],
3838
'property_access' => true,
39+
'http_client' => true,
3940
]);
4041

4142
$c->extension('twig_component', [
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace Symfony\UX\Icons\Tests\Integration\Command;
4+
5+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
6+
use Symfony\Component\Filesystem\Filesystem;
7+
use Zenstruck\Console\Test\InteractsWithConsole;
8+
9+
/**
10+
* @author Kevin Bond <[email protected]>
11+
*/
12+
final class ImportIconCommandTest extends KernelTestCase
13+
{
14+
use InteractsWithConsole;
15+
16+
private const ICON_DIR = __DIR__.'/../../Fixtures/icons';
17+
private const ICONS = ['dashboard.svg', 'renamed.svg'];
18+
19+
/**
20+
* @before
21+
*
22+
* @after
23+
*/
24+
public static function cleanup(): void
25+
{
26+
$fs = new Filesystem();
27+
28+
foreach (self::ICONS as $icon) {
29+
$fs->remove(self::ICON_DIR.'/'.$icon);
30+
}
31+
}
32+
33+
public function testCanImportIcon(): void
34+
{
35+
$this->assertFileDoesNotExist($expectedFile = self::ICON_DIR.'/dashboard.svg');
36+
37+
$this->executeConsoleCommand('ux:icons:import uiw:dashboard')
38+
->assertSuccessful()
39+
->assertOutputContains('Importing uiw:dashboard as dashboard')
40+
->assertOutputContains("render with {{ ux_icon('dashboard') }}")
41+
;
42+
43+
$this->assertFileExists($expectedFile);
44+
}
45+
46+
public function testCanImportIconAndRename(): void
47+
{
48+
$this->assertFileDoesNotExist($expectedFile = self::ICON_DIR.'/renamed.svg');
49+
50+
$this->executeConsoleCommand('ux:icons:import uiw:dashboard@renamed')
51+
->assertSuccessful()
52+
->assertOutputContains('Importing uiw:dashboard as renamed')
53+
->assertOutputContains("render with {{ ux_icon('renamed') }}")
54+
;
55+
56+
$this->assertFileExists($expectedFile);
57+
}
58+
59+
public function testImportInvalidIconName(): void
60+
{
61+
$this->executeConsoleCommand('ux:icons:import something')
62+
->assertStatusCode(1)
63+
->assertOutputContains('[ERROR] Invalid icon name "something".')
64+
;
65+
}
66+
67+
public function testImportNonExistentIcon(): void
68+
{
69+
$this->executeConsoleCommand('ux:icons:import something:invalid')
70+
->assertStatusCode(1)
71+
->assertOutputContains('[ERROR] The icon "something:invalid" does not exist on iconify.design.')
72+
;
73+
74+
$this->assertFileDoesNotExist(self::ICON_DIR.'/invalid.svg');
75+
}
76+
}

0 commit comments

Comments
 (0)