- Led the move to Drupal 11 and Single Directory Components.
- Mentored three developers.
Call to action
gwt_design:call-to-action
Centered heading, text and one button.
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
Related projects
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.
Sample project
2025
A Drupal site with a component-based theme and an editor-friendly page builder.
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'
Project links
gwt_design:project-links
Buttons to the live site and the code.
Props and slots
props:
live_url: '#'
code_url: '#'
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
Props and slots
props:
label: Drupal
count: 12
highlight: true
Story 2
Props and slots
props:
label: Twig
count: 5
highlight: false
Tag list
gwt_design:tag-list
Wrapping list of tags.
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;
}
}