Skip to main content

Drupal website

Demo: Component library

The building blocks of this site’s theme, each with sample content.

Button

gwt_design:button

Link styled as a button.

Story 1

Props and slots
props:
  label: 'Get in touch'
  url: '#'
  variant: primary
  icon: 'fa-solid fa-paper-plane'

Story 2

Props and slots
props:
  label: 'View projects'
  url: '#'
  variant: secondary
  icon: 'fa-solid fa-arrow-right'

Story 3

Props and slots
props:
  label: 'Read more'
  url: '#'
  variant: ghost

Call to action

gwt_design:call-to-action

Centered heading, text and one button.

Let's build something together

Open to Drupal developer and Drupal lead roles.

Props and slots
props:
  heading: "Let's build something together"
  link_title: 'Get in touch'
  link_url: '#'
slots:
  text: '<p>Open to Drupal developer and Drupal lead roles.</p>'

Experience item

gwt_design:experience-item

One role on the experience timeline.

Lead Drupal Developer

Example Agency

Current – Present

  • Led the move to Drupal 11 and Single Directory Components.
  • Mentored three developers.

Related projects

  • Drupal
  • PHP
  • Twig
  • Tailwind CSS
Props and slots
props:
  title: 'Lead Drupal Developer'
  url: '#'
  company: 'Example Agency'
  start: '2021-03-01'
  tags:
    - Drupal
    - PHP
    - Twig
    - 'Tailwind CSS'
  related_projects:
    -
      title: 'Sample project'
      url: '#'
  timeline: false
slots:
  highlights: '<ul><li>Led the move to Drupal 11 and Single Directory Components.</li><li>Mentored three developers.</li></ul>'

Feature card

gwt_design:feature-card

Icon, heading and short text. Several in a region show as a grid.

Reusable components

Each piece of the design is built once and used everywhere.

Props and slots
props:
  icon: 'fa-solid fa-layer-group'
  heading: 'Reusable components'
slots:
  text: '<p>Each piece of the design is built once and used everywhere.</p>'

Hero

gwt_design:hero

Full-width gradient intro with an eyebrow, heading, text and link cards.

Drupal developer

I build Drupal sites that editors enjoy using.

Fifteen years of websites, modules and design systems.

Props and slots
props:
  eyebrow: 'Drupal developer'
  heading: 'I build Drupal sites'
  heading_accent: 'that editors enjoy using.'
  links:
    -
      title: Projects
      url: '#'
      icon: 'fa-solid fa-folder-open'
    -
      title: Experience
      url: '#'
      icon: 'fa-solid fa-briefcase'
    -
      title: Contact
      url: '#'
      icon: 'fa-solid fa-envelope'
slots:
  text: '<p>Fifteen years of websites, modules and design systems.</p>'

Page header

gwt_design:page-header

Full-width band at the top of a page with an eyebrow, the title, a lead line and an optional portrait.

Portfolio

Page title

A short line about what this page holds.

Props and slots
props:
  eyebrow: Portfolio
  lead: 'A short line about what this page holds.'
slots:
  title: 'Page title'

Page title

gwt_design:page-title

Page heading with the accent bar under it.

Page title

Props and slots
slots:
  title: 'Page title'

Project card

gwt_design:project-card

Linked card with a cover image, type badge, title, year, description and tags.

Props and slots
props:
  title: 'Sample project'
  url: '#'
  year: 2025
  description: 'A Drupal site with a component-based theme and an editor-friendly page builder.'
  tags:
    - Drupal
    - SDC
    - 'Tailwind CSS'
  type_label: 'Drupal website'

Section heading

gwt_design:section-heading

Heading for a page section, with the accent bar.

Story 1

Featured projects

A few recent sites and modules.

Props and slots
props:
  heading: 'Featured projects'
  align: left
  description: 'A few recent sites and modules.'

Story 2

Skills

Props and slots
props:
  heading: Skills
  align: center

Tag

gwt_design:tag

Small pill for a skill or technology, with an optional usage count and highlight.

Story 1

Drupal used in 12 projects and roles
Props and slots
props:
  label: Drupal
  count: 12
  highlight: true

Story 2

Twig used in 5 projects and roles
Props and slots
props:
  label: Twig
  count: 5
  highlight: false

Tag list

gwt_design:tag-list

Wrapping list of tags.

  • Drupal
  • PHP
  • Twig
  • Tailwind CSS
  • JavaScript
Props and slots
props:
  tags:
    - Drupal
    - PHP
    - Twig
    - 'Tailwind CSS'
    - JavaScript
How it’s built gwt_demo/src/Plugin/ProjectDemo/ComponentLibrary.php · 272 lines
<?php

declare(strict_types=1);

namespace Drupal\gwt_demo\Plugin\ProjectDemo;

use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Component\Serialization\Yaml;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\DefaultLazyPluginCollection;
use Drupal\Core\Render\Component\Exception\InvalidComponentException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Theme\Component\ComponentValidator;
use Drupal\Core\Theme\ComponentPluginManager;
use Drupal\gwt_demo\Attribute\ProjectDemo;
use Drupal\gwt_demo\ProjectDemo\PluginSystemDemoBase;

/**
 * The theme's Single Directory Components with sample content.
 *
 * Each component shows one or more stories: sets of props and slots. By
 * default they come from the "examples" in the component's schema; story N
 * takes each prop's Nth example (required props fall back to the first). An
 * editor can replace a component's stories with their own YAML, which SDC
 * validates against the component's schema. The components with their own
 * stories are kept in a plugin collection, keyed by component ID.
 *
 * Only presentational components can be shown: none of them reads the site's
 * content or configuration, posts a form or loads outside scripts.
 */
#[ProjectDemo(
  id: 'component_library',
  label: new TranslatableMarkup('Component library'),
  description: new TranslatableMarkup("The theme's components with sample content, like a small Storybook."),
)]
final class ComponentLibrary extends PluginSystemDemoBase {

  /**
   * The components the demo can show.
   */
  private const ALLOWED = [
    'gwt_design:button',
    'gwt_design:call-to-action',
    'gwt_design:experience-item',
    'gwt_design:feature-card',
    'gwt_design:hero',
    'gwt_design:page-header',
    'gwt_design:page-title',
    'gwt_design:project-card',
    'gwt_design:project-links',
    'gwt_design:section-heading',
    'gwt_design:tag',
    'gwt_design:tag-list',
  ];

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    ComponentPluginManager $pluginManager,
    protected ComponentValidator $componentValidator,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $pluginManager);
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration(): array {
    return ['listed' => [], 'components' => []];
  }

  /**
   * {@inheritdoc}
   */
  protected function createPluginCollections(): array {
    return ['components' => new DefaultLazyPluginCollection($this->pluginManager, $this->configuration['components'])];
  }

  /**
   * {@inheritdoc}
   */
  protected function isListed(array|PluginDefinitionInterface $definition, string $plugin_id): bool {
    return in_array($plugin_id, self::ALLOWED, TRUE);
  }

  /**
   * {@inheritdoc}
   */
  protected function getDefinitionLabel(array|PluginDefinitionInterface $definition): string {
    return (string) $definition['name'];
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
    $form['listed'] = $this->buildListingElement($this->t('Components'));
    $form['stories'] = [
      '#type' => 'details',
      '#title' => $this->t('Stories'),
      '#description' => $this->t("A YAML list of stories per component, each with <code>props</code> and <code>slots</code>. They start as the examples in the component's schema."),
      '#tree' => TRUE,
    ];
    foreach ($this->getDefinitions() as $id => $definition) {
      $form['stories'][$id] = [
        '#type' => 'textarea',
        '#title' => $definition['name'],
        '#default_value' => Yaml::encode($this->getStories($id)),
        '#rows' => 6,
        '#attributes' => ['class' => ['font-mono']],
      ];
    }
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
    foreach ($form_state->getValue('stories', []) as $id => $yaml) {
      $element = $form['stories'][$id];
      try {
        $stories = Yaml::decode($yaml) ?? [];
      }
      catch (InvalidDataTypeException $e) {
        $form_state->setError($element, $this->t('@component: the YAML is not valid. @message', [
          '@component' => $element['#title'],
          '@message' => $e->getMessage(),
        ]));
        continue;
      }
      if (!is_array($stories) || !array_is_list($stories)) {
        $form_state->setError($element, $this->t('@component: write a list of stories.', ['@component' => $element['#title']]));
        continue;
      }
      $component = $this->pluginManager->createInstance($id);
      foreach ($stories as $delta => $story) {
        try {
          if (!is_array($story)) {
            throw new InvalidComponentException((string) $this->t('A story needs props, slots or both.'));
          }
          $slots = $story['slots'] ?? [];
          if (!is_array($slots) || array_filter($slots, fn($slot) => !is_string($slot))) {
            throw new InvalidComponentException((string) $this->t('Slots must be text.'));
          }
          $this->componentValidator->validateProps($this->sanitizeProps($story['props'] ?? []), $component);
        }
        catch (InvalidComponentException $e) {
          $form_state->setError($element, $this->t('@component, story @number: @message', [
            '@component' => $element['#title'],
            '@number' => $delta + 1,
            '@message' => $e->getMessage(),
          ]));
        }
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
    // Keep only stories that differ from the schema examples, so changes to
    // the examples reach the demo. YAML that failed validation is skipped (the
    // widget submits even when there are errors; they stop the save).
    $components = [];
    foreach ($form_state->getValue('stories', []) as $id => $yaml) {
      try {
        $stories = Yaml::decode($yaml) ?? [];
      }
      catch (InvalidDataTypeException) {
        continue;
      }
      if (is_array($stories) && array_is_list($stories) && $stories !== $this->buildExampleStories($this->getDefinitions()[$id])) {
        $components[$id] = ['id' => $id, 'stories' => $stories];
      }
    }
    $this->configuration['components'] = $components;
    parent::submitConfigurationForm($form, $form_state);
  }

  /**
   * {@inheritdoc}
   */
  public function build(): array {
    $components = [];
    foreach ($this->getListedDefinitions() as $id => $definition) {
      $stories = [];
      foreach ($this->getStories($id) as $story) {
        $stories[] = [
          'preview' => [
            '#type' => 'component',
            '#component' => $id,
            '#props' => $this->sanitizeProps($story['props'] ?? []),
            // Slots are filtered like admin text by #markup.
            '#slots' => array_map(fn(string $html) => ['#markup' => $html], $story['slots'] ?? []),
          ],
          'code' => Yaml::encode(array_filter($story)),
        ];
      }
      $components[] = [
        'id' => $id,
        'label' => $definition['name'],
        'description' => $definition['description'] ?? '',
        'stories' => $stories,
      ];
    }
    return [
      '#theme' => 'gwt_demo_component_library',
      '#components' => $components,
    ];
  }

  /**
   * Returns a component's stories: the editor's, or the schema examples.
   */
  private function getStories(string $id): array {
    $own = $this->getPluginCollections()['components']->getConfiguration()[$id]['stories'] ?? NULL;
    return $own ?? $this->buildExampleStories($this->getDefinitions()[$id]);
  }

  /**
   * Builds stories from a component's schema examples.
   *
   * Story N takes each prop's and slot's Nth example; required props without
   * one take their first.
   */
  private function buildExampleStories(array $definition): array {
    $fields = [
      'props' => $definition['props']['properties'] ?? [],
      'slots' => $definition['slots'] ?? [],
    ];
    $required = $definition['props']['required'] ?? [];
    $all = [...array_values($fields['props']), ...array_values($fields['slots'])];
    $counts = array_map(fn(array $field) => count($field['examples'] ?? []), $all);
    $count = max(1, ...$counts);

    $stories = [];
    for ($n = 0; $n < $count; $n++) {
      $story = [];
      foreach ($fields as $type => $items) {
        foreach ($items as $name => $field) {
          $example = $field['examples'][$n] ?? (in_array($name, $required, TRUE) ? ($field['examples'][0] ?? NULL) : NULL);
          if ($example !== NULL) {
            $story[$type][$name] = $example;
          }
        }
      }
      $stories[] = $story;
    }
    return $stories;
  }

  /**
   * Removes dangerous protocols (javascript: and the like) from URL props.
   */
  private function sanitizeProps(mixed $props): array {
    if (!is_array($props)) {
      return [];
    }
    array_walk_recursive($props, function (&$value, $key): void {
      if (is_string($value) && is_string($key) && ($key === 'url' || str_ends_with($key, '_url'))) {
        $value = UrlHelper::stripDangerousProtocols($value);
      }
    });
    return $props;
  }

}