Skip to content

Commit

Permalink
Feat(web-twig): SVG Twig extension
Browse files Browse the repository at this point in the history
  • Loading branch information
janicekt authored and literat committed Jul 30, 2022
1 parent 8f77555 commit 8b9e609
Show file tree
Hide file tree
Showing 16 changed files with 451 additions and 2 deletions.
2 changes: 2 additions & 0 deletions packages/web-twig/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

<!-- There should always be "Unreleased" section at the beginning. -->
## Unreleased
- Add Svg twig extension for optimal loading of svg files as inline
- Add configuration param `icons` to define icon set path and alias

## 1.6.0 - 2022-04-29
- Add main props `data-*` and `id` into `Button` and `ButtonLink` components
Expand Down
4 changes: 4 additions & 0 deletions packages/web-twig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ If you want to change the default settings, create a config
paths_alias: 'jobs-ui' # default is 'spirit'
html_syntax_lexer: false # default is true
spirit_css_class_prefix: 'jobs' # default is null
icons: # optional settings for svg assets
paths:
- "%kernel.project_dir%/assets/icons" # define paths for svg icons set
alias: 'jobs-icons' # default is 'icons-assets'
```
## Usage
Expand Down
3 changes: 2 additions & 1 deletion packages/web-twig/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"symfony/http-foundation": "^3.4 || ^4.2 || ^5.0",
"symfony/http-kernel": "^3.4 || ^4.2 || ^5.0",
"symfony/polyfill-php80": "^1.23",
"twig/twig": "^1.44.6 || ^2.12.5 || ^3.0.0"
"twig/twig": "^1.44.6 || ^2.12.5 || ^3.0.0",
"ext-simplexml": "*"
},
"require-dev": {
"phpunit/phpunit": "^9",
Expand Down
23 changes: 23 additions & 0 deletions packages/web-twig/docs/inlineSVG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# How to use inline SVG in components

In some cases, you might want use SVG icons in components. It is Possible.
For these cases, we have prepared `SvgExtension` with function `inlineSvg`.

In the project where the bundle is used, it is necessary to set in the configuration:

**config/packages/spirit_web_twig.yml**
```yaml
spirit_web_twig:
...
icons: # optional settings for svg assets
paths:
- "%kernel.project_dir%/assets/icons" # define paths for svg icons set
alias: 'jobs-icons' # default is 'icons-assets'
```
then it is possible to call in the component
```twig
{{ inlineSvg('@icons-assets/iconName.svg', { class: ..., title: ... }) }}
```

if the icon does not exist, an empty string is returned and messages are sent to the symfony error logger.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public function process(ContainerBuilder $container): void
$pathAlias = $container->getParameter(SpiritWebTwigExtension::PARAMETER_PATH_ALIAS);
$isLexer = $container->getParameter(SpiritWebTwigExtension::PARAMETER_HTML_SYNTAX_LEXER);
$classPrefix = $container->getParameter(SpiritWebTwigExtension::PARAMETER_SPIRIT_CSS_CLASS_PREFIX);
/** @var array<string> $iconsPaths */
$iconsPaths = $container->getParameter(SpiritWebTwigExtension::PARAMETER_ICONS_PATHS);
$iconsPathAlias = $container->getParameter(SpiritWebTwigExtension::PARAMETER_ICONS_PATH_ALIAS);

$twigLoaderDefinition->addMethodCall('addPath', [SpiritWebTwigExtension::DEFAULT_PARTIALS_PATH, SpiritWebTwigExtension::DEFAULT_PARTIALS_ALIAS]);

Expand All @@ -37,6 +40,10 @@ public function process(ContainerBuilder $container): void
}
}

foreach ($iconsPaths as $iconPath) {
$twigLoaderDefinition->addMethodCall('addPath', [$iconPath, $iconsPathAlias]);
}

$twigDefinition->addMethodCall('addGlobal', [self::GLOBAL_PREFIX_TWIG_VARIABLE, $classPrefix]);

if ($isLexer) {
Expand Down
10 changes: 10 additions & 0 deletions packages/web-twig/src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('html_syntax_lexer')
->defaultTrue()
->end()
->arrayNode('icons')
->children()
->arrayNode('paths')
->scalarPrototype()->end()
->end()
->scalarNode('alias')
->defaultValue(SpiritWebTwigExtension::DEFAULT_ICONS_ALIAS)
->end()
->end()
->end()
->end();

return $treeBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class SpiritWebTwigExtension extends Extension

public const PARAMETER_HTML_SYNTAX_LEXER = 'spirit_web_twig.html_syntax_lexer';

public const PARAMETER_ICONS_PATHS = 'spirit_web_twig.icons.paths';

public const PARAMETER_ICONS_PATH_ALIAS = 'spirit_web_twig.icons.alias';

public const DEFAULT_COMPONENTS_PATH = __DIR__ . '/../Resources/components';

public const DEFAULT_PATH_ALIAS = 'spirit';
Expand All @@ -32,6 +36,8 @@ class SpiritWebTwigExtension extends Extension

public const DEFAULT_PARTIALS_ALIAS = 'partials';

public const DEFAULT_ICONS_ALIAS = 'icons-assets';

public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
Expand All @@ -44,5 +50,8 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter(self::PARAMETER_SPIRIT_CSS_CLASS_PREFIX, isset($config['spirit_css_class_prefix']) ? $config['spirit_css_class_prefix'] . '-' : null);
$container->setParameter(self::PARAMETER_PATH_ALIAS, $config['paths_alias']);
$container->setParameter(self::PARAMETER_HTML_SYNTAX_LEXER, (bool) $config['html_syntax_lexer']);

$container->setParameter(self::PARAMETER_ICONS_PATHS, isset($config['icons']) && isset($config['icons']['paths']) ? $config['icons']['paths'] : []);
$container->setParameter(self::PARAMETER_ICONS_PATH_ALIAS, isset($config['icons']) && isset($config['icons']['alias']) ? $config['icons']['alias'] : self::DEFAULT_ICONS_ALIAS);
}
}
4 changes: 4 additions & 0 deletions packages/web-twig/src/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ services:
- '%spirit_web_twig.paths_alias%'

Lmc\SpiritWebTwigBundle\Twig\PropsExtension:
tags:
- { name: twig.extension }

Lmc\SpiritWebTwigBundle\Twig\SvgExtension:
tags:
- { name: twig.extension }
196 changes: 196 additions & 0 deletions packages/web-twig/src/Twig/SvgExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace Lmc\SpiritWebTwigBundle\Twig;

use Psr\Log\LoggerInterface;
use SimpleXMLElement;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Extension\AbstractExtension;
use Twig\Source;
use Twig\TwigFunction;

class SvgExtension extends AbstractExtension
{
private const ALLOW_EXTENSION = '.svg';

private const ATTR = 'attr';

private const ATTR_CLASS = 'class';

private const ATTR_TITLE = 'title';

private const ATTR_ID = 'id';

/**
* @var array<string,SimpleXMLElement|false>
*/
private array $cacheIcon = [];

/**
* @var array<string,string>
*/
private array $cacheReusableIconContent = [];

private const SIMPLE_XML_ROOT_DECLARATION = "<?xml version=\"1.0\"?>\n";

private LoggerInterface $logger;

public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}

public function getFunctions(): array
{
return [
new TwigFunction('inlineSvg', [$this, 'getInlineSvg'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}

/**
* @param SimpleXMLElement|false $svgElement
*/
private function replaceXmlDeclaration($svgElement): string
{
if ($svgElement instanceof SimpleXMLElement && $svgElement->asXML() !== false) {
return str_replace(self::SIMPLE_XML_ROOT_DECLARATION, '', $svgElement->asXML());
}

return '';
}

/**
* @param array<string, string|array<string>> $params
*/
public function getInlineSvg(Environment $environment, string $path, array $params = [], bool $reUsage = true, bool $ignoreMissing = false): string
{
$loader = $environment->getLoader();
$twigSource = null;
$reusableSvg = null;

$ext = mb_substr($path, -4);
if ($ext !== self::ALLOW_EXTENSION) {
$path .= self::ALLOW_EXTENSION;
}

try {
$twigSource = $loader->getSourceContext($path);
} catch (LoaderError $e) {
if (! $ignoreMissing) {
$this->logger->critical('Missing svg "{path}"', [
'path' => $path,
]);
return '';
}
}

$iconId = md5($path . serialize($params));

if (array_key_exists($iconId, $this->cacheReusableIconContent)) {
return $this->cacheReusableIconContent[$iconId];
}

if (! $reUsage) {
$svgElement = $this->makeRegularSvg($twigSource, null, $params);

return $this->replaceXmlDeclaration($svgElement);
}

if (! array_key_exists($iconId, $this->cacheIcon)) {
$this->cacheIcon[$iconId] = $this->makeRegularSvg($twigSource, $iconId, $params);

return $this->cacheIcon[$iconId] !== false ? $this->replaceXmlDeclaration($this->cacheIcon[$iconId]) : '';
}

if ($this->cacheIcon[$iconId]) {
$reusableSvg = $this->makeReusableSVG($iconId, $this->cacheIcon[$iconId]);
}

assert($reusableSvg instanceof SimpleXMLElement);

return $this->replaceXmlDeclaration($reusableSvg);
}

/**
* @param array<string, string|array<string>> $params
* @return false | SimpleXMLElement
*/
private function makeRegularSvg(?Source $source, ?string $iconId, array $params = [])
{
if (! $source instanceof Source) {
return false;
}

$svgString = $source->getCode();

$hasClasses = array_key_exists(self::ATTR_CLASS, $params);
$hasTitle = array_key_exists(self::ATTR_TITLE, $params);
$hasAttributes = array_key_exists(self::ATTR, $params);

$svg = @simplexml_load_string($svgString);

if ($svg !== false) {
if ($iconId !== null) {
$this->replaceAttribute($svg, self::ATTR_ID, $iconId);
}

if ($hasClasses && is_string($params[self::ATTR_CLASS])) {
$this->replaceAttribute($svg, self::ATTR_CLASS, $params[self::ATTR_CLASS]);
}

if ($hasTitle && is_string($params[self::ATTR_TITLE]) && trim($params[self::ATTR_TITLE]) !== '') {
$svg->addChild(self::ATTR_TITLE, htmlspecialchars($params[self::ATTR_TITLE], ENT_QUOTES));
}

if ($hasAttributes && is_array($params[self::ATTR])) {
foreach ($params[self::ATTR] as $key => $value) {
$this->replaceAttribute($svg, $key, $value);
}
}
} else {
$this->logger->error('Error parse svg by simplexml_load_string from {class} in path "{path}"', [
'class' => Source::class,
'path' => $source->getPath(),
]);
}

return $svg;
}

/**
* @return false|SimpleXMLElement
*/
private function makeReusableSVG(string $iconId, SimpleXMLElement $regularSvg)
{
$attributes = $regularSvg->attributes();

$reuseSvg = simplexml_load_string('<svg></svg>');

if ($attributes !== null && $reuseSvg instanceof SimpleXMLElement) {
foreach ($attributes as $key => $value) {
if ($key !== self::ATTR_ID) {
$reuseSvg->addAttribute((string) $key, (string) $value);
} else {
$reuseSvg->addChild('use')->addAttribute('href', '#' . $iconId);
}
}
}

return $reuseSvg;
}

private function replaceAttribute(SimpleXMLElement $simpleXmlElement, string $attributeName, string $value): void
{
if (isset($simpleXmlElement->attributes()[$attributeName])) {
$simpleXmlElement->attributes()[$attributeName] = $value;
} else {
$simpleXmlElement->addAttribute($attributeName, $value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public function testShouldRegisterTwigPaths(array $paths, int $expectedCalls): v
$this->builder->setParameter('spirit_web_twig.paths_alias', 'test');
$this->builder->setParameter('spirit_web_twig.html_syntax_lexer', false);
$this->builder->setParameter('spirit_web_twig.spirit_css_class_prefix', null);
$this->builder->setParameter('spirit_web_twig.icons.paths', []);
$this->builder->setParameter('spirit_web_twig.icons.alias', 'test-icons');
$this->overrideService->process($this->builder);

$filteredAddPathCalls = DefinitionHelper::getMethodCalls(
Expand Down Expand Up @@ -83,6 +85,8 @@ public function testShouldExtendTwigService(bool $isLexer, int $expectedCalls):
$this->builder->setParameter('spirit_web_twig.paths_alias', 'test');
$this->builder->setParameter('spirit_web_twig.html_syntax_lexer', $isLexer);
$this->builder->setParameter('spirit_web_twig.spirit_css_class_prefix', null);
$this->builder->setParameter('spirit_web_twig.icons.paths', []);
$this->builder->setParameter('spirit_web_twig.icons.alias', 'test-icons');
$this->overrideService->process($this->builder);

$filteredAddGlobal = DefinitionHelper::getMethodCalls(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ public function testConfigurationDefinition(): void
paths: []
paths_alias: spirit
spirit_css_class_prefix: null
html_syntax_lexer: true\n
html_syntax_lexer: true
icons:
paths: []
alias: icons-assets\n
CONFIG;

$this->assertEquals($reference, $dumper->dump(new Configuration()));
Expand Down
Loading

0 comments on commit 8b9e609

Please sign in to comment.