b10k / craft-component-guide
Visual Matrix block picker with live previews rendered from your own Twig templates — and a component library in the control panel.
Package info
github.com/b10k-nl/craft-component-guide
Type:craft-plugin
pkg:composer/b10k/craft-component-guide
Requires
- php: >=8.2
- craftcms/cms: ^5.0.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-06 11:17:57 UTC
README
Editors cannot tell which Matrix block is which. They pick “Cards Grid” from a dropdown of names, hope it is the right one, and ask a developer when it is not. Component Guide shows them a rendered preview of every block a page-builder field accepts, inside the Craft control panel, and lets them add the one they picked.
For developers it is a component library for Craft CMS — a living style guide of every Twig component in the project, rendered with your site's own CSS and discovered from the templates you already have. No separate Storybook, no second environment to keep alive, no screenshots to maintain.
For agencies it is where a client tries the blocks of a sprint before they are on any page: on staging, under each preview, the client changes the block's text, choices and pictures and watches it re-render — then approves the story or asks for changes (Pro, when you turn it on), and the approval reaches the story files in git with their name and the date.
It also catches previews that lie: a story can promise a field the entry type does not have, and the guide says so before an editor meets a card they can never reproduce.
Nothing configured yet? The guide starts by explaining how to get there:
Status:
1.11.1— stable. Lite is free, on any site, including production. Pro is $99 per site plus $39/year for updates after the first year, and is free to run in development and staging. New to it? GETTING-STARTED.md is a five-minute walkthrough.
Requirements
- Craft CMS 5.0+
- PHP 8.2+
What it does
- Recursively discovers components from a configured templates folder.
- Supports nested (
button/button.twig+button/button.stories.twig) and adjacent (button.twig+button.stories.twig) conventions. - Two story languages (Twig and PHP) and two shapes (simple + rich), normalized to one internal model.
- Groups, lists and searches components in the CP, with the whole library in the sidebar and filters that stay set while you move between components. Variants as cards shows a card per story instead of per block, each with its own preview — and, with client review on, where it stands.
- Renders each story in an isolated iframe with your front-end CSS, on a desktop, tablet or phone frame, cropped to the component's own height.
- Generates a copy-pasteable Twig
{% include … with {…} only %}snippet. - Controls on every component page: change a story's values — text, on/off, a choice among the values the template or the block's field knows, JSON for lists — and the preview re-renders as you type, on any device. Every component and its stories sit in the sidebar. Keep a case with Copy as story, or send the page's address to show what you see. Handy when a client reviews blocks on staging, long before launch.
- Surfaces per-component errors without breaking the rest of the guide.
- Lists story-less templates via marker files (
GUIDE.md/BLOCKS.md/COMPONENTS.md), so the guide doubles as a full component inventory. - Blocks gallery for editors: a "Blocks gallery" button on every Matrix page-builder field (all view modes — blocks, cards, index) opens a panel of real component previews with descriptions; clicking one adds that block. In "Inline-editable blocks" mode the new block is prefilled with the story's own content, so it is visible on the page immediately. A block with several stories shows them in a dropdown on its card, or — with Variants as cards ticked — as one card per story, for stories that change the layout rather than a colour.
- Editors' own variants: "Save as my variant…" in a block's action menu keeps the block as the editor filled it, under a name, as a card of their own in the gallery. Personal, stored in their Craft user preferences — no table, no migration — and handed to a developer as a ready-to-paste story when it deserves to be one for everybody.
- Works with block templates as they are: a block template that reads the
entry itself —
{{ block.heading }},block.cards.all(),block.image.one().url— previews from a story written in field handles; the guide builds the block it reads, in the shape Craft gives each field. No adapter needed to start. - Bulk setup with a coding agent: a recipe in
AGENT-SETUP.mdany agent can follow to document an existing library in one pass — marker file, one draft story per component, and a report of the blocks that do not match an entry-type handle. - Client review (Pro, off by default): a client approves each story on staging or asks for changes; the approvals are exported and written into your story files, so production knows what was signed off. See Client review and CLIENT-REVIEW.md.
- Promote from the control panel: flip a reviewed component between
draftandstableon its card, and the guide tells you which files it changed so nothing is written behind git's back. - Persistent scan cache keyed by a filesystem fingerprint — invalidates itself the moment a story, template or marker changes.
Terms used here
Craft and its community name the same things several ways; this README uses one name for each.
- Page builder — a Matrix field an editor builds a page from, block by block. Also called a content builder.
- Block — one item in that field; tutorials also say section or element. Craft 5 calls these nested entries; Craft 4 and earlier called them Matrix blocks. A block type is the nested entry's entry type.
- Block template — the Twig file that renders one block type, often kept
as a partial (
_blocks/hero.twig,_partials/_block_hero.twig). - Dispatcher — the loop over the page builder that includes the right block template for each block, by its type handle.
- Component — any template the guide lists. Block templates are components; so are shared partials such as buttons and cards.
- Story — one example state of a component: the values it renders with,
in a
*.stories.twig(or.php) file next to it. - Adapter (optional, recommended) — a small template between the dispatcher and a block template that reads the block's fields and passes them on as plain values. See Best practice.
- Presentational component — a template that renders only from the plain values it is given and never reads an entry.
When not to use this
- Your components query Craft themselves. A component that runs its own
element queries (
craft.entries…) previews whatever those queries find, not what a story says. Reading the block it is given is fine — see Block templates that read the block themselves. - You have no page-builder field. The editor half is built around Matrix. Without one the guide is still a component browser, but the half that earns its keep sits idle.
- You want screenshots you art-direct. Previews are re-rendered from the template every time, so they cannot go stale — and cannot be posed either.
- Your previews need CSS the guide cannot reach. They are exactly as accurate as the stylesheet you point them at.
- You need a verdict on every component. The contract check gives none when it cannot tell which template reads a block — two adapters for one component, or a dispatcher that reuses the block variable. Refusing beats guessing.
Editions
Lite is free. Everything that tells you the truth about your own project is in it:
- the component index, marker files and groups
- live previews rendered with your site's own CSS
- Controls on the component page — change a story's values and watch it re-render
- the contract check and its badge — a story promising a field the entry type does not have
- loud render errors, rather than a silent broken thumbnail
- the story scaffolder, including the whole-library pass
- the write journal
Pro adds the things the plugin does on your behalf:
- inserting a block from the gallery — an editor picks a card and the block lands filled in, from the state whose preview they were looking at
- saving their own variants — "Save as my variant…" on a block, and the card it becomes in the gallery (see below)
- client review — turned on in the settings: a client approves a story or asks for changes, and the approval reaches the story file in git (see Client review)
stories/check— the contract check as a console command with an exit code, for CI. The edition comes from your committedproject.yaml, so a CI runner reads Pro without needing a licence key of its own; under Lite the command explains itself and exits 0 rather than breaking your build
The blocks gallery opens in Lite too. Editors browse the real rendered cards for the blocks a field accepts; only the click that inserts a filled-in block is held back, and Craft's own “New Block” menu works exactly as it always did.
The split is seeing versus acting, not developer versus editor. A component library you cannot see is the problem this plugin exists to solve, so seeing it is not the part with a price on it.
In PHP and Twig:
if (\b10k\componentguide\Plugin::getInstance()->isPro()) { … }
Editors' own variants
The developer's stories are the starting points. An editor who keeps building the same block the same way — the dark coaching hero, the two-plan pricing table — can keep it:
- Fill in a block on any entry as usual.
- Open the block's action menu (⋯) and choose Save as my variant….
- Name it. The panel says which fields are kept before anything is saved.
The variant appears in the blocks gallery next to the block's stories, marked ★, with a live preview. Clicking it adds the block filled in exactly as saved.
What is kept. Only the fields the gallery can fill back in: each reaching the template as exactly one value, read directly or passed on by an adapter — text, number, URL, email, dropdown, radio buttons and lightswitches, up to 500 characters each. Rich text (CKEditor), assets, relations and nested blocks are not kept and start from the block's first story instead; the save panel names them. That rule is what keeps a variant honest: its card previews exactly what a click puts back.
Where it lives. In the editor's own Craft user preferences — personal, across sessions and devices, gone when the user is deleted. The plugin still ships no migration and no table of its own. Nobody else sees an editor's variants, developers included.
Making it everyone's. On the variant's gallery card, Share with a developer copies it as a story entry in Twig — every argument written out — for a developer to paste into the component's story file. That hand-over is deliberate: preferences are personal and cannot be searched efficiently, and a story in git is reviewed like any other change. Delete removes the variant.
Saving a variant is a Pro feature. It works in Matrix fields set to Inline-editable blocks; Cards and Index modes have no block menu to add it to.
Permissions
- Access Component Guide — Craft's own, under General → Access the
control panel: the Component Guide section, with everything in it.
- Approve blocks or ask for changes (client review) — nested under it, so it cannot be given without it (Pro, and only while client review is on in the settings).
- Clear the client review history — beside it: for a developer who is not an admin. Listed only where Enable History Clearing is on; saving a user group where it is off drops it from that group.
- Use the blocks gallery on page-builder fields — under Component Guide: for editors who use the gallery on their entries but never open the section.
Either Access Component Guide or the gallery permission lets someone use the guide; nobody needs both.
Installation
From the Craft Plugin Store: open Plugin Store in your control-panel sidebar and search for “Component Guide”.
Or with Composer:
composer require b10k/craft-component-guide php craft plugin/install component-guide
On DDEV, prefix each command with ddev.
New to it? GETTING-STARTED.md walks through the first five minutes.
Configuration
Settings can be edited in Settings → Plugins → Component Guide, or overridden per-environment with a config file. The config file wins; any field it sets is read-only in the CP.
config/component-guide.php:
<?php return [ 'componentPath' => '', // scan root, relative to templates/ ('' = whole dir) 'storySuffix' => '.stories.php', 'enableCpSection' => true, 'enableIframePreview' => true, 'previewCss' => ['/dist/css/app.css'], // string or array 'previewJs' => ['/dist/js/app.js'], 'previewTemplate' => '', // site template rendered into the preview <head> 'enableScanCache' => true, 'enableClientReview' => false, // Pro: approve stories / ask for changes 'enableReviewClearing' => false, // the review history may be cleared in the CP ];
The two client review switches take true, false, or the name of an
environment variable ('$CG_CLIENT_REVIEW') read as a boolean — in this file
or in the settings screen, where each is a Yes / No / variable menu — so
staging and production can differ while project config stays the same.
| Setting | Default | Notes |
|---|---|---|
componentPath |
'' (whole templates/) |
Scan root relative to templates/. Empty scans everything; set e.g. _components to narrow. No absolute paths or ... |
storySuffix |
.stories.php |
Must end in .php. The Twig equivalent (.stories.twig) is derived from it automatically and discovered too — that's the format the scaffolder writes. |
enableCpSection |
true |
Show/hide the CP nav item. The pages stay reachable by their address; the editors' gallery and the CLI keep working. |
enableIframePreview |
true |
On a component's page: the preview in an iframe (with Controls), or a link to it. |
enableScanCache |
true |
Cache the scan; it invalidates itself when a story, template or marker file changes. |
enableClientReview |
false |
Client review (Pro) — see Client review. Yes or No goes into project config, so every environment follows it; an environment variable lets each decide. |
enableReviewClearing |
false |
Lets admins, and users with the Clear the client review history permission, clear the review history from the CP. For staging only, point it at an environment variable ('$CG_REVIEW_CLEARING') set only there. The console command works either way. |
previewCss / previewJs |
[] |
Front-end assets injected into the preview. |
previewTemplate |
'' |
A site template rendered into the preview document's <head> — the right place for Vite/manifest asset tags (see below). |
The model is Storybook-style: the scanner walks the scan root recursively and
shows every Twig template that has an adjacent story file — anywhere in the
tree (components, page-builder blocks, partials). A template's author opts it into
the guide simply by dropping a *.stories.twig (or *.stories.php) next to it;
nothing else registers it. Components are grouped by their folder path (or by a
story's meta.group).
Directory conventions
templates/_components/
├── button/
│ ├── button.twig
│ └── button.stories.twig
├── card.twig # adjacent convention also works
├── card.stories.twig
└── navigation/menu/
├── menu.twig
└── menu.stories.twig
A Twig file is listed as a component only when it has a matching story file —
unless its folder is opted in via a marker file (see below).
Hidden files/dirs, node_modules, vendor and cache folders are ignored.
Marker files: components without stories
Writing a story per component is an upgrade, not the ticket in. Drop a marker
file — GUIDE.md, BLOCKS.md or COMPONENTS.md — into any folder inside the
scan root, and every plain Twig template in that folder and its subfolders
is listed in the guide, story or not. Story-less templates appear as inert
“undocumented” cards with a hint to add a story; templates whose filename
starts with _ are treated as internal partials and skipped.
The marker is also lightweight documentation:
# Content Blocks Reusable page blocks editors can add through the Matrix page builder. ## Anything below the intro is ignored by the guide — document freely.
- The H1 becomes the group label. Group names mirror the folder hierarchy:
a marker's H1 replaces its own folder's name in the chain and plain
subfolders are humanized (
Components / Cards). Components without an explicitmeta.groupinherit the derived name, so documented and undocumented cards share one section. - The intro text below the H1 (up to the next heading) becomes the group description on the index page.
- One marker per folder; with duplicates, precedence is
GUIDE.md→BLOCKS.md→COMPONENTS.mdand the CLI scan warns. - Not listed as components: files starting with
_(internal partials) andindex.twig/undefined.twig(the dispatcher pattern's entry point and fallback). A story file next to any of them still documents it explicitly. - Markers live in git next to your templates and read as plain folder docs on GitHub. The scan cache tracks them automatically.
Story format
Stories come in two languages — Twig (*.stories.twig) and PHP
(*.stories.php) — with the same shape. Twig is the default: it's the language
the component is already written in, and it's what the scaffolder writes. Both
are discovered automatically; pick per component if you like.
Twig
{% set meta = {
title: 'Button',
group: 'Atoms',
description: 'Primary user-action button.',
status: 'stable',
} %}
{% set stories = {
'Primary': {
args: { label: 'Save', variant: 'primary' },
description: 'The default call to action.',
},
'Secondary': {
args: { label: 'Cancel', variant: 'secondary' },
},
} %}
meta is optional — a file with just stories works. The story file is
rendered as a data-only template: set those two variables and output nothing.
PHP — simple
<?php return [ 'Primary' => ['label' => 'Save', 'variant' => 'primary'], 'Secondary' => ['label' => 'Cancel', 'variant' => 'secondary'], ];
PHP — rich (with metadata)
<?php return [ 'meta' => [ 'title' => 'Button', 'group' => 'Atoms', 'description' => 'Primary user-action button.', 'status' => 'stable', ], 'stories' => [ 'Primary' => [ 'args' => ['label' => 'Save', 'variant' => 'primary'], 'description' => 'The default call to action.', 'background' => '#f5f5f5', 'viewport' => 'phone', 'tags' => ['action', 'form'], ], ], ];
Optional story keys: args, description, background, viewport, tags,
approval ({ by: 'username', date: 'YYYY-MM-DD', version: '…' } — written by
the client review import; see Client review).
viewportpicks the device the preview opens in —desktop(default),tabletorphone. The reader can still switch; this just says which state the story is really about, so a mobile-only variant doesn't open in desktop.backgroundsets the colour behind the preview frame (any CSS colour), useful for components meant to sit on a dark or tinted section.
Status vocabulary: stable, beta, draft, deprecated (case-insensitive;
wip, ready, experimental and legacy are accepted as aliases). Only
stable components are addable from the blocks gallery — see below.
Component example
{# templates/_components/button/button.twig #} {% set label = label ?? 'Button' %} {% set variant = variant ?? 'primary' %} {% set disabled = disabled ?? false %} <button type="button" class="button button--{{ variant }}"{% if disabled %} disabled{% endif %}> {{ label }} </button>
Prefer plain arrays and scalar props. Craft element objects returned by trusted story files are not blocked, but arrays keep stories portable.
Scaffolding stories
Undocumented components (see marker files above) can bootstrap their own
story. Each undocumented card gets an Add story button in environments
where Craft's allowAdminChanges is on (local and staging by convention — the
same gate Craft uses for its own project-file changes); elsewhere the guide
says so instead of hiding the feature. The equivalent on the command line is:
php craft component-guide/components/make hero # writes hero.stories.twig php craft component-guide/components/make hero --states # one story per detected state php craft component-guide/components/make hero --format=php
The scaffolder reads the template and guesses a Default story from it:
- root variables become args;
{% for it in items %}becomes a three-item sample array carrying the keys the loop actually uses (it.title→title); |default('…')and the{% set x = x ?? '…' %}idiom supply real values;- everything else is guessed by name —
*Url→#, image-ish*Url→ an inline SVG placeholder,*Html→ a paragraph,is*/has*→true; - the first sentence of the template's leading
{# … #}comment becomes the component description.
One story, or one per state. If the template switches on a value —
theme == 'dark', mediaPosition == 'right' — those comparisons are its
states, and a second button appears next to Add story: Add 2 stories
(however many it found), with a tooltip naming the values. Taking it writes
one story per value (Light, Dark, Media right…) instead of a single
Default, so the component's variants are documented from the start. Either
way it is one story file — the buttons differ only in how many stories go
inside it.
The result is a draft: it is written with status: draft, the args are
guesses, and an existing story file is never overwritten. Review it, fix what
the heuristics got wrong, and promote the status when the component is
properly documented.
Documenting a whole library with a coding agent
Scaffolding one component at a time is fine for a handful. For a library that already has forty templates, the format is documented well enough that an agent can write the stories for you — so the package ships a recipe for exactly that:
Read AGENT-SETUP.md in vendor/b10k/craft-component-guide and follow it.
It is tool-agnostic — nothing in it names a particular agent — and it is a plain
Markdown file in the package root, so it is on disk as soon as Composer has run.
The recipe tells the agent to find the component folders and ask which to
document, read entry-type handles from config/project/ rather than inventing
them, write one story per component (plus one per switchable state), use
placeholder tokens for what carries no information and real copy where the
story asserts something — and to stop there:
- it never renames a template or an entry type to force a gallery match; the mismatches go into its report instead, which is the most useful thing it hands back;
- it never touches your component templates or
config/project/; - it never commits — the diff is the point;
- every story it writes is
status: draft. Onlystablecomponents are addable from the blocks gallery, so after an agent pass your own index is full of previews while editors see nothing new. That is deliberate: a generated story markedstablewould be a claim nobody had checked.
Then you review — not by reading a large diff, but by looking. Open the guide, scan the rendered previews, and the ones that came out wrong are the ones whose args need a two-line fix.
Trying a component: Controls
Under the preview on a component page, Controls lists the story's arguments, each with an input that fits it:
| The argument | The input |
|---|---|
| a field of the block, for a template that reads the block itself | every field of the entry type, in its order — a dropdown or radio field as a select of its options, a lightswitch as a checkbox, rich text as a text area, Matrix and relations as JSON |
| fed by an adapter from a dropdown or lightswitch field | the same select or checkbox |
compared in the template with a few literals (theme == 'dark') |
a select of those values |
| otherwise | by the values the stories give it: text (with the other stories' values as suggestions), number, checkbox, or JSON for a list or hash |
An image argument — an Assets field holding one image, an @image… token, or
a URL to an image file — gets Choose an image next to its input: Craft's
own asset modal, uploading included where the person may upload. The preview
gets the asset's URL; nothing is attached to anything. Assets in a volume
without public URLs cannot be shown and say so.
Arguments that only another story of the component uses are listed too, empty, so a case nobody wrote a story for is one input away.
The preview re-renders as you type. Nothing is saved: Reset goes back to the story, and Copy as story puts the changed story on the clipboard as Twig for the story file. The page's own address follows the changes, so you can send someone exactly what you see. A page opened from such an address asks before it shows the values — see Security & trust model.
This is the part of the guide a client can use: give them a control-panel account on staging with Access the control panel and Access Component Guide (both under General in Craft's permissions), and they can open each block of the sprint and try it with their own words, without a page built around it.
Client review
Pro, off by default. Turn it on in Settings → Plugins → Component Guide → Client review when you use it with your client. Step by step — accounts, the handover, the requests, the import — in CLIENT-REVIEW.md. The setting is saved in project config, so staging and production follow it alike; with it off there is no review permission, nothing on the component page, no filter, no counts — not a word about it anywhere.
A user with the Approve blocks or ask for changes permission (nested under Craft's Access Component Guide) sees, under the preview:
- Approve this story — the story exactly as the developer wrote it. Values changed in Controls cannot be approved: reset them, or send them with…
- Request changes — a comment, and the values on screen if they were changed, so the developer sees what was tried.
Every story is in one of three states, the same for everyone:
| To review | nobody has decided yet — or the block has changed since the last decision |
| Approved | someone with the permission approved it |
| Changes requested | someone asked for changes |
The latest decision decides, whoever made it. A developer who finds something wrong in a story the client approved asks for changes, and the story is Changes requested for everyone; the client's approval of the fixed version makes it Approved again. Decisions are never withdrawn: each one is kept in the story's History, with who and when — nobody can later say they never approved a block that went live. The one exception is a slip of the mouse: right after Approve or Request changes, a notification offers Undo for a few seconds (an undone request puts its comment back in the box).
When the block changes, the decision made on the earlier version no longer holds: each decision remembers the block as that story showed it — a short hash of the block's template and of the story's args. Change the template (every story of the block) or the story's args (that story) after a request for changes, and the story is back to To review — “the block has changed since, look at it again”; the same after an approval, since the client approved a block that looks different now. A fix outside the template — a partial it includes, the CSS — is not noticed by itself: an admin clicks Mark as fixed on the request (Not fixed yet takes it back).
The sidebar marks ! where changes are requested and ✓ where a story is approved. A block is approved only when all its stories are — Light approved and Dark not yet leaves the block To review — and has changes requested as soon as one story has. The review filter and the counts on the index narrow the library by the block's state. A draft or deprecated block cannot be approved, but changes can be requested on it. The review state sits beside the developer's status and does not replace it: a stable block with changes requested can still be used.
From staging to the story files
To start the client on a clean slate after your own trial runs — with
Enable History Clearing on in the settings — an admin, or anyone with the
Clear the client review history permission, can Clear this
block's review history… (on its page) or Clear review history…
(sidebar); or, with or without it, run php craft component-guide/reviews/clear [--component=<id>] — it asks first, is logged, and leaves the approvals in the
story files alone.
Decisions are made on staging and kept in each reviewer's own Craft user preferences — no table, no migration. Craft deletes a user's preferences with the user, so export before removing a client's staging account. To take them to the story files:
-
On staging, an admin clicks Export client reviews (sidebar) and gets one JSON file, which says the site and Craft environment it came from. The button shows while there are decisions that admin has not exported yet, with how many; after an export it becomes a quiet “exported {date}, nothing new” line with an Export again link.
-
Where the story files are written — usually locally:
php craft component-guide/reviews/import --file=component-guide-reviews-staging.example.com-2026-10-30.json
In DDEV (or any container) the command sees only the project folder, so read the download from your machine through standard input:
ddev craft component-guide/reviews/import --file=- < ~/Downloads/component-guide-reviews-staging.example.com-2026-10-30.json
For each story, the latest decision:
-
approved — the story gets one line, and nothing else in the file changes:
'Light': { approval: { by: 'anna', date: '2026-10-30', version: '3f9c1a2b7d4e' }, args: { … }, },byis the approver's username — the user can always be found by it, and the guide shows their full name where they are a user. (With Craft'suseEmailAsUsername, that is their email address, in your repository.) A later approval replaces it. So production knows a story is approved without anyone reviewing there — and that it no longer is, if the template changes after it. -
changes requested — listed with the comment and the values tried: that is the work. An
approval:the story still has is taken out: it is no longer the latest word. -
approved on an earlier version — the template or the story's args here differ from what the reviewer saw — not imported; ask for another look.
Importing the same file twice changes nothing.
--dry-runshows what would be written. A story written as bare args (withoutargs:), or a PHP story file, gets the line printed to add by hand. -
-
Review the diff, commit, deploy.
Reviewing and promoting
A component whose story is draft gets a Mark stable button on its index
card and on its own page; a stable one gets Back to draft. Both write the
one status line in the story file and leave every other byte of it alone, so
your comments, spacing and argument order survive.
Only those two statuses are toggleable. beta and deprecated say something
about a component's lifecycle that only a developer knows, so they stay in the
editor where you wrote them; the toggle is for the review verdict, which is the
one status change you make in bulk.
The same two gates as scaffolding apply (allowAdminChanges, and a writable
templates directory), so the button is absent — with a stated reason — on
read-only environments.
What the guide changed
The control panel has no git status. So when it writes into templates/ —
a scaffolded story, a promoted status — it says so: a notice on the index lists
the files it changed, until they are dealt with.
It only ever claims what it can still verify. Each entry records the content
hash of what was written, and is re-checked on every read: if the file's hash no
longer matches, someone edited or reverted it after the guide did, and the entry
is dropped. If .git/index is newer than the file, a git add or commit
happened since, and the entry is dropped. Reviewed drops it by hand. The
notice would rather miss a file than cry wolf — one that lies twice is one you
stop reading, and git remains the real safeguard.
The record lives in Craft's runtime storage, never in your repository.
Placeholder tokens
Stories don't have to carry their own copy. Any string arg can be a token, expanded when the preview renders:
{% set stories = {
'Default': {
args: {
heading: '@lorem_w_6',
bodyHtml: '@lorem_p_2',
imageUrl: '@image_1600x600',
iconUrl: '@icon_star',
},
},
} %}
| Token | Result |
|---|---|
@lorem |
a short phrase |
@lorem_w_12 |
12 words |
@lorem_s_3 |
3 sentences |
@lorem_p_2 |
2 paragraphs, wrapped in <p> |
@image |
a photo, 800×600 |
@image_1600x600 |
a photo at that size |
@icon |
one of Craft's system icons |
@icon_star |
that specific system icon |
Three things worth knowing:
- Deterministic. Values are derived from the component, story and argument
path — the same story always renders the same text, so previews don't
flicker and gallery thumbnails match the detail page. Items in a list still
differ from each other, so three identical
@lorem_w_4tokens produce three different samples. - Offline-safe. Photos come from an external service; if it can't be reached, the preview falls back to an inline placeholder of the same size. Icons are always inline (no network).
- Unknown tokens pass through.
@somethingthe plugin doesn't recognise is left exactly as written.
The story scaffolder emits these tokens, which is why generated stories stay short and readable.
What to tokenise — and what not to
Tokens exist to kill busywork, not meaning. A useful rule:
- Tokenise what carries no information: photography, icons, “just some paragraph of text” in a fresh scaffold, the third and fourth items of a list.
- Write real copy where the story asserts something: the flagship state you'd screenshot, and the edge cases you're documenting on purpose — a heading long enough to wrap, a button label that nearly overflows, a card with no image.
A preview full of lorem tells you the component renders. A preview with real copy tells you it works — that the two-line heading still fits, that the tone is right, that “Membership” doesn't break the button. That's also what makes the guide worth showing to a client or a new teammate.
So: scaffold with tokens (one click, previews come alive), then replace the
values that are worth a human's attention. That's exactly what the draft
status is there to remind you of.
Block templates that read the block themselves
This is how most page builders are built, and how Craft's own docs and most tutorials show it: the dispatcher loops the Matrix field and includes one block template per block type, and that template reads the block it is handed:
{# _blocks/hero.twig #} {% set image = block.heroImage.one() %} <section class="{{ block.theme.value == 'dark' ? 'bg-slate-900' : 'bg-white' }}"> {% if image %}<img src="{{ image.url }}" alt="">{% endif %} <h1>{{ block.heading }}</h1> {{ block.bodyText }} </section>
That works without changing a line. Write the story in the entry type's field handles, plainly:
{% set stories = {
'Dark': { args: {
theme: 'dark',
heading: 'Ride better, not just harder',
bodyText: '<p>Fitting, coaching and servicing in one workshop.</p>',
heroImage: '@image_1600x600',
} },
} %}
Variables the dispatcher passes alongside the block — with { block: block, blockIndex: loop.index } is one way tutorials write it — go into the story as
they are (blockIndex: 1); the guide treats them as context, not as fields.
A Matrix field takes a list of items in its nested entry type's handles —
gridCards: [ { title: 'Bike fitting', cardText: 'Two hours on the rig.' } ]
— and the template's {% for card in block.gridCards.all() %} loops them.
The guide notices the template reads a variable no story sets and nothing in
the template defines — block here, whatever your dispatcher calls it — and
builds that entry from the story, using the matched entry type's field types:
rich text prints as HTML, a dropdown has .value and .label and compares as
its value, assets and relations answer .one(), .all(), |length and loops,
Matrix items become entries of their nested type (name it with type: 'handle' when the field allows several), and block.type.handle and
block.fieldLayout.getFieldByHandle() work. The blocks gallery, prefill, the
contract check and editors' own variants all use the same field handles.
Limits, named here rather than discovered:
- What the stand-in cannot answer, the preview says. A field the entry type does not have, a method only a real element has, an image transform — the value is empty and a note under the preview lists it. Nothing is left out quietly.
- Only when there is no adapter. A component something else includes with
plain values is presentational and renders as it always has. So does a story
that sets the variable itself (
args: { block: { … } }). - One template per entry type. If the same entry type is rendered by two templates, the check reports fields the other one reads as unused.
- A template that reassigns its block variable in a loop — a dispatcher — is not treated as a block.
When a block grows, splitting it into an adapter and a presentational component (below) is a good next step — but it is one you take when it pays, not the price of trying the plugin.
Best practice: adapters + presentational components
Not required — the guide works with block templates as they are (above). This three-layer pattern is what we recommend once a page builder has more than a handful of blocks: it keeps the markup reusable outside the Matrix field, puts every field read in one small file per block type, and gets the most out of the guide.
templates/_v2/
├── _blocks.twig # dispatcher — loops the Matrix field (~10 lines)
├── _adapters/ # entry-aware glue (NOT listed in the guide)
│ ├── hero.twig # one per block type, named after its handle
│ ├── promoBanner.twig
│ └── undefined.twig # fallback: dev/preview-only “unmapped block” alert
└── _blocks/ # presentational components (listed in the guide)
├── BLOCKS.md # marker file — inventory + group description
├── hero.twig # pure: accepts scalars/arrays, never an Entry
├── hero.stories.twig # preview states for the guide
└── promoBanner.twig
Presentational components accept plain scalars and arrays with |default
fallbacks — never a Craft element. That is exactly what makes them previewable
in the guide with simple array stories, and reusable outside the Matrix
context.
Adapters are the only layer that touches entries: one small template per
block type, receiving { block }, doing all field access, getFieldByHandle
guards, image transforms, queries and fallbacks — then including the
presentational partial. They contain no markup of their own. Keep the folder
outside any marker-covered subtree so the guide never lists glue code as
components.
The dispatcher collapses to a loop with a convention-based include and an automatic fallback (Twig picks the first template that exists):
{% for block in blocks.all() %}
{% include [
'_v2/_adapters/' ~ block.type.handle ~ '.twig',
'_v2/_adapters/undefined.twig',
] with { block: block } only %}
{% endfor %}
undefined.twig renders a visible warning in dev mode / live preview and
nothing in production — a new block type shows up as an actionable alert
instead of silently rendering nothing.
Rules of thumb: a component never touches an Entry; an adapter never contains markup; adding a block = entry type + adapter + component (+ story when it earns one).
Preview asset configuration
Set previewCss / previewJs (string or array of URLs) to load your compiled
front-end assets into the preview document. With none configured you get a clean,
unstyled document. CSS support matters most; JS is optional.
Using Vite (or any manifest-based build)? Static URLs go stale between dev
and production — point previewTemplate at a small site template instead, and
put your asset tags there. It is rendered into the preview document's <head>
in site mode, so helpers like craft.vite resolve the dev server vs. the
built manifest exactly like on the front end — previews get real styling and
working component JS (carousels included) in both environments:
{# templates/_component-guide-preview.twig #} {{ craft.vite.script('src/js/app.ts') }}
// config/component-guide.php 'previewTemplate' => '_component-guide-preview',
Uninstalling
1. Remove the plugin
Either from the control panel — Settings → Plugins → Component Guide → ⚙ → Uninstall — or from the command line:
php craft plugin/uninstall component-guide composer remove b10k/craft-component-guide
That removes the plugin, its settings, its permission and its scan cache —
and what it kept in Craft's user preferences (keys starting with
componentGuide.): editors' own variants and clients' reviews. Export the
reviews first if they have not reached the story files yet.
2. Decide what to do with your story files
They are left in place on purpose. A *.stories.twig next to a template is
inert without the plugin — nothing includes it, nothing renders it, it costs
nothing. A GUIDE.md is just a markdown file that reads as folder
documentation on GitHub. Keeping them means reinstalling later picks up exactly
where you left off.
If you do want them gone, delete them yourself — they're your files, in your repository, and you should see the diff before it lands:
In your editor (PhpStorm, VS Code, …)
- Search the project for
*.stories.twigand*.stories.php(PhpStorm: ⌘⇧O → type.stories.· VS Code: ⌘P → same). - Review the list — some of these may be files you wrote or heavily edited.
- Select and delete.
- Search for
GUIDE.md,BLOCKS.mdandCOMPONENTS.mdinside your templates folder and delete those too, if you don't want them as folder docs. - Commit, so the removal is reviewable and revertable like any other change.
In a terminal
# preview first — nothing is deleted by this find templates -name '*.stories.twig' -o -name '*.stories.php' # then remove them through git, so the change is staged and revertable git rm 'templates/**/*.stories.twig' 'templates/**/*.stories.php'
Marker files, if you want those gone too:
git rm 'templates/**/GUIDE.md' 'templates/**/BLOCKS.md' 'templates/**/COMPONENTS.md'
What was never touched
Your content, your Matrix fields, your entry types, your templates. The guide
only ever reads them. The exceptions are story files, and only when you ask:
the Add story button (and its CLI equivalent) writes a new one and never
overwrites an existing one, and Mark stable / Back to draft rewrite the
single status line of one.
Editors' saved variants stay in their user preferences, under one key
(componentGuide.stories), unread by anything once the plugin is gone.
The scan cache can also be cleared on its own at any time, without uninstalling anything: Utilities → Caches → Component Guide scan cache.
Security & trust model
-
Story files are project code — Twig templates or PHP files in your repo. Treat them like any other template. They are only ever discovered inside the configured
componentPath. -
The scanner refuses absolute paths and
..traversal; the resolved directory must live inside your templates folder. -
Previews render only component/story IDs resolved through the repository — a request can never supply a raw template path.
-
Every CP/preview action requires login and either Craft's own Access Component Guide permission (the control-panel section) or the plugin's Use the blocks gallery on page-builder fields (
component-guide:access), for editors who only use the gallery. -
The two actions that write into
templates/— scaffolding a story and changing a status — are admin-only, POST-only, CSRF-protected, and gated onallowAdminChangesplus a writable templates directory. A status change rewrites exactly one line. Every such write is recorded and surfaced on the index (see What the guide changed), because a control panel that edits project files silently is worse than one that cannot. -
Previews render in a separate iframe document, so project CSS and JS never mix with the control panel's. It is not a
sandboxed iframe: it shares the control panel's origin, which is how the gallery and the component page measure a preview's height. Metadata is escaped; error messages appear whereallowAdminChangesis on, absolute paths and stack traces only indevMode. -
Editors' variants are read and written only for the signed-in user, in their own user preferences; no request can name another user's. Saving is POST-only and CSRF-protected, keeps only fields the block's template reads, and renders through the same preview as stories — a variant never supplies a template path or args of its own.
-
Controls send the values someone changed only in a POST body, with their own CSRF token, to the same story of the same component — never a template path, and never in the preview frame's URL. The component page keeps them in its own address so it can be shared, but a page opened from an address that carries values shows them only after the reader chooses to: a link can carry anything, and a template that prints a value inside a
<script>or anonclickis not made safe by HTML escaping. The values are rendered, never stored. -
Client review decisions are written only by users with the review permission, only for themselves, POST-only and CSRF-protected, only in Pro and only with client review turned on. They are read for everyone with access to the guide — that is their purpose — from the preferences of users who may review, and nobody else's. A reviewer's values are content, like what an editor types into a field: they render through the same templates. The import command writes only the
approval:line of the story a decision names, and nothing at all with--dry-run.
Do not expose the plugin to untrusted users — story files are project code, not a sandbox.
Troubleshooting
- "No components found." — check
componentPath(relative totemplates/) and that story files end instorySuffix(or its.twigequivalent). The index also walks you through this when nothing is discovered yet. - Component missing — it needs a matching
*.twignext to its story file. - Render error in preview — the component threw; the message is shown
wherever
allowAdminChangesis on, and the full trace indevMode. - A story renders nothing — usually markup behind a condition the args don't satisfy, or a template that reads Craft data itself; the preview says so instead of showing a blank frame.
- Styles missing in preview — set
previewCss, orpreviewTemplateif you build with Vite. - No "Add story" button — scaffolding writes files, so it needs
allowAdminChanges; the index says so where that's off. - No "Blocks gallery" button — the field's entry-type handles must match
component names. Matching ignores case and separators (
inline-donation-form↔inlineDonationForm), so the usual cause is a genuinely different name; opencomponent-guide/picker-mapto compare.
Development
composer install composer test # PHPUnit (scanner, story parser, scaffolder, placeholders) composer analyse # PHPStan (level 5) composer check # both npm install # once (Node 22+) npm test # control-panel JavaScript: gallery prefill and cards (jsdom)
CLI diagnostics
Run the same discovery the CP uses, to verify configuration without opening the control panel:
php craft component-guide/components/scan # DDEV: ddev exec php craft component-guide/components/scan
Roadmap
- Page presets — a developer-defined set of blocks (“service page”, “campaign landing”) an editor adds in one go, filled from the stories
- Craft 6 — a 6.x release as soon as Craft 6 is stable
- Seeding scaffolded args from the matched entry type's field layout, so the draft comes from the schema instead of a guess
Feedback
Bug reports, feature ideas and “this felt weird” notes are all welcome — open an issue. GETTING-STARTED.md lists the current known limitations.
Contributing
See CONTRIBUTING.md. Report security issues privately (see that file) rather than via public issues.
License
The Craft License — the standard licence for commercial Craft plugins: source-available, one licence per production environment. Lite is free everywhere; Pro is free to run in development and staging.



