parisek / styleguide
Twig component styleguide as a self-contained Composer package
Requires
- php: ^8.3
- parisek/twig-attribute: ^1.0
- parisek/twig-typography: ^1.3
- symfony/twig-bridge: ^6.4 || ^7.0 || ^8.0
- symfony/var-dumper: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
- twig/intl-extra: ^3.3
- twig/string-extra: ^3.3
- twig/twig: ^3.27
Requires (Dev)
- ergebnis/composer-normalize: ^2.0
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
- symfony/debug-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/twig-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/web-profiler-bundle: ^6.4 || ^7.0 || ^8.0
Suggests
- symfony/framework-bundle: To serve the catalogue through Bridge\Symfony\StyleguideBundle in a Symfony application, or through Bridge\Symfony\FrontController from a front controller (^6.4 || ^7.0 || ^8.0)
- symfony/http-kernel: Required alongside symfony/framework-bundle for the optional Symfony bundle (^6.4 || ^7.0 || ^8.0)
- symfony/web-profiler-bundle: Profiler and debug toolbar for Bridge\Symfony\FrontController in debug; install as require-dev (^6.4 || ^7.0 || ^8.0)
Provides
None
Conflicts
None
Replaces
None
- v1.30.1
- v1.30.0
- v1.29.2
- v1.29.1
- v1.29.0
- v1.28.1
- v1.28.0
- v1.27.0
- v1.26.0
- v1.25.0
- v1.24.0
- v1.23.0
- v1.22.0
- v1.21.0
- v1.20.0
- v1.19.0
- v1.18.2
- v1.18.1
- v1.18.0
- v1.17.0
- v1.16.2
- v1.16.1
- v1.16.0
- v1.15.0
- v1.14.0
- v1.13.1
- v1.13.0
- v1.12.0
- v1.11.0
- v1.10.2
- v1.10.1
- v1.10.0
- v1.9.0
- v1.8.3
- v1.8.2
- v1.8.1
- v1.8.0
- v1.7.2
- v1.7.1
- v1.7.0
- v1.6.2
- v1.6.1
- v1.6.0
- v1.5.1
- v1.5.0
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.2
- v1.1.1
- v1.1.0
- dev-main / 1.0.x-dev
- v1.0.0
- v0.6.5
- v0.6.4
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.5
- v0.4.4
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.14
- v0.3.13
- v0.3.12
- v0.3.11
- v0.3.10
- v0.3.9
- v0.3.8
- v0.3.7
- v0.3.6
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.1
- v0.2.0
- v0.1.3
- v0.1.2
- 0.1.0
- dev-feat/board-hierarchy
- dev-feat/board-view
This package is auto-updated.
Last update: 2026-10-05 15:53:32 UTC
README
Self-contained Composer package that turns a tree of Twig component templates into a live, browsable styleguide — sidebar, ⌘K search, viewport presets, locale switcher, deep links — without writing any of that chrome yourself.
Drop the package into a project that already renders Twig (Symfony, Drupal, WordPress with Timber, or any standalone Twig setup), wire a 15-line bootstrap into a public PHP file, point a YAML config at the project's CSS/JS bundles, and /styleguide/... works.
What it does
| Surface | What you get |
|---|---|
| SPA chrome | Vue 3 + Pinia + vue-router + Tailwind v4 sidebar with collapsible sections, a keyboard-navigable command palette (⌘K / Ctrl+K — arrows, Enter, Esc; the sidebar's own inline filter keeps working alongside it), iframe preview with named viewport presets (Mobile 375×667 · Tablet 768×1024 · Desktop 1280×800 · Full 100 %) + smooth drag-resize, live dimension readout, a responsive variant grid — one preview tile per discovered styleguide.<variant>.twig sibling, the same viewport preset applied per tile (scaled to fit), a preset-aware Auto |
| Overview | Auto-generated palette / typography / fonts page driven by the project's styleguide.yaml. Colours are click-to-copy hex; typography rolls preview headings + body sample. Lands here by default at /styleguide/. |
| DOKUMENTACE group | Collapsible sidebar section containing Foundations, Overview, and any doc kind entries. doc templates live at templates/doc/<name>/<name>.twig and render inside the iframe like pages. The group always shows (foundations + overview); the doc entries are optional — absent templates/doc/ → /api/docs returns [] and no doc items appear. |
| Iframe preview | Each component / page renders inside an iframe that loads the project's real CSS + JS — what you see is what production renders. The package's Renderer reuses the project's Twig environment, so component templates keep access to project filters / functions (component_*, _x(), placeholder(), custom helpers). |
| Cross-references | Chip panel above each preview: components list "Used in: …", pages list "Components used: …", click to navigate. Driven by per-template usage: YAML metadata. |
| REST endpoints | /styleguide/api/components, /api/pages, /api/docs, /api/fields return JSON for consumers (the SPA itself, plus any external tooling). |
| Open in new tab | Each render can be opened standalone — the iframe template auto-reveals a "← back to styleguide" navbar only when it detects it's NOT inside an iframe. |
| Outage screen | maintenance:render renders the project's maintenance page to one self-contained HTML file for a CMS drop-in to serve while the CMS itself is down — a deploy, a core update, an unreachable database. Nothing renders at that moment, so the screen has to exist beforehand. See Outage screen below. |
| Configuration check | doctor reports what this project's styleguide.yaml will do at runtime: a configured path that does not exist, a stale or unbuilt dist/, a catalogue moved off /styleguide, an empty catalogue. It answers the questions a developer would otherwise answer by deploying. See Checking a configuration below. |
| Asset serving | AssetServer serves the bundled SPA + locale files from vendor/parisek/styleguide/dist/ with path-traversal guard, ETag, and immutable cache headers for hashed filenames. |
The whole package is ~8 PHP classes plus prebuilt JS/CSS — no Node.js required in production.
Install
composer require parisek/styleguide
Local dev against a sibling checkout — register a path repository so the consumer's vendor/parisek/styleguide is a live symlink:
// composer.json (in the consuming project) { "repositories": { "parisek-styleguide-local": { "type": "path", "url": "../styleguide", "canonical": false, // critical: lets Packagist still supply ^1.0 when needed "options": { "symlink": true, "versions": { "parisek/styleguide": "dev-local" } } } }, "scripts": { "styleguide:local": "@composer require parisek/styleguide:dev-local --no-interaction", "styleguide:remote": "@composer require parisek/styleguide:^1.0 --no-interaction" } }
canonical: false is what keeps Packagist visible — without it the path repo would shadow it and the ^1.0 constraint would fail to resolve. The versions override pins the local copy to a fixed dev-local identifier so the switch scripts have a deterministic string to ask for. See AGENTS.md § Local development against a consuming project for the full mechanism.
Bootstrap
Add to whichever public PHP file fronts your project (public/index.php, static/index.php, …):
<?php require __DIR__ . '/vendor/autoload.php'; (new \Parisek\Styleguide\Styleguide([ 'templates_path' => __DIR__ . '/templates', 'static_path' => __DIR__, 'config_yaml' => __DIR__ . '/styleguide.yaml', 'default_locale' => 'cs', 'twig' => $twig, // optional — reuse the project's Twig env 'twig_context' => [ // optional — globals merged into every inner render 'homeUrl' => '/styleguide/', 'templateUrl' => '', 'langcode' => 'cs', ], ]))->run();
run() parses $_SERVER['REQUEST_URI']. If the URI starts with /styleguide, it dispatches (SPA, asset, render, or JSON endpoint) and exits. Otherwise it returns silently and the rest of your index.php continues to handle non-styleguide URLs.
fromYaml() — declare the project once
The array constructor stays the primitive, but a project usually has more than
one thing that renders it: the HTTP entry point, and any CLI that works against
the same tree (maintenance:render, a fixture audit, a screenshot script).
Styleguide::fromYaml() reads the paths from a bootstrap: block in
styleguide.yaml, so they are declared once instead of restated per caller:
# styleguide.yaml bootstrap: templates_path: templates static_path: . default_locale: cs_CZ translations_path: translations
Styleguide::fromYaml(__DIR__ . '/styleguide.yaml', [ 'twig_context' => ['templateUrl' => rtrim(dirname($_SERVER['SCRIPT_NAME']), '/')], ])->run();
Relative bootstrap.* paths resolve against the YAML file's own directory —
not the caller's __DIR__, and not the process's working directory — so the
same config produces the same absolute paths over HTTP and from a CLI invoked
anywhere.
What belongs where is enforced, not merely documented. Project truth
(templates_path, static_path, default_locale, base_url,
typography_config, namespaces) goes in the YAML. Run truth — templateUrl,
computed from $_SERVER and quietly wrong on a CLI process, plus twig,
twig_options, auth — can only arrive through $overrides; putting one in
the YAML throws rather than being silently honoured. Full rules:
docs/API.md § bootstrap:.
Constructor config
| Key | Required | Default | Purpose |
|---|---|---|---|
templates_path |
yes | — | Absolute path to the project's Twig templates root. Used for the @project namespace and for auto-registered subnamespaces (see Conventional namespaces below). |
static_path |
yes | — | Absolute path to the project's webroot (where index.php sits). Used to auto-register @icons (/images/icons) and @images (/images) if those directories exist. |
config_yaml |
yes | — | Absolute path to styleguide.yaml. Missing file ≠ error — yaml just resolves to [] and the overview screen renders empty sections. |
default_locale |
no | 'en' |
Two-letter code used by the SPA shell and forwarded to Renderer as langcode. Also drives the bundled TypographyExtension's per-language typesetting (>= parisek/twig-typography 1.3) — passed as its locale resolver, so ` |
base_url |
no | '/styleguide' |
The mount path: where the catalogue is served, e.g. /catalogue or /tools/ui. In library mode (Styleguide::run()) it is the full public path, so a site installed under /subdir sets /subdir/catalogue. In the Symfony bundle and FrontController it is the path inside the application, and Symfony adds its own base URL (§ Symfony bundle). Must start with /; not / itself; letters, digits, -, _, ~, . per segment; no percent-encoding, query or fragment — anything else throws at boot. The web server must send the mount and everything under it to the front controller. |
twig |
no | null |
Pre-built Twig\Environment. Pass when component templates need project-specific extensions / filters / functions (component_*, _x(), placeholder(), ` |
twig_context |
no | [] |
Globals merged into every component_*() / page_*() render. Typical keys: homeUrl, templateUrl, langcode. |
twig_options |
no | [] |
Options merged onto the package defaults when building the pristine env. Ignored when twig is provided (the package never mutates a consumer-owned env). |
typography_config |
no | null |
Path to a typography settings yaml consumed by \Parisek\Twig\TypographyExtension. Only matters if your templates use ` |
namespaces |
no | [] |
Extra Twig namespaces (<name> => <absolute path>) for paths that live outside templates_path and aren't covered by the auto-registered conventional namespaces. |
auth |
no | null |
Optional callable(array $route): bool gate checked once per request, before any dispatch (SPA, render, JSON API, or asset). Return false to reject with a plain-text 403 Forbidden; return true (or omit the key entirely) to allow. Receives the parsed route array (type, plus slug/kind/endpoint/path/theme depending on route type). Requests loaded inside the styleguide's own iframe (Sec-Fetch-Dest: iframe) are re-typed to type: 'render' (carrying kind: 'component'/'page'/'doc'/'foundations') before the callable ever sees them — don't gate solely on type === 'component', or every iframe-embedded component render will fall through as 'render' and bypass that branch. A non-null, non-callable value throws InvalidArgumentException at construction time (fail loudly at boot) rather than silently allowing every request; a callable that throws is treated as a denial (fail closed) and logged via error_log(), never surfaced to the caller. For publicly reachable deployments, HTTP Basic Auth at the web-server level is usually simpler and more robust than an in-PHP callable — reach for auth when the check needs request context only PHP has access to (e.g. a signed query token, a session check your framework already performs). |
translations_path |
no | null |
Absolute path to a directory of compiled .mo catalogues, one per locale (cs_CZ.mo, en_US.mo, …). When set, __()/_x()/_n()/_nx() become real gettext-backed translators instead of identity stubs; a consumer that pre-registers its own translator still wins, unaffected. The render endpoint then accepts ?locale=<code> to select the catalogue per request — see Locale switching below. |
source_locale |
no | 'en_US' |
The language the msgids are written in. It has no .mo of its own, so it is listed in the locale switcher next to the discovered catalogues and ?locale=<code> renders it with the msgids unchanged. Never competes with a real catalogue: a .mo matching it case-insensitively replaces it, and resolution consults the discovered catalogues first (en still resolves to en_GB.mo) — a source locale a request could not reach is not offered at all. Must be a code the render route accepts (letters, digits, _, -, 2–35 chars). Only read when translations_path is set; null or '' opts out. |
Locale switching
Set translations_path to a directory of compiled .mo catalogues (cs_CZ.mo, en_US.mo, …) and the package discovers every catalogue in it, reads it with a pure-PHP reader (no new Composer dependency), and wires real __()/_x()/_n()/_nx() in place of the identity stubs. A consumer that pre-registers its own translator — WordPress's real __(), for instance — still wins, exactly as it always has.
(new Styleguide([ // … 'translations_path' => __DIR__ . '/translations', ]))->run();
The render endpoint then accepts ?locale=<code> — a full catalogue code (cs_CZ) or a bare two-letter prefix (cs, resolved against the discovered catalogues; an ambiguous prefix like pt matching both pt_BR.mo and pt_PT.mo is a 400, not a silent pick):
/styleguide/render/component/registration?locale=cs_CZ
selects the catalogue for that one render — content strings AND <html lang>/the langcode Twig context value, one switch for both. Absent → default_locale, i.e. unchanged behaviour whether or not translations_path is even set. The SPA's existing chrome language switcher reuses its own selection to drive the iframe's ?locale= too, so a screenshot/harvest script hitting the same URL a human sees never falls out of sync with it.
The source language is offered too. The msgids are written in one language
(English on every fleet project), and that language never has a catalogue, so
discovery alone would leave it out of the switcher. source_locale (default
en_US) lists it next to the discovered catalogues and renders it with the
msgids unchanged — no empty en_US.mo needed. Set it to the real source
language, or null (or '') to opt out.
It is deliberately the last resort in locale resolution: a discovered
catalogue wins both an exact and a prefix match, so adding a source locale
can never turn a code that used to resolve into an ambiguity error. A .mo
whose name matches it case-insensitively (en_us.mo against en_US)
replaces it entirely. And a source locale the discovered catalogues would
answer instead — a bare en next to en_GB.mo — is not listed either: a
switcher entry that silently renders someone else's catalogue is worse than
no entry, so such a project states its region (en_US) to get one.
Cache consequence: a render URL now returns different content per ?locale= — any cache sitting in front of the styleguide must include locale in its key, same as it already must for ?theme=/?variant=. Full contract: docs/API.md § Locale switching.
twig config — when to pass it
If your project's component templates use functions or filters registered on a specific Twig environment (component_*, _x(), placeholder(), |resizer, custom extensions), pass that environment via the twig config key. The package attaches its own template paths to that loader so the project's filters keep working inside the iframe.
If your component templates are self-contained (no project-specific filters), omit twig — the package builds a pristine environment with just @project namespaced at templates_path.
If your environment is already initialised
The package registers its helpers onto the environment you pass, at the moment you construct Styleguide. Twig only allows that while the environment's extension set is still open. Reading a single function or filter closes it — and a framework that builds Twig as a compiled, lazily-booted service (Symfony, notably) may well have read one before your code runs.
On a closed environment the registration cannot take effect, so construction is refused rather than handing back a Styleguide with no helpers on it. The message lists what was lost and repeats the fix below.
Earlier versions did not refuse. They succeeded, dropped every helper, and left a line per loss in error_log() — a file nobody watches, written after the response had been served. component_*, placeholder(), styleguide_data() and |cachebust simply did not exist, and the first symptom was an opaque Twig error a long way from the cause.
The refusal never reads Twig's wording to decide. Twig raises one exception class both for "this name is taken" and for "this environment is closed"; telling those apart by matching the message text would mean an upstream copy edit could start crashing consumers over an ordinary duplicate name. Instead the package remembers which environments it has already registered on, in a weak map, so a second construction recognises its own footprint. Nothing is added to the environment and nothing initialises it — two earlier designs did one or the other and were rejected on review. Constructing Styleguide twice against one environment refuses every name too, as duplicates, and is correctly left alone.
The remedy is to register everything the package would have added yourself, before anything reads from the environment. All of it — the extensions as well as the helpers, since extensions are registered first and a closed environment refuses those too:
use Parisek\Styleguide\Twig\StyleguideTwigExtension; // The extensions the package registers for you when it can. // // Give TypographyExtension a locale resolver, as the package does. Without one // it falls back to the typography package's defaults, so `|typography` and the // `…t` translator aliases stop following the render's language — the same // caveat the `typography_config` row above describes for any hand-registered // instance. $twig->addExtension(new \Parisek\Twig\TypographyExtension( $typographyConfig ?? '', static fn (): string => $locale, )); $twig->addExtension(new \Parisek\Twig\AttributeExtension()); $twig->addExtension(new \Twig\Extra\Intl\IntlExtension()); $twig->addExtension(new \Twig\Extra\String\StringExtension()); $twig->addExtension(new \Symfony\Bridge\Twig\Extension\DumpExtension( new \Symfony\Component\VarDumper\Cloner\VarCloner(), )); // The helpers. $twig->addExtension(new StyleguideTwigExtension(['static_path' => $staticPath]));
That is all. Do not register a StyleguideRuntime or a runtime loader yourself — Styleguide installs its own, and it has to be its own.
StyleguideTwigExtension holds no mutable state, which is what makes it safe to register while a container compiles. Everything a request can move — the render observer, the active Renderer, the resolved locale — lives in StyleguideRuntime, and only Styleguide knows those values. A runtime loader can be added to an already-initialised environment, unlike a function, filter or extension, so Styleguide can still wire its own after your framework has closed the environment.
An earlier version of this section did tell you to register a runtime. Construction succeeded and rendering was broken in two silent ways: Twig resolves runtime loaders in registration order, so the helpers reached your runtime, whose Renderer nothing ever set — styleguide_data() threw "no active render context" — and every component_* call was recorded into an observer renderObserved() does not read. Tests now render through this whole sequence rather than only constructing, because construction succeeding proved nothing.
Styleguide recognises a pre-registered StyleguideTwigExtension and does not mistake the resulting duplicate names for a closed environment, nor for a reason to refuse observation.
Symfony bundle
Optional. The core stays a framework-agnostic library, and nothing moves into require — symfony/framework-bundle and symfony/http-kernel are require-dev plus suggest, so a WordPress or Drupal consumer never pulls them in. A host application enabling the bundle already has both.
It exists so the catalogue is served by the host's own stack: its routing, its security, its access log, its error pages. The library mode's Styleguide::run() cannot do that — it writes the response itself and calls exit.
config/bundles.php
Parisek\Styleguide\Bridge\Symfony\StyleguideBundle::class => ['all' => true],
config/packages/styleguide.yaml
styleguide: config: '%kernel.project_dir%/static/styleguide.yaml'
That is the whole configuration. The catalogue's own settings stay in the project's styleguide.yaml, which the bundle reads through Styleguide::fromYaml() — the bundle deliberately adds no second place to say the same things.
config/routes.yaml
styleguide: resource: '@StyleguideBundle/Resources/config/routes.php' type: php
Two routes: /styleguide and a catch-all /styleguide/{path}. Both are needed. The bare prefix is a real URL the catalogue answers, and the catch-all is what lets the SPA's history-API deep links survive a direct refresh — /styleguide/component/card pasted into a browser has to reach the controller and come back as the shell.
The mount point comes from styleguide.yaml
bootstrap.base_url sets it, as in library mode; /styleguide by default. The bundle's routes read it through the styleguide.base_url container parameter, so the route import above stays the same whatever the mount is:
# static/styleguide.yaml bootstrap: base_url: /tools/ui
In a Symfony application the mount is the path inside the application. When the application itself is installed under /subdir, Symfony's base URL is prepended to every URL the catalogue produces — the shell's asset URLs, the SPA's history base, the API, the theme cookie — and the catalogue answers at /subdir/tools/ui. Do not repeat /subdir in base_url.
There is no bundle option for the mount. The prefix option of 1.18–1.22 never moved the catalogue (it accepted only /styleguide, then only a copy of base_url) and was removed in 1.23; delete it from config/packages/styleguide.yaml if it is still there.
Security is yours
Put the catalogue behind a firewall. The bundle adds no access control of its own, and it cannot: auth is a run-truth key, so fromYaml() refuses it, and the bundle builds the service through fromYaml().
The refusal fires on the first request, not at cache:clear: the service is private and lazily built, so a project styleguide.yaml carrying bootstrap.auth boots fine and then 500s. That is a misconfiguration you will hit immediately, not one that ships quietly.
That is deliberate. Two gates that can disagree are worse than one — auth: null means "allow everything", so a host trusting the internal hook would have left the catalogue open, and an iframe request is rewritten from an SPA route to a render route before that hook would run.
# config/packages/security.yaml access_control: - { path: ^/styleguide, roles: ROLE_ADMIN }
access_control only bites inside a firewall that authenticates. A pattern left on security: false, or a firewall with no authenticator, gets no gate from the rule above — check which firewall ^/styleguide falls into before relying on it.
Because the bundle has no auth, the tile "Code" toggle is off by default here. Once the firewall guards the catalogue, turn it on with a top-level show_source: true in styleguide.yaml (see Showing the fixture source).
Cover the whole prefix, not just the shell. /styleguide/api/*, /styleguide/render/* and /styleguide/assets/* are all under it, and the render endpoint is the one that exposes component markup.
Asset paths come from the request
Nothing to configure, but worth knowing where the value comes from.
iframe.css, iframe.js, iframe.fonts[], the favicon and the logo are all
rebased onto twig_context.templateUrl (see § iframe asset paths). That
value is run truth — correct for exactly one request — which is why
fromYaml() refuses it in the YAML, and why the bundle builds the catalogue
per request rather than once when the container compiles. A service built
at compile time could only ever carry an empty base: right for a host at the
domain root, silently wrong for one serving a theme through a rewrite or
installed in a subdirectory.
The controller passes Symfony's Request::getBasePath(), which is the
framework's equivalent of the front controller's
rtrim(dirname($_SERVER['SCRIPT_NAME']), '/') — equal in all four deployment
shapes, asserted in BundleTest. Note getBasePath(), not getBaseUrl():
the latter keeps the script filename, so /index.php/styleguide/… would
rebase every stylesheet onto /index.php/dist/….
getBasePath() is also the better value where the two are not equal: behind a
trusted proxy sending X-Forwarded-Prefix, it returns the prefix the browser
actually sees, which the front controller's SCRIPT_NAME formula cannot know.
It also URL-encodes a base directory containing a space, where the raw formula
does not.
Building per request costs one YAML parse and one Twig environment — about a millisecond, against roughly seven for the cheapest real request. In a worker-mode runtime (FrankenPHP, RoadRunner) the instance is reclaimed by PHP's cycle collector rather than immediately, because Twig's closures capture it; memory is bounded, not leaked.
One difference from library mode
Symfony normalises Cache-Control and adds private, so the /api/* endpoints send no-cache, private here where the library sends no-cache. Left alone: private only forbids shared-cache storage, which for a developer catalogue is stricter than what the package asked for and never looser.
Front controller (micro-kernel)
Optional, and a second path beside Styleguide::run(): the catalogue served by a small Symfony kernel, without a Symfony application. It brings Symfony's error page for failures outside a render, and, in debug, the profiler and debug toolbar.
composer require symfony/framework-bundle composer require --dev symfony/web-profiler-bundle symfony/twig-bundle symfony/debug-bundle symfony/stopwatch
The profiler packages are require-dev on purpose: the profiler stores every request it sees and serves them back from /_profiler. symfony/stopwatch makes its time panel show real render times instead of 0 ms.
Write the front controller
vendor/bin/styleguide front-controller:init
It writes index.php beside styleguide.yaml (found as doctor finds it, or pass --dir=<static dir>). It copies resources/front-controller.php verbatim and refuses to replace a different index.php without --force, because that file may be your own front controller or a subclassed kernel. Run it again after a package update: it reports when the file is already current.
The file does only what a package cannot do for itself:
// Walk up for the autoloader: vendor/ can sit beside this directory, inside // it, or at a CMS project root several levels up. (Missing → plain 500.) require $styleguideRoot . '/vendor/autoload.php'; if (\Parisek\Styleguide\Bridge\Symfony\FrontController::isBuiltInServerFile(__DIR__)) { return false; // PHP's built-in server sends assets itself } \Parisek\Styleguide\Bridge\Symfony\FrontController::run(__DIR__);
isBuiltInServerFile() answers true only under PHP's built-in server, only for an asset by extension (CSS, JS, images, fonts, media, JSON, HTML, text), and never for styleguide.yaml, PHP, dotfiles, vendor/, node_modules/ or dependency manifests.
__DIR__ is the directory that holds styleguide.yaml. The catalogue reads everything else from that file, as in every other mode.
Debug is off unless the environment asks for it. The file ships inside the theme, so every deployed site serves it, and neither WordPress nor Drupal sets APP_ENV. DDEV counts as asking, through IS_DDEV_PROJECT. Elsewhere, set APP_ENV=dev:
APP_ENV=dev php -S 127.0.0.1:8000 -t static static/index.php
APP_DEBUG=0 turns debug off in any environment. prod never has it.
More of Symfony. Subclass the kernel in the same file and pass it to run():
final class ProjectKernel extends \Parisek\Styleguide\Bridge\Symfony\StyleguideKernel { protected function projectBundles(): iterable { yield new SomeBundle(); } protected function configureProject(ContainerConfigurator $container): void { // services, bundle configuration } protected function configureProjectRoutes(RoutingConfigurator $routes): void { // routes outside /styleguide } } FrontController::run(__DIR__, ProjectKernel::class);
The cache lives in a directory private to the PHP user under the system temp directory, not in the project: the static directory is usually the document root. Nothing needs to be writable in the project. The cache key follows the front controller's contents and the Composer install, so a deploy of either rebuilds the container. A subclass that imports other files returns a fingerprint of them from cacheVersion().
Apache / Nginx rewrite
The package handles routing in PHP, but the entry script needs to receive /styleguide/* requests. Apache:
# .htaccess RewriteEngine On # /styleguide is a virtual path — force it through the entry script RewriteRule ^styleguide(/.*)?$ /index.php [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.php [L]
Nginx equivalent:
location /styleguide { try_files $uri /index.php?$query_string; }
Keep configuration off the web. styleguide.yaml sits in the web root beside index.php, and a rule that serves existing files serves it too. It holds no secrets (auth is refused in the YAML), but it names paths and settings a visitor has no use for. Deny it, with dotfiles and dependency manifests:
<FilesMatch "^(styleguide\.yaml|composer\.(json|lock)|package(-lock)?\.json)$|^\."> Require all denied </FilesMatch>
location ~ (^|/)(styleguide\.yaml|composer\.(json|lock)|package(-lock)?\.json)$ { deny all; } location ~ /\. { deny all; }
For local development without a web server, PHP's built-in server takes the front controller as its router script. With the shipped front controller (§ Front controller), static files it may send are filtered by FrontController::isBuiltInServerFile():
APP_ENV=dev php -S 127.0.0.1:8000 -t static static/index.php
styleguide.yaml — project config
The bootstrap reads config_yaml (typically styleguide.yaml next to index.php). Two blocks are package-aware; everything else is passed through to the overview template, so add whatever your project needs.
project: name: "My Project" # shown in SPA chrome + iframe titles description: "Visual identity" # overview lede paragraph favicon: "/images/touch/favicon.svg" # browser tab + sidebar header # Assets injected into each iframe's <head>. Same paths the production templates # use — guarantees the styleguide preview matches production. Paths are resolved # against `twig_context.templateUrl` (see "iframe asset paths" below), so a short # /dist/... value works whether the static dir is the docroot (standalone) or the # styleguide is served from a theme (WordPress / Drupal). iframe: # `css` and `fonts` each accept a single string OR a list of stylesheet URLs. # A list is handy mid-migration — e.g. a Tailwind bundle plus a legacy sheet. css: "/dist/css/style.css" # string, or e.g. [ "/dist/css/style.css", "/legacy/style.css" ] js: "/dist/js/script.js" # project's main bundled script (ES module if you build with Vite) fonts: # string or list — one entry per @font-face stylesheet - "/fonts/poppins/stylesheet.css" html_class: "" # optional — extra <html> class for the preview frame # (every render also carries `is-styleguide-render`) body_class: "" # optional — <body> class page_wrapper_class: "" # optional — wrapper <div> class for page renders only (see below) base_href: "/" # optional — affects relative URLs inside the iframe # Optional data consumed by the overview screen. All keys optional; missing # blocks simply hide their section. logo: main: { src: "/images/logo.svg", alt: "Logo", label: "Hlavní logo", background: "light" } favicon: { src: "/images/touch/favicon.svg", alt: "Favicon", label: "Favicon" } # Favicon audit block (#73) — all keys optional; missing block hides the # whole #favicon section. See "Favicon audit" below for what each key drives. favicon: svg: "/images/touch/favicon.svg" png_96: "/images/touch/favicon-96x96.png" ico: "/images/touch/favicon.ico" apple_touch: "/images/touch/apple-touch-icon.png" manifest: "/images/touch/site.webmanifest" theme_color: "#18181B" # Project widths — optional. 2–4 widths the width menu lists first, with a # "Compare all" action that shows them side by side (each variant tile gets # its own strip). Absent: the menu lists the presets only; ticking widths # still compares them. See "Compare widths" below. viewports: compare: [1440, 768, 320] # Overview grid as the landing — optional. `grid` makes /styleguide/ show # every component and page as a live preview tile. Absent: Foundations. See # "Overview grid" below. overview: default: grid # The package's own pages — optional. Each is on unless set to false; a page # switched off leaves the sidebar and its URL shows the landing. See # "Switching off the package's pages" below. builtin_pages: fields: false overview: false # Pages grouped by category — optional. The sidebar lists one collapsible # group per page `category` (lowest weight first, uncategorised last). # Absent: a flat list. See "Pages grouped by category" below. pages: group_by: category # Component sections by kind — optional. The sidebar, the grid chips and the # overview sort components by `kind` (Blocks, Page sections, Basic elements, # Parts, Utilities). Absent: the sections by category. See "Component # sections by kind" below. components: group_by: kind # Fixture source ("Code" toggle on each variant tile). Absent: on only when # the `auth` constructor callable is set. A catalogue guarded some other way # (the Symfony bundle, HTTP Basic Auth, a VPN) writes `true`. See # "Showing the fixture source" below. show_source: true # Code panel extras — optional. Highlighting is on unless this is false; # source_url links each file to the repository ({path} = path under # templates_path). See "Showing the fixture source" below. highlight_source: true source_views: [data, html, css, js] # add twig to show the templates source_url: "https://github.com/acme/site/blob/main/templates/{path}" # Open Graph image (#74) — single optional string key. See "OG image audit" # below for what it drives. Set `og_image: false` to hide the section # entirely on projects that don't want it (the audit doesn't run either). og_image: "/images/og-image.png" colors: primary: # Tailwind-style scale — shades: map name: "Primary" css_variable: "primary" default: 500 shades: 50: { hex: "#FFEAEA", oklch: "oklch(95.6% 0.022 17.54)" } # ... 950: { hex: "#1F0000", oklch: "oklch(15.32% 0.059 31.48)" } brand: # No scale? Free list of named swatches name: "Brand" default: red # optional — falls back to the first swatch swatches: - { name: red, hex: "#E63946", css_variable: brand-red } # css_variable optional - { name: cream, hex: "#F1FAEE" }
Text lightness on each swatch is derived from OKLCH lightness (provided or computed from the hex), falling back to WCAG relative luminance of the hex when the OKLCH value can't be parsed; hex is required per swatch — entries without a parseable hex are skipped. oklch is optional and is computed from hex when omitted.
Every swatch also carries contrast_white/contrast_black WCAG contrast ratios against the hex, plus aa_white/aa_black pass/fail verdicts against the AA threshold (4.5:1) — all computed server-side, no yaml input required. Foundations renders dot badges on each swatch tile for a quick read, and an expandable full contrast matrix grading every color × color pair (all palettes plus white and black) with AAA / AA / AA-large / fail verdicts; its heading and legend copy come from the optional labels.contrast_matrix / labels.contrast_matrix_legend keys, both with English defaults.
Favicon audit
When the favicon: block above is present, the logo card's FAVICON slot (in the #logo section) shows the favicon rendered in two compact simulated contexts — a browser tab (light and dark) and an iOS home-screen icon, whose label reads the web app manifest's short_name (falling back to name, then project.name) — instead of the plain swatch it renders when favicon: is unconfigured or the audit couldn't resolve an icon. Foundations also gains a #favicon section: a heading plus a collapsed-by-default audit checklist covering each configured asset — file existence, real pixel dimensions against the expected size (png_96 96×96, apple_touch 180×180), parsed ICO contents, manifest JSON validity with per-icon existence checks, and the theme_color swatch. All checks run server-side against static_path (src/FaviconAudit.php) — nothing is verified client-side, and every configured path is containment-checked to stay under static_path before being read. Checklist labels are overridable via optional labels.favicon* keys (e.g. labels.favicon, labels.favicon_audit, labels.favicon_png_96, …), all with English defaults — no yaml changes are required to adopt the section beyond the favicon: block itself. The Android/PWA maskable icon no longer gets its own mockup card, but manifest.icons/maskable_icon are still audited (manifest sub-rows in the checklist).
OG image audit
Foundations always renders an #og-image section, whether or not og_image: is set. Once
configured, it renders the file as share-card mockups on the three platforms that matter most —
Facebook/LinkedIn (1.91:1 crop), X/Twitter summary_large_image (2:1 crop), and a Slack unfurl —
followed by a compact server-side audit: existence, real pixel dimensions against the ≥ 1200×630
recommendation, aspect ratio vs. the 1.91:1 Open Graph convention, and file size against
platform limits (warn > 1 MB, error > 8 MB — Facebook's hard cap). When og_image: is absent,
the section shows an empty-state prompt instead of vanishing, since every project is expected to
ship one. Set og_image: false to opt a project out entirely — the audit does not run, and the
#og-image section does not render at all, unlike the empty-state prompt a missing key gets.
Checklist labels are overridable via optional labels.og_* keys, all with English
defaults.
typography: fonts: - name: "Poppins" type: "Sans-serif" stylesheet: "/fonts/poppins/stylesheet.css" usage: [Headings, Body] headings: - { tag: h1, size: "text-4xl md:text-5xl", label: "Heading 1", desc: "48px / 3rem" } weights: - { name: "Regular", class: "font-normal", value: "400" } body_sample: "Lorem ipsum…" labels: # i18n labels shown on overview cards logo: "Logo" colors: "Colors" typography: "Typography" click_to_copy: "Click to copy" copied: "Copied!"
Compare widths
The width menu in the toolbar is a checklist. A click on a row shows that width alone. A tick on the row's box (or Shift+click on the row) adds the width, and two to four ticked widths show side by side. Row and box are two controls with their own names, so a screen reader hears "Ukázat jen Tablet 768" and "Přidat vedle: Tablet 768"; opening the menu puts the focus on the line on screen, arrow keys move between lines and between a line's box and row, and Escape returns to the trigger. The custom width and the orientation sit below the menu as an ordinary form. The strip shows them narrowest first, and the trigger names the set: 3 šířky · 320 · 768 · 1440. Unticking back to one width returns to the ordinary single preview at that width. The custom width follows the same rule: Enter shows it alone, + (or Shift+Enter) adds it. Full has no pixel width, so it is a choice only, never a checkbox.
Side by side, each iframe renders at its real width and scales down into its column. The columns are sized in proportion to their widths, so every width shows at the same zoom, and each caption says the width, the height the column renders at and the zoom (320 × 443 · 95 %). The height is the content's own, measured after the load (a render: chrome entry shows its pinned 640 px viewport); it is never a device height, because no column is drawn at one.
viewports.compare lists the project's own 2–4 widths, each 100–4000 px. A width listed twice is merged. The menu shows them first, narrowest first, under "Šířky projektu", with a "Porovnat vše" action that ticks them all. Without the key the menu lists the presets only; ticking still works.
In the variant grid, every tile gets its own strip and the grid shows one tile per row. The grid composes with compare mode instead of isolating one tile: scanning many layouts at every width is what the mode is for. Every compare iframe loads lazily (loading="lazy"), so a family with dozens of tiles loads only what is on screen. The tile density and the orientation switch rest while comparing. The ticked widths persist in the browser (localStorage, sg-preview-compare), as the single width does.
A malformed viewports.compare (one width, five widths, a string, a width out of range) throws at construction.
Switching off the package's pages
The sidebar's DOKUMENTACE group starts with the package's own pages: Základy / Foundations, Ikony / Icons, Pole / Fields, Přehled / Overview and Náhledy / Previews (the grid). A catalogue that has no use for one switches it off in styleguide.yaml:
builtin_pages: fields: false # the fields overview across components overview: false # the index; the grid shows the same entries with previews
A page switched off leaves the sidebar, and its URL shows the landing instead. Every page is on unless named with false, so a catalogue without the key is unchanged. Switching Foundations off makes the grid the landing (and an unknown path shows the grid too). The landing itself cannot be switched off: overview.default: grid with grid: false, or Foundations and the grid both off, throws at construction, as does an unknown page name or a value that is not true or false. The per-component Fields drawer under a preview is not a page and stays.
Overview grid
/styleguide/grid shows every component and page as a tile: a live preview of its fixture, scaled down to the tile, with its name. The sidebar links to it as "Náhledy" / "Previews", next to Overview. A filter bar narrows the tiles by sidebar section (Basic, Blocks, Gutenberg, Pages) and by text; the text filter matches name, id and aliases, like the sidebar filter. A tile opens the entry. An entry with variants shows its default tile (or its first variant when it has no styleguide.twig) and a badge with the number of variant tiles.
Width toggle. A row of buttons in the filter bar sets the width every tile renders at before it is scaled to the tile: the project's viewports.compare widths, smallest first (default 375, 768 and 1280 when the project sets none). A button is named Mobil / Tablet / Desktop when its width class is unique among the options, and by its pixels otherwise. The choice is remembered in the browser (sg-grid-width). Until one is made the widest width applies. A frame wider than 1024 px is 16:10; one of 1024 px or less is square, so a phone tile shows the top of the page. The button's name follows the same line: up to 480 px Mobil, up to 1024 px Tablet, above that Desktop. The tile scales the frame up as well as down, so a narrow frame fills it. Basic elements render at the same width as blocks, which makes a button small at desktop width: pick a narrower width to see it larger. The previews honour the iframe theme and the content locale.
Loading. Every preview is a full render, and a catalogue can hold hundreds. A tile loads its iframe only when it comes within 400 px of the visible area, and at most 6 previews load at once. A tile that scrolls away before its turn drops out of the queue; a loaded tile keeps its preview. Against a synthetic catalogue of 300 entries, the first screen settles with 12 previews loaded, and no more than 6 renders are ever in flight (the Playwright suite measures this).
Board view. A switch in the header, "Mřížka" / "Plátno" ("Grid" / "Board"), shows the same filtered entries on one large surface instead of tiles, the way a design tool shows its frames. The section chips, the text filter and the width buttons act on both views. ?view=board opens the board; the choice is remembered in the browser (sg-grid-view).
- Rows. One row per sidebar section (Základní prvky, Gutenberg, Stránky, ...), each under its heading, in the sidebar's order; with
pages.group_by: categorythe Pages heading stands for one row per category beneath it. So zoomed out, the whole catalogue reads as a few rows. Two levels tell a section from a group inside it: a section has a larger heading and a rule above it, a category a smaller, muted heading, and both carry the number of entries. The space above a heading is larger than the space below it, so a heading belongs to the row under it and never to the row above. Each frame has its name above it; a click on the name opens the entry. The board lines up with the page's own padding (the filter chips above it), and "Vměstnat vše" / "Fit all" uses the full width. - Moving. Scrolling or dragging pans the surface: a drag on the empty surface or on an idle frame works as a hand tool (the browser's own scroll underneath, so scrollbars and keys work). Ctrl or Cmd with the wheel zooms around the cursor, and a trackpad pinch does the same. The
+and-keys and the buttons step the zoom by 25 %. "Fit all" (key0) fits the width and lets the height run up to four windows tall, so one very tall page does not shrink a row of pages to thumbnails. "100 %" (key1) resets the zoom. The view fits again as pages load and as the filter changes, until the first wheel, key, click or drag. - Selecting. A click on a frame selects it (an indigo mark). The arrows walk between frames (Left and Right in reading order, Up and Down to the nearest frame in the next row) and scroll the selection into view.
F, or the "Přiblížit výběr" / "Zoom to selection" button, zooms so the selected page fills the width of the window. Enter, or a second click, makes the page interactive; Ctrl or Cmd with Enter opens the entry. Esc steps back: an interactive page becomes a selected frame, and a selected frame becomes nothing. A click on the empty surface clears the selection. - Using a page. An interactive frame has a red mark, and its page takes the pointer, so menus, accordions, links and forms work at the preset width. Ctrl or Cmd with the wheel and Esc still reach the board from inside the page. A new filter or width ends it. An idle frame takes no pointer, so a wheel or drag over it moves the board.
- A link to a view. Once you have moved, zoomed or selected, the address carries the view:
?view=board&zoom=35&at=1200,300&sel=page:homepage, that is the zoom in percent, the surface point at the middle of the window, and the selected entry. Send the address and the board opens there. The view settles again while pages load, so a link lands in the right place even though frame heights are measured late. A new filter or a switch to the tiles drops these parameters. - Size. Each frame's name carries its measured size, "1440 × 2806", once its page has loaded.
- Height. A page is as tall as it measures, up to 20 000 px; a
render: chromeentry keeps the pinned demo height. - Loading. Frames load as the grid's tiles do (
lib/loadQueue.js): a frame asks for a slot within 800 px of the visible area, and at most 6 renders are in flight. A loaded frame keeps its iframe; nothing unloads. At low zoom the whole surface is "near", so N entries load N renders: the board is made for tens of entries, so filter before you open a catalogue of hundreds.
overview.default: grid in styleguide.yaml makes the grid the landing: /styleguide/ shows it, with the address bar left at the mount. Without the key the landing stays Foundations. Any other value than grid or foundations throws at construction.
Pages grouped by category
pages.group_by: category groups the sidebar's page entries by their category metadata. Each category is one collapsible group with a count. The groups are ordered by the lowest weight among their pages, then by name, and the pages keep their order inside a group. Categories match without regard to case. Pages without a category share one default group ("Ostatní" / "Other"), always last. While the sidebar filter has a query, the pages show as a flat list, as before.
Without the key the Pages section stays a flat list. Any other value throws at construction.
Component sections by kind
components.group_by: kind sorts the components into sidebar sections by their kind metadata instead of their category: block → Blocks, section → Page sections, element → Basic elements, part → Parts, utility → Utilities. The sections read composite first, the way a page is read: Blocks, Page sections, then Basic elements, Parts and Utilities. category is then free for what it names. A component without a valid kind falls back to the rule by category, so a catalogue can move over one component at a time.
The same sections drive the overview grid's filter chips, and the overview's columns.
Without the key the sections come from category as before: gutenberg is Gutenberg, block, blocks and layout are Blocks, anything else is Basic elements. Any other value than kind throws at construction.
The component list
Every sidebar section lists its components flat, in the server order (weight, then name). There are no groups. Each row shows its variant count on the right, counted as the overview grid's tile badge counts it: every tile the component's own variant grid shows, the default included. A component without variants shows no number. The Pages section keeps its groups (see above).
iframe asset paths — resolved against templateUrl
iframe.css, iframe.js, and iframe.fonts[] are resolved relative to the twig_context.templateUrl you pass to the bootstrap — the same base your component templates already use for images ({{ templateUrl }}/images/...). One short, docroot-agnostic value then works across layouts:
| Layout | templateUrl |
css: /dist/css/style.css resolves to |
|---|---|---|
| Standalone (static dir IS the docroot) | '' |
/dist/css/style.css (unchanged) |
| WordPress (served via rewrite from a theme) | /wp-content/themes/<theme>/static |
/wp-content/themes/<theme>/static/dist/css/style.css |
| Drupal | /themes/custom/<theme>/static |
/themes/custom/<theme>/static/dist/css/style.css |
Rules (Renderer::resolveAssetUrl()):
- Relative / root-relative paths (
dist/...,/dist/...) are rebased ontotemplateUrl. - Already-absolute-under-base paths (you hardcoded the full theme path) are left untouched — no double prefix.
- External URLs (
https://…,//cdn…,data:) and anchors (#…) are never rebased. - An empty
templateUrl(standalone) is a no-op — paths pass through unchanged, byte-for-byte.
So: keep iframe.css: /dist/css/style.css in styleguide.yaml, pass the right templateUrl in your bootstrap, and the preview loads the real asset in every environment — no need to hardcode the theme path.
URL surface
Every URL below sits under the catalogue's mount path: /styleguide by default, bootstrap.base_url when set (since 1.22.0; see § base_url below). The table uses the default.
| URL | Served | Purpose |
|---|---|---|
/styleguide/ |
SPA HTML | Landing: Foundations, or the overview grid with overview.default: grid (see Overview grid). The URL stays at the mount |
/styleguide/component/<slug> |
SPA HTML | Deep link — client-side router resolves the right view. Also accepts ?variant=<id>* |
/styleguide/page/<slug> |
SPA HTML | Deep link to a page styleguide. Also accepts ?variant=<id>* |
/styleguide/doc/<slug> |
SPA HTML | Deep link to a doc entry (DOKUMENTACE group). Also accepts ?variant=<id>* |
/styleguide/overview |
SPA HTML | Components & pages master index (grouped by section, optional usage chips) |
/styleguide/grid |
SPA HTML | Overview grid: every component and page as a live preview tile, with a section and text filter — see Overview grid |
/styleguide/foundations |
SPA HTML | Colors / typography / fonts / logo preview built from styleguide.yaml |
/styleguide/fields |
SPA HTML | Field inspector — flattened view of every component's fields: metadata |
/styleguide/render/<kind>/<slug> |
iframe HTML | Bare render — <kind> ∈ component | page | doc | foundations. Used as iframe src, also browsable directly. Accepts ?theme=light|dark (whitelisted, invalid/missing → light) to stamp class="dark" and a matching color-scheme on the iframe <html> for consumers that opt into Tailwind dark mode; inert for projects with no dark-mode CSS. When ?theme= is absent — e.g. a native link click inside the rendered content navigating to another SPA-shell URL — the SPA's own sg-iframe-theme cookie (path = the mount) is consulted as a fallback so the visitor's toggle choice survives in-iframe navigation; an explicit ?theme= always wins over the cookie. Also accepts ?variant=<id> (<id> matching [a-z0-9-]+) to render styleguide.<id>.twig instead of the default styleguide.twig, for component/page/doc kinds — see docs/API.md § Component Twig file conventions. Query-only, no cookie fallback: an absent, invalid, or unknown (deleted/renamed) variant silently falls back to the default styleguide.twig → <slug>.twig chain rather than 404ing, so a bookmarked deep link to a removed variant keeps working. Composes independently with ?theme=. |
/styleguide/api/components |
JSON | List of components — see API below |
/styleguide/api/pages |
JSON | List of pages — same shape as components |
/styleguide/api/docs |
JSON | List of doc entries — same shape as pages; [] when templates/doc/ is absent |
/styleguide/api/fields |
JSON | Field metadata flattened across components |
/styleguide/api/health |
JSON | Parse-resilience diagnostics — see API below |
/styleguide/api/source/<kind>/<slug> |
JSON | Fixture source of one preview (?variant=<id> for a variant tile). Answers only while show_source is on — see Showing the fixture source |
/styleguide/api/markup/<kind>/<slug> |
JSON | The HTML one preview renders (?variant=<id> for a variant tile). Answers only while show_source is on |
/styleguide/api/files/<kind>/<slug> |
JSON | The entry's own Twig template, CSS and JS, as far as source_views lists. Answers only while show_source is on |
/styleguide/assets/<path> |
static | SPA bundle + locales + any package asset (immutable cache for hashed filenames, ETag for unhashed) |
* Same whitelist/fallback rules as the render-endpoint row above (^[a-z0-9-]+$, unknown/removed values fall back to the default rather than 404ing); Router::synthesizeEmbeddedRoute() forwards the SPA-shell's ?variant= across the iframe-embed swap so the preview and the deep link agree.
foundations renders auto-inject the package's own dist/foundations.[hash].css — a dedicated Tailwind bundle scanning foundations.twig, since the consumer's iframe.css only scans its own templates — served from /styleguide/assets/ alongside iframe.css. The package also ships dist/foundations.[hash].js (vanilla, framework-free) injected the same way, so foundations interactivity never depends on the consumer's iframe.js stack.
API
Five read-only catalogue endpoints under /styleguide/api/*, plus the opt-in /api/source below. The five return 200 OK with Content-Type: application/json; charset=utf-8 and Cache-Control: no-cache. No auth, no pagination, no query parameters — the dataset is small enough (one read per component template) that the SPA refetches the whole list on demand. Unknown endpoints return 404 with {"error": "Unknown API endpoint: <name>"}.
The SPA consumes all five (frontend/src/stores/catalog.js); external tooling can do the same — e.g. a CI job that lints fields metadata, a script that mirrors the component list into Notion, a Storybook bridge.
GET /styleguide/api/components
Flat list of every component template under templates/component/**/<id>.twig whose first {# … #} comment parses as YAML and carries at least a name: key. Order: weight ascending, then name (Czech collation when intl is available, otherwise byte-wise strcmp).
Response shape — array<Component>:
[
{
"id": "button", // directory + filename (without .twig)
"name": "Button", // from metadata `name:`
"category": "Basic", // from `category:`, "" if absent
"description": "Primary CTA…", // from `description:`, "" if absent
"asana": "", // from `asana:`, "" if absent — task URL
"figma": "", // from `figma:`, "" if absent — Figma node URL
"drupal": "", // from `drupal:`, "" if absent — Drupal docs / module link
"web": "", // from `web:`, "" if absent — generic external link
"weight": 50, // from `weight:`, default 50, sidebar order
"usage": ["404", "article-list"], // from `usage:`, normalised to an array by the parser
"fields": { // from `fields:`, {} if absent
"url": { "title": "URL", "type": "url", "required": 1 },
"title": { "title": "Label", "type": "text", "required": 1 }
},
"has_styleguide": true // true when a sibling styleguide.twig exists
// OR metadata declares `styleguide:`
}
// …
]
Notes
usageis authored as a comma-separated string in YAML (usage: 404, article-list) — looser whitespace is fine — but normalised to an array byComponentParserbefore it reaches the wire, so consumers (the SPA included) work withstring[]directly instead of re-splitting a CSV.fieldsis passed through verbatim from the YAML. Shape is consumer-defined; the bundled SPA assumes{ title, type, required }per the convention in Per-template metadata, but extra keys are preserved end-to-end.- Templates without a parseable YAML block, or with YAML missing
name:, are silently dropped — that's the only way to keep a.twigfile undertemplates/component/and have the styleguide chrome ignore it.
GET /styleguide/api/pages
Same shape as /api/components, scanned from templates/page/**/<id>.twig instead. Use this when your project renders entire page templates through Twig (Drupal page--*.html.twig, WordPress Timber page-*.twig) and you want them to appear in the styleguide alongside components.
If templates/page/ doesn't exist, response is []. No error.
GET /styleguide/api/docs
Same shape as /api/pages, scanned from templates/doc/**/<id>.twig. Entries appear in the sidebar's DOKUMENTACE group and render inside the iframe like pages (prefer styleguide.twig sibling, fallback <id>.twig).
If templates/doc/ doesn't exist, response is [] — the DOKUMENTACE sidebar group still renders its foundations + overview entries. No error.
GET /styleguide/api/fields
Aggregated view of every component's fields: metadata, flattened across components. Returns one entry per component that has at least one field defined; components with empty / missing fields are skipped.
Response shape — array<ComponentFields>:
[
{
"component_id": "button",
"component_name": "Button",
"fields": {
"url": { "title": "URL", "type": "url", "required": 1 },
"title": { "title": "Label", "type": "text", "required": 1 }
}
}
// …
]
Data source for the SPA's /styleguide/fields inspector — useful for one-shot answers like "where do we use a richtext field?" without walking the whole component list.
GET /styleguide/api/health
Diagnostics for ComponentParser's resilience: parse()/parseAll() now catch \Throwable per file instead of only a YAML parse error, so one pathological template is skipped and recorded rather than 500ing the whole catalogue. This endpoint reports what got skipped, plus how much made it through.
Unlike the four endpoints above, the response is an object, not a bare array — there's no additive slot to bolt a _warnings field onto a bare-array response without breaking every existing consumer of that shape.
Response shape:
{
"warnings": [
{ "file": "component/broken-widget/broken-widget.twig", "error": "…exception message…" }
// empty array when nothing was skipped
],
"counts": { "components": 42, "pages": 7, "docs": 3 }
}
JavaScript errors in the previews
Every preview reports its JavaScript errors to the catalogue: uncaught errors, unhandled promise rejections, console.error calls and files that failed to load (a script, a stylesheet, an image). They appear under the same warning badge as the skipped templates, in their own section "JavaScript na této stránce", and a small mark on the variant tile or compare column says where. Identical errors from several tiles or widths make one row with the list of places, so an error that only happens at 320 px is visible as such.
A small script does the reporting: dist/render-relay.js, served under <mount>/assets/ and loaded by render-cell.twig as the first script in <head>. It runs before any project script, so start-up errors are caught too. It is a file, not an inline script, so a Content-Security-Policy of script-src 'self' lets it run. It posts only when the preview is framed, only to its own origin, and reports the first 50 errors of a document; the dialog then says the list is cut. console.error still reaches the browser console. An error belongs to its iframe: switching entry, theme or locale, reloading, unticking a compare width or isolating a variant clears it, and so does a link clicked inside a preview, also when it leads to a page without the relay. A script from another origin without CORS yields only "Script error.", which the dialog says. The reports are a convenience, not a security boundary: a preview's own scripts could post the same messages. Nothing to configure.
GET /styleguide/api/source/<kind>/<slug>[?variant=<id>]
The fixture file behind one preview — styleguide.<id>.twig, or styleguide.twig without a variant — without its leading {# … #} annotation. Response: { kind, slug, variant, file, source }. 404 when the entry or variant has no fixture file. Off by default on a public catalogue; see Showing the fixture source below. Full contract: docs/API.md § JSON API endpoints.
GET /styleguide/api/files/<kind>/<slug>
The entry's own files: css/**/*.css and js/**/*.js in its folder, tests (*.test.js, *.spec.js) left out, and with twig in source_views its <slug>.twig first. Response: { kind, slug, files: [{ path, language, source }] }, language one of twig, css, js. Files past 200 kB, not UTF-8, or resolving outside templates_path are skipped; at most 20 per entry. Behind the same show_source gate as /api/source.
GET /styleguide/api/markup/<kind>/<slug>[?variant=<id>]
The HTML that one preview renders, without the iframe document around it, re-indented by nesting (blank lines dropped, spaces collapsed; <pre>/<textarea> keep theirs). Response: { kind, slug, variant, html }. 404 when the entry has no template or its render fails. Behind the same show_source gate as /api/source. Full contract: docs/API.md § JSON API endpoints.
Showing the fixture source
Each variant tile gets a Kód / Code toggle that shows the fixture file which rendered it; an isolated tile shows the same in a drawer under the toolbar. The source is what a developer copies: the component call with its sample data.
The panel has up to five views. Data is the fixture: the call with its sample data. Twig is the entry's own <slug>.twig, shown only when source_views lists it (below). HTML is the markup that fixture renders, without the iframe document around it, re-indented by nesting instead of by the template's own indentation, fetched from /api/markup only when the view is opened: what a developer checks for classes, headings and ARIA. CSS and JS list the entry's own stylesheets and scripts, css/**/*.css and js/**/*.js in its folder (tests left out), one block per file with its path; each view appears only when the entry has such files. The lines are numbered, and long lines wrap under their own number; a selection copies the code without the numbers.
Every view is highlighted: Twig delimiters, tags, strings and comments, the HTML tags and attributes around them, CSS and JavaScript. The catalogue bundles Prism (MIT) for the tokenising, about 5 kB gzipped, as a separate file the browser loads with the first open panel; nothing loads from a CDN and the consumer installs nothing. The highlighting only colours text spans, so the code is never rendered as markup, and a selection copies plain text. highlight_source: false turns it off: the panel shows plain text and the browser never loads the highlighter.
source_url links the panel to the repository. Write the address of a template file with {path} where its path relative to templates_path goes:
source_url: "https://github.com/acme/site/blob/main/templates/{path}"
The panel then shows one link, GitHub / GitLab / Git by the host, to the file behind the open view: the fixture for Data, the entry's own <slug>.twig for Twig and HTML; in the CSS and JS views each file's path links to it. It opens in a new tab. One line covers every file. It is a setting rather than read from git, because a deployed catalogue usually has no .git. It travels with show_source: a catalogue that does not show its source does not point at its repository either. An address that is not http(s) or has no {path} throws at construction.
show_source in styleguide.yaml decides whether the catalogue shows it:
show_source |
Result |
|---|---|
| absent | on when the auth constructor callable is set, off otherwise |
true |
on |
false, or any value that is not a boolean |
off |
The default treats a catalogue without an auth callable as public. A public catalogue does not publish its templates unless it says so. The Symfony bundle cannot set auth (the host's firewall guards the catalogue, see Symfony bundle), so a bundle host that wants the toggle writes show_source: true. So does a library-mode catalogue behind HTTP Basic Auth or a VPN.
Off, the toggle is not rendered and /api/source answers 404 like an unknown endpoint: the source never reaches the browser.
source_views picks the views, per project:
source_views: [data, twig, html, css, js] # everything source_views: [data, html] # the call and its output only
Absent, it is [data, html, css, js]: everything but the template. The fixture is sample data a reader copies, and the HTML, CSS and JS are what a browser receives anyway; the template is the implementation, so a project lists twig to publish it (ADR-0007). A view left out is gone from the panel, and its endpoint answers 404 like an unknown one. It takes effect only while show_source is on. The panel keeps its own order; anything but a non-empty list of data, twig, html, css, js throws at construction.
Caching
Every endpoint sets Cache-Control: no-cache. Responses are recomputed per request because the underlying source (YAML in .twig files) changes during dev and there's no invalidation signal. The work is a filesystem walk + one YAML parse per file — acceptable even for large component libraries.
If you need to serve these at scale, wrap them behind your project's own HTTP cache and bust on templates/**/*.twig change.
Adding a new endpoint
The endpoint classes (src/Api/*Endpoint.php) share the same shape: constructor takes the ComponentParser, handle() emits headers + json_encode(). New endpoints follow the same pattern:
- Create
src/Api/<Name>Endpoint.phpmirroring the existing trio. - Wire it into
Styleguide::dispatchApi()(thematchblock on$route['endpoint']). - Add a test under
tests/Api/<Name>EndpointTest.php.
There's deliberately no shared base class — near-identical classes are clearer than an abstraction that hides where the headers and encoding happen.
Command-line catalogue (CLI)
After install, vendor/bin/styleguide exposes the component catalogue without needing the SPA. Useful for AI coding assistants and scripted tooling.
vendor/bin/styleguide list # all components (compact JSON) vendor/bin/styleguide list --pretty # indented for terminals vendor/bin/styleguide list --type=page # pages instead of components vendor/bin/styleguide list --type=doc # doc entries vendor/bin/styleguide show button # one component, full detail vendor/bin/styleguide show landing --type=page # one page vendor/bin/styleguide show intro --type=doc # one doc entry vendor/bin/styleguide lint # metadata quality report vendor/bin/styleguide doctor # is this project's config sound? vendor/bin/styleguide front-controller:init # write the shipped front controller vendor/bin/styleguide maintenance:render # render the outage screen vendor/bin/styleguide maintenance:render --check # is the rendered screen still current?
The CLI wraps ComponentParser — it returns the same normalised records as GET /styleguide/api/components, but without a running webserver. Run it from the consumer's repo root, or set STYLEGUIDE_TEMPLATES=<path> / pass --templates=<path> to override the templates directory location.
Stdout is JSON; stderr carries error messages. Pipe to jq for filtering:
vendor/bin/styleguide list | jq '.[] | select(.category == "Block")'
show <id> exits 1 with an empty stdout when the component is not found, so a missing entry surfaces as a non-zero exit code rather than a parsing error downstream.
CI smoke test — does the catalogue actually render?
lint checks metadata; it cannot tell you whether a template compiles or
renders. Since 1.8.0 that question has a direct answer: a broken template makes
/render/component/<id> return 500 with the real Twig error, so sweeping
the render endpoint is a real check.
BASE=https://your-site.test/styleguide fail=0 for id in $(curl -sf "$BASE/api/components" | jq -r '.[].id'); do code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/render/component/$id") [ "$code" = 200 ] || { echo "$id -> $code"; fail=1; } done exit $fail
No browser, no build step, and it is strictly stronger than a compile check — it also catches a missing partial, a runtime failure, and the "template not found" alert fallback, none of which compiling would reveal. Measured on a 66-component project: ~9 s.
Prior to 1.8.0 this sweep was worthless: a template with a fatal syntax error
answered 200 with an alert box saying it was missing. If you run it against
an older release, it will pass on a catalogue that renders nothing.
lint — metadata quality report
vendor/bin/styleguide lint # scan component + page + doc, text output vendor/bin/styleguide lint --type=component # scan just one type vendor/bin/styleguide lint --format=json --pretty # machine-readable, indented
Reports eleven issue types: templates with no parseable name: (dropped from
the catalogue — unindexed), a styleguide: YAML key carrying content that
the renderer never reads (dead-styleguide-content — see Fixtures &
sample data below), usage: references to ids that don't exist
(broken-usage-ref), render: values outside the four canonical modes
(unknown-render), kind: values outside the five canonical values
(unknown-kind), empty description strings (empty-description,
informational only), a canonical <id>.yaml that is not valid YAML
(sidecar-yaml-invalid — the runtime silently falls back to the twig comment,
so the component renders and nothing else reports it), a twig front-comment
left behind next to a winning <id>.yaml (redundant-twig-metadata — editing
it changes nothing; tailwind-base ADR-0007), and catalogue entries that nothing renders
(no-fixture, informational only — no styleguide.twig, no variant sibling
and no styleguide: key, so the entry shows an empty frame and no visual or
behavioural test can reach it; kind: utility is exempt, since a utility has
no stable appearance to pin), variants_order: ids with no
styleguide.<id>.twig next to them, or a value that is not a list
(unknown-variants-order — the runtime skips them silently), and ignore-list entries that no longer match
anything (stale-ignore, informational only — see Ignoring expected
findings below).
Text output is one line per finding: SEVERITY file message. JSON output
is an array of { severity, file, rule, message } objects. Exit code: 0
clean (or notice-only), 1 when any warning/error finding is present,
2 on a usage/internal error — run it in CI to catch metadata regressions
before they ship.
Ignoring expected findings
Some findings are correct and permanent — a template that deliberately carries
no name: is legitimately not a catalogue entry, and a component built for one
page legitimately has no fixture. Without a way to accept those, the exit code
is 1 on a clean tree forever and the gate stops distinguishing anything.
Underscore-prefixed directories need no entry: the walk skips _partials/ and
its siblings outright, so a shared fragment there produces no finding to accept
in the first place. An ignore entry aimed at one would only report itself as
stale-ignore.
Put them in <templates>/.styleguide-lintignore.yaml (picked up automatically),
or point --ignore=<path> at a file elsewhere:
ignore: - file: component/legacy-embed/* # exact path, or an fnmatch pattern covering a subtree rule: no-fixture # always a specific rule — never the whole file reason: rendered only inside the checkout flow, nothing to demo standalone
The design is deliberately grudging, because a suppression list is how a lint layer goes quiet without anyone deciding it should:
reasonis required. An entry nobody can justify in one line is an entry nobody will dare delete later.- Entries are per (file pattern, rule). There is no "mute this rule everywhere" — that would also hide the next occurrence, written months later, which is the finding worth having.
- An entry that matches nothing reports itself as
stale-ignore(notice), so the list cannot outlive the thing it excused. That finding is itself not suppressible. - Suppressions are announced on STDERR (
N finding(s) suppressed by the ignore list.), so a hidden finding never looks like an absent check. Both output formats are unaffected.
A malformed or missing --ignore file exits 2 (usage error), never 0 —
silently ignoring nothing would recreate the problem one level up.
doctor — is this project's configuration sound?
vendor/bin/styleguide doctor vendor/bin/styleguide doctor --config=static/styleguide.yaml vendor/bin/styleguide doctor --format=json --pretty
lint reads the templates. doctor reads the configuration, and reports
only what would otherwise be found by deploying:
| Check | What it catches |
|---|---|
config |
The YAML does not load. Reported as one finding with the library's own message, not as a stack trace — and nothing else is checked, because every other check needs the configuration this one could not produce. |
paths |
A configured directory or file that does not exist. static_path, translations_path, typography_config and every namespaces.* entry. A namespace whose directory is absent is skipped silently, so a typo there never errors — the template using it just stops resolving. |
dist |
The built SPA has lost its #sg-config injection point, or references an asset the build no longer contains. The first is a 500 on a live request; the second PHP never learns about at all, because the browser asks for the asset and the page goes blank. |
base_url |
Notice: the catalogue is served somewhere other than /styleguide, and the web server has to route it there. An invalid value is the config error. |
render |
The catalogue is empty — templates_path holds no fixture. |
twig |
A notice listing every helper on the environment bar Twig's own language — the styleguide's component_*() and __(), and the bundled extras (create_attribute(), |typography, dump(), Intl, String) that are registered when their packages are installed. A consumer writing a helper of the same name needs them before Twig locks its extension set. |
Same exit codes as lint: 0 clean or notice-only, 1 when a warning or
error is present, 2 for a usage error such as a styleguide.yaml that
cannot be found. The helper listing is a notice, so a sound project exits 0
with output.
doctor does not check a fixture's Twig. renderObserved() tolerates an
unknown helper by design, so a broken template comes back rendered — lint
is what walks the tree.
Replacing a bespoke, hand-rolled styleguide with this package? See
docs/MIGRATION.md for a step-by-step guide, including
worked per-project notes for the fleet's Tailwind/SCSS, Drupal-Twig, and
Bootstrap 5 stacks.
Outage screen
A CMS shows a fallback screen exactly when it cannot render one. WordPress reads
.maintenance in wp_maintenance() before plugins and theme load, and reaches
wp-content/db-error.php with no database at all. At that moment there is no
theme, no template engine and no translation catalogue — so the screen has to be
a finished file before the outage starts.
vendor/bin/styleguide maintenance:render --css=/dist/css/style.min.css
It renders the project's @page/maintenance/maintenance.twig inside a document
shell and writes
<templates_path>/component/maintenance/maintenance.html — beside the component
it renders, so the committed artefact and the template whose change makes it
stale share one listing.
Configuration comes from styleguide.yaml: bootstrap: locates the templates,
the static root and the .mo catalogues, and iframe.css names the stylesheet.
Pass --css when the project builds a separate minified stylesheet — the
default inlines whatever iframe.css points at.
| Flag | Default |
|---|---|
--config=<path> |
./styleguide.yaml, then ./static/styleguide.yaml |
--locale=<code> |
bootstrap.default_locale, then project.locale, then en |
--css=<path> |
the first entry of iframe.css, resolved under static_path |
--out=<path> |
<templates_path>/component/maintenance/maintenance.html |
--check |
off — see Staleness below |
The project supplies page/maintenance/maintenance.twig. Its absence is an
error rather than an empty document: page_*() logs a miss and substitutes an
alert block, which would write a file that looks rendered and shows an error
banner during the one outage it exists for.
Self-containment is the contract
The file is served by a drop-in with no web server behind it worth trusting, so it may reach for nothing:
- the stylesheet is inlined;
- every
@font-facerule is stripped, and every remainingurl()that is not adata:URI becomesnone— a background image or a vendor spinner reaches for the same unreachable server a font would; - the packaged shell carries no script, no font, and its own layout rules (a template inside a Composer package is not scanned by the project's Tailwind build, so borrowing utilities from it would leave the screen unstyled).
One file, one language. A drop-in runs before anything that knows about
languages, so it cannot choose. Render another with --locale and --out.
Staleness — --check
The rendered file is committed, and nothing about a committed artefact stops somebody editing the template beside it and forgetting the render.
vendor/bin/styleguide maintenance:render --check
Exit 0 current, 1 stale, absent, or rendered before fingerprints existed. It
writes nothing and needs no built stylesheet, so it runs in CI with no Node and
no build step.
The fingerprint covers the screen's content and structure — the two
templates, the .mo catalogue for the rendered locale, the document shell, and
MaintenanceRenderer::RENDERER_VERSION. It deliberately excludes the compiled
stylesheet the file inlines: a fingerprint over the output would go stale on
every unrelated CSS change and make the check a chore every pull request pays.
The trade is worth knowing before you rely on it: a design-token change that alters the screen's colour or type does not invalidate the fingerprint. Re-render after touching tokens.
Overriding the shell
A file at <templates_path>/maintenance-document.twig wins over the packaged
one. It receives stylesheet (already stripped) and langcode, and renders
page_maintenance() itself.
The packaged shell sets the document title through a contextless
__('The site is briefly unavailable') — a project translates it by adding that
msgid to its catalogue; untranslated it falls back to English in the browser tab
only.
Serving it
Rendering is this package's half. Something has to install the drop-ins that
send the file — on WordPress, parisek/timber-kit >= 1.31 does it with
wp timber-kit outage-screen install. Full API reference for
MaintenanceRenderer and the Styleguide::renderTemplate() / hasTemplate()
primitives behind it: docs/API.md § Offline outage render.
Conventional Twig namespaces
When the package builds its own Twig environment (or attaches loaders to a project-provided one), it auto-registers these namespaces whenever the matching directory exists. Component templates can rely on them without the consuming project calling $loader->addPath(…):
| Namespace | Source | Notes |
|---|---|---|
@project |
templates_path |
Renderer template lookup. Always registered. |
@component |
templates_path/component |
Resolves {% include '@component/<name>/<name>.twig' %} and powers the component_*() helper. |
@page |
templates_path/page |
Sibling of @component; powers page_*(). |
@doc |
templates_path/doc |
Sibling of @page. Resolves {% include '@doc/<name>/<name>.twig' %} in doc templates; auto-registered only when templates_path/doc/ exists. |
@macro |
templates_path/macro |
Shared Twig macros. |
@static |
templates_path |
Fallback namespace for templates that live directly under the templates root. |
@icons |
static_path/images/icons |
Inline SVG icons referenced as @icons/<file>.svg. |
@images |
static_path/images |
Project image assets. |
Anything else — non-standard image roots, third-party template packs — goes into the namespaces config map as <name> => <absolute path>. Last write wins, so you can also override a conventional location if your layout is exotic.
When a component_*() / page_*() call fails
The two outcomes are deliberately different, because the two causes need different reactions from the author:
| Cause | Result |
|---|---|
The template is not there (LoaderError) |
Renders the project's @component/alert/alert.twig saying "Component template <name>.twig not found", and the surrounding page keeps rendering. Falls back to a bare inline message if the alert component is missing too. |
| The template is there and is broken — a Twig syntax error, or any throw while rendering | Propagates. Renderer::render() catches it, sets HTTP 500, and shows the real Twig message. |
The second row matters for anything automated: before 1.7.3 a broken template
was reported as a missing one and served 200, so a smoke test polling
/render/component/<id> saw success for a component that rendered nothing.
A consumer's CI can now treat a non-2xx render as the failure it is.
Per-template metadata
Each component / page Twig template's first {# … #} comment is parsed as YAML and becomes the metadata for that entry. The styleguide registrar reads these to build the sidebar, the cross-reference panel, and the API responses.
{# name: "Button" category: "Basic" weight: 1 usage: 404,article-list,header-menu description: "Primary CTA — three sizes, primary + secondary skin." fields: url: { title: "URL", type: "url", required: 1 } title: { title: "Label", type: "text", required: 1 } #} <a href="{{ content.url }}" class="btn …">{{ content.title }}</a>
| Key | Used by |
|---|---|
name |
sidebar label, iframe title |
category |
sidebar bucket — folded into a small set of canonical sections by sectionOf() in frontend/src/stores/catalog.js. Unknown labels never get dropped, they fall into a default bucket. |
weight |
sort order within a bucket (lower = earlier; default 50) |
usage |
authored as comma-separated ids of pages/components that USE this one (component view) or that THIS one uses (page view); normalized to an array by the parser — drives the cross-reference chip panel |
aliases |
other names the ⌘K palette and the sidebar filter find the entry by — see Search aliases below |
description |
sidebar tooltip + overview cards |
fields |
/api/fields endpoint + the Fields inspector view |
asana |
external link chip — Asana task URL |
figma |
external link chip — Figma design URL |
drupal |
external link chip — Drupal docs / module URL |
web |
external link chip — generic external URL |
render |
iframe-wrapper rendering mode for components — see Component render modes below |
styleguide |
legacy presence-only flag — prefer a sibling styleguide.twig file (the renderer already prefers it; see Fixtures & sample data below). Content nested under this YAML key is never read; vendor/bin/styleguide lint reports it as dead-styleguide-content. |
responsive |
true (default) — when false, the SPA hides the responsive-width toolbar for this entry; use for fixed-layout demos where resizing has no meaning. Ignored for doc templates — a doc page is prose, not a widget, so responsive is always forced to false there regardless of this key |
body_class |
optional class string applied to the render iframe's <body>, merged after the global iframe.body_class — see Per-entry body class below. For doc templates the global iframe.body_class is skipped entirely, so this per-entry key is the only body class that ever applies |
variants_order |
order of the variant tiles: the listed ids first, the rest by file name — see File-convention variants below |
variants |
legacy fallback map of display titles (and optional descriptions) for auto-discovered styleguide.<variant>.twig sibling files, keyed by id — prefer a title:/description: annotation in the sibling file itself; see File-convention variants below |
Search aliases. aliases: lists other names an entry is found by — the source catalogue's layout numbers, an old name, a client's word for it. A plain string opens the entry. A map with variant opens that variant tile:
aliases: - "Hero banner" - { name: "Layout 238", variant: image-side }
The ⌘K palette shows each matching alias as a second line under the entry name, and gives every variant alias its own row. The sidebar filter shows the first matching alias the same way. A variant that names no styleguide.<id>.twig sibling opens the entry instead. The <id>.yaml sidecar takes the same key. lint does not flag it, and versions before it ignore it.
YAML reserved indicator gotcha: the first comment is parsed as YAML, so avoid {% %} tags inside it (% is a YAML directive marker). Put usage examples in a second {# #} comment block, or in the sibling styleguide.twig file.
Component render modes
By default every component renders inside a 24 px-padded wrapper — right for atomic UI (button, alert, breadcrumb) that would otherwise sit flush against the iframe edge. Hero / slider / page-chrome / modal components want the full viewport instead. The render YAML key opts each component into one of four modes:
| Mode | Effect | Use for |
|---|---|---|
inset (default) |
24 px padding wrapper, body min-height untouched. |
Atomic UI: button, alert, breadcrumb, picture, pagination, accordion. |
bleed |
No wrapper. Resets --header-height to 0px so consumer "tuck under sticky header" hacks (margin-top: var(--header-height, 75px) * -1) collapse cleanly in styleguide isolation. |
Hero, slider, page-header — anything that wants to fill the iframe edge-to-edge. |
chrome |
Same as bleed, plus body { min-height: 200vh }. Sticky / fixed elements have room to scroll against. |
header with sticky variant, footer, cookieconsent. |
overlay |
Same iframe wrapper as bleed. Separate label exists so future UI can surface "this is a modal" without a wrapper change. |
Native <dialog> modals. |
{# name: "Slider" category: "Gutenberg" render: bleed fields: items: type: array required: 1 #}
Missing key, typo, or non-string value falls back to inset — legacy components without render: keep their pre-feature wrapper, so adopting the package is a no-op until you opt in.
Per-entry body class
iframe.body_class in styleguide.yaml sets one <body> class for every render. Some pages need their own — a blog/category page whose production <body> carries a dark brand background, for example. Declare it per entry with body_class:
{# name: "Blog" body_class: "bg-secondary-500 body-secondary" #}
The render iframe builds <body> via create_attribute({ class: [iframe.body_class, <entry>.body_class] }), so the per-entry value is appended after the global one and empty values are dropped (no stray class=""). This mirrors what the production layout puts on <body> (e.g. from an ACF body_background_color), so the styleguide preview matches production without wrapping the page content in a styleguide-only <div>.
foundations and doc are exceptions to the global class. foundations is a package-owned page with a fixed zinc-on-white palette — the global iframe.body_class (and any per-entry class) never applies to it, since a dark site-wide class would make it unreadable. doc pages get a narrower exception: the global iframe.body_class is skipped (same readability rationale — prose needs to stay legible regardless of the consumer's brand background), but the per-entry body_class above still applies, since that's an explicit opt-in by the doc's own author rather than a site-wide bleed.
File-convention variants
Drop a styleguide.<variant>.twig file next to styleguide.twig and it's automatically discovered — no YAML required:
component/hero/
├── hero.twig
├── styleguide.twig ← default variant
├── styleguide.secondary.twig ← discovered variant "secondary"
└── styleguide.dark-bg.twig ← discovered variant "dark-bg"
The SPA preview area shows every variant at once — a responsive grid of independent preview tiles (default fixture first, then each discovered variant in filename order) — the moment at least one sibling exists; no toolbar switcher, no clicking through.
Display metadata — annotate the sibling file itself (preferred). Give the variant a title and optional description with the same front-comment convention every component/page template already uses, right in the sibling file it describes:
{# styleguide.dark-bg.twig #} {# title: "Dark background" description: "Same hero, tuned for a dark section background." #} <div class="hero hero--dark">…</div>
{# styleguide.secondary.twig #} {# title: "Secondary style" #} <div class="hero hero--secondary">…</div>
Metadata lives next to the markup it describes instead of a centralised map you'd otherwise have to keep in sync by id as variants are added, renamed, or removed.
The default tile's title. styleguide.twig takes the same annotation. Its title: labels the default tile (since 1.24.0); without one the tile reads "Default" / "Výchozí":
{# styleguide.twig #} {# title: "Layout 238" #} <div class="hero">…</div>
Tile order — variants_order:. The tiles follow the file names. To put some first, list their ids in the component's own metadata (front comment or <id>.yaml, since 1.24.0):
variants_order: [image-side, image-top]
The listed ids come first, in that order; the rest follow by file name. The default tile stays first. An id with no styleguide.<id>.twig is skipped at runtime, and lint reports it as unknown-variants-order.
Legacy fallback — the variants: map. Templates written before per-sibling annotations existed (or not yet migrated) can still supply titles/descriptions from the component's own front comment, keyed by variant id — either a plain string, or a map with an optional description too:
{# name: "Hero" variants: secondary: "Secondary style" dark-bg: title: "Dark background" description: "Same hero, tuned for a dark section background." #}
(label: is also accepted in the map as a legacy alias for title: — title: wins when both are present.) A sibling's own annotation always wins over its map entry when both exist; an id with no annotation falls back to the map, then to the id itself.
An entry with no matching file is ignored — the filesystem is always the source of truth for which variants exist. <variant> must match [a-z0-9-]+.
Deep link to one tile. /styleguide/component/<slug>?variant=<id> opens that tile isolated. Clicking a tile header writes the same URL and adds a browser history entry, so Back returns to the grid and Forward isolates the tile again. An unknown id opens the full grid, without an error. The default tile has no id, so it has no deep link of its own.
The render endpoint itself (/styleguide/render/component/<slug>) is unaffected by any of this SPA chrome: with no ?variant= it renders the single default styleguide.twig body, exactly as it always has; ?variant=<id> isolates that one block; an unknown or since-deleted variant silently falls back to the default body instead of 404ing.
All named, no bare default. styleguide.twig itself is optional — a component can ship only named variant siblings, with every variant a first-class entry and no implicit "Default":
component/hero/
├── hero.twig
├── styleguide.primary.twig ← discovered variant "primary"
└── styleguide.secondary.twig ← discovered variant "secondary"
This still counts as a renderable entry (has_styleguide: true — the sidebar, palette, and overview never filter it out just because there's no bare fixture) and the SPA grid shows exactly the two named tiles, no synthetic "Default" tile ahead of them — the first tile isolates via click-to-isolate exactly like any other. /api/components et al. expose the distinction via the additive has_default_variant field (true only when the bare styleguide.twig sibling exists on disk); the grid consults it, not has_styleguide, to decide whether to render that synthetic tile. The render endpoint's own fallback chain (styleguide.<variant>.twig → styleguide.twig → the component's own <slug>.twig) is unaffected — a no-?variant= request to a variants-only component still resolves to its <slug>.twig, the raw production template rather than a styleguide-authored fixture, which is exactly why the grid — not that fallback — is what a no-variant deep link shows in the SPA.
Default view (SPA). With no ?variant= (a bare deep link), the preview area becomes a grid — one independent <iframe> tile per variant (the default fixture first when one exists, see All named, no bare default above; then each discovered variant in filename order), each with its own slim header (title + optional description). This is the whole point of having variants: see every treatment at a glance, no switcher to click through. Deep-linking a specific ?variant=<id> still shows the classic single, resizable preview of just that one variant. An entry with no discovered variants is unaffected — it renders the single default preview exactly as it always has.
Device presets, per tile. The toolbar's width menu (presets, custom width, orientation; see Compare widths) stays visible and works the same way in the grid as in the classic single preview — the chosen width applies to every tile at once. A fixed-width/fixed-height preset renders each tile's iframe at exactly that preset's logical size, then scales the whole tile down (never up) to fit the tile's own available width. Every tile shares one zoom, so the scale shows once, in the toolbar trigger (e.g. 375 × 667 (84 %)), not in each tile. Full stays fluid — each tile's iframe simply tracks its cell's width with auto content height, no scaling.
Tile density — Auto | 1 | 2 | 3 | 4. A dropdown next to the width menu (visible only while the grid is active, and hidden while comparing widths, when the grid shows one tile per row) controls how many tiles fit per row. "Auto" (the default) derives the column basis from the active viewport preset rather than one fixed number for every preset — a Desktop preset (1280 px) settles on far fewer tiles per row than a Mobile preset (375 px) on the same canvas, always scaled down to fit each tile's own cell as usual. "1"–"4" fix the column count exactly, ignoring the preset — "1" is the direct replacement for the earlier "rows" stacked layout (a single-column grid renders identically). The choice is remembered across visits (localStorage, key sg-variant-columns); upgrading from a pre-2.0 install migrates an existing sg-variant-layout value once ("rows" → 1, "grid" → "auto").
Click-to-isolate. Clicking (or pressing Enter/Space on) a tile's header jumps straight to that variant's classic single preview — the same as typing ?variant=<id> by hand. The Default tile's header is the one exception: it has no dedicated single-preview URL of its own (an entry with variants and no ?variant= always resolves back to the grid), so it isn't clickable. Once a variant is isolated this way, the toolbar breadcrumb gains the variant's name, and the component's name in it links back to the grid.
Page wrapper
body_class styles the iframe's <body>; iframe.page_wrapper_class adds the structural shell most projects wrap their page in — the <div class="page-wrapper …"> that owns the sticky-footer flex column and min-h-dvh height in the production layout. Set it once in styleguide.yaml and every page render is wrapped:
iframe: page_wrapper_class: "page-wrapper flex flex-col relative min-h-dvh w-full h-full"
Rules:
- Page-only. The wrapper is applied solely to
kind: pagerenders — never to component or doc previews, so the full-height shell can't leak into a small component preview. - Empty = no wrapper. The default is
"", which renders nothing. The package stays framework-agnostic: Bootstrap / custom-CSS consumers simply leave it blank, Tailwind projects set their shell utilities. - Built through
create_attribute— same class-escaping contract as the<body>line, no strayclass="".
This completes the production-parity pair: body_class reproduces the page's <body> styling, page_wrapper_class reproduces the wrapper <div> around header + main + footer — so a page preview matches production without each consumer hand-wrapping every page/<name>/styleguide.twig.
Fixtures & sample data
The only supported convention for demo content is a sibling
styleguide.twig next to the component or page it demos:
templates/component/breadcrumb/
├── breadcrumb.twig # the component itself — receives content.* from the CMS in production
└── styleguide.twig # sample data, rendered ONLY in the styleguide preview
{# templates/component/breadcrumb/styleguide.twig #} {{ component_breadcrumb({ container: 'container', items: [ { title: 'Úvod', url: '#' }, { title: 'Služby', url: '#' }, { title: 'Detail služby', url: '#' }, ], }) }}
Renderer auto-detects the sibling file and prefers it — no YAML key
required. The styleguide: front-comment key (nested sample data under the
YAML metadata) still works for backward compatibility, but content placed
under it is never read — only its presence is checked. Run
vendor/bin/styleguide lint to find leftover instances (reported as
dead-styleguide-content) and move the data into a styleguide.twig
sibling; see docs/MIGRATION.md for a worked before/after.
Placeholder images — no external network calls
Use the bundled placeholder() Twig function in styleguide.twig files
instead of a service like picsum.photos. It's deterministic (the same
seed always renders the same image), fully offline (an inline SVG data
URL — no network round-trip, no rate limit, no dead links when a
third-party service changes its API), and returns an image-array shape
most component_picture-style helpers already expect:
{# bare call — abstract subject, pastel mood, 3/2 aspect #} {{ component_picture({ image: placeholder() }) }} {# tuned for a hero — landscape subject, warm mood, explicit size #} {{ component_picture({ image: placeholder({ subject: 'landscape', mood: 'warm', width: 1920, height: 1080, seed: 'hero-1' }), }) }} {# repeatable across a gallery loop — same subject, distinct seed per index avoids visually identical repeats #} {% for i in 1..4 %} {{ component_picture({ image: placeholder({ subject: 'product', seed: 'gallery-' ~ i }) }) }} {% endfor %}
| Option | Values | Default |
|---|---|---|
subject |
abstract | landscape | portrait | product | food | architecture | avatar |
abstract |
mood |
pastel | vibrant | monochrome | warm | cold | natural | vintage |
pastel |
seed |
any string — same seed ⇒ same image | auto-incrementing counter |
width / height / aspect |
pixels, or a "w/h" ratio string |
aspect: '3/2', 1200px wide |
label |
true | a string | false |
false |
See docs/API.md § Twig functions for the full option list (grain,
vignette, alt).
YAML sidecar data — styleguide.data.yaml / styleguide.data-<name>.yaml
Twig's {% include %} can't export variables back to the caller — {% set %}
inside an include is include-local. That's a real limitation once several
styleguide.<variant>.twig siblings (File-convention variants
above) want to share the same bulky demo data: there's no clean way to
{% include %} a "data partial" and have its variables land in the including
template's scope. Projects have worked around this with partials that own
both the data AND the component call (duplicated per variant), or
{% extends %}-based "data template" tricks — both add template machinery
around what is really just data.
A component/page/doc directory may instead ship one or more styleguide.data*.yaml
sidecars — pure YAML, no Twig — read via the bundled styleguide_data() Twig
function. Two flat filename shapes, both living directly in the component
directory next to the variant .twig siblings (no subdirectory):
| File | Read via |
|---|---|
styleguide.data.yaml |
styleguide_data() — no argument, the DEFAULT set |
styleguide.data-<name>.yaml |
styleguide_data('<name>') — a NAMED set. <name> matches [a-z0-9-]+, the same id rule styleguide.<variant>.twig variant ids already use |
default is a reserved set name — styleguide_data('default') throws an InvalidArgumentException before ever touching the filesystem, pointing you at the no-arg call instead. The default set only has one door in: styleguide_data(). A stray styleguide.data-default.yaml file sitting in a component directory is therefore always dead weight — it can never be reached by name, and the no-arg form never reads it either (it only ever reads the bare styleguide.data.yaml).
templates/component/hero/
├── hero.twig
├── styleguide.twig # {{ component_hero(styleguide_data()) }}
├── styleguide.secondary.twig # {{ component_hero(styleguide_data('gallery')) }}
├── styleguide.data.yaml # the default set — styleguide_data()
└── styleguide.data-gallery.yaml # a named set — styleguide_data('gallery')
# templates/component/hero/styleguide.data.yaml title: "Grow your business" image: placeholder: subject: people seed: 42 ratio: "16:9" cta: url: /contact
{# templates/component/hero/styleguide.twig #}
{{ component_hero(styleguide_data()) }}
Resolution defaults to the CURRENT fixture's own directory — whichever component/page/doc is rendering picks up its own sidecar(s), no path/id to keep in sync. That covers almost every call.
When a fixture genuinely needs another one's data — typically a page rendering shared chrome — reference it by path:
{# templates/page/about/styleguide.twig #} {{ component_header(styleguide_data('component/header')) }} {{ component_header(styleguide_data('component/header/dark')) }} {# named set there #}
The argument is a path and its segment count decides the meaning: <name> is a
set in the current directory, <kind>/<slug> is another fixture's default set,
<kind>/<slug>/<name> is a named set there. They cannot collide, because / is
illegal inside an id or a set name.
<kind> must be component, page or doc, and every segment must match
[a-z0-9-]+ — nothing else is accepted, so no path can be smuggled through.
Reach for it only when the data is genuinely shared. Duplicating a couple of
keys is still cheaper to read than a reference; the cross-fixture form exists
for the case where the alternative is a data partial that accumulates every
consumer's fixture data in one file (see the escape hatch below for why an
{% include %} cannot share data any other way).
Why flat suffix naming instead of a
data/subdirectory? A nesteddata/<name>.yamllayout was considered and deliberately deferred: the flatstyleguide.data-<name>.yamlshape mirrors the already-shippedstyleguide.<variant>.twigconvention exactly (same directory, same[a-z0-9-]+id rule, same "glob the component directory" discovery model), so there's one nesting concept in the package, not two. It also keeps discovery a single flatglob()per component directory instead of a directory-existence check plus a second glob one level down.
Missing set → loud failure that lists what IS there. A styleguide_data()
/ styleguide_data('<name>') call with no matching file on disk throws a
RuntimeException naming the expected path — relative to templates_path, so
an absolute filesystem path never reaches rendered 500-page markup; the absolute
one goes to error_log() — AND enumerating every
styleguide.data*.yaml set actually present in that directory — e.g.
sidecar file not found: …/styleguide.data-gallry.yaml (available data sets in this directory: default, gallery, hero). Fixtures are dev-time only, so
failing loudly (and pointing at the likely typo) beats silently returning
[]. An invalid <name> (doesn't match [a-z0-9-]+) is also rejected with
a RuntimeException, before the filesystem is even touched.
Integrated placeholder support. Anywhere in the YAML tree, a mapping shaped like:
image: placeholder: subject: people seed: 42 ratio: "16:9"
is recursively detected and resolved into the exact same value shape the
Twig placeholder() function itself returns — after resolution, image in
the returned array looks exactly as if you had written
placeholder({subject: 'people', seed: 42, aspect: '16/9'}) inline in a
.twig fixture. This works at any depth (inside a list of items, several
levels deep) — see docs/API.md § styleguide_data() for the exact
detection rule.
ratio: — a YAML-only alias for aspect:. Placeholder::generate()
itself only has an aspect: option (slash-separated, "W/H", e.g. "3/2"),
but a placeholder: node inside a styleguide.data*.yaml sidecar also
accepts the friendlier ratio: key (colon-separated, "W:H", e.g. "16:9")
— resolved into aspect: before the call, converting the separator along the
way ("16:9" → "16/9"). If both ratio: and aspect: are present on the
same node, the explicit aspect: wins and ratio: is dropped. This alias is
sidecar-only — a placeholder({ratio: '16:9'}) call written directly in
a .twig fixture is unaffected; only YAML-authored data gets the alias.
Path rebasing. Any src: string value in the tree is rebased onto the
consumer's asset base (twig_context.templateUrl) — same rule
resolveAssetUrl() already applies to iframe.css/styleguide.logo[*].src.
Any url: string value is rebased onto twig_context.homeUrl when that key
is present in the render context; otherwise it's left unchanged. A
root-relative path (/dist/foo.png) IS rebased, exactly like a
bare-relative one (dist/foo.png) — it is NOT treated as "absolute" for this
purpose. Only a URI scheme (incl. data:), a protocol-relative URL (//…),
or an in-page anchor (#…) pass through untouched — see docs/API.md §
styleguide_data() for the full table. src:/url: are reserved,
always-rebased keys by design — any node using them for demo image/link data
gets this treatment regardless of surrounding shape.
Malformed YAML propagates Symfony's own ParseException unchanged — the
same (uncaught) contract styleguide.yaml itself already has; the package
doesn't add a resilience layer here that it doesn't already have there.
Escape hatch — Twig "data templates" for expression-heavy demos. The
YAML sidecar is the DEFAULT approach for flat demo data, but it can't express
Twig logic — chained |resizer calls, computed values, loops. For those
cases, a Twig-based "data template" (an {% extends %} sibling that sets
variables a child block reads) remains valid; this feature doesn't remove or
restrict that pattern, it just gives the common flat-data case a much
simpler home.
File layout (after install)
vendor/parisek/styleguide/
├── src/ # PHP runtime (PSR-4 Parisek\Styleguide\)
│ ├── Styleguide.php # public bootstrap
│ ├── Router.php # URI → route descriptor
│ ├── Renderer.php # component / page / overview → iframe HTML
│ ├── ComponentParser.php # first-comment YAML parser + sidebar builder
│ ├── AssetServer.php # path-traversal guard + ETag + immutable cache
│ └── Api/ # ComponentsEndpoint, PagesEndpoint, FieldsEndpoint
├── templates/ # Twig templates the package renders
│ ├── render-cell.twig # iframe HTML wrapper
│ ├── foundations.twig # palette + typography + fonts
│ ├── icons.twig # icon sheet
│ └── styleguide-404.twig
├── dist/ # prebuilt SPA bundle (committed)
│ ├── index.html
│ ├── styleguide.<hash>.js
│ ├── styleguide.<hash>.css
│ └── locales/{cs,en}.json
├── composer.json
├── LICENSE
├── README.md
└── CHANGELOG.md
Tests, frontend source, and tooling files (frontend/, tests/, phpunit.xml, composer.lock) are present in the GitHub repo for contributors but excluded from the Composer tarball via .gitattributes export-ignore.
Local development (for package contributors)
git clone git@github.com:parisek/styleguide.git cd styleguide # PHP unit tests (Router, Renderer, ComponentParser, AssetServer) composer install vendor/bin/phpunit # SPA chrome (Vite + Vue 3 + Pinia + Tailwind v4) cd frontend npm install npm run watch # rebuilds dist/ on every edit npm test # Vitest unit suite (src/lib, src/stores, src/composables, src/components) npm run test:e2e # Playwright, full-browser parity checklist
Changes to PHP src/ are picked up immediately (no build step). Changes to frontend/* require a Vite build — committed dist/ artifacts are what consumers receive, so always commit the rebuilt bundle when the SPA changes.
Stability & versioning
The package follows SemVer. For an exhaustive list of what's covered by the public API contract (PHP classes/methods, YAML schemas, JSON endpoints, Twig functions, URL surface, CLI), see docs/API.md.
PHP classes outside of Styleguide itself are marked @internal and can change in any minor release. Consumers should only call new Styleguide([…])->run() — the rest of the surface is reached via YAML config, JSON endpoints, or Twig functions in component templates.
License
MIT © Petr Parimucha