Skip to content

Commit

Permalink
Add Iconify provider
Browse files Browse the repository at this point in the history
  • Loading branch information
ocubom committed Mar 26, 2023
1 parent 228e380 commit a3ea107
Show file tree
Hide file tree
Showing 15 changed files with 585 additions and 0 deletions.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ $twig->addRuntimeLoader([
new \Ocubom\Twig\Extension\Svg\Provider\FontAwesome\FontAwesomeLoader($paths)
);
},
\Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyRuntime::class => function () use ($paths) {
return new \Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyRuntime(
new \Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyLoader($paths)
);
},
]);

// You can also dynamically create a RuntimeLoader
Expand All @@ -111,6 +116,12 @@ $twig->addRuntimeLoader(new class() implements RuntimeLoaderInterface {
);
}
if (\Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyRuntime::class === $class) {
return new \Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyRuntime(
new \Ocubom\Twig\Extension\Svg\Provider\Iconify\IconifyLoader($paths)
);
}
return null;
}
});
Expand Down Expand Up @@ -237,6 +248,36 @@ This filter looks for FontAwesome tags and replaces them by embedding the corres
>
> The `fa` function can be used to generate the tags.
### Iconify Provider

#### `iconify` filter

This filter looks for Iconify SVG Framework or Web Component and replaces them by embedding the corresponding SVG.

> **Warning**
>
> The filter must be applied at HTML document level.
>
> If the filter is used in a fragment, an exception will be generated.
```twig
{%- apply iconify -%}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<span class="mdi:home" title="This is a title"></span>
</body>
</html>
{%- endapply -%}
```

## Roadmap

See the [open issues](https://github.com/ocubom/twig-svg-extension/issues) for a full list of proposed features (and known issues).
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"ext-dom": "*",
"ext-libxml": "*",
"bentools/iterable-functions": "^2.0",
"iconify/json-tools": "^1.0",
"masterminds/html5": "^2.0",
"ocubom/base-convert": "^2.0",
"symfony/deprecation-contracts": "^2.5|^3.2",
Expand All @@ -42,6 +43,7 @@
"require-dev": {
"enshrined/svg-sanitize": "^0.15",
"friendsofphp/php-cs-fixer": "^3.4|^3.9",
"iconify/json": "*",
"symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0"
},
"scripts": {
Expand Down
155 changes: 155 additions & 0 deletions src/Svg/Provider/Iconify/IconifyLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

/*
* This file is part of ocubom/twig-svg-extension
*
* © Oscar Cubo Medina <https://ocubom.github.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Ocubom\Twig\Extension\Svg\Provider\Iconify;

use Iconify\JSONTools\Collection;
use Iconify\JSONTools\SVG as Icon;
use Ocubom\Twig\Extension\Svg\Exception\LoaderException;
use Ocubom\Twig\Extension\Svg\Exception\LogicException;
use Ocubom\Twig\Extension\Svg\Loader\LoaderInterface;
use Ocubom\Twig\Extension\Svg\Svg;
use Ocubom\Twig\Extension\Svg\Util\PathCollection;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

use function BenTools\IterableFunctions\iterable_to_array;
use function Ocubom\Twig\Extension\is_string;

class IconifyLoader implements LoaderInterface
{
private PathCollection $searchPath;
private ?string $cacheDir;
private static Filesystem $fs;
private const CACHE_BASEDIR = 'iconify';
private const CACHE_EXTENSION = 'php';

public function __construct(PathCollection $searchPath, iterable $options = null)
{
self::$fs = new Filesystem();

$this->searchPath = $searchPath;

// Parse options
$options = static::configureOptions()
->resolve(iterable_to_array($options ?? /* @scrutinizer ignore-type */ []));
$this->cacheDir = Path::canonicalize($options['cache_dir'] ?? '');
if ('' === $this->cacheDir) {
$this->cacheDir = null; // @codeCoverageIgnore
} else {
// Ensure cache dir is inside prefixed subdir
if (self::CACHE_BASEDIR !== Path::getFilenameWithoutExtension($this->cacheDir)) {
$this->cacheDir = Path::join($this->cacheDir, self::CACHE_BASEDIR);
}
}
}

public function resolve(string $ident, iterable $options = null): Svg
{
// Split ident
$tokens = preg_split('@[/:\-]+@Uis', $ident);
$count = count($tokens);
for ($idx = 1; $idx < $count; ++$idx) {
$icon = $this->loadIcon(
join('-', array_slice($tokens, 0, $idx)),
join('-', array_slice($tokens, $idx - $count))
);

if ($icon) {
return new Svg($icon->getSVG(iterable_to_array($options ?? [])));
}
}

throw new LoaderException($ident, new \ReflectionClass($this));
}

private function loadIcon(string $prefix, string $name): ?Icon
{
foreach ($this->getCollectionPaths($prefix) as $path) {
if (!self::$fs->exists($path)) {
continue;
}

$collection = new Collection();
if (!$collection->loadFromFile($path, null, $this->getCacheFile($prefix))) {
continue;
}

$data = $collection->getIconData($name);
if (!$data) {
continue;
}

return new Icon($data);
}

// Unable to find icon
return null;
}

private function getCacheFile(string $collection): ?string
{
if (null === $this->cacheDir) {
return null; // @codeCoverageIgnore
}

$path = Path::changeExtension(
Path::join($this->cacheDir, $collection),
self::CACHE_EXTENSION
);
if (!Path::isBasePath($this->cacheDir, $path)) {
// @codeCoverageIgnoreStart
throw new LogicException(sprintf(
'The generated cache path `%s` is outside the base cache directory `%s`.',
$path,
$this->cacheDir
));
// @codeCoverageIgnoreEnd
}

// Ensure cache dir exists
self::$fs->mkdir($this->cacheDir, 0755);

return $path;
}

/**
* @psalm-return \Generator<int, string, mixed, void>
*/
private function getCollectionPaths(string $name): \Generator
{
foreach ($this->searchPath as $basePath) {
// Try @iconify/json path (full set)
yield Path::join((string) $basePath, 'json', $name.'.json');

// Try @iconify-json path (cherry picking)
yield Path::join((string) $basePath, $name, 'icons.json');
}
}

/** @psalm-suppress MissingClosureParamType */
protected static function configureOptions(OptionsResolver $resolver = null): OptionsResolver
{
$resolver = $resolver ?? new OptionsResolver();

$resolver->define('cache_dir')
->default(null)
->allowedTypes('null', 'string', \SplFileInfo::class)
->normalize(function (Options $options, $value): string {
return is_string($value) ? $value : ($value ?? '');
})
->info('Where cache files will be stored');

return $resolver;
}
}
144 changes: 144 additions & 0 deletions src/Svg/Provider/Iconify/IconifyRuntime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of ocubom/twig-svg-extension
*
* © Oscar Cubo Medina <https://ocubom.github.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Ocubom\Twig\Extension\Svg\Provider\Iconify;

use Ocubom\Twig\Extension\Svg\Loader\LoaderInterface;
use Ocubom\Twig\Extension\Svg\Util\DomUtil;
use Ocubom\Twig\Extension\Svg\Util\Html5Util;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;

use function BenTools\IterableFunctions\iterable_merge;
use function BenTools\IterableFunctions\iterable_to_array;
use function Ocubom\Twig\Extension\is_string;

class IconifyRuntime implements RuntimeExtensionInterface
{
private LoaderInterface $loader;

private array $options;

public function __construct(IconifyLoader $loader, iterable $options = null)
{
$this->loader = $loader;
$this->options = static::configureOptions()
->resolve(iterable_to_array($options ?? /* @scrutinizer ignore-type */ []));
}

public function replaceIcons(
Environment $twig,
string $html,
array $options = []
): string {
// Load HTML
$doc = Html5Util::loadHtml($html);

/** @var \DOMNode $node */
foreach (iterable_to_array($this->queryIconify($doc, $options)) as $node) {
if ($node instanceof \DOMElement) {
if ($twig->isDebug()) {
DomUtil::createComment(DomUtil::toHtml($node), $node, true);
}

$ident = $node->hasAttribute('data-icon')
? $node->getAttribute('data-icon')
: $node->getAttribute('icon');
$icon = $this->loader->resolve(
$ident, // Resolve icon
DomUtil::getElementAttributes($node) // … and clone all its attributes as options
);

// Replace node
DomUtil::replaceNode($node, $icon->getElement());
}
}

// Generate normalized HTML
return Html5Util::toHtml($doc);
}

/**
* @return iterable<\DOMElement>
*/
private function queryIconify(\DOMDocument $doc, iterable $options = null)
{
$options = static::configureOptions()->resolve(iterable_to_array(iterable_merge(
$this->options,
$options ?? /* @scrutinizer ignore-type */ []
)));

// SVG Framework
// <span class="iconify" data-icon="mdi:home"></span>
// <span class="iconify-inline" data-icon="mdi:home"></span>
if ($options['svg_framework']) {
$query = implode(' | ', array_map(
function (string $class): string {
return sprintf(
'descendant-or-self::*[@class and contains(concat(\' \', normalize-space(@class), \' \'), \' %s \')]',
$class
);
},
$options['svg_framework']
));

foreach (DomUtil::query($query, $doc) as $node) {
if ($node instanceof \DOMElement && $node->hasAttribute('data-icon')) {
yield $node;
}
}
}

// Web Component
// <icon icon="mdi:home" />
// <iconify-icon icon="mdi:home"></iconify-icon>
if ($options['web_component']) {
foreach ($options['web_component'] as $tag) {
foreach ($doc->getElementsByTagName($tag) as $node) {
if ($node instanceof \DOMElement && $node->hasAttribute('icon')) {
yield $node;
}
}
}
}
}

/** @psalm-suppress MissingClosureParamType */
protected static function configureOptions(OptionsResolver $resolver = null): OptionsResolver
{
$resolver = $resolver ?? new OptionsResolver();

$normalizeStringArray = function (Options $options, $value): array {
return array_filter(
is_string($value) ? preg_split('@\s+@Uis', $value) : ($value ?? []),
function (string $item): bool {
return !empty($item);
}
);
};

$resolver->define('svg_framework')
->default(['iconify', 'iconify-inline'])
->allowedTypes('null', 'string', 'string[]')
->normalize($normalizeStringArray)
->info('SVG Framework classes');

$resolver->define('web_component')
->default(['icon', 'iconify-icon'])
->allowedTypes('null', 'string', 'string[]')
->normalize($normalizeStringArray)
->info('Web Component tags');

return $resolver;
}
}

0 comments on commit a3ea107

Please sign in to comment.