17 Commits
0.1.4 ... 1.1.0

13 changed files with 174 additions and 72 deletions

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# Changelog
## [X.X.X] - XXXX-XX-XX
- Allow passing in a space-separated string to the `classes` option
- Add new exceptions and messages to make debugging a little bit faster
- Set `aria-hidden` attribute depending on whether a `title` has been set
## [1.1.0] - 2024-08-05
- Changing requirements to work with Symfony 7
## [1.0.0] - 2023-06-27
- First major version release

View File

@@ -33,11 +33,12 @@ pcm_icon:
fill-group-hover: 'group-hover:fill-red-800'
stroke-group-hover: 'group-hover:stroke-red-800'
```
# @TODO default options
`default` - Override the default options provided to the bundle.
`directories` - Which directories to look in for icons.
`colours` - Custom colours to use when colouring icons. Because this extension relies on Tailwind classes for colouring we must specify all the classes. This is a bit awkward, but you can just copy this template replacing COLOUR as appropriate:
`colours` - Custom colours to use when colouring icons. Because this extension relies on Tailwind classes for colouring we **must** explicitly list all the classes required for Tailwind to render them properly. You can just copy this template replacing COLOUR as appropriate:
```yaml
COLOUR:
@@ -51,26 +52,17 @@ COLOUR:
## Options
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension.
`title (string)` Optional text to show on hover
`title (string)` Optional text to show on hover. This can be null, but not an empty string.
`size (int)` Size of the icon in pixels
`size (int)` Size of the icon in pixels.
`colour (string)` Name of the colour to use. Defaults to `primary` as it assumes you have a Tailwind colour set up called `primary`. If however you are not using `primary` to set a Tailwind primary colour in your project, you can instead set the default colour of the icon by changing what colour classes the primary colour uses. EG:
```yaml
primary:
fill: 'fill-purple-700'
stroke: 'stroke-purple-700'
fill-hover: 'hover:fill-purple-700'
stroke-hover: 'hover:stroke-purple-700'
fill-group-hover: 'group-hover:fill-purple-700'
stroke-group-hover: 'group-hover:stroke-purple-700'
```
`colour (string)` Name of the colour to use. Defaults to `primary` as it assumes you have a Tailwind colour set up called `primary`. If you are not using `primary` to set a Tailwind primary colour in your project you can change the default colour in the `pcm_icon.yaml` file.
`hover (string)` Name of the colour to use when the icon is hovered over
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so only use it as a last resort.
`classes (string[]|string)` Additional classes to add to the icon as either an array of strings or a space-separated string. This can cause Tailwind class collisions, so it is not recommended to use.
## Reminders
Remember to add `./config/packages/*.yaml` as a value in your Tailwind config content array if it does not already exist. This will ensure Tailwind classes inside of the config file get compiled.

View File

@@ -1,42 +1,37 @@
{
"name": "pcm/icon-bundle",
"description": "Twig icon bundle",
"type": "symfony-bundle",
"license": "MIT",
"authors": [
{
"name": "Bradley Goode",
"email": "bg@pcmsystems.co.uk"
},
{
"name": "Matt Feeney",
"email": "mf@pcmsystems.co.uk"
}
],
"require": {
"symfony/dependency-injection": "^6.1",
"symfony/framework-bundle": "^6.1",
"symfony/yaml": "^6.1",
"symfony/http-client": "^6.1",
"symfony/twig-bundle": "^6.1"
"name": "pcm/icon-bundle",
"description": "Twig icon bundle",
"type": "symfony-bundle",
"license": "MIT",
"authors": [
{
"name": "Bradley Goode",
"email": "bg@pcmsystems.co.uk"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/phpunit-bridge": "^6.1"
},
"autoload": {
"psr-4": {
"Pcm\\IconBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Pcm\\IconBundle\\Tests\\": "tests/"
}
{
"name": "Matt Feeny",
"email": "mf@pcmsystems.co.uk"
}
],
"require": {
"symfony/dependency-injection": "^6.1 || ^7.0",
"symfony/framework-bundle": "^6.1 || ^7.0",
"symfony/yaml": "^6.1 || ^7.0",
"symfony/http-client": "^6.1 || ^7.0",
"symfony/twig-bundle": "^6.1 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/phpunit-bridge": "^6.1 || ^7.0"
},
"autoload": {
"psr-4": {
"Pcm\\IconBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Pcm\\IconBundle\\Tests\\": "tests/"
}
}
}

View File

@@ -14,3 +14,7 @@ services:
Pcm\IconBundle\Twig\Runtime\IconRuntime:
tags:
- { name: twig.runtime }
arguments:
$defaultOptions: '%pcm.icon_bundle.default_options%'
$directories: '%pcm.icon_bundle.directories%'
$colours: '%pcm.icon_bundle.colours%'

View File

@@ -23,10 +23,8 @@ class PcmIconExtension extends Extension
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$definition = $container->getDefinition('pcm_icon.icon_extension');
$definition->addArgument($config['default']);
$definition->addArgument($config['directories']);
$definition->addArgument($config['colours']);
$container->setParameter('pcm.icon_bundle.default_options', $config['default']);
$container->setParameter('pcm.icon_bundle.directories', $config['directories']);
$container->setParameter('pcm.icon_bundle.colours', $config['colours']);
}
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Exception;
class EmptyFileException extends \Exception {};

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Exception;
class InvalidSvgException extends \Exception {};

View File

@@ -16,7 +16,7 @@ final class IconExtension extends AbstractExtension
public function getFunctions(): array
{
return [
new TwigFunction('pcm_icon', [IconRuntime::class, 'renderIcon'])
new TwigFunction('pcm_icon', [IconRuntime::class, 'renderIcon'], ['is_safe' => ['html']])
];
}
}

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Runtime;
use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\EmptyFileException;
use Pcm\IconBundle\Exception\IconNotFound;
use Pcm\IconBundle\Exception\InvalidSvgException;
use Twig\Extension\RuntimeExtensionInterface;
final class IconRuntime implements RuntimeExtensionInterface
@@ -16,7 +18,7 @@ final class IconRuntime implements RuntimeExtensionInterface
'size' => null,
'colour' => null,
'hover' => null,
'classes' => [],
'classes' => "",
];
public function __construct(private array $defaultOptions, private array $directories, private array $colours)
@@ -26,12 +28,13 @@ final class IconRuntime implements RuntimeExtensionInterface
* @param array $options
* ```
* $options = [
* 'icon' => (string) **REQUIRED** Icon name without trailing `.svg`
* 'title' => (?string) Title text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour
* 'hover' => (?string) Hover colour
* 'classes' => (array) Additional classes to add to the icon. Not recommended.
* 'icon' => (string) **REQUIRED** Icon name without trailing `.svg`
* 'title' => (?string) Title text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour
* 'hover' => (?string) Hover colour
* 'classes' => (string[]|string) Additional classes to add to the icon, given as
* an array of strings or a space-separated string.
* ]
* ```
*
@@ -43,6 +46,14 @@ final class IconRuntime implements RuntimeExtensionInterface
$svg = $this->getSvg($options['icon']);
if (empty($svg)) {
throw new EmptyFileException(\sprintf("The file %s.svg was found, but it was empty!", $options['icon']));
}
if (!$this->isValidXml($svg)) {
throw new InvalidSvgException(\sprintf("The file %s.svg was found, but it does not contain valid SVG code!", $options['icon']));
}
$this->sanitiseSvg($svg);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
@@ -51,8 +62,11 @@ final class IconRuntime implements RuntimeExtensionInterface
$classes = trim($colourClasses.' '.$extraClasses);
$this->addClassesToSvg($svg, $classes);
$svg = $this->addAttribute($svg, 'aria-hidden', 'true');
if (null !== $options['title']) {
$this->addTitleToSvg($svg, $options['title']);
$svg = $this->addAttribute($svg, 'aria-hidden', 'false');
}
$this->setSvgHeightAndWidth($svg, $options['size']);
@@ -60,9 +74,9 @@ final class IconRuntime implements RuntimeExtensionInterface
return $svg;
}
private function getExtraClasses(array $extraClasses): string
private function getExtraClasses(array|string $extraClasses): string
{
return implode(' ', $extraClasses);
return \is_array($extraClasses) ? implode(' ', $extraClasses) : $extraClasses;
}
private function mergeWithDefaultOptions(array $userOptions): array
@@ -103,6 +117,9 @@ final class IconRuntime implements RuntimeExtensionInterface
$this->removeBlackFillAttributes($svg);
}
/**
* @throws IconNotFound when an svg file with the passed in name cannot be found
*/
private function findSvgFilepath(string $iconName): string
{
foreach ($this->directories as $directory) {
@@ -202,4 +219,23 @@ final class IconRuntime implements RuntimeExtensionInterface
$svg = $this->removeXMLDeclaration($svgAsXml->saveXML());
}
private function addAttribute(string $svg, string $attribute, string $value): string
{
$xml = new \SimpleXMLElement($svg);
$xml = $this->addAttributeToXmlElement($xml, $attribute, $value);
return $this->removeXMLDeclaration($xml->saveXML());
}
private function isValidXml(string $input): bool
{
try {
new \SimpleXMLElement($input);
} catch (\Exception) {
return false;
}
return true;
}
}

View File

@@ -6,7 +6,9 @@ namespace Pcm\IconBundle\Tests\Twig\Functions;
use Pcm\IconBundle\DependencyInjection\Configuration;
use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\EmptyFileException;
use Pcm\IconBundle\Exception\IconNotFound;
use Pcm\IconBundle\Exception\InvalidSvgException;
use Pcm\IconBundle\Twig\Runtime\IconRuntime;
use PHPUnit\Framework\TestCase;
@@ -50,6 +52,18 @@ class IconRuntimeTest extends TestCase
$this->icon->renderIcon(['icon' => random_bytes(8)]);
}
public function testThrowsIfPassedInAnEmptyFile(): void
{
$this->expectException(EmptyFileException::class);
$this->icon->renderIcon(['icon' => 'empty']);
}
public function testThrowsIfContentsIsNotValidSvg(): void
{
$this->expectException(InvalidSvgException::class);
$this->icon->renderIcon(['icon' => 'invalid']);
}
public function testNoTitleExistsIfNotPassedIn(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -225,19 +239,26 @@ class IconRuntimeTest extends TestCase
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testExtraClassesThrowsIfNotAnArray(): void
public function testExtraClassesThrowsIfNotAnArrayOrString(): void
{
$this->expectException(\TypeError::class);
$this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'string_value']);
$this->icon->renderIcon(['icon' => self::ICON, 'classes' => 1]);
}
public function testExtraClassesGetAdded(): void
public function testExtraClassesGetAddedFromArray(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc', 'def']]);
$this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*def.*".*>/', $contents);
}
public function testExtraClassesGetAddedFromString(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'ghi jkl']);
$this->assertMatchesRegularExpression('/<svg.+class=".*ghi.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*jkl.*".*>/', $contents);
}
public function testAddingExtraClassesDoesntStripAwayColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc']]);
@@ -259,4 +280,23 @@ class IconRuntimeTest extends TestCase
$this->assertStringNotContainsString($needleA, $contents);
$this->assertStringNotContainsString($needleB, $contents);
}
public function testAriaHiddenAttributeIsAdded(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="true".*>/', $contents);
}
public function testAriaHiddenAttributeIsSetToTrueIfAlreadyPresent(): void
{
$contents = $this->icon->renderIcon(['icon' => 'attr']);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="true".*>/', $contents);
$this->assertDoesNotMatchRegularExpression('/<svg.+aria-hidden="false".*>/', $contents);
}
public function testAriaHiddenAttributeIsSetToFalseIfATitleIsPassedIn(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'title' => 'something']);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="false".*>/', $contents);
}
}

10
tests/icons/attr.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg aria-hidden="false" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="96 96 320 320">
<title>Some cross lol</title>
<line fill="rgb(0, 0, 0)"></line>
<line fill="#000"></line>
<line fill="#000000"></line>
<line stroke="currentColor" fill="black"></line>
<line x1="256" y1="112" x2="256" y2="400" width="101" height="101" style="fill: none; stroke: rgb(0,0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
<line x1="400" y1="256" x2="112" y2="256" width="102" height="102" style="fill: none; stroke: #000; stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
<polyline points="112 244 256 100 400 244" style="fill:#000;fill:#000000;fill: black;fill:rgb(0,0,0);stroke:#000;stroke:#000000;stroke:black;stroke:rgb(0, 0, 0);stroke-linecap:round;stroke-linejoin:round;stroke-width:48px" stroke='currentColor'/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

0
tests/icons/empty.svg Normal file
View File

1
tests/icons/invalid.svg Normal file
View File

@@ -0,0 +1 @@
<svg><svg>

After

Width:  |  Height:  |  Size: 11 B