Skip to content

Instantly share code, notes, and snippets.

@janklan
Last active September 18, 2023 07:27
Show Gist options
  • Save janklan/5afbd9c2ecb0f8f1c089631c28bd7b2d to your computer and use it in GitHub Desktop.
Save janklan/5afbd9c2ecb0f8f1c089631c28bd7b2d to your computer and use it in GitHub Desktop.
FontAwesome - icons embedded in a Twig extension
  1. Look for GIST NOTE comment in the files I posted
  2. Install FontAwesome SVG packages via npm/yarn
  3. Copy files in this gist into your project & modify to your needs
  4. Run bin/console app:icons any time you change the set of icons you need. It will update the Twig extension and the sprite file. Assuming your icon set is fairly stable, this manual step might not bother you.

If the steps above worked, you should be now able to refer to your icons in Twig, for example {{ regularIcon('xmark') }}.

I found it super useful to use those enums as it enables static code analysis, discovery of dangling icons and so on. If you want to render the backed enum in twig directly, use {{ enumIcon(iconVariable) }}.

The enumIcon, regularIcon etc functions have a couple of arguments, check out the Twig extension what are they all about.

My last notes are that this might also exist as a Twig UX component for better syntactic sugar, but it's probably not necessary from any other PoV.

<?php
namespace App\Command;
use App\Enum\Icons\BrandIcon;
use App\Enum\Icons\LightIcon;
use App\Enum\Icons\RegularIcon;
use App\Enum\Icons\SolidIcon;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\When;
/**
* @codeCoverageIgnore
*/
#[When(env: 'dev')]
#[AsCommand(
name: 'app:icons',
description: 'Rebuilds SVG sprites containing registered Font Awesome icons.',
)]
class IconSpritesCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
###
# GIST NOTE: Only "RegularIcon" is part of this gist, so I commented out references to other icon styles.
# Use the RegularIcon as a template for other styles
###
$icons = [
// ...$this->extractIcons(LightIcon::getStyle(), LightIcon::cases()),
...$this->extractIcons(RegularIcon::getStyle(), RegularIcon::cases()),
// ...$this->extractIcons(SolidIcon::getStyle(), SolidIcon::cases()),
// ...$this->extractIcons(BrandIcon::getStyle(), BrandIcon::cases()),
];
ksort($icons);
$this->exportSvgSprite($icons);
$this->updateTwigExtension($icons);
$io->success('Done.');
return Command::SUCCESS;
}
private function exportSvgSprite(array $icons): void
{
$xml = implode(
"\n",
array_map(
static fn (\SimpleXMLElement $icon) => trim(
preg_replace('/>[\s\n]+</', '><', $icon->asXML())
),
$icons
)
);
$out = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" style="display: none;">'."\n".$xml."\n".'</svg>');
$out->saveXML(__DIR__.'/../../assets/images/sprites/icons.svg');
}
private function extractIcons(string $style, array $cases): array
{
###
# GIST NOTE: I'm using Pro icons, but it should all work with the free set as well, as long as you update the path here
###
$sourceSprite = 'node_modules/@fortawesome/fontawesome-pro/sprites/'.$style.'.svg';
$fileName = realpath(__DIR__.'/../../'.$sourceSprite);
if (empty($fileName) || !is_readable($fileName)) {
throw new \RuntimeException(sprintf('File %s does not exist. Did you run `yarn install`?', $sourceSprite));
}
$source = new \SimpleXMLElement(file_get_contents($fileName));
$icons = [];
foreach ($cases as $case) {
$results = $source->xpath("*[@id='".$case->value."'][1]");
$icon = array_shift($results);
if (!$icon) {
throw new \RuntimeException(sprintf('Icon %s was not found in %s sprite.', $case->value, $style));
}
$id = $style.'-'.$icon->attributes()->id;
$icon->attributes()->id = $id;
$icons[$id] = $icon;
}
return $icons;
}
/**
* @param \SimpleXMLElement[] $icons
*/
private function updateTwigExtension(array $icons): void
{
$code = [];
foreach ($icons as $id => $icon) {
$icon->attributes()?->addAttribute('class', '%s');
$icon->attributes()?->addAttribute('width', '16');
$icon->attributes()?->addAttribute('height', '16');
$icon->attributes()?->addAttribute('xmlns', 'http://www.w3.org/2000/svg');
$code[$id] = str_replace('symbol', 'svg', trim(preg_replace('/>[\s\n]+</', '><', $icon->asXML())));
}
$extensionFilePath = __DIR__.'/../../src/Twig/FontAwesomeExtension.php';
$extensionFile = file_get_contents($extensionFilePath);
$finalCode = var_export($code, true);
$finalCode = preg_replace('/ /', ' ', $finalCode);
$finalCode = str_replace(['array (', ')'], ['[', ' ]'], $finalCode);
$finalCode = "\n public const ICONS = ".$finalCode.";\n ";
$pattern = '~// ##> app/icons ##.*// ##< app\/icons ##~ims';
$replacement = '// ##> app/icons ##'.$finalCode.'// ##< app/icons ##';
if (!preg_match($pattern, $extensionFile)) {
throw new \RuntimeException('The regex pattern looking for app/icons section did not find anything in the extension file. Did somebody remove the markers?');
}
$extensionFile = preg_replace($pattern, $replacement, $extensionFile);
file_put_contents($extensionFilePath, $extensionFile);
}
}
<?php
namespace App\Enum\Icons;
use App\Command\IconSpritesCommand;
use App\Twig\FontAwesomeExtension;
enum RegularIcon: string implements FontAwesomeIconInterface
{
case EDIT = 'pen-to-square';
// ... any other icons you wish to include
case TRASH = 'trash';
case XMARK = 'xmark';
/**
* @codeCoverageIgnore
*/
public static function getStyle(): string
{
return 'regular';
}
}
<?php
namespace App\Enum\Icons;
interface FontAwesomeIconInterface extends \BackedEnum
{
public static function getStyle();
}
<?php
namespace App\Twig;
// use App\Enum\Icons\BrandIcon;
use App\Enum\Icons\FontAwesomeIconInterface;
// use App\Enum\Icons\LightIcon;
use App\Enum\Icons\RegularIcon;
// use App\Enum\Icons\SolidIcon;
use Symfony\Component\Asset\Packages;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;
class FontAwesomeExtension extends AbstractExtension
{
/**
* Auto-generated extract of all icons we're using on the website.
*
* @see docs/icons.md
*
* @var array|string[]
*/
// ##> app/icons ##
public const ICONS = [
'regular-pen-to-square' => '<svg id="regular-pen-to-square" viewBox="0 0 512 512" class="%s" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M454.6 45.3l12.1 12.1c12.5 12.5 12.5 32.8 0 45.3L440 129.4 382.6 72l26.7-26.7c12.5-12.5 32.8-12.5 45.3 0zM189 265.6l171-171L417.4 152l-171 171c-4.2 4.2-9.6 7.2-15.4 8.6l-65.6 15.1L180.5 281c1.3-5.8 4.3-11.2 8.6-15.4zm197.7-243L166.4 243c-8.5 8.5-14.4 19.2-17.1 30.9l-20.9 90.6c-1.2 5.4 .4 11 4.3 14.9s9.5 5.5 14.9 4.3l90.6-20.9c11.7-2.7 22.4-8.6 30.9-17.1L489.4 125.3c25-25 25-65.5 0-90.5L477.3 22.6c-25-25-65.5-25-90.5 0zM80 64C35.8 64 0 99.8 0 144V432c0 44.2 35.8 80 80 80H368c44.2 0 80-35.8 80-80V304c0-8.8-7.2-16-16-16s-16 7.2-16 16V432c0 26.5-21.5 48-48 48H80c-26.5 0-48-21.5-48-48V144c0-26.5 21.5-48 48-48H208c8.8 0 16-7.2 16-16s-7.2-16-16-16H80z"/></svg>',
'regular-trash' => '<svg id="regular-trash" viewBox="0 0 448 512" class="%s" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M177.7 32h92.5c5.5 0 10.6 2.8 13.6 7.5L299.1 64H148.9l15.3-24.5c2.9-4.7 8.1-7.5 13.6-7.5zM336.9 64L311 22.6C302.2 8.5 286.8 0 270.3 0H177.7C161.2 0 145.8 8.5 137 22.6L111.1 64H64.1 32 16C7.2 64 0 71.2 0 80s7.2 16 16 16H34.3L59.8 452.6C62.1 486.1 90 512 123.6 512H324.4c33.6 0 61.4-25.9 63.8-59.4L413.7 96H432c8.8 0 16-7.2 16-16s-7.2-16-16-16H416 383.9 336.9zm44.8 32L356.3 450.3C355.1 467 341.2 480 324.4 480H123.6c-16.8 0-30.7-13-31.9-29.7L66.4 96H381.6z"/></svg>',
'regular-xmark' => '<svg id="regular-xmark" viewBox="0 0 384 512" class="%s" width="16" height="16" xmlns="http://www.w3.org/2000/svg"><path d="M324.5 411.1c6.2 6.2 16.4 6.2 22.6 0s6.2-16.4 0-22.6L214.6 256 347.1 123.5c6.2-6.2 6.2-16.4 0-22.6s-16.4-6.2-22.6 0L192 233.4 59.5 100.9c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6L169.4 256 36.9 388.5c-6.2 6.2-6.2 16.4 0 22.6s16.4 6.2 22.6 0L192 278.6 324.5 411.1z"/></svg>',
];
// ##< app/icons ###
public function __construct(private readonly Packages $packages)
{
}
public function enumIcon(FontAwesomeIconInterface|string $icon, string $cssClasses = 'fa-4', bool $fromSprite = false): string
{
if (is_string($icon)) {
$icon = RegularIcon::from($icon);
}
$id = $icon->getStyle().'-'.$icon->value;
if (!isset(self::ICONS[$id])) {
throw new \RuntimeException(sprintf('The icon %s does not exist. Did you forget to run `bin/console app:icons`?', $id));
}
if ($fromSprite) {
return $this->fromSprite($icon, $cssClasses);
}
return sprintf(self::ICONS[$icon->getStyle().'-'.$icon->value], $cssClasses);
}
public function getFunctions(): array
{
return [
new TwigFunction('enumIcon', $this->enumIcon(...), ['is_safe' => ['html']]),
// new TwigFunction('lightIcon', $this->getLightIcon(...), ['is_safe' => ['html']]),
new TwigFunction('regularIcon', $this->getRegularIcon(...), ['is_safe' => ['html']]),
// new TwigFunction('solidIcon', $this->getSolidIcon(...), ['is_safe' => ['html']]),
// new TwigFunction('brandIcon', $this->getBrandIcon(...), ['is_safe' => ['html']]),
];
}
public function getTests(): array
{
return [
new TwigTest('enumIcon', $this->isEnumIcon(...)),
];
}
public function isEnumIcon(mixed $object): bool
{
return $object instanceof FontAwesomeIconInterface;
}
// public function getLightIcon(string|array $icon, ?string $cssClasses = 'fa-4', bool $fromSprite = false): string
// {
// if (is_array($icon)) {
// $icon = $this->findIconInArray($icon);
// }
// return $this->enumIcon(LightIcon::from($icon), $cssClasses, $fromSprite);
// }
public function getRegularIcon(string|array $icon, ?string $cssClasses = 'fa-4', bool $fromSprite = false): string
{
if (is_array($icon)) {
$icon = $this->findIconInArray($icon);
}
return $this->enumIcon(RegularIcon::from($icon), $cssClasses, $fromSprite);
}
// public function getSolidIcon(string|array $icon, ?string $cssClasses = 'fa-4', bool $fromSprite = false): string
// {
// if (is_array($icon)) {
// $icon = $this->findIconInArray($icon);
// }
// return $this->enumIcon(SolidIcon::from($icon), $cssClasses, $fromSprite);
// }
// public function getBrandIcon(string|array $icon, ?string $cssClasses = 'fa-4', bool $fromSprite = false): string
// {
// if (is_array($icon)) {
// $icon = $this->findIconInArray($icon);
// }
// return $this->enumIcon(BrandIcon::from($icon), $cssClasses, $fromSprite);
// }
private function findIconInArray(array $icons): string
{
foreach ($icons as $icon => $condition) {
if (true === $condition) {
return $icon;
}
}
throw new \RuntimeException(sprintf('None of the available icons was active: %s', implode(array_keys($icons))));
}
/**
* Load an icon from the generated SVG sprite.
*
* Use if you are loading a LOT of the same icons on one page, and the SVG code is long. If this is not the case,
* please use {@link getIcon()}
*/
private function fromSprite(FontAwesomeIconInterface $icon, string $cssClasses = 'fa-4'): string
{
return
($cssClasses ? '<svg class="'.$cssClasses.'">' : '<svg>').
'<use xlink:href="'.$this->packages->getUrl('build/images/sprites/icons.svg').'#'.$icon->getStyle().'-'.$icon->value.'"></use>'.
'</svg>';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment