Verified Commit d1faffc7 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3340712 by e0ipso, alexpott, larowlan, dww, _pratik_, catch,...

Issue #3340712 by e0ipso, alexpott, larowlan, dww, _pratik_, catch, penyaskito, mherchel, pdureau, jonathanshaw, mglaman, idiaz.roncero, geek-merlin, ctrlADel, mrweiner, bnjmnm, longwave, guschilds, cosmicdreams, dreamleaf, ckrina, Gábor Hojtsy, lauriii, unstatu, markconroy, nod_: Add Single Directory Components as a new experimental module
parent bf912193
Loading
Loading
Loading
Loading
+26 −0
Original line number Diff line number Diff line
The API of Single Directory Components includes:

  - The component plugin manager (the service with name plugin.manager.sdc).
    This service will be needed by modules that need to find and instantiate
    components.
  - The exceptions. Code using Single Directory Components can rely, and extend,
    the exceptions provided by the experimental module.
  - The folder structure of a component and the naming conventions of the files
    in it.
  - The structure of the component metadata (the my-component.component.yml).
    Note that the metadata of the component is described, and optionally
    validated, by the schema in metadata.schema.json (this file is for internal validation and not part of the API).
  - The render element and its class \Drupal\sdc\Element\ComponentElement.
  - The naming convention for the ID when using Twig's include, embed, and
    extends. This naming convention is [module/theme]:[component machine name].
    See the example below.

{% embed 'my-theme:my-component' with { prop1: content.field_for_prop1 } %}
  {% block slot1 %}
    {{ content|without('field_for_prop1') }}
  {% endblock %}
{% endembed %}

This way  of specifying the component for Twig's include, embed, and
extends('my-theme:my-component' in the example) will not change, as it is
considered an API.
+8 −0
Original line number Diff line number Diff line
name: Single Directory Components
type: module
description: 'Allows discovery and rendering of self-contained UI components.'
version: VERSION
package: Core (Experimental)
lifecycle: experimental
dependencies:
  - drupal:serialization
+77 −0
Original line number Diff line number Diff line
<?php

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

use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\sdc\Plugin\Component;
use Drupal\sdc\ComponentPluginManager;

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

// Set class aliases for the classes that will go into core.
// See the experimental modules policy https://www.drupal.org/core/experimental
// @todo: remove class aliases in #3354389
@class_alias('Drupal\sdc\Element\ComponentElement', 'Drupal\Core\Render\Element\ComponentElement');
@class_alias('Drupal\sdc\Exception\ComponentNotFoundException', 'Drupal\Core\Render\Component\Exception\ComponentNotFoundException');
@class_alias('Drupal\sdc\Exception\IncompatibleComponentSchema', 'Drupal\Core\Render\Component\Exception\IncompatibleComponentSchema');
@class_alias('Drupal\sdc\Exception\InvalidComponentDataException', 'Drupal\Core\Render\Component\Exception\InvalidComponentDataException');
@class_alias('Drupal\sdc\Exception\InvalidComponentException', 'Drupal\Core\Render\Component\Exception\InvalidComponentException');

/**
 * Implements hook_help().
 */
function sdc_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    case 'help.page.sdc':
      $output = '<h3>' . t('About') . '</h3>';
      $output .= '<p>' . t('Single Directory Components is a module that aims to simplify the front-end development workflow, and improve maintainability of core and contrib themes. For more information, see the <a href=":docs">online documentation for the Single Directory Components module</a>.', [
        ':docs' => 'https://www.drupal.org/docs/develop/theming-drupal/using-single-directory-components',
      ]) . '</p>';
      $output .= '<dl>';
      $output .= '<dt>' . t('General') . '</dt>';
      $output .= '<dd>' . t('Single Directory Components introduces the concept of UI components to Drupal core. A component is a combination of a Twig template, stylesheets, scripts, assets, and metadata, that live in the same directory. Components represent an encapsulated and re-usable UI element.') . '</dd>';
      $output .= '<dd>' . t('<a href=":sdc-docs">Single Directory Components</a> reduce the number of framework implementation details required to put templated HTML, CSS, and JS in a Drupal page. They also define explicit component APIs, and provide a methodology to replace a component provided upstream (in a parent theme or module).', [
        ':sdc-docs' => 'https://www.drupal.org/docs/develop/theming-drupal/using-single-directory-components',
      ]) . '</dd>';
      $output .= '</dl>';

      return $output;
  }
  return NULL;
}

/**
 * Implements hook_library_info_build().
 */
function sdc_library_info_build() {
  // Iterate over all the components to get the CSS and JS files.
  $plugin_manager = \Drupal::service('plugin.manager.sdc');
  assert($plugin_manager instanceof ComponentPluginManager);
  $components = $plugin_manager->getAllComponents();
  $libraries = array_reduce(
    $components,
    static function (array $libraries, Component $component) {
      $library = $component->library;
      if (empty($library)) {
        return $libraries;
      }
      $library_name = $component->getLibraryName();
      [, $library_id] = explode('/', $library_name);
      return array_merge($libraries, [$library_id => $library]);
    },
    []
  );
  $libraries['all'] = [
    'dependencies' => array_map(
      static fn(Component $component) => $component->getLibraryName(),
      $components
    ),
  ];
  return $libraries;
}
+55 −0
Original line number Diff line number Diff line
services:
  _defaults:
    public: false

  # Twig loader to allow embedding templates with a component identifier.
  # Note that this service name is not guaranteed to remain the same once this
  # module is out of the experimental phase.
  Drupal\sdc\Twig\TwigComponentLoader:
    arguments:
      - '@plugin.manager.sdc'
    tags:
      - { name: twig.loader, priority: 5 }

  # Note that this service name is not guaranteed to remain the same once this
  # module is out of the experimental phase.
  Drupal\sdc\ComponentNegotiator:
    arguments:
      - '@theme.manager'
      - '@extension.list.module'

  # Note that this service name is not guaranteed to remain the same once this
  # module is out of the experimental phase.
  Drupal\sdc\Twig\TwigExtension:
    arguments:
      - '@plugin.manager.sdc'
      - '@Drupal\sdc\Component\ComponentValidator'
    tags:
      - { name: twig.extension }

  # This service is part of the module's API and it's guaranteed to have the
  # same name once the module is stable.
  plugin.manager.sdc:
    public: true
    class: Drupal\sdc\ComponentPluginManager
    arguments:
      - '@module_handler'
      - '@theme_handler'
      - '@cache.discovery'
      - '@config.factory'
      - '@theme.manager'
      - '@Drupal\sdc\ComponentNegotiator'
      - '@file_system'
      - '@Drupal\sdc\Component\SchemaCompatibilityChecker'
      - '@Drupal\sdc\Component\ComponentValidator'
      - '%app.root%'

  # Note that this service name is not guaranteed to remain the same once this
  # module is out of the experimental phase.
  Drupal\sdc\Component\SchemaCompatibilityChecker: {}

  # Note that this service name is not guaranteed to remain the same once this
  # module is out of the experimental phase.
  Drupal\sdc\Component\ComponentValidator:
    calls:
      - [setValidator, []]
+202 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\sdc\Component;

use Drupal\Core\Extension\ExtensionLifecycle;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\sdc\Exception\InvalidComponentException;

/**
 * Component metadata.
 *
 * @internal
 */
final class ComponentMetadata {

  use StringTranslationTrait;

  /**
   * The absolute path to the component directory.
   *
   * @var string
   */
  public readonly string $path;

  /**
   * The component documentation.
   *
   * @var string
   */
  public readonly string $documentation;

  /**
   * The status of the component.
   *
   * @var string
   */
  public readonly string $status;

  /**
   * The machine name for the component.
   *
   * @var string
   */
  public readonly string $machineName;

  /**
   * The component's name.
   *
   * @var string
   */
  public readonly string $name;

  /**
   * The PNG path for the component thumbnail.
   *
   * @var string
   */
  private string $thumbnailPath;

  /**
   * The component group.
   *
   * @var string
   */
  public readonly string $group;

  /**
   * Schema for the component props.
   *
   * @var array[]|null
   *   The schemas.
   */
  public readonly ?array $schema;

  /**
   * The component description.
   *
   * @var string
   */
  public readonly string $description;

  /**
   * TRUE if the schemas for props and slots are mandatory.
   *
   * @var bool
   */
  public readonly bool $mandatorySchemas;

  /**
   * Slot information.
   *
   * @var array
   */
  public readonly array $slots;

  /**
   * ComponentMetadata constructor.
   *
   * @param array $metadata_info
   *   The metadata info.
   * @param string $app_root
   *   The application root.
   * @param bool $enforce_schemas
   *   Enforces the definition of schemas for props and slots.
   *
   * @throws \Drupal\sdc\Exception\InvalidComponentException
   */
  public function __construct(array $metadata_info, string $app_root, bool $enforce_schemas) {
    $path = $metadata_info['path'];
    // Make the absolute path, relative to the Drupal root.
    $app_root = rtrim($app_root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
    if (str_starts_with($path, $app_root)) {
      $path = substr($path, strlen($app_root));
    }
    $this->mandatorySchemas = $enforce_schemas;
    $this->path = $path;

    [, $machine_name] = explode(':', $metadata_info['id'] ?? []);
    $this->machineName = $machine_name;
    $this->name = $metadata_info['name'] ?? mb_convert_case($machine_name, MB_CASE_TITLE);
    $this->description = $metadata_info['description'] ?? $this->t('- Description not available -');
    $this->status = ExtensionLifecycle::isValid($metadata_info['status'] ?? '')
      ? $metadata_info['status']
      : ExtensionLifecycle::STABLE;
    $this->documentation = $metadata_info['documentation'] ?? '';

    $this->group = $metadata_info['group'] ?? $this->t('All Components');

    // Save the schemas.
    $this->parseSchemaInfo($metadata_info);
    $this->slots = $metadata_info['slots'] ?? [];
  }

  /**
   * Parse the schema information.
   *
   * @param array $metadata_info
   *   The metadata information as decoded from the component definition file.
   *
   * @throws \Drupal\sdc\Exception\InvalidComponentException
   */
  private function parseSchemaInfo(array $metadata_info): void {
    if (empty($metadata_info['props'])) {
      if ($this->mandatorySchemas) {
        throw new InvalidComponentException(sprintf('The component "%s" does not provide schema information. Schema definitions are mandatory for components declared in modules. For components declared in themes, schema definitions are only mandatory if the "enforce_prop_schemas" key is set to "true" in the theme info file.', $metadata_info['id']));
      }
      $schema = NULL;
    }
    else {
      $schema = $metadata_info['props'];
      if (($schema['type'] ?? 'object') !== 'object') {
        throw new InvalidComponentException('The schema for the props in the component metadata is invalid. The schema should be of type "object".');
      }
      if ($schema['additionalProperties'] ?? FALSE) {
        throw new InvalidComponentException('The schema for the %s in the component metadata is invalid. Arbitrary additional properties are not allowed.');
      }
      $schema['additionalProperties'] = FALSE;
      // All props should also support "object" this allows deferring rendering
      // in Twig to the render pipeline.
      $schema_props = $metadata_info['props'];
      foreach ($schema_props['properties'] ?? [] as $name => $prop_schema) {
        $type = $prop_schema['type'] ?? '';
        $schema['properties'][$name]['type'] = array_unique([
          ...(array) $type,
          'object',
        ]);
      }
    }
    $this->schema = $schema;
  }

  /**
   * Gets the thumbnail path.
   *
   * @return string
   *   The path.
   */
  public function getThumbnailPath(): string {
    if (!isset($this->thumbnailPath)) {
      $thumbnail_path = sprintf('%s/thumbnail.png', $this->path);
      $this->thumbnailPath = file_exists($thumbnail_path) ? $thumbnail_path : '';
    }
    return $this->thumbnailPath;
  }

  /**
   * Normalizes the value object.
   *
   * @return array
   *   The normalized value object.
   */
  public function normalize(): array {
    return [
      'path' => $this->path,
      'machineName' => $this->machineName,
      'status' => $this->status,
      'name' => $this->name,
      'group' => $this->group,
    ];
  }

}
Loading