66 Commits
0.0.4 ... main

Author SHA1 Message Date
f3ec9c7d4b Update README.md 2024-08-05 15:26:14 +00:00
c1ac0faf78 Update readme 2023-06-27 11:41:57 +01:00
1d91b50c32 Set is_safe html to true 2023-06-27 11:33:38 +01:00
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
Brabli
7469cc78f8 Update readme 2023-01-03 11:00:38 +00:00
Brabli
87a6204f25 Add 'classes' option to add extra classes 2023-01-03 10:58:16 +00:00
Brabli
d5278f3473 Add to readme 2022-12-05 12:44:28 +00:00
Brabli
b2f6e07d97 Remove space 2022-09-22 09:18:34 +01:00
Brabli
9195df9028 Refactoring 2022-08-31 12:13:59 +01:00
Brabli
f82317cf99 Add documentation 2022-08-30 00:03:48 +01:00
Brabli
03212310fd Throw exception if option is not recognised 2022-08-29 23:32:12 +01:00
Brabli
eb2bea62a7 Update readme 2022-08-29 23:17:07 +01:00
Brabli
b28a6f69f9 Make sure required keys exist in pallete arrays 2022-08-29 23:17:00 +01:00
Brabli
9ef5d58a8a Update readme 2022-08-29 21:00:43 +01:00
Brabli
a4344687d4 Add cursor pointer class on hover 2022-08-29 20:29:04 +01:00
Brabli
4ec6224b25 Remove space 2022-08-29 20:27:48 +01:00
Brabli
7a3366c2da Add group hover 2022-08-29 20:12:55 +01:00
Brabli
78a321137e Get hover colours added 2022-08-29 20:08:27 +01:00
Brabli
5e3756773e Get tests passing 2022-08-29 20:01:39 +01:00
Brabli
6553d0d32f Change method name 2022-08-29 19:43:18 +01:00
Brabli
92eee8fa04 Add info 2022-08-29 19:41:57 +01:00
Brabli
e12eceed26 Adjust comment spacing 2022-08-29 19:41:54 +01:00
Brabli
27dfa0af2e Test for black fill and stroke style removal 2022-08-29 19:31:27 +01:00
Brabli
3e4b948569 Add example config 2022-08-29 19:21:08 +01:00
Brabli
e72ce28af4 Remove args from services.yaml 2022-08-29 19:18:47 +01:00
Brabli
4329780c39 See if moving args down works 2022-08-29 18:38:48 +01:00
14 changed files with 621 additions and 306 deletions

View File

@@ -1,3 +1,70 @@
# DEPRECATED in favour of Symfony UX Icon
# PCM Icon Bundle
# WIP DON'T USE YET
Use icons inside of Twig templates with ease! Project must be using Tailwind for the colours to work properly.
```php
{{ pcm_icon({ icon: 'person' }) }}
```
## Config
Example config:
```yaml
# config/packages/pcm_icon.yaml
pcm_icon:
default:
size: 32
colour: primary
directories:
- '%kernel.project_dir%/public/icons'
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'
red:
fill: 'fill-red-800'
stroke: 'stroke-red-800'
fill-hover: 'hover:fill-red-800'
stroke-hover: 'hover:stroke-red-800'
fill-group-hover: 'group-hover:fill-red-800'
stroke-group-hover: 'group-hover:stroke-red-800'
```
`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** explicitly list all the classes required for Tailwind to render them properly. You can just copy this template replacing COLOUR as appropriate:
```yaml
COLOUR:
fill: 'fill-COLOUR'
stroke: 'stroke-COLOUR'
fill-hover: 'hover:fill-COLOUR'
stroke-hover: 'hover:stroke-COLOUR'
fill-group-hover: 'group-hover:fill-COLOUR'
stroke-group-hover: 'group-hover:stroke-COLOUR'
```
## Options
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension.
`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.
`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.
## 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,18 +5,16 @@ services:
pcm_icon.icon_extension:
public: true
class: Pcm\IconBundle\Twig\Functions\IconExtension
arguments:
$directories:
- '%kernel.project_dir%/public/icons'
$palletes:
- primary:
stroke: stroke-primary,
fill: fill-primary
- white:
stroke: stroke-white,
fill: fill-white
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'], ['is_safe' => ['html']])
];
}
}

View File

@@ -1,203 +0,0 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Functions;
use InvalidArgumentException;
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'
];
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!');
$pelletesContainNonarray = array_reduce($this->palletes,
fn($notArray, $path) => $notArray || !is_array($path));
if ($pelletesContainNonarray)
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))) {
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) Which icon to use
* 'title' => (?string) Text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour pallete
* ]
* ```
*/
public function renderIcon(array $userOptions): string
{
$options = $this->mergeUserOptionsWithDefaults($userOptions);
$iconFilepath = $this->findSvgFilepath($options['icon']);
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
$cleanSvgMarkup = $this->cleanSvgMarkup($rawSvgMarkup);
$mainPallete = $this->getMainPallete($options['colour']);
$xml = new \SimpleXMLElement($cleanSvgMarkup);
$xml = $this->addAttributeToXmlElement($xml, 'class', $mainPallete['stroke'] . ' ' . $mainPallete['fill']);
$markup = $this->removeXMLDeclaration($xml->saveXML());
if ($this->isNonEmptyString($options['title']))
$markup = $this->addTitleToMarkup($markup, $options['title']);
if ($options['size'] < 0)
throw new \InvalidArgumentException('Size must not be negative');
if (!is_int($options['size']))
throw new \TypeError('Size value must be an integer');
$markup = $this->setSize($markup, $options['size']);
$markup = $this->removeBlackStrokeAttributes($markup);
$markup = $this->removeBlackFillAttributes($markup);
return $markup;
}
private function mergeUserOptionsWithDefaults(array $userOptions): array
{
return array_merge(self::DEFAULT_OPTIONS, $userOptions);
}
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 cleanSvgMarkup(string $markup): string
{
return preg_replace('/<title>.*<\/title>/', '', $markup);
}
private function getMainPallete(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 isNonEmptyString(mixed $title): bool
{
if (!is_string($title) && null !== $title)
throw new \TypeError('Title must be a string!');
if ('' === $title)
throw new \InvalidArgumentException('Title string must not be empty!');
return true;
}
private function addTitleToMarkup(string $markup, ?string $title): string
{
if (null === $title)
return $markup;
return preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $markup);
}
private function setSize(string $content, int $size): string
{
$svgAsXmlElement = new \SimpleXMLElement($content);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'width', $size);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'height', $size);
return $this->removeXMLDeclaration($svgAsXmlElement->saveXML());
}
private function removeXMLDeclaration(string $content): string
{
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
}
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 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);
}
}
class IconNotFound extends \Exception {};
class PalleteNotFound extends \Exception {};
// class="stroke-neutral-400 fill-neutral-400"
// hover:stroke-ses-highlight
// group-hover:stroke-ses-highlight
// hover:fill-ses-highlight
// group-hover:fill-ses-highlight
// cursor-pointer

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,57 +4,44 @@ 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',
'fill' => 'fill-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',
],
'white' => [
'fill' => 'fill-white',
'stroke' => 'stroke-white',
'fill' => 'fill-white'
'fill-hover' => 'hover:fill-white',
'stroke-hover' => 'hover:stroke-white',
'fill-group-hover' => 'group-hover:fill-white',
'stroke-group-hover' => 'group-hover:stroke-white',
]
];
/**
* @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
@@ -137,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}\"/";
@@ -149,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}\"/";
@@ -161,36 +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' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['stroke' => '']]);
}
public function testBlackStrokeAttributeValuesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -200,6 +157,15 @@ class IconExtensionTest extends TestCase
$this->assertDoesNotMatchRegularExpression('/stroke="\s*rgb\(0,\s*0,\s*0\)\s*"/', $content);
}
public function testBlackStrokeStylesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*#000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*#000000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*black\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
}
public function testBlackFillAttributeValuesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -209,18 +175,88 @@ class IconExtensionTest extends TestCase
$this->assertDoesNotMatchRegularExpression('/fill="\s*rgb\(0,\s*0,\s*0\)\s*"/', $content);
}
public function testThrowsIfPalleteIsNotFound(): void
public function testBlackFillStylesAreRemoved(): void
{
$this->expectException(PalleteNotFound::class);
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertDoesNotMatchRegularExpression('/fill:\s*#000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*#000000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*black\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
}
public function testThrowsIfColourIsNotFound(): void
{
$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 testThrowsIfHoverColourIsNotFound(): void
{
$this->expectException(ColourNotFound::class);
$this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'red']);
}
public function testSvgClassContainsHoverColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white']);
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:stroke-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testSvgClassContainsHoverAndColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white', 'colour' => 'primary']);
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*stroke-primary.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:stroke-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testExtraClassesThrowsIfNotAnArray(): void
{
$this->expectException(\TypeError::class);
$this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'string_value']);
}
public function testExtraClassesGetAdded(): 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 testAddingExtraClassesDoesntStripAwayColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc']]);
$this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
}
public function testThrowsIfInvalidOptionPassed(): void
{
$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,7 +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" stroke='currentColor'/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 904 B