IconFinder.php

Namespace

Drupal\Core\Theme\Icon

File

core/lib/Drupal/Core/Theme/Icon/IconFinder.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Core\Theme\Icon;

use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Finder;

/**
 * Icon finder to discover files under specific paths or URLs.
 *
 * This class locates icon files based on a provided source, which can be either
 * a local path with optional pattern to extract id and group or an URL.
 *
 * Local Paths:
 * For local paths, the class leverage Symfony Finder features with some extra
 * functionalities related to our icon definition:
 * - Icon ID Extraction (`{icon_id}`): A placeholder `{icon_id}` within
 *   the filename allows extracting a portion as the icon ID. For
 *   example, a source definition like `/{icon_id}-24.svg` would extract
 *   "book" as the icon ID from the file "book-24.svg".
 * - Group Metadata Extraction (`{group}`): A placeholder `{group}` within
 *   the path allows extracting a folder name as group metadata for the icon.
 *   For instance, a source definition like `/foo/{group}/*` for the file
 *   "foo/outline/icon.svg" would assign "outline" as the group for the icon.
 * The source path can be:
 * - Absolute: Starting with a slash `/`, indicating a path relative to the
 *   Drupal installation root.
 * - Relative: Without a leading slash, indicating a path relative to the
 *   definition folder.
 * URLs:
 * For URLs, the source is treated as the direct URL to the icon resource.
 * Patterns can not be applied so the filename will be used as icon_id and no
 * group is possible.
 * Query parameters that change the file display are not supported as icon_id is
 * based on the resource filename through pathinfo().
 *
 * The class returns an array containing information about the discovered icon:
 * - icon_id (string)
 *   Id based on filename or {icon_id} pattern for path
 * - source (string)
 *   URL to the file, can be external or internal to the Drupal
 * - absolute_path (string)
 *   Local path to the file or url, some extractors may need to read the file
 * - group (string|null)
 *   Optional metadata extracted from {group} pattern if used in the source
 *
 * When multiple icons with the same filename (or icon_id) are discovered for
 * the same icon pack, only the last one in definition order will be kept.
 * The icon_id does not include the extension of the file or any query
 * parameters.
 * This is intentional to allow moving icons in different folders and even
 * switching format while keeping the same id.
 *
 * In the same way, the filename is used as icon_id without transformation and
 * could contain special characters.
 *
 * @internal
 *   This API is experimental.
 */
class IconFinder implements ContainerInjectionInterface, IconFinderInterface {
    use AutowireTrait;
    
    /**
     * Pattern to match a group placeholder in a source path.
     *
     * This constant is used to identify and extract group metadata from source
     * paths defined for icon pack.
     */
    private const GROUP_PATTERN = '{group}';
    
    /**
     * Pattern to match an icon ID placeholder in a filename.
     *
     * This constant is used to identify and extract icon IDs from filenames
     * within source paths defined for icon pack.
     */
    private const ICON_ID_PATTERN = '{icon_id}';
    
    /**
     * List of allowed file extensions for local icon files.
     *
     * This restriction is in place for security reasons, so a definition can not
     * be used to expose the content of a non image to the extractor.
     * Furthermore it make no sense to use other file format as Icons.
     */
    private const ALLOWED_EXTENSION = [
        'svg',
        'png',
        'gif',
    ];
    
    /**
     * Constructs a new IconFinder object.
     *
     * @param \Drupal\Core\File\FileUrlGeneratorInterface $fileUrlGenerator
     *   The file URL generator service.
     * @param \Psr\Log\LoggerInterface $logger
     *   A logger instance.
     * @param string $appRoot
     *   The application root.
     */
    public function __construct(FileUrlGeneratorInterface $fileUrlGenerator, LoggerInterface $logger, string $appRoot) {
    }
    
    /**
     * {@inheritdoc}
     */
    public function getFileContents(string $uri) : string|bool {
        $url = parse_url($uri);
        if (isset($url['scheme']) || isset($url['host'])) {
            return FALSE;
        }
        if (!file_exists($uri)) {
            return FALSE;
        }
        return file_get_contents($uri);
    }
    
    /**
     * {@inheritdoc}
     */
    public function getFilesFromSources(array $sources, string $relative_path) : array {
        $result = [];
        foreach ($sources as $source) {
            // Detect if source is path or remote, parse_url will have no scheme for
            // a path.
            $url = parse_url($source);
            if (isset($url['scheme']) && isset($url['path'])) {
                $result = array_merge($result, $this->getFileFromUrl($url['scheme'], $url['path'], $source));
                continue;
            }
            $result = array_merge($result, $this->getFilesFromPath($source, $relative_path));
        }
        return $result;
    }
    
    /**
     * Get filename from an URL source.
     *
     * Because url to an image can be of various form, there is no extension
     * validation, only scheme, the extractor must handle the constraints.
     *
     * @param string $scheme
     *   The url scheme from parse_url().
     * @param string $path
     *   The url path from parse_url().
     * @param string $source
     *   The source.
     *
     * @return array<string, array<string, string|null>>
     *   The file discovered.
     */
    private function getFileFromUrl(string $scheme, string $path, string $source) : array {
        if (!in_array($scheme, UrlHelper::getAllowedProtocols(), TRUE)) {
            $this->logger
                ->warning('Invalid icon source: @source', [
                '@source' => $source,
            ]);
            return [];
        }
        // Decode url to have cleaner filename.
        $icon_id = pathinfo(urldecode($path), PATHINFO_FILENAME);
        // Icon ID is used as index to avoid duplicates.
        return [
            $icon_id => [
                'icon_id' => $icon_id,
                'source' => $source,
                'absolute_path' => $source,
            ],
        ];
    }
    
    /**
     * Get files from a local path.
     *
     * This is a wrapper to use Symfony Finder with 2 extras features {group} and
     * {icon_id}.
     *
     * @param string $source
     *   The source path, which can be absolute (starting with '/') or relative
     *   to the definition folder.
     * @param string $relative_path
     *   The relative path to the definition folder.
     *
     * @return array<string, array<string, string|null>>
     *   The file discovered.
     */
    private function getFilesFromPath(string $source, string $relative_path) : array {
        $path_info = pathinfo($source);
        $dirname = $path_info['dirname'] ?? '';
        $extension = $path_info['extension'] ?? '';
        $filename = $path_info['filename'] ?? '';
        if (empty($dirname)) {
            return [];
        }
        // Set extension to wildcard if empty, and validate against allowed
        // extensions.
        $extension = empty($extension) ? '*' : $extension;
        if ('*' !== $extension && !in_array($extension, self::ALLOWED_EXTENSION, TRUE)) {
            $this->logger
                ->warning('Invalid icon path extension @filename.@extension in source: @source', [
                '@filename' => $filename,
                '@extension' => $extension,
                '@source' => $source,
            ]);
            return [];
        }
        // Use allowed extension bracket for Finder if wildcard.
        $extension = '*' === $extension ? '{' . implode(',', self::ALLOWED_EXTENSION) . '}' : $extension;
        // Prepare filename wildcard if empty or with {icon_id} pattern.
        $filename_wildcard = empty($filename) ? '*' : str_replace(self::ICON_ID_PATTERN, '*', $filename);
        // If icons are in the same folder dirname is 'dot'.
        if ('.' === $dirname) {
            $dirname = '';
        }
        // Prepare path to search for icons for Finder::in().
        $path = str_starts_with($source, '/') ? $this->appRoot . $dirname : sprintf('%s/%s/%s', $this->appRoot, $relative_path, $dirname);
        // Prepare file names for Finder::name().
        $names = sprintf('%s.%s', $filename_wildcard, $extension);
        if (!($finder = $this->findFiles($path, $names))) {
            return [];
        }
        // Wildcard around filename are ignored for extractIconIdFromFilename.
        $filename = str_replace('*', '', $filename);
        return $this->processFoundFiles($finder, $source, $filename, self::determineGroupPosition($path));
    }
    
    /**
     * Creates a Finder instance with configured patterns and return result.
     *
     * @param string $path
     *   The path to search for icons.
     * @param string $names
     *   The file names for Finder::names().
     *
     * @return \Symfony\Component\Finder\Finder|null
     *   The configured Finder instance.
     */
    private function findFiles(string $path, string $names) : ?Finder {
        $path = str_replace(self::GROUP_PATTERN, '*', $path);
        $finder = new Finder();
        try {
            $finder->depth(0)
                ->in($path)
                ->files()
                ->name($names)
                ->sortByExtension();
        } catch (\Throwable) {
            $this->logger
                ->warning('Invalid icon path in source: @source', [
                '@source' => $path,
            ]);
            return NULL;
        }
        if (!$finder->hasResults()) {
            $this->logger
                ->warning('No icon found in source: @source', [
                '@source' => $path,
            ]);
            return NULL;
        }
        return $finder;
    }
    
    /**
     * Process files and format icon information.
     *
     * @param \Symfony\Component\Finder\Finder $finder
     *   The Finder instance with found files.
     * @param string $source
     *   The source.
     * @param string $path_info_filename
     *   The filename from path_info().
     * @param int|null $group_position
     *   The position of the group in the path, or null if not applicable.
     *
     * @return array<string, array<string, string|null>>
     *   List of files with metadata.
     */
    private function processFoundFiles(Finder $finder, string $source, string $path_info_filename, ?int $group_position) : array {
        $result = [];
        $has_icon_pattern = \str_contains($path_info_filename, self::ICON_ID_PATTERN);
        foreach ($finder as $file) {
            $file_absolute_path = $file->getPathName();
            $icon_id = $file->getFilenameWithoutExtension();
            // If an {icon_id} pattern is used, extract it to be used.
            if ($has_icon_pattern) {
                $icon_id = self::extractIconIdFromFilename($icon_id, $path_info_filename);
            }
            // Icon ID is used as index to avoid duplicates.
            $result[$icon_id] = [
                'icon_id' => $icon_id,
                'source' => $this->fileUrlGenerator
                    ->generateString(str_replace($this->appRoot, '', $file_absolute_path)),
                'absolute_path' => $file_absolute_path,
                'group' => self::extractGroupFromPath($file->getPath(), $group_position),
            ];
        }
        return $result;
    }
    
    /**
     * Check if {icon_id} is a part of the name and need to be extracted.
     *
     * @param string $filename
     *   The filename found to match against.
     * @param string $filename_pattern
     *   The path with {icon_id}.
     *
     * @return string
     *   The extracted icon ID or the original filename.
     */
    private static function extractIconIdFromFilename(string $filename, string $filename_pattern) : string {
        $pattern = str_replace(self::ICON_ID_PATTERN, '(?<icon_id>.+)?', $filename_pattern);
        if (preg_match('@' . $pattern . '@', $filename, $matches)) {
            return $matches['icon_id'] ?? $filename;
        }
        return $filename;
    }
    
    /**
     * Extracts the group from a file path based on the group position.
     *
     * @param string $path
     *   The file path.
     * @param int|null $group_position
     *   The position of the group in the path, or null if not applicable.
     *
     * @return string|null
     *   The extracted group, or null if not found.
     */
    private static function extractGroupFromPath(string $path, ?int $group_position) : ?string {
        $parts = explode('/', trim($path, '/'));
        return $parts[$group_position] ?? NULL;
    }
    
    /**
     * Determines the group position based on the URI.
     *
     * The group position is needed to use it as a mask to find the folder name
     * matching this position.
     *
     * @param string $source
     *   The source with optional group pattern.
     *
     * @return int|null
     *   The determined group position.
     */
    private static function determineGroupPosition(string $source) : ?int {
        $parts = explode('/', trim($source, '/'));
        if ($result = array_search(self::GROUP_PATTERN, $parts, TRUE)) {
            return (int) $result;
        }
        return NULL;
    }

}

Classes

Title Deprecated Summary
IconFinder Icon finder to discover files under specific paths or URLs.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.