# Document a client's WordPress site

In this tutorial you will build a page of a client handbook: two annotated screenshots showing how to start a post and how to add an image to it. The site runs on your own machine, shotlist signs into it once and reuses that session, and the second screenshot reaches inside the block editor's canvas.

It is the first tutorial where the pages are behind a login, so it assumes you have finished [Your first screenshot](/docs/tutorials/first-screenshot/) and that recipes, marks and callouts are familiar.

## Before you begin

You need Node.js 20 or later, and [WordPress.com Studio](https://developer.wordpress.com/studio/), which runs WordPress locally with no server to configure.

Written and run against WordPress 7.0.3. The editor's markup moves between releases, so see [when WordPress changes](#when-wordpress-changes) below if a query stops matching.

The choice of using WordPress is deliberate: it is a complex application with a JavaScript editor, and the tutorial shows how to reach into that editor's canvas. The same techniques work on any site, so you can follow along on a different application if you prefer.

## Step 1: create the site

In Studio, choose **Add site** and name it `shotlist`. Studio installs WordPress and starts it. Two things from its panel are what the rest of this needs:

- the site's address, written here as `http://localhost:8883`
- the administrator password, which Studio generates and shows you

Do not give it a custom domain. Studio offers names like `shotlist.wp.local`, and macOS resolves anything ending in `.local` through multicast DNS — which takes about five seconds per lookup even with the name in `/etc/hosts`. A browser resolves per connection, so an admin page pulling a hundred assets never finishes loading. The `localhost` address Studio gives you by default has none of that.

## Step 2: start the handbook project

The handbook is its own project. You do not want a `node_modules` folder inside a client's WordPress install:

```
mkdir client-handbook
cd client-handbook
npm init -y
npm install --save-dev shotlist playwright
```

## Step 3: point at the site and name the session

shotlist.config.yaml

```
site:
  url: http://localhost:8883
  viewport: { width: 1440, height: 900 }
  sessions:
    editor:
      path: .shotlist/editor.json
      verify: 'body.wp-admin'

allowEnv:
  - WP_ADMIN_PASSWORD

install:
  handbook: handbook/images
```

`path` is where shotlist writes the cookies once you have signed in. `verify` is a selector only a signed-in page has. `body.wp-admin` is the class WordPress puts on every admin screen, and a signed-out visitor is redirected to a login page that does not carry it.

Do not reach for `#wpadminbar` here, which is the obvious choice and the wrong one: the editor opens full-screen and hides the admin bar with CSS, so the element is in the page but never visible, and every recipe fails as though the session had expired.

Set `verify` on every WordPress session. Without it an expired login does not fail: WordPress redirects to its sign-in form, every screenshot becomes a picture of that form, and `--install` publishes them into the handbook.

## Step 4: sign in once

Signing in is ordinary steps, so it goes in a macro. Create `screenshots/macros/sign-in.yaml`:

screenshots/macros/sign-in.yaml

```
steps:
  - goto: http://localhost:8883/wp-login.php
  - fill: { css: '#user_login' }
    value: admin
  - fill: { css: '#user_pass' }
    value: ${env.WP_ADMIN_PASSWORD}
  - click: { css: '#wp-submit' }
  - wait: { css: '#wpadminbar' }
    comment: the admin bar only exists once the sign-in has gone through
```

The last `wait` is not decoration. Clicking the button starts a navigation, and without something to wait for, shotlist would save the session before WordPress had finished signing you in — leaving a file that looks fine and works for nothing.

Put the password in a file rather than in an `export`, which your shell writes to its history in plaintext and keeps there. Create `.env` beside the configuration:

.env

```
WP_ADMIN_PASSWORD='the password Studio shows you'
```

Then load it and run the sign-in. `set -a` exports every name defined while it is on, so the file arrives as ordinary environment; the `allowEnv` line in your configuration is what lets the macro read it:

```
set -a; . ./.env; set +a
npx shotlist --login editor --using sign-in
```

`${env.WP_ADMIN_PASSWORD}` resolves only because that name was allowed. The password itself is never in the configuration, never in the macro, and never in anything shotlist writes — only its name is. A name that was allowed but is empty stays unresolved and the run says which one, rather than submitting an empty field and reporting a sign-in that failed for no visible reason.

Loading the file is the shell's job because shotlist never opens a `.env` — that name is on the list of paths it refuses in every mode, so a configuration cannot aim anything at one either. [Capture a page behind a sign-in](/docs/how-to/sign-in/) has the `node --env-file` form, for a shell without `set -a`.

Both files are credentials: the `.env` holds the password, and anyone holding the session file is signed in as that administrator without needing it. Add `.shotlist/` and `.env` to `.gitignore` before committing anything.

## Step 5: show the way in

A handbook starts where the reader is. Create `screenshots/recipes/add-post-menu.yaml`:

screenshots/recipes/add-post-menu.yaml

```
name: add-post-menu
install: handbook
session: editor
url: http://localhost:8883/wp-admin/edit.php

clip: viewport

marks:
  posts: { css: '#menu-posts .wp-menu-name' }
  add: { css: '#adminmenu a', text: Add Post }

numbered: [posts, add]
```

`numbered` puts a disc on each mark in the order you listed them, which is what a numbered list in the prose beside it needs.

The `url` is the Posts screen rather than the dashboard, and that is deliberate. On the dashboard the Posts submenu is a flyout that appears on hover, and WordPress parks a closed flyout at `top: -13000px` rather than hiding it — so a mark still resolves, the canvas grows thirteen thousand pixels to reach it, and the screenshot is mostly empty. On the Posts screen the submenu is simply open.

## Step 6: the editor's controls

screenshots/recipes/new-post.yaml

```
name: new-post
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php

setup:
  - optional:
      - click: { role: button, name: Close }
  - wait: { role: button, name: Block Inserter }

clip: { css: '.editor-header', pad: 12 }

marks:
  inserter: { role: button, name: Block Inserter }
  publish: { within: clip, text: Publish }

callouts:
  - { mark: inserter, text: Add a block, place: bottom }
  - { mark: publish, text: Publish when it is ready, place: bottom }
```

- `session: editor` is what makes this work at all. Without it the browser is a stranger and `post-new.php` is a login form.
- The `optional` block dismisses the welcome dialog WordPress shows the first time somebody opens the editor. It ignores failures, so the recipe works whether or not the dialog is there — and it is there exactly once per account.
- `wait` holds until the toolbar exists. The editor is a JavaScript application, so the page responding is not the page being ready.

## Step 7: choosing the Image block

screenshots/recipes/block-search.yaml

```
name: block-search
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php

setup:
  - optional:
      - click: { role: button, name: Close }
  - click: { role: button, name: Block Inserter }
  - fill: { placeholder: Search }
    value: image
  - wait: 800
    comment: the list filters on a debounce, and Image is in the unfiltered list too
  - wait: { role: option, name: Image, exact: true }

clip: viewport

marks:
  search: { css: '.block-editor-inserter__menu .components-input-control__container' }
  image: { role: option, name: Image, exact: true }

numbered: [search, image]
```

The `wait: 800` is doing something a query cannot. The list filters on a debounce, and `Image` is in the unfiltered list too — so waiting for the Image option succeeds immediately and photographs every block in WordPress. Waiting for a state that both versions satisfy is not waiting.

The search field is marked by its container rather than the `input`. The input stops short of the magnifier and the clear button, and a box around two thirds of a control looks like a mistake.

## Step 8: the block in the post

screenshots/recipes/image-block.yaml

```
name: image-block
install: handbook
session: editor
url: http://localhost:8883/wp-admin/post-new.php

setup:
  - optional:
      - click: { role: button, name: Close }
  - click: { role: button, name: Block Inserter }
  - fill: { placeholder: Search }
    value: image
  - wait: 800
    comment: the list filters on a debounce, and Image is in the unfiltered list too
  - click: { role: option, name: Image, exact: true }
  - wait:
      frame: { css: 'iframe[name="editor-canvas"]' }
      text: Media Library
  - wait: 1500
    comment: the editor rebuilds the canvas after the first insert; let it settle

clip: viewport

marks:
  upload:
    frame: { css: 'iframe[name="editor-canvas"]' }
    text: Upload
  library:
    frame: { css: 'iframe[name="editor-canvas"]' }
    text: Media Library

callouts:
  - mark: upload
    text: Upload an image
    place: left
    inside: true
  - mark: library
    text: Or choose one already uploaded
    place: bottom
    inside: true
    dy: 100
```

`frame:` is the new part. The post being edited is not in the page shotlist opened — the editor renders it inside an iframe, and a query cannot cross into one on its own. `frame:` names that iframe with a query of its own, and everything beside it resolves in the document inside. The `wait` uses it too, since a step takes a query like any other key.

The plain `wait: 1500` after it is not padding. Inserting the first block makes the editor rebuild its canvas, and the iframe that replaces the old one has not painted yet — capture at that moment and the block is in the page, the callout lands in the right place, and the picture is empty.

`inside: true` keeps the labels over the screenshot rather than in a margin the canvas grows to make, and `dy` pushes them clear of the block, so the image stays exactly the viewport size. The two callouts name different sides, so each arrow is short and neither label has to be moved out of the other's way.

## Step 9: take them all, and install them

```
npx shotlist --all --install
```

Both images land in `handbook/images/`, ready for the page that explains the steps beside them.

When the session expires the run stops and says so, rather than photographing the login form:

```
recipe "new-post": session "editor" is signed out — run: shotlist --login editor
```

Run `npx shotlist --login editor` again and the screenshots are current once more.

## What to know about frames

A rect measured inside a frame starts at that frame's top-left, and the canvas sits well down the page. shotlist converts it to page coordinates, so a callout lands where the reader sees the element rather than where the frame thinks it is. A frame inside a frame costs nothing extra.

Two things are worth knowing before you rely on it:

- A cross-origin frame works, because resolution goes through the browser rather than the page's own JavaScript — an embedded checkout or a payment field is reachable.
- A `transform` or `zoom` on the iframe itself is not compensated for, so a scaled preview will place its callouts off the mark.

## When WordPress changes

The block editor is a moving target, and the queries here are not all equally exposed to that. Two kinds are doing the work:

- What a person can see — `Block Inserter`, `Image`, `Publish`, `Media Library`. These are accessible names and visible text, so they change only when WordPress changes the interface, and then the screenshot needed updating anyway.
- Gutenberg's own class names — `.editor-header` and `.wp-block-image`. These are internal, and they are the ones that break quietly. They appear here only where the markup offers no other handle: the editor header carries no role or label, and a placed block has no accessible name.

That second kind has moved before. The inserter button used to be `.edit-post-header-toolbar__inserter-toggle`, which had already gone by WordPress 6.9.4 — a recipe written against it would fail today with nothing to say beyond "no element matched". It is the first place to look after a WordPress update.

We keep this page current as WordPress moves. If a query here stops matching on a version you are running, [open an issue](https://github.com/SirDarcanos/shotlist/issues/new?title=Docs%3A%20WordPress%20tutorial%20selector%20) with the version and the query, and it gets fixed here rather than in everybody's private copy.

## What you have learned

- run a real WordPress site locally, with no server to configure
- declare a named session, and prove it is live with a `verify` selector
- sign in once by hand, without the password reaching shotlist
- drive a JavaScript application before capturing it
- ignore a dialog that may or may not be there
- clip, mark and wait inside an iframe with `frame:`

## Next steps

- [Capture a page behind a sign-in](/docs/how-to/sign-in/) covers signing in with a macro, for a run with nobody at the keyboard.
- [Hide data that changes](/docs/how-to/hide-changing-data/) deals with the dates and counts a WordPress admin screen is full of.
- [Keeping a screenshot current](/docs/tutorials/keeping-a-screenshot-current/) catches these going stale when the client updates WordPress.

Written by Nicola Mustone · Applies to shotlist 0.6.0 · Maintained by Nicola Mustone

Published date unavailable · Updated date unavailable · [View source](https://github.com/SirDarcanos/shotlist.dev/blob/main/src/pages/docs/tutorials/document-a-wordpress-site.astro) · [Propose a correction](https://github.com/SirDarcanos/shotlist.dev/edit/main/src/pages/docs/tutorials/document-a-wordpress-site.astro)
