WordPress plugin
The WP Pattern Library WordPress plugin adds a front-end endpoint which renders a single registered pattern in isolation, and a manifest that lists every pattern and variant available to capture.
Installing
composer require humanmade/wp-pattern-library
wp plugin activate wp-pattern-library
The Composer package declares "type": "wordpress-plugin", so composer/installers routes it to the project’s plugin directory. This can be activated as a standard plugin, or in code from mu-plugins.
The plugin belongs in whichever environment you capture from. If you only generate locally, a require-dev install is fine.
Enabling it temporarily
Because the plugin is inert unless its query var is present, it’s reasonable to activate it on a production site, capture, and deactivate again — for instance to document patterns that were built in the editor rather than committed to the theme.
Nothing persists when deactivated except the role, and the routes can be switched off without deactivating:
add_filter( 'pattern_library_enabled', '__return_false' );
Choosing which patterns to expose
By default the manifest lists every pattern in the registry, core’s included. Limit it to your own:
add_filter( 'pattern_library_namespaces', fn () => [ 'my-theme/', 'my-plugin/' ] );
Prefixes are matched literally, so keep the trailing slash. You can also filter on the generator side, but doing it here means the site never serves patterns that are none of the library’s business.
Access control
The routes are gated behind view_pattern_library, a custom primitive capability. No built-in role holds it — administrators included. That’s the point: the account that reads the library should hold that one capability and nothing else.
wp pattern-library setup # Create the role.
wp pattern-library setup --login=<login> # ...and a user in it.
wp pattern-library setup --login=<login> --email=<email>
wp pattern-library grant <user> # Grant it to an existing user.
<user> accepts a login, an email address, or a numeric ID. Both commands mint and print an application password when they touch a user; it isn’t recoverable, so capture it at the time.
The bundled pattern_library role has the read and view_pattern_library caps and nothing else, so the account can’t create or change content.
- On multisite, roles are stored per site. Run
setupwith--url=for each site you want to capture. - Application passwords need HTTPS, or
WP_ENVIRONMENT_TYPEset tolocal. WordPress refuses to generate one otherwise.
To grant access through your own role management instead:
add_filter( 'pattern_library_user_can', fn () => current_user_can( 'edit_theme_options' ) );
The endpoints
Both are query vars on index.php, on the front end.
| URL | Returns |
|---|---|
/index.php?pattern-library-preview=__manifest |
JSON manifest of patterns and categories. |
/index.php?pattern-library-preview=<name> |
One pattern, rendered in a bare page. |
<name> is a full pattern name (my-theme/hero) or an unambiguous basename (hero).
index.php is targeted rather than / because a site may have proxy or rewrite rules that intercept the root path before WordPress sees the query var.
A pattern renders in a minimal HTML document with wp_head() and wp_footer() intact, so the theme’s real stylesheets, fonts and block styles load — but with no site header, footer or admin bar. The pattern is wrapped in #pattern-library-preview, which is what the capture tool crops to.
Every response carries X-Robots-Tag: noindex, nofollow and cache-busting headers. An unauthenticated request gets a 401 with a WWW-Authenticate challenge.
| Query var | Purpose |
|---|---|
pattern-library-post-type |
Wrap the pattern in a one-item query loop over this post type. |
pattern-library-placeholder |
Substitute a placeholder featured image. |
pattern-library-wrapper |
JSON group-block attributes to wrap the pattern in. |
How authentication works
Worth understanding, because both of these fail silently — WordPress serves the logged-out page with a 200, so a broken setup doesn’t error. It produces a complete, plausible, entirely wrong pattern library.
Requests authenticate with a standard WordPress application password over HTTP Basic Auth. Two things make that work on a front-end route:
wp_authenticate_application_password()ignores any request that isn’t REST or XML-RPC. A front-end route authenticates as nobody otherwise. The route opts itself in through theapplication_password_is_api_requestfilter, scoped to its own query var.- Browsers only attach Basic credentials after a
401carryingWWW-Authenticate, and never send them preemptively. Playwright’shttpCredentials.send: 'always'looks like it fixes this and doesn’t — it applies to Playwright’s API request context, not to page navigation. So the route sends a real challenge with its401.
As a backstop, the CLI navigates the browser to the manifest URL and requires a 200 before capturing anything.
An origin behind HTTP Basic can’t be captured. The application password needs the
Authorization: Basicheader, and a request can only carry one. Gates that use other headers — Cloudflare Access service tokens, for instance — are supported; see access proxies.
The manifest
The manifest is the contract between the plugin and the NPM package: the plugin produces it, and the package consumes it.
{
"manifestVersion": 1,
"features": [ "variants" ],
"site": { "name": "Example", "url": "https://example.com/" },
"categories": [ { "slug": "banner", "label": "Banners" } ],
"patterns": [
{
"name": "my-theme/hero",
"basename": "hero",
"title": "Hero",
"description": "A full-width hero.",
"categories": [ "banner" ],
"keywords": [ "masthead" ],
"blockTypes": [ "core/group" ],
"postTypes": [],
"viewportWidth": 1440,
"inserter": true,
"source": "theme"
}
]
}
Pattern markup is deliberately omitted — the library documents patterns visually, and shipping every pattern’s content would make the manifest an order of magnitude larger.
manifestVersion only moves when the shape changes incompatibly; the CLI refuses a version it doesn’t understand rather than misreading it. features is additive, so a CLI too old to know a feature never asks for it, and one configured to use a feature can say plainly that the site is too old.
Keep the plugin and the CLI on the same version. They ship from one repository under one tag for exactly this reason.
Filters
| Filter | Purpose |
|---|---|
pattern_library_enabled |
Disable the routes entirely. |
pattern_library_namespaces |
Pattern-name prefixes to expose. |
pattern_library_user_can |
Override the capability check. |
pattern_library_placeholder_image |
Markup of the placeholder featured image. |
pattern_library_wrapper_open |
Opening markup of a variant wrapper. |
pattern_library_wrapper_close |
Closing markup of a variant wrapper. |
Disable the routes outside development:
add_filter( 'pattern_library_enabled', fn () => 'production' !== wp_get_environment_type() );
Use a themed placeholder image:
add_filter( 'pattern_library_placeholder_image', function (): string {
return '<img src="' . esc_url( get_theme_file_uri( 'images/placeholder.svg' ) ) . '" alt="">';
} );
Replace the variant wrapper with your own section markup:
add_filter( 'pattern_library_wrapper_open', function ( string $open, array $attributes ): string {
return '<div class="section section--' . esc_attr( $attributes['className'] ?? 'default' ) . '">';
}, 10, 2 );
add_filter( 'pattern_library_wrapper_close', fn (): string => '</div>' );
Variant wrappers
When the CLI asks for a section variant, the plugin builds the wrapper server-side as a real core/group block, from JSON attributes in the query var. What travels in the URL is data, never markup.
className, align, backgroundColor, gradient, textColor, style and layout are accepted — the attributes of a section group. Anything else is dropped, so a config typo can’t quietly become a block attribute nobody meant to set.
Two details make this a real group block rather than a class added in the browser:
- Block stylesheets registered with
wp_enqueue_block_style()only load when their block actually renders. A pattern containing no group of its own would otherwise be captured without the very CSS the wrapper’s style lives in. - Colour classes are written out in full, because core applies colour supports server-side only to dynamic blocks.
Troubleshooting
wp pattern-library isn’t a command. The plugin isn’t active on the site you’re running WP-CLI against. Check wp plugin list --status=active, and add --url= on multisite.
Application passwords are unavailable for this user. Set WP_ENVIRONMENT_TYPE to local, or use HTTPS. The role and user are still created; run wp pattern-library grant <login> afterwards to mint the password.
The manifest returns a WordPress 404 page. The plugin isn’t active on that site.
The manifest returns an HTML login page. Something is answering before WordPress — an access proxy, an SSO gateway, or a “coming soon” plugin short-circuiting the front end before template_redirect.