I'm Brian Hanson
@brianjhanson
I used this talk as motivation
I'm not an expert on Storybook
This is a "figuring things out" talk not one about well worn paths
Storybook is an open source tool for building UI components and pages in isolation.
I wanted this on server rendered sites
I want to be able to add this to a site I'm already working on
I don't want to regret adding it later (or have to tear it out)
ƒ(x)
Fetch
Unfamiliar
Not too simple
https://github.com/craftcms/europa-museum
Europa Demo Site
{% extends "_/layouts/site" %}
{% block inline_css %}
{{ source ("_/#{entry.type.handle}-critical.min.css", ignore_missing = true) }}
{% endblock %}
{% block content %}
{% if entry.heroImage|length %}
{% set heroImage = entry.heroImage.one() %}
{% do heroImage.setTransform({
width: 1024,
quality: 80,
format: 'jpg',
}) %}
{% endif %}
<article data-router-view="newsArticle" data-handle="{{ entry.type.handle }}">
<div class="news-article-container" data-scroll-section>
{% include '_/svg/arrowLeft' %}
<a class="link-back" href="{{ url('news') }}">{{ 'Back to News'|t }}</a>
<h5 class="hero-heading">{{ entry.title |typogrify }}</h5>
<img class="hero-image" src="{{ heroImage.url }}" alt="{{ heroImage.title }}">
</div>
{% include "_/components/block-matrix" %}
</article>
{% endblock %}
{% set contentBlocks = contentBlocks ?? null ? contentBlocks : entry.contentBlocks.all() | default([]) %}
{% if contentBlocks | length %}
<section class="content-blocks">
{% for theBlock in contentBlocks %}
{% set theBlockName = theBlock.type.handle %}
{% set isFirst = loop.index == 1 %}
{% include "_/components/blocks/" ~ theBlockName ignore missing with {
'theBlock' : theBlock,
"isFirst" : isFirst
} %}
{% endfor %}
</section>
{% endif %}
{% set richText = theBlock.richText %}
{% set topBorder = theBlock.topBorder ? "topBorder" : "" %}
{% set layout = theBlock.layout %}
{% set narrowWidth = theBlock.narrowWidth ? "narrowWidth" : "" %}
{% if richText %}
<div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
<div class="rich-text">
{{ richText | typogrify }}
</div>
</div>
{% endif %}
Storybook
Server
npx storybook init -t server
Everyone loves an npm install
.storybook/main.js
module.exports = {
"stories": [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(json)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
],
"framework": "@storybook/server"
}
export const parameters = {
server: {
url: 'https://europa-museum.ddev.site/storybook/preview',
},
};
.storybook/preview.js
Config files
package.json
{
"private": true,
"scripts": {
...
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"devDependencies": { ... },
"dependencies": { ... },
}
Scripts
Register the route
use craft\events\RegisterUrlRulesEvent;
use craft\web\UrlManager;
use yii\base\Event;
Event::on(
UrlManager::class,
UrlManager::EVENT_REGISTER_SITE_URL_RULES,
function(RegisterUrlRulesEvent $event) {
$event->rules = array_merge($event->rules, [
'storybook/preview/<component:.+>' => 'storybook/stories/preview'
]);
}
);
plugin.php
<?php
use craft\web\Controller;
use yii\web\Response;
class StoriesController extends Controller
{
protected int|bool|array $allowAnonymous = ['preview'];
public function beforeAction($action): bool
{
$this->response->getHeaders()
->add('Access-Control-Allow-Origin', '*')
->add('Access-Control-Allow-Credentials', 'true');
return parent::beforeAction($action);
}
public function actionPreview(string $component = null): Response
{
$params = Craft::$app->request->getQueryParams();
$vars = $this->normalizeParams($params);
return $this->renderTemplate($component, $vars);
}
}
Render the template
StoriesController.php
// Button.stories.js|jsx
import React from 'react';
import { Button } from './Button';
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Button',
component: Button,
};
//👇 We create a “template” of how args map to rendering
const Template = (args) => <Button {...args} />;
//👇 Each story then reuses that template
export const Primary = Template.bind({});
Primary.args = {
primary: true,
label: 'Button',
};
A regular story file
{
"title": "Components/Rich Text",
"parameters": {...},
"args": {...},
"argTypes": {...},
"stories": [...]
}
Server story file
https://craft-storybook-example.ddev.site/storybook/preview/_/components/blocks/richText
{
"title": "Components/Rich Text",
"parameters": {
"server": {
"id": "_/components/blocks/richText",
"params": {
"richText": "Lorem ipsum ..."
}
}
},
"args": {...},
"argTypes": {...},
"stories": [...]
}
Parameters
{
"title": "Components/Rich Text",
"parameters": {...},
"args": {
"topBorder": false,
"layout": "imageLeft",
"isFirst": false,
"narrowWidth": true
},
"argTypes": {...},
"stories": [...]
}
Arguments
{
"title": "Components/Rich Text",
"parameters": {...},
"args": {...},
"argTypes": {
"layout": {
"control": {
"type": "select",
"options": [
"imageLeft",
"imageFullWidth",
"imageRight"
]
}
}
},
"stories": [...]
}
Argument Types
{
"title": "Components/Rich Text",
"parameters": {...},
"args": {...},
"argTypes": {...},
"stories": [
{
"name": "Default",
"args": {}
}
]
}
Stories
{
"title": "Components/Rich Text",
"parameters": {...},
"args": {...},
"argTypes": {...},
"stories": [
{
"name": "Default",
"args": {}
},
{
"name": "With Top Border",
"args": {
"topBorder": true
}
},
{
"name": "Image Left",
"args": {
"layout": "imageLeft"
}
}
]
}
Stories
{% set richText = theBlock.richText %}
{% set topBorder = theBlock.topBorder ? "topBorder" : "" %}
{% set layout = theBlock.layout %}
{% set narrowWidth = theBlock.narrowWidth ? "narrowWidth" : "" %}
{% if richText %}
<div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
<div class="rich-text">
{{ richText | typogrify }}
</div>
</div>
{% endif %}
{% set richText = richText ?? theBlock.richText %}
{% set topBorder = topBorder ?? theBlock.topBorder ? "topBorder" : "" %}
{% set layout = layout ?? theBlock.layout %}
{% set narrowWidth = narrowWidth ?? theBlock.narrowWidth ? "narrowWidth" : "" %}
{% set isFirst = isFirst ?? false %}
{% if richText %}
<div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
<div class="rich-text">
{{ richText | typogrify }}
</div>
</div>
{% endif %}
<link
rel="stylesheet"
href="https://europa-museum.ddev.site/assets/dist/css/site.css"
>
.storybook/preview-head.html
{
"title": "Components/Rich Text",
"parameters": {
"server": {
"id": "_/components/blocks/richText",
"params": {
"richText": "<h4>Sed eiusmod tempor encodidunt ut labore lore magna aliqua.</h4><p>Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, ut enim aden minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p><figure><img src=\"https://europa-museum.ddev.site/assets/volumes/images/sensory-art-house-QPUao07JBlY-unsplash.jpg\" alt=\"\"></figure><p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Velit esse cillum dolore eu fugiat nulla pariatur excepteur sint</p>"
}
}
},
"args": {...},
"argTypes": {...},
"stories": [...]
}
{
"title": "Components/Featured Entry",
"parameters": {
"server": {
"id": "_/components/blocks/featuredEntry"
}
},
"args": {
"textColor": "#000",
"title": "Featured entry title",
"isFirst": false
},
"argTypes": {},
"stories": [
{
"name": "Default",
"args": {}
}
]
}
_/components/blocks/featuredEntry.twig
{% set featuredEntry = theBlock.entry.one() ?? null %}
{% set textColor = theBlock.textColor | default('#000') %}
{% set title = featuredEntry.title %}
{% set heroImage = featuredEntry.heroImage.one() ?? null %}
{% if featuredEntry %}
<div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
<div class="featured-entry-container">
<a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
<div class="title-container" data-scroll data-scroll-speed="2">
<h3 class="title {{ textColor }} underline">
{{ title }}
</h3>
</div>
{% if heroImage %}
<div class="hero-image" data-scroll data-scroll-speed="1">
{# with, classes: 'example' #}
{% include "_/components/picture" with {
asset: heroImage,
transform: 'base',
sizes: sizes ?? '100vw',
lazytransition: 'none'
} %}
</div>
{% endif %}
</a>
</div>
</div>
{% endif %}
{% set featuredEntry = featuredEntry ?? theBlock.entry.one() ?? null %}
{% set textColor = textColor ?? theBlock.textColor | default('#000') %}
{% set title = title ?? featuredEntry.title %}
{% set heroImage = heroImage ?? featuredEntry.heroImage.one() ?? null %}
{% if featuredEntry %}
<div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
<div class="featured-entry-container">
<a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
<div class="title-container" data-scroll data-scroll-speed="2">
<h3 class="title {{ textColor }} underline">
{{ title }}
</h3>
</div>
{% if heroImage %}
<div class="hero-image" data-scroll data-scroll-speed="1">
{# with, classes: 'example' #}
{% include "_/components/picture" with {
asset: heroImage,
transform: 'base',
sizes: sizes ?? '100vw',
lazytransition: 'none'
} %}
</div>
{% endif %}
</a>
</div>
</div>
{% endif %}
{% set featuredEntry = featuredEntry ?? theBlock.entry.one() ?? null %}
{% set textColor = textColor ?? theBlock.textColor | default('#000') %}
{% set title = title ?? featuredEntry.title %}
{% set heroImage = heroImage ?? featuredEntry.heroImage.one() ?? null %}
{% if featuredEntry %}
<div class="content-block featured-entry-block {{ isFirst }}" data-scroll-section>
<div class="featured-entry-container">
<a class="link no-underline" href="{{ featuredEntry.url }}" data-scroll data-scroll-speed="-3">
<div class="title-container" data-scroll data-scroll-speed="2">
<h3 class="title {{ textColor }} underline">
{{ title }}
</h3>
</div>
{% if heroImage %}
<div class="hero-image" data-scroll data-scroll-speed="1">
{# with, classes: 'example' #}
{% include "_/components/picture" with {
asset: heroImage,
transform: 'base',
sizes: sizes ?? '100vw',
lazytransition: 'none'
} %}
</div>
{% endif %}
</a>
</div>
</div>
{% endif %}
{
"title": "Components/Featured Entry",
"parameters": {
"server": {
"id": "_/components/blocks/featuredEntry"
}
},
"args": {
"textColor": "#000",
"title": "Featured entry title",
"isFirst": false
},
"argTypes": {},
"stories": [
{
"name": "Default",
"args": {}
}
]
}
<?php
namespace brianjhanson\storybook\services;
use Craft;
use craft\base\Component;
use craft\base\ElementInterface;
use craft\helpers\StringHelper;
class StoriesService extends Component
{
public function parseStoryRefs(string $str): ElementInterface|null
{
$elementService = Craft::$app->getElements();
$core = StringHelper::trim($str, '{%}');
$parts = array_pad(explode(':', $core), 2, null);
$refHandle = $parts[0];
$ref = $parts[1];
$elementType = $elementService->getElementTypeByRefHandle($refHandle);
$elementQuery = $elementService->createElementQuery($elementType)
->status(null);
if ($ref) {
$elementQuery->id($ref);
}
return $elementQuery->one();
}
}
<?php
namespace brianjhanson\storybook\controllers;
use brianjhanson\storybook\Storybook;
/**
* @author Brian Hanson
* @package craft-storybook-example
* @since 1.0.0
*/
class StoriesController extends Controller
{
// ... additional functions
/**
* @param string $value
* @return bool|string|ElementInterface|null
*/
private function normalizeValue(string $value): mixed
{
if (StringHelper::startsWith($value, '{%')) {
return Storybook::getInstance()->stories->parseStoryRefs($value);
}
// Other normalization
}
}
{
"title": "Components/Featured Entry",
"parameters": {
"server": {
"id": "_/components/blocks/featuredEntry"
}
},
"args": {
"textColor": "#000",
"title": "Featured entry title",
"isFirst": false
},
"argTypes": {},
"stories": [
{
"name": "Default",
"args": {}
}
]
}
{
"title": "Components/Featured Entry",
"parameters": {
"server": {
"id": "_/components/blocks/featuredEntry"
}
},
"args": {
"featuredEntry": "{%entry:95}"
"textColor": "#000",
"title": "Featured entry title",
"isFirst": false
},
"argTypes": {},
"stories": [
{
"name": "Default",
"args": {}
}
]
}
Inject new styles into preview
Reload when markup changes
<link
rel="stylesheet"
href="https://europa-museum.ddev.site/assets/dist/css/site.css"
>
.storybook/preview-head.html
<link
rel="stylesheet"
href="http://localhost:3002/assets/dist/css/site.css
>
.storybook/preview-head.html
Browsersync
{% do story({...}) %}
{% set theBlock = theBlock ?? craft.matrixBlocks.type('richText').one() %}
{% set richText = richText ?? theBlock.richText %}
{% set topBorder = topBorder ?? theBlock.topBorder ? "topBorder" : "" %}
{% set layout = layout ?? theBlock.layout %}
{% set narrowWidth = narrowWidth ?? theBlock.narrowWidth ? "narrowWidth" : "" %}
{% set isFirst = isFirst ?? false %}
{% if richText %}
<div class="content-block rich-text-block {{ isFirst }} {{ topBorder }} layout-{{ layout }} {{ narrowWidth }}" data-scroll-section>
<div class="rich-text">
{{ richText | typogrify }}
</div>
</div>
{% endif %}
Stories directly in twig
Move stories into a `src` directory
Point Storybook at a `dist` directory
Add node task that watches for changes to twig files, and copies the JSON files over
Twig UX Components?
h/t Leevi Graham
CORs will be CORs
Promising but needs more work
✅
Uses Craft to
render templates
✅
Uses Craft to
render templates
✅
Uses Craft to
render templates
✅
Minimal template modifications
✅
Minimal template modifications
✅
Uses Craft to
render templates
✅
Uses Craft to
render templates
✅
Minimal template modifications
✅
Minimal template modifications
❌
Easy to use and maintain
Pip
Millie