Skip to main content

Writing Documentation

How these docs get written. Three rules, then the screenshot pipeline.

1. Be pithy

One idea per sentence. Cut every sentence that does not change what the reader does.

  • Lead with what the reader needs, not with background.
  • Tables over prose for anything with more than three options.
  • Say the default, the effect, and the gotcha. Skip the rest.
  • No "as you can see", no "simply", no restating the heading.

If a section can be a table, make it a table. If a paragraph can be a sentence, make it a sentence.

2. Screenshot every user-visible surface

Every feature doc ships screenshots. A settings screen described in words is a support ticket waiting to happen. Capture:

  • Each admin screen or settings section the feature adds.
  • Each per-post control (metabox, sidebar panel).
  • Each state a visitor can land in — including the error states.
  • Each configuration that changes what is on screen. If a dropdown reveals extra fields, that is a second screenshot.

Captions carry the detail the image cannot: what is switched on, what is inherited, what is missing and why.

3. Always split block editor from classic and page builders

Yoko sites are split roughly evenly between block themes and Beaver Builder, and the two behave differently often enough that "it depends" is never an acceptable answer. Whenever a feature touches the editor, the theme's markup, or the front end, say what happens in both:

SurfaceBlock themes / GutenbergClassic themes / page builders
Per-post controlsDocument sidebar panels (PluginDocumentSettingPanel) — absent in the classic editorCMB2/native metaboxes — render in both editors
Content wrapper.wp-block-post-content.entry-content, .post-content; Beaver Themer uses .fl-post-content
TemplatesSite editor templates and template partsPHP theme files, or Themer layouts
Styling hookstheme.json presets as CSS custom propertiesTheme stylesheet; no presets to inherit

Two habits that follow from this:

  • Never assume a sidebar panel is reachable. A panel registered on enqueue_block_editor_assets does not exist under the classic editor, so a post type a page builder has taken over has no per-post UI at all. Say so, and give the alternative.
  • Never hardcode one content selector in docs or code. State the wrapper per site type, and point at the setting or filter that overrides it.

When a difference exists, a :::warning naming the affected editor beats a footnote.

Screenshots

Where they go

Files in website/static/img/<feature>/, referenced as /img/<feature>/name.png. Lower-case, hyphenated, named for the state — gate-expired-blocked.png, not screenshot-4.png.

Two shapes

ShapeUse forMarkup
Full widthA whole screen, a front-end state, a complete settings section![Alt text](/img/feature/name.png) then an italic caption line
FloatedA cropped fragment — one sidebar panel, one field, one button<figure> with yoko-figure yoko-figure--right

Float anything cropped. A tall narrow crop at full width pushes the prose a screenful down for no benefit.

<figure className="yoko-figure yoko-figure--right">
<img src="/img/feature/panel.png" alt="The Feature panel in the document sidebar" />
<figcaption>What is switched on, and what the blank fields inherit.</figcaption>
</figure>

Floats cap at 320px, clear at the next heading, and drop to full width under 996px. Use --left sparingly; right reads better beside prose.

Capturing them

Screenshots come from a throwaway install, never a client site — client branding makes the shot about them, and a page builder's layout makes the feature's own defaults impossible to see.

DEMO_DB_SOCKET=~/Library/Application\ Support/Local/run/<id>/mysql/mysqld.sock \
./website/scripts/demo-site.sh up

That installs current WordPress on localhost:8930 with a stock block theme, activates the plugin from your working copy, and prints the server command. Then write a capture script against website/scripts/capture.mjs:

import { openCapture, wp, ORIGIN } from './capture.mjs';

// Set the state you're documenting. wp() is synchronous, and runs through a
// shell — quote anything with spaces or semicolons.
wp( [ 'option', 'update', 'my_setting', 'on' ] );

const { page, login, shootViewport, shootElement, hideAdminChrome, browser } =
await openCapture( { outDir: process.argv[ 2 ] } );

await login();
await page.goto( `${ ORIGIN }/wp-admin/options-general.php?page=my_page` );
await hideAdminChrome();
await shootViewport( 'settings-overview' ); // full width
await shootElement( 'panel', '.my-panel' ); // cropped → float it

await browser.close();

Need to document two mutually exclusive states — the same screen with a plugin on and off? Make each one its own pass: change state, open a browser, shoot, close it. website/scripts/soft-gate-exclusions.mjs does exactly that for the two editors.

Run it with Node from the plugin root — capture.mjs uses playwright-core, which comes from the plugin's own node_modules (via @wordpress/scripts), not the site's:

npm install && node website/scripts/my-feature.mjs website/static/img/my-feature

Then tear it down:

DEMO_DB_SOCKET=... ./website/scripts/demo-site.sh down

Traps worth knowing

  • Block themes need a second capture pass. A block theme shows the sidebar panel; a classic-editor post type does not. If the docs claim both, shoot both — install the Classic Editor plugin on the demo for the second pass.
  • waitUntil: 'networkidle' never resolves in the block editor. Use domcontentloaded and wait on a selector.
  • Serve with PHP_CLI_SERVER_WORKERS=8. The block editor fires many parallel requests and stalls on a single-worker PHP server. Even with workers it stalls a request outright now and then, which is why login() retries once.
  • Turn off cron and auto-updates on the demo. DISABLE_WP_CRON, AUTOMATIC_UPDATER_DISABLED, WP_AUTO_UPDATE_CORE. A core auto-update firing mid-run wedges requests for a minute and looks exactly like a broken script.
  • Arm a navigation waiter before the action that triggers it. await page.click() and then await page.waitForURL() is a race — click() waits for the navigation it triggers, so when that wins, the waiter is left waiting for a second navigation that never comes. Put both in one Promise.all(). This one is worth knowing by name: it presents as an intermittent timeout on a step that demonstrably succeeded, and it tempts a fix aimed at the wrong cause.
  • shootElement() captures the whole element, including what is below the fold — locator.screenshot() scrolls for you. No need to size the viewport around it, though a taller viewport still gives a less scrolled-looking shot.
  • Check what you captured. Open the PNG before committing. An empty-looking field is often a correctly-rendered empty widget, not a bug — verify before reporting one.

Before opening the PR

cd website && npm ci && npm run build

Docusaurus fails the build on broken links, and MDX evaluates {...} in prose as JavaScript — wrap merge tags and placeholders in backticks or the build throws ReferenceError.