diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..193bf01 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a265979 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +vendor/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..452a6e5 --- /dev/null +++ b/README.md @@ -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 `. + +### 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` | `/README.md` | Index path. | +| `screenshotsDir` | `/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 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..f69795f --- /dev/null +++ b/action.yml @@ -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 diff --git a/bin/pattern-library.mjs b/bin/pattern-library.mjs new file mode 100755 index 0000000..2d92a11 --- /dev/null +++ b/bin/pattern-library.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * Generate a Markdown pattern library from a WordPress site's block patterns. + * + * pattern-library build Fetch, capture, and write Markdown. + * pattern-library manifest Print the filtered manifest as JSON. + * pattern-library capture Screenshots only. + * pattern-library generate Markdown only, from existing screenshots. + * + * Credentials come from PATTERN_LIBRARY_WP_USER and + * PATTERN_LIBRARY_WP_APP_PASSWORD; the site from --site or PATTERN_LIBRARY_SITE. + */ + +import { loadConfig, requireConfig } from '../src/config.mjs'; +import { fetchManifest, filterPatterns } from '../src/manifest.mjs'; +import { captureAll } from '../src/capture.mjs'; +import { generate } from '../src/markdown.mjs'; + +const COMMANDS = [ 'build', 'manifest', 'capture', 'generate' ]; + +/** + * Parse `--flag value` and `--flag=value` arguments. + * + * @param {string[]} argv Raw arguments after the command. + * @return {{options: Object, filters: string[]}} Parsed flags and bare filters. + */ +function parseArgs( argv ) { + const options = {}; + const filters = []; + + for ( let index = 0; index < argv.length; index++ ) { + const arg = argv[ index ]; + + if ( ! arg.startsWith( '--' ) ) { + filters.push( arg ); + continue; + } + + const [ key, inline ] = arg.slice( 2 ).split( '=' ); + const camel = key.replace( /-([a-z])/g, ( _, letter ) => letter.toUpperCase() ); + + if ( inline !== undefined ) { + options[ camel ] = inline; + } else if ( argv[ index + 1 ] && ! argv[ index + 1 ].startsWith( '--' ) ) { + options[ camel ] = argv[ ++index ]; + } else { + options[ camel ] = true; + } + } + + return { options, filters }; +} + +const log = ( message ) => process.stdout.write( `${ message }\n` ); + +/** + * Run the CLI. + */ +async function main() { + const [ command = 'build', ...rest ] = process.argv.slice( 2 ); + + if ( ! COMMANDS.includes( command ) ) { + throw new Error( + `Unknown command "${ command }". Expected one of: ${ COMMANDS.join( ', ' ) }.` + ); + } + + const { options, filters } = parseArgs( rest ); + const { site, dryRun, ...overrides } = options; + const config = await loadConfig( process.cwd(), { ...overrides, siteUrl: site } ); + + requireConfig( config, [ 'siteUrl', 'username', 'appPassword' ] ); + + const manifest = await fetchManifest( config ); + const { patterns, skipped } = filterPatterns( manifest, config ); + + if ( command === 'manifest' ) { + log( JSON.stringify( { ...manifest, patterns }, null, '\t' ) ); + return; + } + + const targets = filters.length + ? patterns.filter( ( pattern ) => + filters.some( ( filter ) => pattern.basename.includes( filter ) ) + ) + : patterns; + + log( `${ manifest.site.name } — ${ config.siteUrl }` ); + log( + `${ patterns.length } patterns in the library, ${ skipped.length } skipped, ${ targets.length } targeted.` + ); + + if ( skipped.length ) { + const reasons = skipped.reduce( ( counts, pattern ) => { + counts[ pattern.reason ] = ( counts[ pattern.reason ] ?? 0 ) + 1; + return counts; + }, {} ); + log( + `Skipped: ${ Object.entries( reasons ) + .map( ( [ reason, count ] ) => `${ count } ${ reason }` ) + .join( ', ' ) }.` + ); + } + + if ( dryRun ) { + log( '\nDry run — nothing written.' ); + return; + } + + if ( command === 'build' || command === 'capture' ) { + log( `\nCapturing to ${ config.screenshotsDir }` ); + const summary = await captureAll( targets, config, log ); + + log( + `\n${ summary.written } written, ${ summary.unchanged } unchanged, ` + + `${ summary.empty.length } empty, ${ summary.failed.length } failed.` + ); + + if ( summary.empty.length ) { + log( + `Rendered empty (need post context or a live query): ${ summary.empty.join( ', ' ) }` + ); + } + + if ( summary.failed.length ) { + log( `Failed: ${ summary.failed.map( ( item ) => item.basename ).join( ', ' ) }` ); + process.exitCode = 1; + } + } + + if ( command === 'build' || command === 'generate' ) { + const result = await generate( manifest, patterns, config ); + log( `\nWrote ${ result.index } and ${ result.pages } category pages.` ); + } +} + +main().catch( ( error ) => { + process.stderr.write( `\n${ error.message }\n` ); + process.exit( 1 ); +} ); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8663312 --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "humanmade/wp-pattern-library", + "description": "Serve a manifest and isolated previews of a WordPress site's registered block patterns, so a pattern library can be generated from them.", + "type": "wordpress-muplugin", + "license": "GPL-2.0-or-later", + "keywords": [ "wordpress", "block-patterns", "documentation", "pattern-library" ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "humanmade/coding-standards": "^1.2", + "php-stubs/wordpress-stubs": "^6.7", + "phpstan/phpstan": "^1.11", + "szepeviktor/phpstan-wordpress": "^1.3" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf", + "analyze": "phpstan analyse" + } +} diff --git a/docs/architecture/2026-07-21-extract-the-pattern-library-generator.md b/docs/architecture/2026-07-21-extract-the-pattern-library-generator.md new file mode 100644 index 0000000..221282c --- /dev/null +++ b/docs/architecture/2026-07-21-extract-the-pattern-library-generator.md @@ -0,0 +1,192 @@ +# Extract the pattern library generator into a distributable package + +Date: 2026-07-21 + +## Status + +Accepted + +## Context + +This package began as a set of project-local scripts that generated a browsable +Markdown pattern library from a WordPress theme's registered block patterns: + +- A front-end route, gated to local development, rendering a single registered + pattern in a chrome-free shell, plus a JSON manifest of every pattern to + capture. +- A Playwright script that read the manifest, visited each pattern at its + declared viewport width, forced animated blocks to their end state, waited for + lazy media, and cropped to the pattern. +- A standalone PHP script that regex-parsed pattern file headers and the theme's + `register_block_pattern_category()` calls, then wrote an index plus one + Markdown page per category. + +The result is genuinely useful — on the originating project, 170 patterns across +27 pages, each with a screenshot, description, keywords and viewport — and other +projects want it. This ADR records how it becomes an installable package. + +Three projects are in scope as initial consumers. They differ in ways that +constrain the design: + +| | Platform | Category slugs | Labels | Kinds | Patterns | +|---|----------|-------------------------------|------------------------------|-------|----------| +| A | Altis | unnamespaced (`hero`) | two kind-prefixes on labels | 3 | 170 | +| B | VIP | namespaced (`my-theme/hero`) | flat (`Hero`, `Cards`) | 0 | 212 | +| C | VIP | namespaced (`my-theme/hero`) | flat | 0 | 4 | + +Project A is where the scripts originated, and it is the outlier: it is the only +one with a multi-kind category taxonomy, and the only one on Altis. + +## Decision + +### The manifest is the entire data contract + +The generator no longer reads the filesystem. It consumes a JSON manifest served +by the WordPress site, built from `WP_Block_Patterns_Registry` and the registered +pattern-category list. + +This removes both of the original generator's brittle inputs — globbing a +hardcoded `patterns/` directory and regex-scraping +`register_block_pattern_category()` out of a hardcoded PHP file — neither of which +survives contact with a second project. It also means patterns registered from +plugins and mu-plugins are included for free, and the Node CLI needs no PHP +runtime, which is what lets it run on a plain Node container in CI. + +WordPress core already serves almost exactly this at +`/wp/v2/block-patterns/patterns` and `/wp/v2/block-patterns/categories`. We do not +use it, for three reasons: + +1. Its permission check is hardcoded to `current_user_can( 'edit_posts' )` + (`WP_REST_Block_Patterns_Controller::get_items_permissions_check()`) with no + filter, which is incompatible with the least-privilege capability below. +2. `get_items()` calls `_load_remote_block_patterns()`, making outbound + wordpress.org requests on every call and returning pattern-directory entries + mixed in with the project's own. +3. It ties the package to core's REST schema across WordPress versions. + +Our own route filters by namespace server-side, which matters on any site where +the registry also holds patterns from core or from plugins such as Gutenberg. + +### Access is an application password plus a dedicated capability + +Authentication uses WordPress's own application passwords rather than a bespoke +shared-secret token, so credentials are minted, listed and revoked through core UI +and WP-CLI, and the request runs through the normal authentication and capability +stack. + +Authorization uses a new capability, `view_pattern_library`, and nothing else. The +package ships a WP-CLI command that creates a `pattern_library` role holding +exactly `read` and `view_pattern_library` — no `edit_posts`, no upload, no +publish. A service account for taking screenshots should not be able to create +draft content, which is what reusing `edit_posts` would have required. + +Role creation is a WP-CLI command rather than an activation hook because the +package installs as an mu-plugin on both target platforms, and mu-plugins have no +activation hooks. On multisite the command must be run per site, since roles are +stored per blog. + +#### The route must opt in to application-password authentication + +`wp_authenticate_application_password()` returns early unless the request is an +API request — by default only `XMLRPC_REQUEST` or `REST_REQUEST` +(`wp-includes/user.php:398`). A front-end route authenticates as nobody, and, +because WordPress serves the unauthenticated page with a `200`, this fails +*silently*: the capture run produces plausible-looking logged-out screenshots. + +The preview route must therefore opt itself in via the +`application_password_is_api_request` filter, narrowly scoped to its own query +var. This widens application-password auth to exactly one front-end URL that +serves only already-registered pattern markup; the same credential already grants +the whole REST API, so the marginal exposure is nil. + +The route stays a front-end route rather than becoming a REST route because +Playwright needs to navigate to a real HTML page, and because a front-end request +gives patterns the same theme context they get in production. + +#### The 401 must carry a WWW-Authenticate challenge + +Opting in is necessary but not sufficient. A *browser* only attaches HTTP Basic +credentials to a navigation after receiving a `401` carrying `WWW-Authenticate`; +it never sends them preemptively. Playwright's `httpCredentials.send = 'always'` +does not help here — per its own API documentation that option "only applies to +the requests sent from corresponding APIRequestContext and does not affect +requests sent from the browser." + +WordPress does not send `WWW-Authenticate` on its own. Since the preview route +issues its own `401`, it sends the challenge itself, which is also what RFC 7235 +requires of any `401` response. Credentials are then handled by the browser's +native Basic-auth machinery and scoped to the origin, so they are never attached +to third-party requests a theme makes for fonts, analytics or CDN assets. The +capture context additionally pins `httpCredentials.origin` to the site. + +Both failure modes above are silent — WordPress serves the logged-out page with a +`200` — so the rendered shell carries a marker that only the authenticated path +emits, and the CLI asserts on it in the browser before starting the capture loop. +Verifying via the manifest fetch is not enough: Node's `fetch` and the browser +authenticate through entirely different paths, and in testing the first worked +while the second did not. + +### The category taxonomy is a function, not a schema + +The package's default output is **flat**: one Markdown page per registered pattern +category, plus an index. That is what most projects need, and it requires no +configuration beyond a namespace. + +A multi-kind taxonomy — for example, functional categories and section categories +distinguished by a label prefix, plus a catalog-only kind lifted out by slug — is +expressed as an optional `classify()` function in project config, mapping a +category to a kind, a directory and a display label. + +A declarative `labelPrefix` schema was considered and rejected: it describes one +project's convention and no other, and any second convention (slug namespaces, an +explicit allowlist, ordering rules) would need a new config key. A ten-line +function in one project's config is smaller than a schema in every project's. + +### One repository, two published packages + +| Artifact | Registry | Contents | +|-----------------------------------|-----------|-------------------------------------------| +| `humanmade/wp-pattern-library` | Packagist | Preview route, capability, WP-CLI command | +| `@humanmade/wp-pattern-library` | npm | CLI: fetch, capture, generate | +| `humanmade/wp-pattern-library@v1` | GitHub | Composite action wrapping the CLI | + +They ship from one repository so the manifest contract and its only consumer are +versioned together; the failure mode of two repositories is an endpoint change +silently breaking the CLI. + +The composer package declares `"type": "wordpress-muplugin"`. Both target +platforms already route that type by type rather than by name — Altis to +`content/mu-plugins/{$name}/`, VIP to `client-mu-plugins/{$name}/` — so it +installs to the right place on both with no `installer-paths` edit. Each platform +still needs one line to load it, since mu-plugins in subdirectories are not +auto-loaded. + +### Screenshots are WebP, and only written when changed + +On the originating project the committed screenshots are ~96 MB across 170 PNGs, +in a repository whose `.git` is already ~600 MB; another consumer has 212 +patterns. Regenerating everything on each run and committing the result is not +viable at that weight. + +Screenshots are therefore encoded as WebP, and a capture is only written when its +bytes differ from what is on disk. The second rule matters more than it looks: +capturing against production means query-loop patterns render real, changing +content, so a naive run rewrites dozens of images that are visually identical. + +## Consequences + +- Generating the library requires a reachable, authenticated site. This was + already true for screenshots; it is now also true for the Markdown. +- Authenticated requests bypass the page cache on both Altis and VIP, so a full + run is a few hundred uncached origin renders. The capture loop stays serial. +- Screenshots reflect *deployed* code. A pattern that exists only on the branch + being documented renders as "preview pending" until it ships. +- Staging environments behind their own HTTP Basic gate cannot be used as a + capture source: two `Authorization: Basic` headers cannot coexist. Production is + the default source. +- The originating project migrates off its local scripts. That migration is + verified by regenerating its docs byte-for-byte against the committed files, + which doubles as proof that the generalized engine plus a `classify()` function + reproduces the original special case exactly. +- Build order is C, then B, then A — the outlier last, so its assumptions do not + get baked into the engine. diff --git a/examples/refresh-pattern-library.yml b/examples/refresh-pattern-library.yml new file mode 100644 index 0000000..20f2e80 --- /dev/null +++ b/examples/refresh-pattern-library.yml @@ -0,0 +1,70 @@ +# Copy to .github/workflows/refresh-pattern-library.yml in a consuming project. +# +# Requires two repository secrets, created by `wp pattern-library setup` on the +# site you capture from: +# +# PATTERN_LIBRARY_WP_USER +# PATTERN_LIBRARY_WP_APP_PASSWORD +# +# ...and a repository variable PATTERN_LIBRARY_SITE holding the site origin. + +name: Refresh pattern library + +on: + workflow_dispatch: + inputs: + base_branch: + description: Branch to open the pull request against. + required: true + default: main + output_path: + description: Directory to write the pattern library into. + required: true + default: docs/pattern-library + site_url: + description: Site to capture from. Defaults to the PATTERN_LIBRARY_SITE variable. + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + refresh: + runs-on: ubuntu-latest + # Ships Chromium and its system dependencies preinstalled, which saves a + # minute or so of apt traffic on every run. + container: mcr.microsoft.com/playwright:v1.61.1-noble + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.base_branch }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build the pattern library + uses: humanmade/wp-pattern-library@v1 + with: + site-url: ${{ inputs.site_url || vars.PATTERN_LIBRARY_SITE }} + username: ${{ secrets.PATTERN_LIBRARY_WP_USER }} + app-password: ${{ secrets.PATTERN_LIBRARY_WP_APP_PASSWORD }} + output-path: ${{ inputs.output_path }} + + - name: Open a pull request + uses: peter-evans/create-pull-request@v6 + with: + base: ${{ inputs.base_branch }} + branch: pattern-library/refresh + title: Refresh the pattern library + commit-message: Refresh the pattern library + body: | + Regenerated from `${{ inputs.site_url || vars.PATTERN_LIBRARY_SITE }}`. + + Screenshots reflect the code deployed to that site, so patterns added + on this branch but not yet released will show as "preview pending". + add-paths: ${{ inputs.output_path }} + delete-branch: true diff --git a/inc/cli.php b/inc/cli.php new file mode 100644 index 0000000..06335df --- /dev/null +++ b/inc/cli.php @@ -0,0 +1,163 @@ +] + * : Also create this user with the pattern library role, and print an + * application password for it. Grants the role if the user already exists. + * Named `--login` rather than `--user` because WP-CLI reserves `--user` + * globally for setting the acting user. + * + * [--email=] + * : Email for a user created via --login. Defaults to @. + * + * ## EXAMPLES + * + * wp pattern-library setup + * wp pattern-library setup --login=pattern-library-bot + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + */ +function setup( array $args, array $assoc_args ): void { + remove_role( ROLE ); + $role = add_role( + ROLE, + 'Pattern Library', + [ + 'read' => true, + CAPABILITY => true, + ] + ); + + if ( null === $role ) { + WP_CLI::error( sprintf( 'Could not create the %s role.', ROLE ) ); + } + + WP_CLI::success( sprintf( 'Created the %s role with the %s capability.', ROLE, CAPABILITY ) ); + + $login = (string) ( $assoc_args['login'] ?? '' ); + if ( '' === $login ) { + WP_CLI::log( 'Grant an existing user access with: wp pattern-library grant ' ); + return; + } + + $user = get_user_by( 'login', $login ); + + if ( ! $user ) { + $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); + $email = (string) ( $assoc_args['email'] ?? $login . '@' . $host ); + + $user_id = wp_insert_user( + [ + 'user_login' => $login, + 'user_email' => $email, + 'user_pass' => wp_generate_password( 32, true, true ), + 'role' => ROLE, + ] + ); + + if ( is_wp_error( $user_id ) ) { + WP_CLI::error( $user_id->get_error_message() ); + } + + $user = get_user_by( 'id', $user_id ); + WP_CLI::success( sprintf( 'Created user %s (%d).', $login, (int) $user_id ) ); + } else { + $user->add_role( ROLE ); + WP_CLI::success( sprintf( 'Granted the %s role to the existing user %s.', ROLE, $login ) ); + } + + print_application_password( $user ); +} + +/** + * Grant the pattern library capability to an existing user. + * + * ## OPTIONS + * + * + * : User login, email, or ID. + * + * ## EXAMPLES + * + * wp pattern-library grant editor@example.com + * + * @param array $args Positional arguments. + */ +function grant( array $args ): void { + $user = get_user_by( 'login', $args[0] ) + ?: get_user_by( 'email', $args[0] ) + ?: get_user_by( 'id', (int) $args[0] ); + + if ( ! $user ) { + WP_CLI::error( sprintf( 'No such user: %s', $args[0] ) ); + } + + if ( ! wp_roles()->is_role( ROLE ) ) { + WP_CLI::error( sprintf( 'The %s role does not exist on this site. Run `wp pattern-library setup` first.', ROLE ) ); + } + + $user->add_role( ROLE ); + WP_CLI::success( sprintf( 'Granted the %s role to %s.', ROLE, $user->user_login ) ); + + print_application_password( $user ); +} + +/** + * Mint and print an application password for a user. + * + * @param \WP_User $user User to create the password for. + */ +function print_application_password( \WP_User $user ): void { + if ( ! wp_is_application_passwords_available_for_user( $user ) ) { + WP_CLI::warning( 'Application passwords are unavailable for this user. They require HTTPS, or WP_ENVIRONMENT_TYPE=local.' ); + return; + } + + $created = \WP_Application_Passwords::create_new_application_password( $user->ID, [ 'name' => 'Pattern Library' ] ); + + if ( is_wp_error( $created ) ) { + WP_CLI::error( $created->get_error_message() ); + } + + WP_CLI::log( '' ); + WP_CLI::log( 'Store these as CI secrets — the password is not recoverable:' ); + WP_CLI::log( ' PATTERN_LIBRARY_WP_USER=' . $user->user_login ); + WP_CLI::log( ' PATTERN_LIBRARY_WP_APP_PASSWORD=' . $created[0] ); +} diff --git a/inc/manifest.php b/inc/manifest.php new file mode 100644 index 0000000..580d26d --- /dev/null +++ b/inc/manifest.php @@ -0,0 +1,114 @@ +get_all_registered() as $pattern ) { + if ( ! is_in_namespace( (string) $pattern['name'], $namespaces ) ) { + continue; + } + $patterns[] = prepare_pattern( $pattern ); + } + + usort( $patterns, static fn( array $a, array $b ): int => strcmp( $a['name'], $b['name'] ) ); + + $categories = []; + foreach ( \WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered() as $category ) { + $categories[] = [ + 'slug' => (string) $category['name'], + 'label' => (string) ( $category['label'] ?? $category['name'] ), + ]; + } + + wp_send_json( + [ + 'manifestVersion' => MANIFEST_VERSION, + 'site' => [ + 'name' => get_bloginfo( 'name' ), + 'url' => home_url( '/' ), + ], + 'categories' => $categories, + 'patterns' => $patterns, + ] + ); +} + +/** + * Whether a pattern name falls within one of the configured namespaces. + * + * @param string $name Registered pattern name, e.g. `my-theme/hero`. + * @param string[] $namespaces Allowed prefixes. Empty means allow everything. + */ +function is_in_namespace( string $name, array $namespaces ): bool { + if ( empty( $namespaces ) ) { + return true; + } + + foreach ( $namespaces as $namespace ) { + if ( str_starts_with( $name, (string) $namespace ) ) { + return true; + } + } + + return false; +} + +/** + * Reduce a registered pattern to the fields the generator needs. + * + * Deliberately omits `content`: the library documents patterns visually, and + * shipping every pattern's markup would make the manifest an order of magnitude + * larger for no benefit. + * + * @param array $pattern Registered pattern, as stored by the registry. + * @return array + */ +function prepare_pattern( array $pattern ): array { + $name = (string) $pattern['name']; + + return [ + 'name' => $name, + 'basename' => basename_for( $name ), + 'title' => (string) ( $pattern['title'] ?? $name ), + 'description' => (string) ( $pattern['description'] ?? '' ), + 'categories' => array_values( array_map( 'strval', (array) ( $pattern['categories'] ?? [] ) ) ), + 'keywords' => array_values( array_map( 'strval', (array) ( $pattern['keywords'] ?? [] ) ) ), + 'blockTypes' => array_values( array_map( 'strval', (array) ( $pattern['blockTypes'] ?? [] ) ) ), + 'postTypes' => array_values( array_map( 'strval', (array) ( $pattern['postTypes'] ?? [] ) ) ), + 'viewportWidth' => (int) ( $pattern['viewportWidth'] ?? 0 ), + 'inserter' => (bool) ( $pattern['inserter'] ?? true ), + 'source' => (string) ( $pattern['source'] ?? '' ), + ]; +} + +/** + * Strip the namespace from a pattern name, for use as a filename. + * + * `my-theme/hero` becomes `hero`. Names without a namespace are returned as-is. + * + * @param string $name Registered pattern name. + */ +function basename_for( string $name ): string { + $position = strpos( $name, '/' ); + + return false === $position ? $name : substr( $name, $position + 1 ); +} diff --git a/inc/namespace.php b/inc/namespace.php new file mode 100644 index 0000000..96e4647 --- /dev/null +++ b/inc/namespace.php @@ -0,0 +1,150 @@ + + +> + + + + + + + <?php echo esc_html( (string) ( $pattern['title'] ?? $pattern['name'] ) ); ?> — Pattern Preview + + + + +
+
+ +
+
+ + + + get_registered( $slug ); + if ( null !== $pattern ) { + return $pattern; + } + + if ( str_contains( $slug, '/' ) ) { + return null; + } + + foreach ( $registry->get_all_registered() as $candidate ) { + if ( basename_for( (string) $candidate['name'] ) === $slug ) { + return $candidate; + } + } + + return null; +} + +/** + * Optionally wrap a pattern in a one-item query loop. + * + * Patterns designed to sit inside `core/post-template` — card item templates — + * render as empty in isolation, because their post-title/post-content blocks + * resolve against whatever the global query happens to be. Passing a post type + * gives them a real post to bind to. + * + * @param array $pattern Registered pattern. + * @param string $pattern_content Raw pattern markup. + */ +function with_optional_post_context( array $pattern, string $pattern_content ): string { + $block_types = (array) ( $pattern['blockTypes'] ?? [] ); + if ( ! in_array( 'core/post-template', $block_types, true ) ) { + return $pattern_content; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route, authenticated by the caller. + if ( ! isset( $_GET[ POST_TYPE_QUERY_VAR ] ) ) { + return $pattern_content; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route, authenticated by the caller. + $post_type = sanitize_key( wp_unslash( (string) $_GET[ POST_TYPE_QUERY_VAR ] ) ); + if ( '' === $post_type || ! post_type_exists( $post_type ) ) { + return $pattern_content; + } + + $query = [ + 'queryId' => 0, + 'query' => [ + 'perPage' => 1, + 'pages' => 0, + 'offset' => 0, + 'postType' => $post_type, + 'order' => 'desc', + 'orderBy' => 'date', + 'inherit' => false, + ], + ]; + + return '' + . '
' + . '' + . $pattern_content + . '' + . '
' + . ''; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9597f7d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,597 @@ +{ + "name": "@humanmade/wp-pattern-library", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@humanmade/wp-pattern-library", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "playwright": "^1.49.0", + "sharp": "^0.33.0" + }, + "bin": { + "pattern-library": "bin/pattern-library.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ef2eb2 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@humanmade/wp-pattern-library", + "version": "0.1.0", + "description": "Generate a Markdown pattern library, with screenshots, from a WordPress site's registered block patterns.", + "license": "GPL-2.0-or-later", + "type": "module", + "engines": { + "node": ">=20" + }, + "bin": { + "pattern-library": "bin/pattern-library.mjs" + }, + "files": [ + "bin", + "src", + "action.yml" + ], + "keywords": [ + "wordpress", + "block-patterns", + "documentation", + "pattern-library", + "playwright" + ], + "dependencies": { + "playwright": "^1.49.0", + "sharp": "^0.33.0" + }, + "allowScripts": { + "sharp@0.33.5": true + } +} diff --git a/plugin.php b/plugin.php new file mode 100644 index 0000000..0694353 --- /dev/null +++ b/plugin.php @@ -0,0 +1,29 @@ + { + document + .querySelectorAll( '[data-aos]' ) + .forEach( ( el ) => el.classList.add( 'aos-animate' ) ); + + // Walk the full height so intersection- and scroll-triggered content (and + // native lazy images) start loading, then return to the top. + const step = window.innerHeight || 800; + for ( let y = 0; y <= document.body.scrollHeight; y += step ) { + window.scrollTo( 0, y ); + await new Promise( ( r ) => setTimeout( r, 40 ) ); + } + window.scrollTo( 0, 0 ); + + // Force still-lazy images to fetch, then await decode so the capture never + // lands on an undecoded (blank) image. + const images = Array.from( document.images ); + images.forEach( ( image ) => { + if ( image.loading === 'lazy' ) { + image.loading = 'eager'; + } + } ); + await Promise.all( + images.map( ( image ) => + image.complete ? Promise.resolve() : image.decode().catch( () => {} ) + ) + ); + } ); +} + +/** + * Confirm the browser context is authenticating, before capturing anything. + * + * Playwright and Node's fetch authenticate independently, so a working manifest + * fetch does not prove the *browser* is authenticated. Probe the manifest URL + * with the browser: it returns 200 when authenticated and 401 when not, and — + * unlike rendering a pattern — cannot be brought down by one broken pattern, so a + * genuine render failure is never misreported as an auth failure. + * + * @param {import('playwright').Page} page Page from the capture context. + * @param {Object} config Resolved configuration. + */ +async function assertAuthenticated( page, config ) { + const url = previewUrl( config, '__manifest' ); + const response = await page.goto( url, { waitUntil: 'domcontentloaded' } ); + const status = response?.status(); + + if ( status !== 200 ) { + throw new Error( + `The browser is not authenticating against ${ url } (HTTP ${ status }). ` + + 'Browsers only attach credentials after a 401 carrying WWW-Authenticate, which the preview ' + + 'route sends — check that the site runs a wp-pattern-library new enough to send it, and that ' + + 'the credentials are valid for this origin.' + ); + } +} + +/** + * Encode a PNG buffer to the configured output format. + * + * @param {Buffer} buffer PNG bytes from Playwright. + * @param {Object} config Resolved configuration. + * @return {Promise} Encoded image bytes. + */ +async function encode( buffer, config ) { + if ( config.imageFormat === 'png' ) { + return buffer; + } + + return sharp( buffer ) + [ config.imageFormat ]( { quality: config.imageQuality } ) + .toBuffer(); +} + +/** + * Write an image only when its bytes differ from what is on disk. + * + * Captures run against a live site, so query-loop patterns render real content + * that changes underneath them. Rewriting visually identical files would add + * megabytes of noise to every pull request. + * + * @param {string} path Destination path. + * @param {Buffer} bytes Encoded image. + * @return {Promise} Whether the file was written. + */ +async function writeIfChanged( path, bytes ) { + const current = await readFile( path ).catch( () => null ); + + if ( current && current.equals( bytes ) ) { + return false; + } + + await writeFile( path, bytes ); + + return true; +} + +/** + * Capture every pattern, returning a per-pattern result. + * + * @param {Array} patterns Patterns to capture. + * @param {Object} config Resolved configuration. + * @param {Function} log Progress reporter. + * @return {Promise<{written: number, unchanged: number, empty: string[], failed: Array}>} Summary. + */ +export async function captureAll( patterns, config, log = () => {} ) { + await mkdir( config.screenshotsDir, { recursive: true } ); + + const browser = await chromium.launch(); + const context = await browser.newContext( { + ignoreHTTPSErrors: true, + // 1x keeps committed images small; the rendered width is already ample. + deviceScaleFactor: 1, + // Themes commonly gate scroll-driven and entrance animations behind + // prefers-reduced-motion. Emulating "reduce" collapses each to its rest + // state, so a still capture shows the finished design rather than a + // half-played keyframe. + reducedMotion: 'reduce', + // Credentials are attached in response to the WWW-Authenticate challenge the + // preview route sends with its 401. `send: 'always'` is deliberately not + // used: it only affects Playwright's API request context, not page + // navigation. Scoped to the site's origin so the password is never attached + // to third-party requests a theme makes (fonts, analytics, CDNs). + httpCredentials: { + username: config.username, + password: config.appPassword, + origin: new URL( config.siteUrl ).origin, + }, + } ); + + const page = await context.newPage(); + const summary = { written: 0, unchanged: 0, empty: [], failed: [] }; + + try { + if ( patterns.length ) { + await assertAuthenticated( page, config ); + } + + for ( const pattern of patterns ) { + const filename = `${ pattern.basename }.${ config.imageFormat }`; + const destination = join( config.screenshotsDir, filename ); + const width = pattern.viewportWidth > 0 ? pattern.viewportWidth : config.defaultViewport; + const postType = config.postTypeContext[ pattern.basename ] ?? ''; + + try { + await page.setViewportSize( { width, height: 1000 } ); + const response = await page.goto( previewUrl( config, pattern.name, postType ), { + waitUntil: 'networkidle', + timeout: config.captureTimeout, + } ); + + const status = response?.status(); + if ( status && status >= 400 ) { + // The request reached authenticated render code — auth is fine — but + // the pattern itself failed to render. Almost always a block or + // binding in the pattern that assumes a post context it lacks in + // isolation. Surface it as this pattern's failure and move on. + throw new Error( + `HTTP ${ status } rendering the pattern (likely a block that needs post context; ` + + 'try postTypeContext, or exclude it).' + ); + } + await page.evaluate( () => document.fonts && document.fonts.ready ); + await prepareForCapture( page ); + await page.waitForTimeout( 300 ); + + const target = page.locator( '#pattern-library-preview' ); + const box = await target.boundingBox(); + const isEmpty = ! box || box.height < 8; + + if ( isEmpty ) { + // A dynamic pattern that renders nothing in isolation, e.g. a query + // loop with no matching posts. Capture the viewport so the gap is + // visible in review, and flag it. + summary.empty.push( pattern.basename ); + } + + const shot = isEmpty + ? await page.screenshot() + : await target.screenshot(); + + const changed = await writeIfChanged( destination, await encode( shot, config ) ); + summary[ changed ? 'written' : 'unchanged' ] += 1; + + log( + ` ${ changed ? 'write ' : 'same ' } ${ pattern.basename } (${ width }px, ${ + box ? Math.round( box.height ) : '?' + }px tall)${ isEmpty ? ' — EMPTY' : '' }` + ); + } catch ( error ) { + summary.failed.push( { basename: pattern.basename, error: error.message } ); + log( ` FAIL ${ pattern.basename } — ${ error.message }` ); + } + } + } finally { + await browser.close(); + } + + return summary; +} diff --git a/src/config.mjs b/src/config.mjs new file mode 100644 index 0000000..651c053 --- /dev/null +++ b/src/config.mjs @@ -0,0 +1,146 @@ +/** + * Configuration loading and defaults. + */ + +import { pathToFileURL } from 'node:url'; +import { access } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { resolve, isAbsolute, join } from 'node:path'; + +const CONFIG_FILENAMES = [ + 'pattern-library.config.js', + 'pattern-library.config.mjs', +]; + +/** + * Default classifier: every category is its own page, in the output root. + * + * Projects with a multi-kind taxonomy override this in their config. See the + * README for an example that splits categories by label prefix. + * + * @param {{slug: string, label: string}} category Registered pattern category. + * @return {{kind: string, dir: string, label: string}} Placement for the category. + */ +export const flatClassify = ( { label } ) => ( { + kind: 'category', + dir: '.', + label, +} ); + +const DEFAULTS = { + siteUrl: '', + username: '', + appPassword: '', + namespaces: [], + title: 'Pattern Library', + outputDir: 'docs/pattern-library', + indexFile: null, // Defaults to /README.md. + screenshotsDir: null, // Defaults to /screenshots. + imageFormat: 'webp', + imageQuality: 80, + defaultViewport: 1440, + captureTimeout: 30000, + exclude: { + inserterHidden: true, + postTypes: [ 'wp_template', 'wp_template_part' ], + patterns: [], + }, + postTypeContext: {}, + classify: flatClassify, +}; + +const exists = ( path ) => + access( path, constants.F_OK ) + .then( () => true ) + .catch( () => false ); + +const absolute = ( path, cwd ) => ( isAbsolute( path ) ? path : resolve( cwd, path ) ); + +/** + * Load `pattern-library.config.js`, layered over defaults and environment. + * + * Precedence, lowest first: defaults, config file, environment, CLI overrides. + * Credentials come from the environment only — they should never be committed to + * a config file. + * + * @param {string} cwd Directory to resolve the config and output paths from. + * @param {Object} overrides Values from CLI flags. + * @return {Promise} Resolved configuration. + */ +export async function loadConfig( cwd = process.cwd(), overrides = {} ) { + let fileConfig = {}; + let configPath = null; + + for ( const filename of CONFIG_FILENAMES ) { + const candidate = resolve( cwd, filename ); + if ( await exists( candidate ) ) { + configPath = candidate; + const loaded = await import( pathToFileURL( candidate ).href ); + fileConfig = loaded.default ?? loaded; + break; + } + } + + const fromEnv = clean( { + siteUrl: process.env.PATTERN_LIBRARY_SITE, + username: process.env.PATTERN_LIBRARY_WP_USER, + appPassword: process.env.PATTERN_LIBRARY_WP_APP_PASSWORD, + } ); + + const config = { + ...DEFAULTS, + ...fileConfig, + exclude: { ...DEFAULTS.exclude, ...( fileConfig.exclude ?? {} ) }, + ...fromEnv, + ...clean( overrides ), + configPath, + }; + + config.siteUrl = config.siteUrl.replace( /\/$/, '' ); + config.outputDir = absolute( config.outputDir, cwd ); + config.screenshotsDir = config.screenshotsDir + ? absolute( config.screenshotsDir, cwd ) + : join( config.outputDir, 'screenshots' ); + config.indexFile = config.indexFile + ? absolute( config.indexFile, cwd ) + : join( config.outputDir, 'README.md' ); + + return config; +} + +/** + * Drop keys whose value is undefined or empty, so they do not mask a lower layer. + * + * @param {Object} object Candidate overrides. + * @return {Object} Only the keys that carry a value. + */ +function clean( object ) { + return Object.fromEntries( + Object.entries( object ).filter( + ( [ , value ] ) => value !== undefined && value !== null && value !== '' + ) + ); +} + +/** + * Throw a helpful error when required configuration is missing. + * + * @param {Object} config Resolved configuration. + * @param {string[]} keys Required config keys. + */ +export function requireConfig( config, keys ) { + const missing = keys.filter( ( key ) => ! config[ key ] ); + + if ( missing.length ) { + const hints = { + siteUrl: '--site or PATTERN_LIBRARY_SITE', + username: 'PATTERN_LIBRARY_WP_USER', + appPassword: 'PATTERN_LIBRARY_WP_APP_PASSWORD', + }; + throw new Error( + `Missing required configuration: ${ missing + .map( ( key ) => `${ key } (set via ${ hints[ key ] ?? key })` ) + .join( ', ' ) }` + ); + } +} diff --git a/src/manifest.mjs b/src/manifest.mjs new file mode 100644 index 0000000..ec3e33b --- /dev/null +++ b/src/manifest.mjs @@ -0,0 +1,151 @@ +/** + * Fetch and filter the site's pattern manifest. + */ + +const SUPPORTED_MANIFEST_VERSION = 1; + +/** + * Build the URL of a preview route. + * + * Targets `/index.php` rather than `/`, because a site may have proxy or rewrite + * rules that intercept the root path before WordPress sees the query var. + * + * @param {Object} config Resolved configuration. + * @param {string} slug Pattern name, or `__manifest`. + * @param {string} postType Optional post type to give the pattern query context. + * @return {string} Absolute URL. + */ +export function previewUrl( config, slug, postType = '' ) { + const params = new URLSearchParams( { 'pattern-library-preview': slug } ); + + if ( postType ) { + params.set( 'pattern-library-post-type', postType ); + } + + return `${ config.siteUrl }/index.php?${ params.toString() }`; +} + +/** + * HTTP Basic credentials for the application password. + * + * @param {Object} config Resolved configuration. + * @return {string} Value for an Authorization header. + */ +export function authHeader( config ) { + const encoded = Buffer.from( + `${ config.username }:${ config.appPassword }` + ).toString( 'base64' ); + + return `Basic ${ encoded }`; +} + +/** + * Fetch the manifest, and fail loudly on the ways this usually goes wrong. + * + * @param {Object} config Resolved configuration. + * @return {Promise} Parsed manifest. + */ +export async function fetchManifest( config ) { + const url = previewUrl( config, '__manifest' ); + const response = await fetch( url, { + headers: { Authorization: authHeader( config ) }, + redirect: 'follow', + } ); + + const body = await response.text(); + + if ( response.status === 401 || response.status === 403 ) { + throw new Error( + `Authentication failed (HTTP ${ response.status }) for ${ config.username } at ${ url }. ` + + 'Check the application password, and that the user holds the view_pattern_library capability ' + + 'on this site (roles are per-site on multisite).' + ); + } + + if ( ! response.ok ) { + throw new Error( `Manifest request failed: HTTP ${ response.status } from ${ url }` ); + } + + let manifest; + try { + manifest = JSON.parse( body ); + } catch { + throw new Error( + `Expected JSON from ${ url } but got ${ response.headers.get( 'content-type' ) ?? 'unknown' }. ` + + `Response starts: ${ JSON.stringify( body.slice( 0, 200 ) ) }. ` + + 'Is the wp-pattern-library mu-plugin loaded on this site?' + ); + } + + if ( manifest.manifestVersion !== SUPPORTED_MANIFEST_VERSION ) { + throw new Error( + `Manifest version ${ manifest.manifestVersion } is not supported (expected ` + + `${ SUPPORTED_MANIFEST_VERSION }). Update the wp-pattern-library package on the site or in this project.` + ); + } + + return manifest; +} + +/** + * Apply namespace and exclusion rules to the manifest's patterns. + * + * Namespace filtering also happens server-side; repeating it here lets a project + * narrow further without redeploying, and keeps the CLI honest when pointed at a + * site configured differently. + * + * @param {Object} manifest Parsed manifest. + * @param {Object} config Resolved configuration. + * @return {{patterns: Array, skipped: Array}} Kept and dropped patterns. + */ +export function filterPatterns( manifest, config ) { + const { namespaces, exclude } = config; + const patterns = []; + const skipped = []; + + for ( const pattern of manifest.patterns ) { + const reason = exclusionReason( pattern, namespaces, exclude ); + + if ( reason ) { + skipped.push( { ...pattern, reason } ); + } else { + patterns.push( pattern ); + } + } + + return { patterns, skipped }; +} + +/** + * Why a pattern should be left out of the library, if it should. + * + * @param {Object} pattern Manifest pattern entry. + * @param {string[]} namespaces Allowed name prefixes; empty allows all. + * @param {Object} exclude Exclusion rules. + * @return {string|null} Human-readable reason, or null to keep the pattern. + */ +function exclusionReason( pattern, namespaces, exclude ) { + if ( + namespaces.length && + ! namespaces.some( ( namespace ) => pattern.name.startsWith( namespace ) ) + ) { + return 'outside configured namespaces'; + } + + if ( exclude.patterns?.includes( pattern.name ) ) { + return 'explicitly excluded'; + } + + if ( exclude.inserterHidden && pattern.inserter === false ) { + return 'hidden from the inserter'; + } + + const blockedPostTypes = ( exclude.postTypes ?? [] ).filter( ( postType ) => + pattern.postTypes?.includes( postType ) + ); + if ( blockedPostTypes.length ) { + return `scoped to ${ blockedPostTypes.join( ', ' ) }`; + } + + return null; +} diff --git a/src/markdown.mjs b/src/markdown.mjs new file mode 100644 index 0000000..0c3fe6c --- /dev/null +++ b/src/markdown.mjs @@ -0,0 +1,319 @@ +/** + * Render the manifest as a Markdown pattern library. + */ + +import { mkdir, writeFile, access } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +const exists = ( path ) => + access( path, constants.F_OK ) + .then( () => true ) + .catch( () => false ); + +/** + * Build a GitHub-compatible heading anchor from heading text. + * + * @param {string} text Heading text. + * @return {string} Anchor without the leading hash. + */ +export function anchor( text ) { + return text + .toLowerCase() + .replace( /[^a-z0-9 -]+/g, '' ) + .trim() + .replace( / /g, '-' ); +} + +/** + * Pluralize a pattern count. + * + * @param {number} count Number of patterns. + * @return {string} e.g. "1 pattern." or "12 patterns." + */ +const patternCount = ( count ) => `${ count } pattern${ count === 1 ? '' : 's' }.`; + +/** + * Group patterns into pages, one per registered category the classifier keeps. + * + * Categories keep the site's registration order, which is the order a project + * chose to declare them in — more meaningful than alphabetical. + * + * @param {Object} manifest Parsed manifest. + * @param {Array} patterns Patterns that survived filtering. + * @param {Object} config Resolved configuration. + * @return {Array} Page descriptors. + */ +export function buildPages( manifest, patterns, config ) { + const byCategory = new Map(); + + for ( const pattern of patterns ) { + for ( const slug of pattern.categories ) { + if ( ! byCategory.has( slug ) ) { + byCategory.set( slug, [] ); + } + byCategory.get( slug ).push( pattern ); + } + } + + const pages = []; + + for ( const category of manifest.categories ) { + const members = byCategory.get( category.slug ); + + if ( ! members?.length ) { + continue; + } + + const placement = config.classify( category ); + + if ( ! placement ) { + continue; // A classifier may drop a category from the library entirely. + } + + pages.push( { + slug: category.slug, + // Category slugs are commonly namespaced (`my-theme/hero`). Flatten the + // separator so the slug cannot silently write into a subdirectory and + // bypass the placement the classifier asked for. + filename: category.slug.replace( /\//g, '-' ), + label: placement.label ?? category.label, + kind: placement.kind ?? 'category', + dir: placement.dir ?? '.', + leadsIn: placement.leadsIn ?? null, + description: placement.description ?? '', + patterns: [ ...members ].sort( ( a, b ) => a.title.localeCompare( b.title ) ), + } ); + } + + return pages; +} + +/** + * Categories whose patterns lead the pages of another kind. + * + * A project may mark a cross-cutting category — say, whole-page references — as + * leading the pages of a given kind, so those patterns appear in a highlighted + * section at the top rather than mixed into the list. + * + * @param {Array} pages Page descriptors. + * @return {Map>} Target kind to the pattern names that lead it. + */ +function buildLeaders( pages ) { + const leaders = new Map(); + + for ( const page of pages.filter( ( candidate ) => candidate.leadsIn ) ) { + if ( ! leaders.has( page.leadsIn ) ) { + leaders.set( page.leadsIn, new Set() ); + } + const names = leaders.get( page.leadsIn ); + page.patterns.forEach( ( pattern ) => names.add( pattern.name ) ); + } + + return leaders; +} + +/** + * Render one pattern as a Markdown section. + * + * @param {Object} pattern Manifest pattern entry. + * @param {string} shotsRelPath Screenshot directory, relative to the page. + * @param {Object} config Resolved configuration. + * @param {Set} haveShots Basenames with a screenshot on disk. + * @param {Map} labels Category slug to display label. + * @return {string} Markdown. + */ +function renderPattern( pattern, shotsRelPath, config, haveShots, labels ) { + const lines = [ `### ${ pattern.title }`, '', `\`${ pattern.name }\``, '' ]; + + if ( haveShots.has( pattern.basename ) ) { + const file = `${ pattern.basename }.${ config.imageFormat }`; + lines.push( `![${ pattern.title }](${ shotsRelPath }/${ file })`, '' ); + } else { + lines.push( '_Preview pending._', '' ); + } + + if ( pattern.description ) { + lines.push( pattern.description, '' ); + } + + const meta = []; + const categories = pattern.categories + .map( ( slug ) => labels.get( slug ) ) + .filter( Boolean ); + + if ( categories.length ) { + meta.push( `**Categories:** ${ categories.join( ', ' ) }` ); + } + if ( pattern.keywords.length ) { + meta.push( `**Keywords:** ${ pattern.keywords.join( ', ' ) }` ); + } + if ( pattern.viewportWidth > 0 ) { + meta.push( `**Viewport:** ${ pattern.viewportWidth }px` ); + } + if ( pattern.inserter === false ) { + meta.push( '**Inserter:** hidden' ); + } + if ( meta.length ) { + lines.push( meta.join( ' \n' ), '' ); + } + + return lines.join( '\n' ); +} + +/** + * Write every category page and the index. + * + * @param {Object} manifest Parsed manifest. + * @param {Array} patterns Patterns that survived filtering. + * @param {Object} config Resolved configuration. + * @return {Promise<{pages: number, index: string}>} Summary. + */ +export async function generate( manifest, patterns, config ) { + const pages = buildPages( manifest, patterns, config ); + const leaders = buildLeaders( pages ); + const labels = new Map( pages.map( ( page ) => [ page.slug, page.label ] ) ); + + const haveShots = new Set(); + await Promise.all( + patterns.map( async ( pattern ) => { + const file = join( + config.screenshotsDir, + `${ pattern.basename }.${ config.imageFormat }` + ); + if ( await exists( file ) ) { + haveShots.add( pattern.basename ); + } + } ) + ); + + for ( const page of pages ) { + const path = resolve( config.outputDir, page.dir, `${ page.filename }.md` ); + await mkdir( dirname( path ), { recursive: true } ); + + const shotsRelPath = toPosix( + relative( dirname( path ), config.screenshotsDir ) + ); + const indexRelPath = toPosix( relative( dirname( path ), config.indexFile ) ); + + const leadNames = leaders.get( page.kind ) ?? new Set(); + const lead = page.patterns.filter( ( pattern ) => leadNames.has( pattern.name ) ); + const rest = page.patterns.filter( ( pattern ) => ! leadNames.has( pattern.name ) ); + + const out = [ + `# ${ page.label }`, + '', + `[← ${ config.title }](${ indexRelPath })`, + '', + patternCount( page.patterns.length ), + '', + ]; + + if ( page.description ) { + out.push( page.description, '' ); + } + + out.push( '## Contents', '' ); + out.push( + ...[ ...lead, ...rest ].map( + ( pattern ) => `- [${ pattern.title }](#${ anchor( pattern.title ) })` + ), + '' + ); + + const section = ( list ) => + list.map( ( pattern ) => + renderPattern( pattern, shotsRelPath, config, haveShots, labels ) + ); + + if ( lead.length ) { + out.push( '## Full page references', '' ); + out.push( ...section( lead ) ); + out.push( '## Sections', '' ); + } + + out.push( ...section( rest ) ); + + await writeFile( path, `${ out.join( '\n' ).trimEnd() }\n` ); + } + + await writeIndex( pages, patterns, config ); + + return { pages: pages.length, index: config.indexFile }; +} + +/** + * Write the index page, grouping the category pages by kind. + * + * @param {Array} pages Page descriptors. + * @param {Array} patterns Patterns that survived filtering. + * @param {Object} config Resolved configuration. + */ +async function writeIndex( pages, patterns, config ) { + await mkdir( dirname( config.indexFile ), { recursive: true } ); + + const kinds = [ ...new Set( pages.map( ( page ) => page.kind ) ) ]; + const uncategorized = patterns.filter( ( pattern ) => + ! pattern.categories.some( ( slug ) => + pages.some( ( page ) => page.slug === slug ) + ) + ); + + const out = [ + `# ${ config.title }`, + '', + '> Generated by [@humanmade/wp-pattern-library](https://github.com/humanmade/wp-pattern-library).', + '> Do not edit by hand.', + '', + `${ patterns.length } patterns across ${ pages.length } categories.`, + '', + ]; + + for ( const kind of kinds ) { + const group = pages.filter( ( page ) => page.kind === kind ); + + if ( kinds.length > 1 ) { + out.push( `## ${ titleCase( kind ) }`, '' ); + } + + for ( const page of group ) { + const rel = toPosix( + relative( + dirname( config.indexFile ), + resolve( config.outputDir, page.dir, `${ page.filename }.md` ) + ) + ); + out.push( `- [${ page.label }](${ rel }) (${ page.patterns.length })` ); + } + out.push( '' ); + } + + if ( uncategorized.length ) { + out.push( + '## Uncategorized', + '', + '_These patterns carry no category that appears above._', + '' + ); + out.push( ...uncategorized.map( ( pattern ) => `- \`${ pattern.name }\`` ), '' ); + } + + await writeFile( config.indexFile, `${ out.join( '\n' ).trimEnd() }\n` ); +} + +/** + * Normalize a path for Markdown links on any platform. + * + * @param {string} path Relative path. + * @return {string} Path with forward slashes. + */ +const toPosix = ( path ) => path.split( /[\\/]/ ).join( '/' ); + +/** + * Capitalize a kind name for an index heading. + * + * @param {string} value Kind identifier. + * @return {string} Display heading. + */ +const titleCase = ( value ) => + value.charAt( 0 ).toUpperCase() + value.slice( 1 ).replace( /-/g, ' ' ) + 's';