Commit dcff25b4 authored by Mateu Aguiló Bosch's avatar Mateu Aguiló Bosch Committed by Mateu Aguiló Bosch
Browse files

Issue #3256810 by e0ipso: Create a way to embed components via twig

parent 6847352e
Loading
Loading
Loading
Loading
+7 −2
Original line number Diff line number Diff line
# Storybook Components

## Assumptions

- Component templates use the file extension `.twig` (instead of `.html.twig`) -
  this prevents Drupal from picking up templates and rendering them when they
  share a name with a template Drupal already knows about
- Components are structured like:

```console
[DRUPAL_ROOT]/web/themes/.../my-theme
  |- components
@@ -30,6 +32,7 @@
```

## Component Metadata

The component metadata JSON file is used to provide information about the
component that needs to be consistent in Drupal and in Storybook.

@@ -51,16 +54,18 @@ the color palette (like your navigation), but others do not support palettes
  }
}
```

With that you can have your Storybook integration not to show the custom palette
selector for that component. At the same time Drupal will know to hide the
dropdown for palettes when choosing the component.

At the same time since this component is not
to be used directly in Drupal, but in other components, we can flag that with
At the same time since this component is not to be used directly in Drupal, but
in other components, we can flag that with
`embeddable: false`. Then our custom module can remove it from the available
components in Drupal.

Component metadata schema:

```json
{
  "$id": "https://www.drupal.org/cl_components/metadata.schema.json",
+5 −0
Original line number Diff line number Diff line
<?php

/**
 * @file
 * Module implementation file.
 */

use Drupal\cl_components\Component;
use Drupal\cl_components\ComponentDiscovery;

+13 −1
Original line number Diff line number Diff line
@@ -2,4 +2,16 @@ services:
  Drupal\cl_components\ComponentDiscovery:
    arguments:
      - '@config.factory'
    public: true

  Drupal\cl_components\Service\ComponentRenderer:
    arguments:
      - '@renderer'
      - '@Drupal\cl_components\ComponentDiscovery'
    calls:
      - ['setTwigEnvironment', ['@twig']]

  Drupal\cl_components\Twig\TwigExtension:
    arguments:
      - '@Drupal\cl_components\Service\ComponentRenderer'
    tags:
      - { name: twig.extension }
+42 −37
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@

namespace Drupal\cl_components;

use Drupal\cl_components\Exception\InvalidComponentException;

/**
 * Simple value object that contains information about the component.
 */
@@ -58,7 +60,7 @@ class Component {
   * @param \Drupal\cl_components\ComponentMetadata $metadata
   *   The component metadata.
   *
   * @throws \Drupal\cl_components\InvalidComponentException
   * @throws \Drupal\cl_components\Exception\InvalidComponentException
   *   If the component is invalid.
   */
  public function __construct(string $id, array $templates, array $styles, array $scripts, ComponentMetadata $metadata) {
@@ -71,21 +73,44 @@ class Component {
  }

  /**
   * The styles.
   * Validates the data for the component object.
   *
   * @throws \Drupal\cl_components\Exception\InvalidComponentException
   *   If the component is invalid.
   */
  private function validate() {
    $num_main_templates = count($this->getMainTemplates());
    if ($num_main_templates === 0) {
      $message = sprintf('Unable to find main template %s.twig or any of its variants.', $this->getId());
      throw new InvalidComponentException($message);
    }
    if (strpos($this->getId(), '/') !== FALSE) {
      $message = sprintf('Component ID cannot contain slashes: %s', $this->getId());
      throw new InvalidComponentException($message);
    }
  }

  /**
   * The main templates for the component.
   *
   * @return string[]
   *   The template names.
   */
  public function getStyles(): array {
    return $this->styles;
  public function getMainTemplates(): array {
    return array_filter($this->getTemplates(), function (string $template) {
      $regexp = sprintf('%s(%s[^\.]+)?\.(twig)', $this->getId(), static::TEMPLATE_VARIANT_SEPARATOR);
      return (bool) preg_match('/' . $regexp . '/', $template);
    });
  }

  /**
   * The JS.
   * The template names.
   *
   * @return string[]
   *   The names.
   */
  public function getScripts(): array {
    return $this->scripts;
  public function getTemplates(): array {
    return $this->templates;
  }

  /**
@@ -98,26 +123,21 @@ class Component {
  }

  /**
   * The template names.
   * The styles.
   *
   * @return string[]
   *   The names.
   */
  public function getTemplates(): array {
    return $this->templates;
  public function getStyles(): array {
    return $this->styles;
  }

  /**
   * The main templates for the component.
   * The JS.
   *
   * @return string[]
   *   The template names.
   */
  public function getMainTemplates(): array {
    return array_filter($this->getTemplates(), function (string $template) {
      $regexp = sprintf('%s(%s[^\.]+)?\.(twig)', $this->getId(), static::TEMPLATE_VARIANT_SEPARATOR);
      return (bool) preg_match('/' . $regexp . '/', $template);
    });
  public function getScripts(): array {
    return $this->scripts;
  }

  /**
@@ -126,7 +146,7 @@ class Component {
   * @param string $variant
   *   The template variant.
   *
   * @throws \Drupal\cl_components\InvalidComponentException
   * @throws \Drupal\cl_components\Exception\InvalidComponentException
   */
  public function getTemplateName(string $variant = ''): string {
    $filename = sprintf('%s%s%s.twig', $this->getId(), static::TEMPLATE_VARIANT_SEPARATOR, $variant);
@@ -141,6 +161,9 @@ class Component {
    return $filename;
  }

  /**
   *
   */
  public function getLibraryName(): string {
    return sprintf('cl_components/%s', $this->getId());
  }
@@ -161,22 +184,4 @@ class Component {
    return $this->metadata;
  }

  /**
   * Validates the data for the component object.
   *
   * @throws \Drupal\cl_components\InvalidComponentException
   *   If the component is invalid.
   */
  private function validate() {
    $num_main_templates = count($this->getMainTemplates());
    if ($num_main_templates === 0) {
      $message = sprintf('Unable to find main template %s.twig or any of its variants.', $this->getId());
      throw new InvalidComponentException($message);
    }
    if (strpos($this->getId(), '/') !== FALSE) {
      $message = sprintf('Component ID cannot contain slashes: %s', $this->getId());
      throw new InvalidComponentException($message);
    }
  }

}
+93 −86
Original line number Diff line number Diff line
@@ -2,19 +2,19 @@

namespace Drupal\cl_components;

use DirectoryIterator;
use Drupal\cl_components\Exception\InvalidComponentException;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use UnexpectedValueException;

/**
 *
 */
class ComponentDiscovery {

  use DependencySerializationTrait;

  private static $directoryIteratorFlags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_SELF | FilesystemIterator::SKIP_DOTS;
  private static $directoryIteratorFlags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_SELF | \FilesystemIterator::SKIP_DOTS;

  /**
   * Cached component information.
@@ -30,11 +30,29 @@ class ComponentDiscovery {
   */
  private array $scanDirs = [];

  /**
   *
   */
  public function __construct(ConfigFactoryInterface $config_factory) {
    $this->scanDirs = $config_factory->get('cl_components.settings')
      ->get('paths');
  }

  /**
   * Finds all the components that represent IDL modules.
   *
   * @return \Drupal\cl_components\Component[]
   *   The modules.
   */
  public function findAllModules(): array {
    $components = $this->findAll();
    return array_filter($components, function (Component $component) {
      $metadata = $component->getMetadata();
      return $metadata->getComponentType() === ComponentMetadata::COMPONENT_TYPE_MODULE
        && $metadata->getStatus() !== ComponentMetadata::COMPONENT_STATUS_WIP;
    });
  }

  /**
   * Returns all the components in the repository.
   *
@@ -47,9 +65,9 @@ class ComponentDiscovery {
    }
    $unflattened = array_map(function (string $path) {
      try {
        $directory_iterator = new RecursiveDirectoryIterator($path, static::$directoryIteratorFlags);
        $directory_iterator = new \RecursiveDirectoryIterator($path, static::$directoryIteratorFlags);
      }
      catch (UnexpectedValueException $exception) {
      catch (\UnexpectedValueException $exception) {
        watchdog_exception('cl_components', $exception);
        return [];
      }
@@ -63,34 +81,6 @@ class ComponentDiscovery {
    return $this->components;
  }

  /**
   * Finds a component by its ID.
   *
   * @param string $id
   *   The ID of the component to find.
   *
   * @return \Drupal\cl_components\Component
   *   The component.
   *
   * @throws \Drupal\cl_components\InvalidComponentException
   *   When the component cannot be found.
   */
  public function find(string $id): Component {
    $components = $this->findAll();
    $matches = array_filter(
      $components,
      static function (Component $component) use ($id) {
        return $component->getId() === $id;
      }
    );
    $component = reset($matches);
    if (!$component instanceof Component) {
      $message = sprintf('Unable to find component "%s" in the component repository', $id);
      throw new InvalidComponentException($message);
    }
    return $component;
  }

  /**
   * Returns all the components in the repository pointed by the iterator.
   *
@@ -104,11 +94,11 @@ class ComponentDiscovery {
   *
   * @see https://fractal.build/guide/components/#what-defines-a-component
   */
  private function discoverComponentPaths(RecursiveDirectoryIterator $it, array $paths): array {
  private function discoverComponentPaths(\RecursiveDirectoryIterator $it, array $paths): array {
    // If this is a folder, keep drilling down.
    if ($it->isDir() && $it->hasChildren()) {
      $children = $it->getChildren();
      assert($children instanceof RecursiveDirectoryIterator);
      assert($children instanceof \RecursiveDirectoryIterator);
      $paths = $this->discoverComponentPaths($children, $paths);
    }
    if (
@@ -123,21 +113,6 @@ class ComponentDiscovery {
    return $it->valid() ? $this->discoverComponentPaths($current, array_unique($paths)) : $paths;
  }

  /**
   * Finds all the components that represent IDL modules.
   *
   * @return \Drupal\cl_components\Component[]
   *   The modules.
   */
  public function findAllModules(): array {
    $components = $this->findAll();
    return array_filter($components, function (Component $component) {
      $metadata = $component->getMetadata();
      return $metadata->getComponentType() === ComponentMetadata::COMPONENT_TYPE_MODULE
        && $metadata->getStatus() !== ComponentMetadata::COMPONENT_STATUS_WIP;
    });
  }

  /**
   * @param \Drupal\cl_components\Component $component
   *   The component info.
@@ -178,10 +153,11 @@ class ComponentDiscovery {
   *
   * @param string $filename
   *   File name.
   *
   * @return Component|null
   *   The component.
   *
   * @throws \Drupal\cl_components\InvalidComponentException
   * @throws \Drupal\cl_components\Exception\InvalidComponentException
   */
  public function findBySiblingFile(string $filename): ?Component {
    // The file may be relative to something else.
@@ -200,13 +176,16 @@ class ComponentDiscovery {
      return NULL;
    }
    $metadata = Json::decode(file_get_contents($metadata_file));
    $machine_name = $metadata['machineName'];
    $machine_name = $metadata['machineName'] ?? NULL;
    if (!$machine_name) {
      return NULL;
    }
    return $this->find($machine_name);
  }

  /**
   *
   */
  private function findPartialFile(string $filename): ?string {
    if (file_exists(DRUPAL_ROOT . DIRECTORY_SEPARATOR . $filename)) {
      return $filename;
@@ -218,6 +197,34 @@ class ComponentDiscovery {
    return $this->findPartialFile($new_filename);
  }

  /**
   * Finds a component by its ID.
   *
   * @param string $id
   *   The ID of the component to find.
   *
   * @return \Drupal\cl_components\Component
   *   The component.
   *
   * @throws \Drupal\cl_components\Exception\InvalidComponentException
   *   When the component cannot be found.
   */
  public function find(string $id): Component {
    $components = $this->findAll();
    $matches = array_filter(
      $components,
      static function (Component $component) use ($id) {
        return $component->getId() === $id;
      }
    );
    $component = reset($matches);
    if (!$component instanceof Component) {
      $message = sprintf('Unable to find component "%s" in the component repository', $id);
      throw new InvalidComponentException($message);
    }
    return $component;
  }

  /**
   * Creates a component from a component path.
   *
@@ -247,34 +254,6 @@ class ComponentDiscovery {
    }
  }

  /**
   * Given a component path discover all the twig templates to include.
   *
   * @param string $path
   *   The component path.
   *
   * @return string[]
   *   The list of templates to include for the component.
   */
  private function discoverTemplates(string $path): array {
    $files = [];
    try {
      $it = new RecursiveDirectoryIterator($path, static::$directoryIteratorFlags);
      $files = $this->findSubpathByExtension($it, 'twig');
    }
    catch (UnexpectedValueException $exception) {
    }
    // Ensure the templates DO NOT end in '.html.twig'.
    return array_filter($files, function (string $filename) {
      $extension = '.html.twig';
      $pos = strpos($filename, $extension);
      if ($pos === FALSE) {
        return TRUE;
      }
      return $pos !== strlen($filename) - strlen($extension);
    });
  }

  /**
   * Given a component path discover all the CSS and JS assets to include.
   *
@@ -296,7 +275,7 @@ class ComponentDiscovery {
      $files = [];
      if (file_exists($full_path) && is_dir($full_path)) {
        try {
          $it = new RecursiveDirectoryIterator($full_path, static::$directoryIteratorFlags);
          $it = new \RecursiveDirectoryIterator($full_path, static::$directoryIteratorFlags);
          $files = array_map(
            function ($file) use ($prefix, $extension) {
              return sprintf('%s/%s', $prefix, $file);
@@ -304,7 +283,7 @@ class ComponentDiscovery {
            $this->findSubpathByExtension($it, $extension)
          );
        }
        catch (UnexpectedValueException $exception) {
        catch (\UnexpectedValueException $exception) {
        }
      }
      return array_merge($carry, [$extension => $files]);
@@ -322,7 +301,7 @@ class ComponentDiscovery {
   * @return array
   *   The list of subpaths with all the files of the given extension.
   */
  private function findSubpathByExtension(DirectoryIterator $it, string $extension): array {
  private function findSubpathByExtension(\DirectoryIterator $it, string $extension): array {
    $files = [];
    while ($it->valid()) {
      if ($it->getExtension() === $extension) {
@@ -333,6 +312,34 @@ class ComponentDiscovery {
    return $files;
  }

  /**
   * Given a component path discover all the twig templates to include.
   *
   * @param string $path
   *   The component path.
   *
   * @return string[]
   *   The list of templates to include for the component.
   */
  private function discoverTemplates(string $path): array {
    $files = [];
    try {
      $it = new \RecursiveDirectoryIterator($path, static::$directoryIteratorFlags);
      $files = $this->findSubpathByExtension($it, 'twig');
    }
    catch (\UnexpectedValueException $exception) {
    }
    // Ensure the templates DO NOT end in '.html.twig'.
    return array_filter($files, function (string $filename) {
      $extension = '.html.twig';
      $pos = strpos($filename, $extension);
      if ($pos === FALSE) {
        return TRUE;
      }
      return $pos !== strlen($filename) - strlen($extension);
    });
  }

  /**
   * Given a component path discover all the variant information.
   *
Loading