ComponentPluginManager.php

Same filename in this branch
  1. 10 core/modules/sdc/src/ComponentPluginManager.php
Same filename in other branches
  1. 11.x core/modules/sdc/src/ComponentPluginManager.php
  2. 11.x core/lib/Drupal/Core/Theme/ComponentPluginManager.php

Namespace

Drupal\Core\Theme

File

core/lib/Drupal/Core/Theme/ComponentPluginManager.php

View source
<?php

namespace Drupal\Core\Theme;

use Drupal\Component\Assertion\Inspector;
use Drupal\Component\Discovery\YamlDirectoryDiscovery;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Plugin\Factory\ContainerFactory;
use Drupal\Core\Theme\Component\ComponentValidator;
use Drupal\Core\Theme\Component\SchemaCompatibilityChecker;
use Drupal\Core\Plugin\Component;
use Drupal\Core\Render\Component\Exception\ComponentNotFoundException;
use Drupal\Core\Render\Component\Exception\IncompatibleComponentSchema;
use Drupal\Core\Plugin\Discovery\DirectoryWithMetadataPluginDiscovery;

/**
 * Defines a plugin manager to deal with components.
 *
 * Modules and themes can create components by adding a folder under
 * MODULENAME/components/my-component/my-component.component.yml.
 *
 * @see plugin_api
 */
class ComponentPluginManager extends DefaultPluginManager {
    
    /**
     * {@inheritdoc}
     */
    protected $defaults = [
        'class' => Component::class,
    ];
    
    /**
     * Constructs ComponentPluginManager object.
     *
     * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
     *   The module handler.
     * @param \Drupal\Core\Extension\ThemeHandlerInterface $themeHandler
     *   The theme handler.
     * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
     *   Cache backend instance to use.
     * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
     *   The configuration factory.
     * @param \Drupal\Core\Theme\ThemeManagerInterface $themeManager
     *   The theme manager.
     * @param \Drupal\Core\Theme\ComponentNegotiator $componentNegotiator
     *   The component negotiator.
     * @param \Drupal\Core\File\FileSystemInterface $fileSystem
     *   The file system service.
     * @param \Drupal\Core\Theme\Component\SchemaCompatibilityChecker $compatibilityChecker
     *   The compatibility checker.
     * @param \Drupal\Core\Theme\Component\ComponentValidator $componentValidator
     *   The component validator.
     * @param string $appRoot
     *   The application root.
     */
    public function __construct(ModuleHandlerInterface $module_handler, ThemeHandlerInterface $themeHandler, CacheBackendInterface $cacheBackend, ConfigFactoryInterface $configFactory, ThemeManagerInterface $themeManager, ComponentNegotiator $componentNegotiator, FileSystemInterface $fileSystem, SchemaCompatibilityChecker $compatibilityChecker, ComponentValidator $componentValidator, string $appRoot) {
        // We are skipping the call to the parent constructor to avoid initializing
        // variables aimed for annotation discovery, that are unnecessary here.
        // Plugin managers using YAML discovery also skip the parent constructor,
        // like LinkRelationTypeManager.
        $this->moduleHandler = $module_handler;
        $this->factory = new ContainerFactory($this);
        $this->setCacheBackend($cacheBackend, 'component_plugins');
        // Note that we are intentionally skipping $this->alterInfo('component_info');
        // We want to ensure that everything related to a component is in the
        // single directory. If the alteration of a component is necessary,
        // component replacement is the preferred tool for that.
    }
    
    /**
     * Creates an instance.
     *
     * @throws \Drupal\Core\Render\Component\Exception\ComponentNotFoundException
     *
     * @internal
     */
    public function createInstance($plugin_id, array $configuration = []) : Component {
        $configuration['app_root'] = $this->appRoot;
        $configuration['enforce_schemas'] = $this->shouldEnforceSchemas($this->definitions[$plugin_id] ?? []);
        try {
            $instance = parent::createInstance($plugin_id, $configuration);
            if (!$instance instanceof Component) {
                throw new ComponentNotFoundException(sprintf('Unable to find component "%s" in the component repository.', $plugin_id));
            }
            return $instance;
        } catch (PluginException $e) {
            // Cast the PluginNotFound to a more specific exception.
            $message = sprintf('Unable to find component "%s" in the component repository. [%s]', $plugin_id, $e->getMessage());
            throw new ComponentNotFoundException($message, $e->getCode(), $e);
        }
    }
    
    /**
     * Gets a component for rendering.
     *
     * @param string $component_id
     *   The component ID.
     *
     * @return \Drupal\Core\Plugin\Component
     *   The component.
     *
     * @throws \Drupal\Core\Render\Component\Exception\ComponentNotFoundException
     */
    public function find(string $component_id) : Component {
        $definitions = $this->getDefinitions();
        if (empty($definitions)) {
            throw new ComponentNotFoundException('Unable to find any component definition.');
        }
        $negotiated_plugin_id = $this->componentNegotiator
            ->negotiate($component_id, $definitions);
        return $this->createInstance($negotiated_plugin_id ?? $component_id);
    }
    
    /**
     * Gets all components.
     *
     * @return \Drupal\Core\Plugin\Component[]
     *   An array of Component objects.
     */
    public function getAllComponents() : array {
        $plugin_ids = array_keys($this->getDefinitions());
        return array_values(array_filter(array_map([
            $this,
            'createInstance',
        ], $plugin_ids)));
    }
    
    /**
     * {@inheritdoc}
     */
    public function clearCachedDefinitions() : void {
        parent::clearCachedDefinitions();
        $this->componentNegotiator
            ->clearCache();
    }
    
    /**
     * Creates the library declaration array from a component definition.
     *
     * @param array $definition
     *   The component definition.
     *
     * @return array
     *   The library for the Library API.
     */
    protected function libraryFromDefinition(array $definition) : array {
        $metadata_path = $definition[YamlDirectoryDiscovery::FILE_KEY];
        $component_directory = $this->fileSystem
            ->dirname($metadata_path);
        // Add the JS and CSS files.
        $library = [];
        $css_file = $this->findAsset($component_directory, $definition['machineName'], 'css', TRUE);
        if ($css_file) {
            $library['css']['component'][$css_file] = [];
        }
        $js_file = $this->findAsset($component_directory, $definition['machineName'], 'js', TRUE);
        if ($js_file) {
            $library['js'][$js_file] = [];
        }
        // We allow component authors to use library overrides to use files relative
        // to the component directory. So we need to fix the paths here.
        if (!empty($definition['libraryOverrides'])) {
            $overrides = $this->translateLibraryPaths($definition['libraryOverrides'], $component_directory);
            // Apply library overrides.
            $library = array_merge($library, $overrides);
            // Ensure that 'core/drupal' is always a dependency. This will ensure that
            // JS behaviors are attached.
            $library['dependencies'][] = 'core/drupal';
            $library['dependencies'] = array_unique($library['dependencies']);
        }
        return $library;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getDiscovery() : DirectoryWithMetadataPluginDiscovery {
        if (!isset($this->discovery)) {
            $directories = $this->getScanDirectories();
            $this->discovery = new DirectoryWithMetadataPluginDiscovery($directories, 'component', $this->fileSystem);
        }
        return $this->discovery;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function providerExists($provider) {
        return $this->moduleHandler
            ->moduleExists($provider) || $this->themeHandler
            ->themeExists($provider);
    }
    
    /**
     * {@inheritdoc}
     */
    protected function alterDefinitions(&$definitions) {
        // Save in the definition whether this is a module or a theme. This is
        // important because when creating the plugin instance (the Component
        // object) we'll need to negotiate based on the active theme.
        $definitions = array_map([
            $this,
            'alterDefinition',
        ], $definitions);
        // Validate the definition after alterations.
        assert(Inspector::assertAll(fn(array $definition) => $this->isValidDefinition($definition), $definitions));
        parent::alterDefinitions($definitions);
        // Finally, validate replacements.
        $replacing_definitions = array_filter($definitions, static fn(array $definition) => ($definition['replaces'] ?? NULL) && ($definitions[$definition['replaces']] ?? NULL));
        $validation_errors = array_reduce($replacing_definitions, function (array $errors, array $new_definition) use ($definitions) {
            $original_definition = $definitions[$new_definition['replaces']];
            $original_schemas = $original_definition['props'] ?? NULL;
            $new_schemas = $new_definition['props'] ?? NULL;
            if (!$original_schemas || !$new_schemas) {
                return [
                    sprintf("Component \"%s\" is attempting to replace \"%s\", however component replacement requires both components to have schema definitions.", $new_definition['id'], $original_definition['id']),
                ];
            }
            try {
                $this->compatibilityChecker
                    ->isCompatible($original_schemas, $new_schemas);
            } catch (IncompatibleComponentSchema $e) {
                $errors[] = sprintf("\"%s\" is incompatible with the component is wants to replace \"%s\". Errors:\n%s", $new_definition['id'], $original_definition['id'], $e->getMessage());
            }
            return $errors;
        }, []);
        if (!empty($validation_errors)) {
            throw new IncompatibleComponentSchema(implode("\n", $validation_errors));
        }
    }
    
    /**
     * Alters the plugin definition with computed properties.
     *
     * @param array $definition
     *   The definition.
     *
     * @return array
     *   The altered definition.
     */
    protected function alterDefinition(array $definition) : array {
        $definition['extension_type'] = $this->moduleHandler
            ->moduleExists($definition['provider']) ? ExtensionType::Module : ExtensionType::Theme;
        $metadata_path = $definition[YamlDirectoryDiscovery::FILE_KEY];
        $component_directory = $this->fileSystem
            ->dirname($metadata_path);
        $definition['path'] = $component_directory;
        [
            ,
            $machine_name,
        ] = explode(':', $definition['id']);
        $definition['machineName'] = $machine_name;
        $definition['library'] = $this->libraryFromDefinition($definition);
        // Discover the template.
        $template = $this->findAsset($component_directory, $definition['machineName'], 'twig');
        $definition['template'] = basename($template);
        $definition['documentation'] = 'No documentation found. Add a README.md in your component directory.';
        $documentation_path = sprintf('%s/README.md', $this->fileSystem
            ->dirname($metadata_path));
        if (file_exists($documentation_path)) {
            $definition['documentation'] = file_get_contents($documentation_path);
        }
        return $definition;
    }
    
    /**
     * Validates the metadata info.
     *
     * @param array $definition
     *   The component definition.
     *
     * @return bool
     *   TRUE if it's valid.
     *
     * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
     */
    private function isValidDefinition(array $definition) : bool {
        return $this->componentValidator
            ->validateDefinition($definition, $this->shouldEnforceSchemas($definition));
    }
    
    /**
     * Get the list of directories to scan.
     *
     * @return string[]
     *   The directories.
     */
    private function getScanDirectories() : array {
        $extension_directories = [
            $this->moduleHandler
                ->getModuleDirectories(),
            $this->themeHandler
                ->getThemeDirectories(),
        ];
        return array_map(static fn(string $path) => rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'components', $extension_directories);
    }
    
    /**
     * Changes the library paths, so they can be used by the library system.
     *
     * We need this so we can let users apply overrides to JS and CSS files with
     * paths relative to the component.
     *
     * @param array $overrides
     *   The library overrides as provided by the component author.
     * @param string $component_directory
     *   The directory for the component.
     *
     * @return array
     *   The overrides with the fixed paths.
     */
    private function translateLibraryPaths(array $overrides, string $component_directory) : array {
        // We only alter the keys of the CSS and JS entries.
        $altered_overrides = $overrides;
        unset($altered_overrides['css'], $altered_overrides['js']);
        $css = $overrides['css'] ?? [];
        $js = $overrides['js'] ?? [];
        foreach ($css as $dir => $css_info) {
            foreach ($css_info as $filename => $options) {
                if (!UrlHelper::isExternal($filename)) {
                    $absolute_filename = sprintf('%s%s%s', $component_directory, DIRECTORY_SEPARATOR, $filename);
                    $altered_filename = $this->makePathRelativeToLibraryRoot($absolute_filename);
                    $altered_overrides['css'][$dir][$altered_filename] = $options;
                }
                else {
                    $altered_overrides['css'][$dir][$filename] = $options;
                }
            }
        }
        foreach ($js as $filename => $options) {
            if (!UrlHelper::isExternal($filename)) {
                $absolute_filename = sprintf('%s%s%s', $component_directory, DIRECTORY_SEPARATOR, $filename);
                $altered_filename = $this->makePathRelativeToLibraryRoot($absolute_filename);
                $altered_overrides['js'][$altered_filename] = $options;
            }
            else {
                $altered_overrides['js'][$filename] = $options;
            }
        }
        return $altered_overrides;
    }
    
    /**
     * Assess whether schemas are mandatory for props.
     *
     * Schemas are always mandatory for component provided by modules. It depends
     * on a theme setting for theme components.
     *
     * @param array $definition
     *   The plugin definition.
     *
     * @return bool
     *   TRUE if schemas are mandatory.
     */
    private function shouldEnforceSchemas(array $definition) : bool {
        $provider_type = $definition['extension_type'] ?? NULL;
        if ($provider_type !== ExtensionType::Theme) {
            return TRUE;
        }
        return $this->themeHandler
            ->getTheme($definition['provider'])?->info['enforce_prop_schemas'] ?? FALSE;
    }
    
    /**
     * Finds assets related to the provided metadata file.
     *
     * @param string $component_directory
     *   The component directory for the plugin.
     * @param string $machine_name
     *   The component's machine name.
     * @param string $file_extension
     *   The file extension to detect.
     * @param bool $make_relative
     *   TRUE to make the filename relative to the core folder.
     *
     * @return string|null
     *   Filenames, maybe relative to the core folder.
     */
    private function findAsset(string $component_directory, string $machine_name, string $file_extension, bool $make_relative = FALSE) : ?string {
        $absolute_path = sprintf('%s%s%s.%s', $component_directory, DIRECTORY_SEPARATOR, $machine_name, $file_extension);
        if (!file_exists($absolute_path)) {
            return NULL;
        }
        return $make_relative ? $this->makePathRelativeToLibraryRoot($absolute_path) : $absolute_path;
    }
    
    /**
     * Takes a path and makes it relative to the library provider.
     *
     * Drupal will take a path relative to the library provider in order to put
     * CSS and JS in the HTML page. Core is the provider for all the
     * auto-generated libraries for the components. This means that in order to
     * add <root>/themes/custom/my_theme/components/my-component/my-component.css
     * in the page, we need to crawl back up from <root>/core first:
     * ../themes/custom/my_theme/components/my-component/my-component.css.
     *
     * @param string $path
     *   The path to the file.
     *
     * @return string
     *   The path relative to the library provider root.
     */
    private function makePathRelativeToLibraryRoot(string $path) : string {
        $path_from_root = str_starts_with($path, $this->appRoot) ? substr($path, strlen($this->appRoot) + 1) : $path;
        // The library owner is in <root>/core, so we need to go one level up to
        // find the app root.
        return '../' . $path_from_root;
    }

}

Classes

Title Deprecated Summary
ComponentPluginManager Defines a plugin manager to deal with components.

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