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.