41 Commits
0.0.9 ... 0.1.5

Author SHA1 Message Date
9248b51908 Try explicitly loading arguments 2023-06-27 11:30:19 +01:00
0e2ee19f8f Adjust config 2023-06-27 11:13:29 +01:00
6e357ee8b7 Adjust config 2023-06-27 10:56:06 +01:00
3df034d309 Adjust config 2023-06-27 10:51:46 +01:00
abfff194f5 Merge default options into user options 2023-06-27 10:32:08 +01:00
6cf1deb136 Pass in default array 2023-06-27 10:30:20 +01:00
03a1f033ee Pass in default options to runtime test 2023-06-27 10:30:10 +01:00
173c08b0d0 Use constants for default values 2023-06-27 10:27:28 +01:00
e97ef857ef Further refactoring 2023-06-27 10:03:22 +01:00
dbd4ccb35d Further refactoring 2023-06-27 10:01:35 +01:00
4142c24e00 Extract method 2023-06-27 09:55:42 +01:00
8b698bb3fd Use references to edit string in place 2023-06-27 09:51:26 +01:00
692789b21d Test removing current color 2023-06-27 09:36:40 +01:00
3b6a85c3b3 Remove stroke current color 2023-06-27 09:36:32 +01:00
ac6a573ce2 Change double quotes to be single quotes for further testing 2023-06-27 09:35:21 +01:00
f6443f5dff Add failing test case 2023-06-27 09:31:06 +01:00
9c2321fe3a Add stroke current color to icon svg to test removing it 2023-06-27 09:30:45 +01:00
f9e35368c0 Add return docblock type 2023-06-27 09:19:33 +01:00
6d63e2f29b Add comment 2023-06-27 09:15:13 +01:00
brabli
7b76f8f01f Add commented out code for future work 2023-06-26 21:48:08 +01:00
brabli
4ce873d15c Move braces to newline 2023-06-26 21:47:46 +01:00
brabli
f33bf7ce6a Update README 2023-06-26 21:31:22 +01:00
brabli
19076b58e1 Test default config 2023-06-26 21:31:05 +01:00
brabli
fdab83bd28 Add config to set default values 2023-06-26 21:30:55 +01:00
brabli
e3d3818b10 Inline method call 2023-06-26 21:30:41 +01:00
brabli
90a354f822 Change function to be pcm_icon instead of icon 2023-06-26 21:30:07 +01:00
Brabli
6ad58d9cfc Change method name 2023-05-31 12:19:55 +01:00
Brabli
850ba765a2 Add constant, adjust text 2023-05-31 11:56:46 +01:00
Brabli
493f0c275b Split extension into extension and runtime classes to match how the maker bundle creates twig extensions 2023-05-31 11:51:36 +01:00
Brabli
08d906260a Move exceptions to own folder 2023-05-31 11:50:32 +01:00
Brabli
ec4a730b53 Remove validation tests 2023-05-31 11:39:16 +01:00
Brabli
66276640d7 Test config 2023-05-31 11:38:58 +01:00
Brabli
b2e76dd1b3 Remove validation from extension class 2023-05-31 11:38:51 +01:00
Brabli
eec11801b0 Fix config tree issues 2023-05-31 11:38:32 +01:00
Brabli
633e4a284e Replace 'palletes' with 'colours' which is a bit more intuitive and is also spelt correctly. 2023-05-30 17:18:33 +01:00
Brabli
b66e7821a7 Fix typo 2023-05-30 17:05:31 +01:00
9983121cbb Remove old tests 2023-05-25 14:17:49 +01:00
2b1c7b9c12 Remove directory arg checks, add braces where missing on conditionals 2023-05-25 14:16:38 +01:00
a34d2c5dca Cleanup 2023-05-25 14:13:28 +01:00
839c96a104 Test directory validation 2023-05-25 14:13:11 +01:00
e7c0465fdb Add validation to directory key 2023-05-25 14:13:03 +01:00
14 changed files with 488 additions and 366 deletions

View File

@@ -4,7 +4,7 @@ Use icons inside of Twig templates with ease! Project must be using Tailwind for
```php
{{ icon({ icon: 'person' }) }}
{{ pcm_icon({ icon: 'person' }) }}
```
## Config
@@ -12,9 +12,12 @@ Example config:
```yaml
# config/packages/pcm_icon.yaml
pcm_icon:
default:
size: 32
colour: primary
directories:
- '%kernel.project_dir%/public/icons'
palletes:
colours:
primary:
fill: 'fill-primary'
stroke: 'stroke-primary'
@@ -30,13 +33,14 @@ pcm_icon:
fill-group-hover: 'group-hover:fill-red-800'
stroke-group-hover: 'group-hover:stroke-red-800'
```
# @TODO default options
`directories` - Which directories to look in for icons.
`palletes` - Custom colour palletes to use when colouring icons. Because this extension relies on Tailwind classes for colouring we must specify all the classes. This is annoyingly verbose, but you can just copy this template:
`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:
```yaml
PALLETE_NAME:
COLOUR:
fill: 'fill-COLOUR'
stroke: 'stroke-COLOUR'
fill-hover: 'hover:fill-COLOUR'
@@ -53,7 +57,7 @@ PALLETE_NAME:
`size (int)` Size of the icon in pixels
`colour (string)` Name of the main colour pallete to use. Defaults to `"primary"`, which is a pallete that uses the project's primary colour. 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 pallete uses. EG:
`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'
@@ -64,9 +68,9 @@ primary:
stroke-group-hover: 'group-hover:stroke-purple-700'
```
`hover (string)` Name of the colour pallete to use when the icon is hovered over
`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 use with caution if at all.
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so only use it as a last resort.
## 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

@@ -2,18 +2,16 @@ when@dev:
pcm_icon:
directories:
- '%kernel.project_dir%/public/icons'
palletes: []
colours: []
when@prod:
pcm_icon:
directories:
- '%kernel.project_dir%/public/icons'
palletes: []
colours: []
when@test:
pcm_icon:
directories:
- '%kernel.project_dir%/tests/icons'
palletes:
- []
colours: []

View File

@@ -5,8 +5,16 @@ services:
pcm_icon.icon_extension:
public: true
class: Pcm\IconBundle\Twig\Functions\IconExtension
class: Pcm\IconBundle\Twig\Extension\IconExtension
Pcm\IconBundle\Twig\Functions\IconExtension:
Pcm\IconBundle\Twig\Extension\IconExtension:
public: false
alias: pcm_icon.icon_extension
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

@@ -4,26 +4,78 @@ declare(strict_types=1);
namespace Pcm\IconBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public const DEFAULT_SIZE = 32;
public const DEFAULT_COLOUR = 'primary';
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('pcm_icon');
$this->addValidationRules($treeBuilder->getRootNode());
$treeBuilder->getRootNode()
return $treeBuilder;
}
private function addValidationRules(ArrayNodeDefinition $rootNode): void
{
// I've split the tree up like this because Intelephense was crying.
// Plus I think it's a little easier to read.
$rootNode
->children()
->arrayNode('directories')
->scalarPrototype()->end()
->end()
->arrayNode('palletes')
->variablePrototype()->end()
->arrayNode('default')
->addDefaultsIfNotSet()
->children()
->scalarNode('colour')->defaultValue(self::DEFAULT_COLOUR)->end()
->integerNode('size')->defaultValue(self::DEFAULT_SIZE)->end()
->end()
->end()
->end()
;
return $treeBuilder;
$rootNode
->children()
->arrayNode('directories')
->validate()
->ifEmpty()
->thenInvalid("Directories cannot be empty!")
->end()
->scalarPrototype()
->validate()
->ifTrue(fn($path) => !is_dir($path))
->thenInvalid('%s is not a directory!')
->end()
->end()
->end()
->end()
;
$rootNode
->children()
->arrayNode('colours')
->validate()
->ifEmpty()
->thenInvalid('Colours cannot be empty!')
->end()
->arrayPrototype()
->normalizeKeys(false) # Don't normalize hyphens into underscores etc
->children()
->scalarNode('fill')->isRequired()->end()
->scalarNode('stroke')->isRequired()->end()
->scalarNode('fill-hover')->isRequired()->end()
->scalarNode('stroke-hover')->isRequired()->end()
->scalarNode('fill-group-hover')->isRequired()->end()
->scalarNode('stroke-group-hover')->isRequired()->end()
->end()
->end()
->end()
->end()
;
}
}

View File

@@ -23,9 +23,8 @@ class PcmIconExtension extends Extension
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$definition = $container->getDefinition('pcm_icon.icon_extension');
$definition->addArgument($config['directories']);
$definition->addArgument($config['palletes']);
$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,8 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Exception;
class ColourNotFound extends \Exception {};

View File

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

View File

@@ -5,10 +5,7 @@ declare(strict_types=1);
namespace Pcm\IconBundle;
use Pcm\IconBundle\DependencyInjection\PcmIconExtension;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class PcmIconBundle extends AbstractBundle

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Extension;
use Pcm\IconBundle\Twig\Runtime\IconRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class IconExtension extends AbstractExtension
{
/**
* @inheritDoc
*/
public function getFunctions(): array
{
return [
new TwigFunction('pcm_icon', [IconRuntime::class, 'renderIcon'])
];
}
}

View File

@@ -1,240 +0,0 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Functions;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class IconExtension extends AbstractExtension
{
public const DEFAULT_SIZE = 32;
private const DEFAULT_OPTIONS = [
'icon' => null,
'title' => null,
'size' => self::DEFAULT_SIZE,
'colour' => 'primary',
'hover' => null,
'classes' => [],
];
public function __construct(private array $directories, private array $palletes)
{
if (empty($this->directories))
throw new \InvalidArgumentException('Directories array must contain at least one path!');
$dirsContainNonString = array_reduce($this->directories,
fn($notString, $path) => $notString || !is_string($path));
if ($dirsContainNonString)
throw new \TypeError('Directories array must only contain strings!');
if (empty($this->palletes))
throw new \InvalidArgumentException('Palletes array must contain at least one pallet!');
$palletesContainNonarray = array_reduce($this->palletes,
fn($notArray, $path) => $notArray || !is_array($path));
if ($palletesContainNonarray)
throw new \TypeError('Palletes array must only contain arrays!');
foreach ($this->palletes as $pallete) {
if (!(
array_key_exists('stroke', $pallete) &&
array_key_exists('fill', $pallete) &&
array_key_exists('fill-hover', $pallete) &&
array_key_exists('stroke-hover', $pallete) &&
array_key_exists('fill-group-hover', $pallete) &&
array_key_exists('stroke-group-hover', $pallete))
) {
throw new \Exception('Palletes must contain a "stroke" and "fill" key!');
}
}
}
/**
* @inheritDoc
*/
public function getFunctions(): array
{
return [
new TwigFunction('icon', [$this, 'renderIcon'], [
'is_safe' => ['html']
])
];
}
/**
* @param array $options
* ```
* $options = [
* 'icon' => (string) REQUIRED Which icon to use
* 'title' => (?string) Text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour pallete
* 'hover' => (?string) Hover colour pallete
* 'classes' => (array) Additional classes to add to the icon.
* Use with caution as this can potentially
* cause Tailwind class conflicts!
* ]
* ```
*/
public function renderIcon(array $userOptions): string
{
$options = $this->getMergedOptions($userOptions);
$svg = $this->getSanitisedIconSvg($options['icon']);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
$extraClasses = $this->getExtraClasses($options['classes']);
$svg = $this->addClassesToSvg($svg, trim($colourClasses.' '.$extraClasses));
$svg = $this->addTitleToSvgIfNotNull($svg, $options['title']);
$svg = $this->setSvgHeightAndWidth($svg, $options['size']);
return $svg;
}
private function getExtraClasses(array $extraClasses): string
{
return implode(' ', $extraClasses);
}
private function getMergedOptions(array $userOptions): array
{
$this->throwIfUnrecognisedOptionExists($userOptions);
return $this->mergeUserOptionsWithDefaults($userOptions);
}
private function mergeUserOptionsWithDefaults(array $userOptions): array
{
return array_merge(self::DEFAULT_OPTIONS, $userOptions);
}
private function throwIfUnrecognisedOptionExists(array $options): void
{
foreach (array_keys($options) as $key) {
if (!key_exists($key, self::DEFAULT_OPTIONS))
throw new \InvalidArgumentException("Unrecognised option '{$key}'!");
}
}
private function getSanitisedIconSvg(string $iconName): string
{
$iconFilepath = $this->findSvgFilepath($iconName);
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
$cleanSvgMarkup = $this->removeExistingTitleElement($rawSvgMarkup);
$cleanSvgMarkup = $this->removeBlackStrokeAttributes($cleanSvgMarkup);
return $this->removeBlackFillAttributes($cleanSvgMarkup);
}
private function findSvgFilepath(string $iconName): string
{
foreach ($this->directories as $directory) {
$potentialFilepath = sprintf('%s/%s.svg', $directory, $iconName);
if (file_exists($potentialFilepath))
return $potentialFilepath;
}
throw new IconNotFound(sprintf('File "%s.svg" not found in %s', $iconName, implode(', ', $this->directories)));
}
private function getSvgMarkup(string $filepath): string
{
return file_get_contents($filepath);
}
private function removeExistingTitleElement(string $svg): string
{
return preg_replace('/<title>.*<\/title>/', '', $svg);
}
private function removeBlackStrokeAttributes(string $content): string
{
return preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
}
private function removeBlackFillAttributes(string $content): string
{
return preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
}
private function getColourClasses(string $primaryColour, ?string $hoverColour): string
{
$mainPallete = $this->getPallete($primaryColour);
$colourClasses = "{$mainPallete['stroke']} {$mainPallete['fill']}";
if (null !== $hoverColour) {
$hoverPallete = $this->getPallete($hoverColour);
$colourClasses .= " cursor-pointer {$hoverPallete['stroke-hover']} {$hoverPallete['fill-hover']} {$hoverPallete['stroke-group-hover']} {$hoverPallete['fill-group-hover']}";
}
return $colourClasses;
}
private function getPallete(string $palleteName): array
{
if (array_key_exists($palleteName, $this->palletes))
return $this->palletes[$palleteName];
throw new PalleteNotFound("The pallete '$palleteName' was not found!");
}
private function addClassesToSvg(string $svg, string $classes): string
{
$xml = new \SimpleXMLElement($svg);
$xml = $this->addAttributeToXmlElement($xml, 'class', $classes);
return $this->removeXMLDeclaration($xml->saveXML());
}
private function addTitleToSvgIfNotNull(string $svg, ?string $title): string
{
if (null === $title) return $svg;
$this->throwIfTitleIsEmpty($title);
return preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $svg);
}
private function addAttributeToXmlElement(\SimpleXMLElement $xml, string $attrName, mixed $attrValue): \SimpleXMLElement
{
if (isset($xml->attributes()->$attrName)) {
$xml->attributes()->$attrName = strval($attrValue);
} else {
$xml->addAttribute($attrName, strval($attrValue));
}
return $xml;
}
private function removeXMLDeclaration(string $content): string
{
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
}
private function throwIfTitleIsEmpty(string $title): void
{
if ('' === $title)
throw new \InvalidArgumentException('Title string must not be empty!');
}
private function setSvgHeightAndWidth(string $content, int $size): string
{
$this->throwIfSizeIsNegative($size);
$svgAsXmlElement = new \SimpleXMLElement($content);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'width', $size);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'height', $size);
return $this->removeXMLDeclaration($svgAsXmlElement->saveXML());
}
private function throwIfSizeIsNegative(int $size): void
{
if ($size < 0)
throw new \InvalidArgumentException('Size must not be negative');
}
}
class IconNotFound extends \Exception {};
class PalleteNotFound extends \Exception {};

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Runtime;
use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\IconNotFound;
use Twig\Extension\RuntimeExtensionInterface;
final class IconRuntime implements RuntimeExtensionInterface
{
private const OPTIONS = [
'icon' => null,
'title' => null,
'size' => null,
'colour' => null,
'hover' => null,
'classes' => [],
];
public function __construct(private array $defaultOptions, private array $directories, private array $colours)
{}
/**
* @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.
* ]
* ```
*
* @return string Processed SVG
*/
public function renderIcon(array $userOptions): string
{
$options = $this->mergeWithDefaultOptions($userOptions);
$svg = $this->getSvg($options['icon']);
$this->sanitiseSvg($svg);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
$extraClasses = $this->getExtraClasses($options['classes']);
$classes = trim($colourClasses.' '.$extraClasses);
$this->addClassesToSvg($svg, $classes);
if (null !== $options['title']) {
$this->addTitleToSvg($svg, $options['title']);
}
$this->setSvgHeightAndWidth($svg, $options['size']);
return $svg;
}
private function getExtraClasses(array $extraClasses): string
{
return implode(' ', $extraClasses);
}
private function mergeWithDefaultOptions(array $userOptions): array
{
$this->throwIfUnrecognisedOptionExists($userOptions);
return $this->mergeUserOptionsWithDefaults($userOptions);
}
private function mergeUserOptionsWithDefaults(array $userOptions): array
{
$options = self::OPTIONS;
$options['size'] = $this->defaultOptions['size'];
$options['colour'] = $this->defaultOptions['colour'];
return array_merge($options, $userOptions);
}
private function throwIfUnrecognisedOptionExists(array $options): void
{
foreach (array_keys($options) as $key) {
if (!key_exists($key, self::OPTIONS)) {
throw new \InvalidArgumentException("Unrecognised option '{$key}'!");
}
}
}
private function getSvg(string $iconName): string
{
$iconFilepath = $this->findSvgFilepath($iconName);
return file_get_contents($iconFilepath);
}
private function sanitiseSvg(string &$svg): void
{
$this->removeExistingTitleElement($svg);
$this->removeBlackStrokeAttributes($svg);
$this->removeStrokeCurrentColor($svg);
$this->removeBlackFillAttributes($svg);
}
private function findSvgFilepath(string $iconName): string
{
foreach ($this->directories as $directory) {
$potentialFilepath = sprintf('%s/%s.svg', $directory, $iconName);
if (file_exists($potentialFilepath)) {
return $potentialFilepath;
}
}
throw new IconNotFound(sprintf('File "%s.svg" not found in %s', $iconName, implode(', ', $this->directories)));
}
private function removeExistingTitleElement(string &$svg): void
{
$svg = preg_replace('/<title>.*<\/title>/', '', $svg);
}
private function removeBlackStrokeAttributes(string &$svg): void
{
$svg = preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $svg);
}
private function removeStrokeCurrentColor(string &$svg): void
{
$svg = \preg_replace('/stroke=(\'|")currentColor(\'|")/', '', $svg);
}
private function removeBlackFillAttributes(string &$svg): void
{
$svg = preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $svg);
}
private function getColourClasses(string $primaryColour, ?string $hoverColour): string
{
$mainColour = $this->getColour($primaryColour);
$colourClasses = "{$mainColour['stroke']} {$mainColour['fill']}";
if (null !== $hoverColour) {
$hoverColour = $this->getColour($hoverColour);
$colourClasses .= " cursor-pointer {$hoverColour['stroke-hover']} {$hoverColour['fill-hover']} {$hoverColour['stroke-group-hover']} {$hoverColour['fill-group-hover']}";
}
return $colourClasses;
}
private function getColour(string $colourName): array
{
if (array_key_exists($colourName, $this->colours)) {
return $this->colours[$colourName];
}
throw new ColourNotFound("The colour \"$colourName\" was not found!");
}
private function addClassesToSvg(string &$svg, string $classes): void
{
$xml = new \SimpleXMLElement($svg);
$xml = $this->addAttributeToXmlElement($xml, 'class', $classes);
$svg = $this->removeXMLDeclaration($xml->saveXML());
}
private function addTitleToSvg(string &$svg, ?string $title): void
{
if ('' === $title) {
throw new \InvalidArgumentException('Title must not be an empty string!');
}
$svg = preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $svg);
}
private function addAttributeToXmlElement(\SimpleXMLElement $xml, string $attrName, mixed $attrValue): \SimpleXMLElement
{
if (isset($xml->attributes()->$attrName)) {
$xml->attributes()->$attrName = strval($attrValue);
} else {
$xml->addAttribute($attrName, strval($attrValue));
}
return $xml;
}
private function removeXMLDeclaration(string $content): string
{
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
}
private function setSvgHeightAndWidth(string &$svg, int $size): void
{
if ($size < 0) {
throw new \InvalidArgumentException('Size must not be negative');
}
$svgAsXml = new \SimpleXMLElement($svg);
$svgAsXml = $this->addAttributeToXmlElement($svgAsXml, 'width', $size);
$svgAsXml = $this->addAttributeToXmlElement($svgAsXml, 'height', $size);
$svg = $this->removeXMLDeclaration($svgAsXml->saveXML());
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Tests\Config;
use Pcm\IconBundle\DependencyInjection\Configuration;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Processor;
class ConfigurationTest extends TestCase
{
private Configuration $configuration;
private Processor $processor;
protected function setUp(): void
{
$this->configuration = new Configuration();
$this->processor = new Processor();
}
public function testThrowsIfDirectoriesIsEmpty()
{
$config = $this->getValidConfig();
$config['directories'] = [];
$this->expectExceptionMessage("Directories cannot be empty!");
$this->validateConfig($config);
}
public function testThrowsIfDirectoryIsNotFound(): void
{
$config = $this->getValidConfig();
$fakeDir = "abc/def/ghi";
$config['directories'] = [$fakeDir];
$this->expectExceptionMessage("\"abc\/def\/ghi\" is not a directory!");
$this->validateConfig($config);
}
public function testThrowsIfMultipleDirectoriesPassedAndOneNotFound(): void
{
$config = $this->getValidConfig();
$fakeDir = "abc/def/ghi";
$config['directories'] = ['./', $fakeDir];
$this->expectExceptionMessage("\"abc\/def\/ghi\" is not a directory!");
$this->validateConfig($config);
}
public function testThrowsIfNoColoursExist(): void
{
$config = $this->getValidConfig();
$config['colours'] = [];
$this->expectExceptionMessage("Colours cannot be empty!");
$this->validateConfig($config);
}
public function testThrowsIfColourHasMissingClass(): void
{
$config = $this->getValidConfig();
unset($config['colours']['primary']['fill']);
$this->expectException(\Exception::class);
$this->validateConfig($config);
}
public function testThrowsIfColourHasExtraClass(): void
{
$config = $this->getValidConfig();
$config['colours']['primary']['extra'] = "test";
$this->expectException(\Exception::class);
$this->validateConfig($config);
}
public function testDefaultSizeIs32Pixels(): void
{
$config = $this->getValidConfig();
unset($config['default']['size']);
$validatedConfig = $this->validateConfig($config);
$this->assertSame(32, $validatedConfig['default']['size']);
}
public function testSizeIsSetToProvidedValue(): void
{
$config = $this->getValidConfig();
$config['default']['size'] = 99;
$validatedConfig = $this->validateConfig($config);
$this->assertSame(99, $validatedConfig['default']['size']);
}
public function testDefaultColourIsPrimary(): void
{
$config = $this->getValidConfig();
unset($config['default']['colour']);
$validatedConfig = $this->validateConfig($config);
$this->assertSame('primary', $validatedConfig['default']['colour']);
}
private function validateConfig(array $config): array
{
return $this->processor->processConfiguration($this->configuration, [$config]);
}
private function getValidConfig(): array
{
return [
'default' => [
'colour' => 'primary',
'size' => 32
],
'directories' => [
'./',
],
'colours' => [
'primary' => [
'fill' => 'fill-primary',
'stroke' => 'stroke-primary',
'fill-hover' => 'hover:fill-primary',
'stroke-hover' => 'hover:stroke-primary',
'fill-group-hover' => 'group-hover:fill-primary',
'stroke-group-hover' => 'group-hover:stroke-primary',
],
],
];
}
}

View File

@@ -4,16 +4,17 @@ declare(strict_types=1);
namespace Pcm\IconBundle\Tests\Twig\Functions;
use Pcm\IconBundle\Twig\Functions\IconExtension;
use Pcm\IconBundle\Twig\Functions\IconNotFound;
use Pcm\IconBundle\Twig\Functions\PalleteNotFound;
use Pcm\IconBundle\DependencyInjection\Configuration;
use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\IconNotFound;
use Pcm\IconBundle\Twig\Runtime\IconRuntime;
use PHPUnit\Framework\TestCase;
class IconExtensionTest extends TestCase
class IconRuntimeTest extends TestCase
{
private const ICON = 'test';
private const PALLETES = [
private const COLOURS = [
'primary' => [
'fill' => 'fill-primary',
'stroke' => 'stroke-primary',
@@ -32,37 +33,15 @@ class IconExtensionTest extends TestCase
]
];
/**
* @var IconExtension
*/
private IconExtension $icon;
private IconRuntime $icon;
protected function setUp(): void
{
$this->icon = new IconExtension(['tests/icons'], self::PALLETES);
}
public function testInstanceOf(): void
{
$this->assertInstanceOf(IconExtension::class, $this->icon);
}
public function testThrowsIfDirectoriesIsEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
new IconExtension([], self::PALLETES);
}
public function testThrowsIfDirectoriesContainsNonString(): void
{
$this->expectException(\TypeError::class);
new IconExtension([99], self::PALLETES);
}
public function testThrowsIfDirectoriesContainsNonStringAmongStrings(): void
{
$this->expectException(\TypeError::class);
new IconExtension(['string', 99, 'string'], self::PALLETES);
$this->icon = new IconRuntime(
defaultOptions: ['size' => Configuration::DEFAULT_SIZE, 'colour' => Configuration::DEFAULT_COLOUR],
directories: ['tests/icons'],
colours: self::COLOURS
);
}
public function testThrowsWhenPassedAnInvalidIconName(): void
@@ -145,7 +124,7 @@ class IconExtensionTest extends TestCase
public function testDefaultSizeIsSetOnSvgIfNoSizeOptionPassed(): void
{
$defaultSize = IconExtension::DEFAULT_SIZE;
$defaultSize = Configuration::DEFAULT_SIZE;
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$regex = "/^<svg.+?width=\"{$defaultSize}\"/";
@@ -157,7 +136,7 @@ class IconExtensionTest extends TestCase
public function testDefaultSizeIsOnlySetOnce(): void
{
$defaultSize = IconExtension::DEFAULT_SIZE;
$defaultSize = Configuration::DEFAULT_SIZE;
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$widthRegex = "/width=\"{$defaultSize}\"/";
@@ -169,60 +148,6 @@ class IconExtensionTest extends TestCase
$this->assertSame(1, $timesMatched);
}
public function testThrowsIfPalletsIsEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
new IconExtension(['/'], []);
}
public function testThrowsIfPalletesContainsNonArray(): void
{
$this->expectException(\TypeError::class);
new IconExtension(['/'], [99]);
}
public function testThrowsIfPalletesContainsNonArrayInbetweenArrays(): void
{
$this->expectException(\TypeError::class);
new IconExtension(['/'], [[], 99, []]);
}
public function testThrowsIfChildArrayDoesntContainStrokeKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainStrokeHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillGroupHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainStrokeGroupHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '']]);
}
public function testBlackStrokeAttributeValuesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -259,26 +184,26 @@ class IconExtensionTest extends TestCase
$this->assertDoesNotMatchRegularExpression('/fill:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
}
public function testThrowsIfColourPalleteIsNotFound(): void
public function testThrowsIfColourIsNotFound(): void
{
$this->expectException(PalleteNotFound::class);
$this->expectException(ColourNotFound::class);
$this->icon->renderIcon(['icon' => self::ICON, 'colour' => 'red']);
}
public function testSvgClassContainsPalleteClasses(): void
public function testSvgClassContainsColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'colour' => 'white']);
$this->assertMatchesRegularExpression('/<svg.*?class=".*?fill-white.*?>/', $contents);
$this->assertMatchesRegularExpression('/<svg.*?class=".*?stroke-white?.*>/', $contents);
}
public function testThrowsIfHoverPalleteIsNotFound(): void
public function testThrowsIfHoverColourIsNotFound(): void
{
$this->expectException(PalleteNotFound::class);
$this->expectException(ColourNotFound::class);
$this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'red']);
}
public function testSvgClassContainsHoverPalleteClasses(): void
public function testSvgClassContainsHoverColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white']);
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
@@ -288,7 +213,7 @@ class IconExtensionTest extends TestCase
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testSvgClassContainsHoverAndColourPalleteClasses(): void
public function testSvgClassContainsHoverAndColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white', 'colour' => 'primary']);
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
@@ -325,4 +250,13 @@ class IconExtensionTest extends TestCase
$this->expectException(\InvalidArgumentException::class);
$this->icon->renderIcon(['icon' => self::ICON, 'fake-key-should-throw' => null]);
}
public function testStrokeCurrentColorInstancesAreRemoved(): void
{
$needleA = 'stroke="currentColor"';
$needleB = 'stroke=\'currentColor\'';
$contents = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertStringNotContainsString($needleA, $contents);
$this->assertStringNotContainsString($needleB, $contents);
}
}

View File

@@ -3,8 +3,8 @@
<line fill="rgb(0, 0, 0)"></line>
<line fill="#000"></line>
<line fill="#000000"></line>
<line fill="black"></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"/>
<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>

Before

Width:  |  Height:  |  Size: 860 B

After

Width:  |  Height:  |  Size: 904 B