# BW Guides — Claude Development Guide

**Version:** 0.3.5 | **Last Updated:** 2026-08-13

## What this plugin does

Documentation and guides for your website, delivered and updated by Bowden Works.

## Process

This plugin follows the standard BW Plugins process defined in the umbrella `/srv/apps/bw-plugins/CLAUDE.md`. That covers:

- **Versioning** — default bump is patch; minor only for significantly new feature sets; major reserved.
- **Release gating** — never run `tools/release.sh` without an explicit "release it" from rian.
- **Session protocol** — read `docs/SESSION-LOG.md` + `docs/HANDOFF-NOTES.md` at session start, update at session end.
- **Always / Never** lists (escaping, nonces, `defined( 'ABSPATH' ) || exit;`, `vendor/`, secrets, etc.).

Plus full release process in `/srv/apps/bw-plugins/docs/RELEASE-PROCESS.md`.

## Before making changes

1. Read `docs/SESSION-LOG.md` for recent context.
2. Read `docs/HANDOFF-NOTES.md` for any pending async messages.
3. Read `docs/SPEC.md` for current requirements.
4. Check `docs/KNOWN-ISSUES.md` for active bugs and workarounds.

## Architecture Quick Reference

- Main file: `bw-guides.php` (constant `BW_GUIDES_VERSION`)
- Core classes: `includes/class-bw-guides-*.php`
- Admin UI: `admin/`
- Frontend assets: `assets/`
- Docs: `docs/`
- Vendored libs: `vendor/`

## Plugin-specific notes

- **This is HALF of a two-plugin system.** The other half is `bw-guides-server` on the
  dist host (`/srv/apps/bw-plugins-dist/wp-content/plugins/bw-guides-server/`) — the
  central publishing hub. Read its CLAUDE.md before changing anything about sync, the
  REST contract (`bw-guides/v1`), or the payload schema. Protocol changes must be
  coordinated: hub first (backward-compatible), client release after.
- **Sync safety invariants** (do not weaken): a failed/invalid manifest changes nothing
  locally; removals only trash (never hard delete) and only after a fully successful
  run; sync never touches taxonomy terms or `_bw_guides_notes` — that is the entire
  mechanism by which client tags/notes survive.
- **Hub guides are read-only** via `map_meta_cap` (`_bw_guides_source === 'hub'`).
  The sync engine bypasses this legitimately (`wp_update_post` does no cap checks).
  Tags/notes on locked guides go through the plugin's own admin-ajax endpoints.
- **Forward-compat:** the per-guide `meta` payload object is stored raw in
  `_bw_guides_remote_meta`. Deferred features (tokens, min_wp_version,
  requires_plugins) are implemented by READING that meta at render/query time — never
  by rewriting stored content at sync time (would break content_hash stability).
- **Keyword/category contract (0.3.0), read by `BW_Guides_Keywords`.** Both fields live
  in the same `meta` object as above (hub: inside `_bw_guides_remote_meta`; local
  guides: their own `_bw_guides_local_category` / `_bw_guides_local_keywords` meta —
  no authoring UI for either yet). Everything is optional and tolerated absent/malformed
  — every guide synced before 0.3.0 has neither.
  - `category`: string. `""` or absent = uncategorized.
  - `section_keywords`: `[{"heading":"<exact heading text>","keywords":"kw, kw"}, ...]`.
    `heading` is matched **case-insensitively, trimmed, exact** against this guide's own
    parsed headings — **never by slug** (keeps this plugin decoupled from whatever
    generates slugs on the hub or here). The heading text `"*"` means guide-level
    keywords (not tied to one section).
  - **Ranking (`BW_Guides_Content::search_sections()`):** per heading, sum
    phrase-in-heading (5) or all-words-in-heading (3); phrase-in-section-keywords (4) or
    all-words-in-section-keywords (2); guide-level keyword match adds a flat +1 to every
    section of that guide. `via` on a hit is `'heading'` if the heading text itself
    scored, else `'keyword'` — a UI hint only (no scores shown), not a precise
    attribution when both signals fired. A heading needs no keyword metadata to be
    found: heading-text matches alone already score above zero and always outrank a
    keyword-only match, so this degrades to pre-0.3.0 behavior wherever a guide (or all
    of them) carries none.
  - **Browse grouping (`BW_Guides_Keywords::group_guides()`):** hub guides bucket by
    `category` (alphabetical); hub guides with none fall under "More guides"; every
    local guide is grouped last under "Your guides" **regardless of its own category** —
    a client's own guides stay one easy-to-find block rather than scattering into the
    standard library's categories.
- **Two capabilities, never one (0.3.4).** `BW_Guides_Admin::read_cap()` (filter
  `bw_guides_read_capability`) gates browsing/opening a guide;
  `BW_Guides_Admin::manage_cap()` (filter `bw_guides_manage_capability`) gates anything that
  WRITES — tags (shared taxonomy terms), notes (on the guide, not per-user), the sync
  trigger, and the authoring screens. Both default to `edit_posts`.
  **`BW_Guides_Ajax` must always use manage, never read** — every endpoint there writes, and
  that separation is the only thing making "open reading to everyone" safe. A first attempt
  at the site-side workaround lent `edit_posts` to the AJAX endpoints along with the page
  render, which would have let any logged-in user retag the documentation site-wide.
  The **settings screen stays on `manage_options`** — it exposes the site key.
- The site key is a bearer credential in the `bw_guides_settings` option: mask it in
  any UI, never log it, never echo it in wp-cli output or chat.
- **Guide rendering is a no-theme environment.** Guides render inside wp-admin via
  `do_blocks()`, so the client's theme never applies — which is why a guide looks
  identical on every client site. The trade-off: WordPress hooks
  `wp_enqueue_global_styles()` to `wp_enqueue_scripts` only, never
  `admin_enqueue_scripts`, so theme.json output is absent. No `--wp--preset--*`
  variables, no `--wp--style--block-gap`, no `.wp-element-button` styling. Anything
  global styles would normally supply must live in
  `assets/css/bw-guides-admin.css`, scoped to `.bw-guides-content`.
- **Guide structure: one guide per admin area, one task per heading.** A guide is the
  unit of publishing but a *question* is the unit of use, so every heading is made
  individually addressable by `BW_Guides_Content` — anchors on render, an auto
  "In this guide" list, and heading-level search that deep-links into a section.
  Consequences when authoring or changing that class: slugs are derived from heading
  text (editing a heading breaks old links — degraded, lands at the top of the guide),
  and `prepare()` (parses rendered HTML) and `headings()` (parses raw block markup)
  **must produce identical slugs in identical order**, or search links point at
  anchors that do not exist. There is a check for this — see SESSION-LOG.
- **Design-kit components** (`bw-callout` + variants, `bw-callout-label`, `bw-steps`)
  are styled here and applied by the hub's authoring patterns via `className`. The
  same rules are mirrored in the hub's `assets/css/editor.css` for editor preview —
  **keep the two in sync** when changing a colour or spacing value. Restyling a
  component here changes every published guide without re-syncing content.
