Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = tab

[*.{yml,yaml,md}]
indent_style = space
indent_size = 2
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
vendor/
.DS_Store
*.log
204 changes: 204 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# WP Pattern Library

Generate a browsable Markdown pattern library — with screenshots — from a
WordPress site's registered block patterns.

The site serves a manifest of its registered patterns and renders each one in
isolation; a Node CLI captures them with Playwright and writes an index plus one
page per pattern category. A GitHub Action runs the whole thing and opens a pull
request with the refreshed docs.

Two packages ship from this repository:

| Package | Install |
|---------------------------------|--------------------------------------------------|
| `humanmade/wp-pattern-library` | `composer require humanmade/wp-pattern-library` |
| `@humanmade/wp-pattern-library` | `npm install -D @humanmade/wp-pattern-library` |

## Install

### 1. The mu-plugin

The Composer package declares `"type": "wordpress-muplugin"`, so it installs to
`content/mu-plugins/` on Altis and `client-mu-plugins/` on WordPress VIP without
any `installer-paths` change.

mu-plugins in subdirectories are not auto-loaded, so require it from your loader:

```php
// Altis: add the package to `extra.mu-plugins` in composer.json, then
// `composer dump-autoload`.

// VIP: in client-mu-plugins/plugin-loader.php
require_once WPMU_PLUGIN_DIR . '/wp-pattern-library/plugin.php';
```

Limit it to your own patterns, so core and plugin patterns stay out of the
library:

```php
add_filter( 'pattern_library_namespaces', fn () => [ 'my-theme/' ] );
```

### 2. A service account

The routes require a dedicated capability, `view_pattern_library`. The bundled
WP-CLI command creates a role holding that capability and `read` — and nothing
else, so the account cannot create or edit content:

```bash
wp pattern-library setup --login=pattern-library-bot
```

It prints an application password. Store it, along with the login, as CI secrets;
it is not recoverable.

> On multisite, roles are stored per site. Run the command with `--url=` for each
> site whose patterns you want to capture.

To use an existing user instead, run `wp pattern-library setup` and then
`wp pattern-library grant <user>`.

### 3. Configuration

Create `pattern-library.config.js` in your project root:

```js
export default {
title: 'My Pattern Library',
namespaces: [ 'my-theme/' ],
outputDir: 'docs/pattern-library',
};
```

Credentials come from the environment, never the config file:

```bash
export PATTERN_LIBRARY_SITE="https://example.com"
export PATTERN_LIBRARY_WP_USER="pattern-library-bot"
export PATTERN_LIBRARY_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx"
```

## Usage

```bash
npx pattern-library build # capture screenshots, then write Markdown
npx pattern-library build --dry-run # report what would be included, write nothing
npx pattern-library capture hero # screenshots only, for patterns matching "hero"
npx pattern-library generate # Markdown only, from existing screenshots
npx pattern-library manifest # print the filtered manifest as JSON
```

Screenshots are written only when their bytes change, so re-running against a
live site does not churn images whose content merely shifted underneath them.

## Configuration reference

| Key | Default | Notes |
|-------------------|-------------------------------|----------------------------------------------------------|
| `title` | `Pattern Library` | Index heading. |
| `namespaces` | `[]` (all) | Pattern-name prefixes to include. |
| `outputDir` | `docs/pattern-library` | Where pages and screenshots are written. |
| `indexFile` | `<outputDir>/README.md` | Index path. |
| `screenshotsDir` | `<outputDir>/screenshots` | Screenshot path. |
| `imageFormat` | `webp` | `webp`, `avif`, `jpeg` or `png`. |
| `imageQuality` | `80` | Ignored for `png`. |
| `defaultViewport` | `1440` | Used when a pattern declares no `Viewport Width`. |
| `exclude` | see below | What to leave out of the library. |
| `postTypeContext` | `{}` | Basename to post type, for `core/post-template` patterns. |
| `classify` | flat | Category placement. See below. |

`exclude` defaults to skipping patterns hidden from the inserter and those scoped
to `wp_template` / `wp_template_part`, since template parts render meaninglessly
in isolation:

```js
exclude: {
inserterHidden: true,
postTypes: [ 'wp_template', 'wp_template_part' ],
patterns: [], // Exact pattern names.
}
```

### Patterns that render empty

A pattern built as a query-loop *item template* has nothing to bind to when
rendered alone. Give it a post type, keyed by basename:

```js
postTypeContext: { 'person-card': 'person' }
```

The CLI reports any pattern that rendered empty, so these are easy to find.

### Grouping categories

By default every registered category becomes one page in the output root. A
project with a richer taxonomy can supply a `classify()` function, called once per
category, returning where it belongs — or nothing, to drop it from the library:

```js
classify: ( { slug, label } ) => {
// A cross-cutting category of whole-page references, which should also lead
// each section page rather than sitting in the list.
if ( slug === 'full-page' ) {
return { kind: 'reference', dir: 'references', label: 'Full page', leadsIn: 'section' };
}
if ( label.startsWith( 'Component - ' ) ) {
return { kind: 'component', dir: 'components', label: label.slice( 12 ) };
}
return { kind: 'section', dir: 'sections', label };
}
```

`kind` groups pages under headings on the index, `dir` places the page, `label`
sets its title, and `leadsIn` promotes its patterns to the top of every page of
the named kind.

## GitHub Action

Copy [`examples/refresh-pattern-library.yml`](examples/refresh-pattern-library.yml)
into `.github/workflows/`. It is `workflow_dispatch`-triggered, takes a base
branch and output path, and opens a pull request with the refreshed docs.

Capture from **production**. Screenshots reflect deployed code, so a pattern that
exists only on the branch being documented renders as "preview pending" until it
ships. Staging environments sitting behind their own HTTP Basic gate cannot be
used: two `Authorization: Basic` headers cannot coexist on one request.

## How authentication works

Requests authenticate with a standard WordPress application password over HTTP
Basic. Two details are worth knowing, because both fail *silently* otherwise —
WordPress serves the logged-out page with a `200`, which would yield a run's worth
of plausible but wrong screenshots:

- `wp_authenticate_application_password()` ignores any request that is not REST or
XML-RPC. The preview route opts itself in through the
`application_password_is_api_request` filter, scoped to its own query var.
- Browsers only attach Basic credentials after a `401` carrying
`WWW-Authenticate`, and never send them preemptively. Playwright's
`httpCredentials.send: 'always'` does not change this — it applies only to its
API request context, not to page navigation. The route therefore sends a proper
challenge with its `401`.

As a backstop, before capturing anything the CLI navigates the browser to the
manifest URL and requires a `200` — a probe that authenticates through the same
browser path the captures use, yet cannot be brought down by a single pattern
that fails to render.

## 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. |

## Requirements

PHP 8.1+, WordPress 6.0+, Node 20+.

## License

GPL-2.0-or-later
51 changes: 51 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Build pattern library
description: Generate a Markdown pattern library, with screenshots, from a WordPress site's registered block patterns.

inputs:
site-url:
description: Origin of the WordPress site to read patterns from, e.g. https://example.com
required: true
username:
description: WordPress user login holding the view_pattern_library capability.
required: true
app-password:
description: Application password for that user.
required: true
output-path:
description: Directory to write the pattern library into. Overrides the config file.
required: false
default: ''
working-directory:
description: Directory containing pattern-library.config.js.
required: false
default: '.'
version:
description: Version of @humanmade/wp-pattern-library to run.
required: false
default: 'latest'

runs:
using: composite
steps:
# Playwright needs a browser. The official container image ships one, so skip
# the install when the workflow already runs in it.
- name: Install Chromium
shell: bash
run: |
if [ -z "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && [ ! -d "$HOME/.cache/ms-playwright" ]; then
npx --yes playwright install --with-deps chromium
fi

- name: Build the pattern library
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
PATTERN_LIBRARY_SITE: ${{ inputs.site-url }}
PATTERN_LIBRARY_WP_USER: ${{ inputs.username }}
PATTERN_LIBRARY_WP_APP_PASSWORD: ${{ inputs.app-password }}
run: |
args=""
if [ -n "${{ inputs.output-path }}" ]; then
args="--output-dir=${{ inputs.output-path }}"
fi
npx --yes @humanmade/wp-pattern-library@${{ inputs.version }} build $args
Loading