# GEO Bundle — Generative Engine Optimization for Forum Press Zone

**Date:** 2026-04-27
**Status:** Approved design
**Scope:** 8 features, single release, no external API dependencies

---

## 1. Goal

Make forum content maximally discoverable, ingestible, and citable by AI search engines (ChatGPT, Perplexity, Claude, Google AI Overviews, Bing Copilot) while improving classic SEO. No LLM/API costs introduced. All processing local + cacheable.

## 2. Existing Infrastructure (do NOT duplicate)

- `includes/class-presszone-forum-schema.php` — emits `DiscussionForumPosting`, `Comment`, `CollectionPage`, `BreadcrumbList` JSON-LD via `Schema::set()` + `wp_head` render hook.
- `includes/class-presszone-forum-sitemap.php` — registers `WP_Sitemaps_Provider` for nodes + threads (WP core sitemap).
- `includes/class-presszone-forum-router.php` — owns ~40 `add_rewrite_rule()` calls, query var dispatch.
- `includes/class-presszone-forum-query.php` — sole DB access layer.
- `includes/class-presszone-forum-post-creator.php` — content validation + creation hooks.

## 3. Features

### F1 — Accepted Answer

**Problem:** No way to mark canonical reply on Q&A-style threads. Without it, schema cannot emit `acceptedAnswer`, AI engines cannot identify authoritative response.

**Design:**
- New column `accepted_post_id BIGINT UNSIGNED NULL` on `presszone_forum_threads` (migration in `includes/migrations/`).
- Permission: thread starter OR moderator can mark/unmark.
- REST endpoint `POST /wp-json/presszone-forum/v1/threads/{id}/accept` body `{ post_id }`. Permission callback: starter or `Roles::canModerate`.
- DELETE for unmark.
- Frontend UI: "Mark as answer" button on each reply when current user has permission. Uses existing button styles, accessible (button element, aria-pressed).
- Single-thread template renders accepted reply at top with badge AND in original position.
- Schema F2 reads `accepted_post_id` to emit `acceptedAnswer`.
- TL;DR feature (F6) reads accepted reply first sentence when present.

**Files:**
- `includes/migrations/2026-04-accepted-answer.php` (new)
- `includes/class-presszone-forum-query.php` — `setAcceptedAnswer()`, `getAcceptedAnswer()`, hydrate in `getThread()`
- `includes/api/class-presszone-forum-rest-public.php` — register routes
- `templates/presszone/single-thread.php` — render badge + duplicated top block
- `assets/js/single-thread.js` — button handler, optimistic UI, REST call w/ nonce
- `assets/scss/components/_accepted-answer.scss` (new)

### F2 — QAPage Schema (Conditional)

**Problem:** `DiscussionForumPosting` is correct for chat-style threads but Q&A threads benefit from `QAPage` schema with `Question` + `acceptedAnswer` + `suggestedAnswer` for AI engine extraction.

**Design:**
- Detect Q&A intent: thread title starts with question word (Who/What/Where/When/Why/How/Can/Is/Are/Does/Do) OR ends with `?` OR has accepted answer (F1).
- When Q&A detected, emit BOTH `DiscussionForumPosting` (preserves existing rich result) AND `QAPage` as a graph (`@graph` array) — Google supports multiple types.
- `QAPage` structure:
  ```
  @type: QAPage
  mainEntity: { @type: Question, name: title, text: OP body, answerCount: N,
                acceptedAnswer: { @type: Answer, text, author, upvoteCount, dateCreated, url },
                suggestedAnswer: [ { @type: Answer, ... }, ... ]  // top 3 by reactions/recency
              }
  ```
- Helper: `Schema::generateQAPageForThread()` parallel to existing `generateForThread()`.
- Plugin.php detects intent + calls helper, merges into `Schema::set()` payload as `@graph`.

**Files:**
- `includes/class-presszone-forum-schema.php` — add `generateQAPageForThread()`, `isQuestionThread()`, `selectTopAnswers()`
- `includes/class-presszone-forum-plugin.php` — wire detection at single-thread render

### F3 — `llms.txt` + `llms-full.txt`

**Problem:** Emerging convention (proposed by Answer.AI, adopted by Cloudflare, Anthropic docs) — `/llms.txt` lists key URLs in markdown for LLM crawlers; `/llms-full.txt` contains full content.

**Design:**
- Two virtual endpoints at site root: `/llms.txt`, `/llms-full.txt`.
- `llms.txt` format:
  ```
  # {Site Name} Forums
  > {site description}
  ## Forums
  - [{Node title}]({url}): {description}
  ## Recent threads
  - [{Thread title}]({url}): {first 80 chars of OP, plain}
  ```
- `llms-full.txt` includes thread bodies (OP + accepted answer if any) up to N threads (admin setting, default 200 most recent active).
- Implementation:
  - Hook `init` to register rewrite rules: `^llms\.txt$` → `index.php?presszone_forum_llms=index`, `^llms-full\.txt$` → `presszone_forum_llms=full`.
  - Hook `template_redirect` to detect query var, render plain text, exit.
  - Cache output as transient (`presszone_forum_llms_index`, `presszone_forum_llms_full`) for 1 hour. Invalidate on thread create/update/delete via existing hooks.
  - Set `Content-Type: text/plain; charset=utf-8`, `Cache-Control: public, max-age=3600`.
- robots.txt should reference both via F5.

**Files:**
- `includes/class-presszone-forum-llms-txt.php` (new) — `LlmsTxt` class with `renderIndex()`, `renderFull()`, `buildMarkdown()`, `invalidateCache()`
- `includes/class-presszone-forum-router.php` — add 2 rewrite rules + query var
- `includes/class-presszone-forum-plugin.php` — wire `template_redirect` listener + cache invalidation hooks

### F4 — Markdown Raw View (`?format=md`)

**Problem:** AI crawlers parsing rendered HTML must strip chrome (nav, sidebar, ads). A markdown variant gives them clean structured content — higher chance of accurate citation.

**Design:**
- Append `?format=md` to any thread URL → respond with markdown.
- Output structure:
  ```
  # {Thread title}
  **By:** {author} | **Posted:** {iso date} | **Forum:** {node breadcrumb}
  ---
  {OP body converted: HTML → markdown}
  ---
  ## Replies ({N})
  ### Reply by {author} — {date} {(✓ Accepted)}
  {body}
  ```
- HTML→Markdown: pure PHP. Use `league/html-to-markdown` (composer dep, ~5KB, permissive license, already common in WP plugin ecosystem) OR write minimal converter (~120 LOC) handling p, h1-h6, strong, em, a, ul/ol/li, blockquote, code, pre, br, img.
- **Decision:** ship minimal in-house converter to avoid composer dep additions. Class `MarkdownRenderer` with `fromHtml(string $html): string`.
- Cache rendered markdown per thread in transient `presszone_forum_md_{thread_id}`, invalidate on thread/post update.
- Set `Content-Type: text/markdown; charset=utf-8`.
- Reachable from canonical thread URL — set `<link rel="alternate" type="text/markdown" href="...">` in single-thread `<head>` for discoverability.

**Files:**
- `includes/class-presszone-forum-markdown-renderer.php` (new)
- `includes/class-presszone-forum-router.php` — detect `format=md` query var on thread route
- `templates/presszone/single-thread.php` — emit `<link rel="alternate">`
- `includes/class-presszone-forum-plugin.php` — wire cache invalidation

### F5 — AI Bot robots.txt Toggles

**Problem:** Site owner needs explicit control over which AI crawlers may index forum content. Current Schema is great but irrelevant if robots blocks GPTBot.

**Design:**
- Admin Design/SEO settings tab → new section "AI search engine access" with toggles:
  - GPTBot (OpenAI)
  - ChatGPT-User (OpenAI on-demand)
  - OAI-SearchBot (OpenAI search index)
  - ClaudeBot, Claude-Web, anthropic-ai (Anthropic)
  - PerplexityBot, Perplexity-User
  - Google-Extended (Bard/Gemini training)
  - CCBot (Common Crawl)
  - Applebot-Extended, FacebookBot, Bytespider
- Default: ALL allowed (opt-out model — site owner can disable).
- Each toggle stored as bool option `presszone_forum_robots_{slug}` (e.g. `presszone_forum_robots_gptbot`).
- Hook `robots_txt` filter — append rules:
  ```
  User-agent: GPTBot
  Disallow: /
  ```
  for each disabled bot. Append `Allow:` lines for sitemap + llms.txt URLs (already public).
- Always append: `Sitemap: ...wp-sitemap.xml`, `Sitemap: .../llms.txt` reference (informational, in comment).

**Files:**
- `admin/src-vanilla/pages/seo.js` (new tab section) OR existing settings page
- `admin/src-vanilla/styles/pages/_seo.css` (new tab section if needed)
- `includes/class-presszone-forum-plugin.php` — `robots_txt` filter handler
- `includes/api/class-presszone-forum-rest-admin.php` — settings get/save endpoints (likely already exists, just add fields)

### F6 — TL;DR Auto-Summary (Extractive, No LLM)

**Problem:** AI engines (and humans) benefit from a 1-2 sentence summary above the fold. We do this without external API by using extractive heuristics.

**Design:**
- Algorithm priority order:
  1. If accepted answer exists (F1): take first 1-2 sentences of accepted reply (stripped of HTML/BBCode).
  2. Else if OP is question (F2 detection): take first 2 sentences of OP body.
  3. Else: take first 2 sentences of OP body.
- Cap output at 280 chars. End on sentence boundary; ellipsis if truncated mid-word.
- Stored in `presszone_forum_threads.tldr_summary VARCHAR(320) NULL`. Computed on:
  - thread create
  - OP edit
  - accepted answer change (F1 hook)
  - reply edit if it's the accepted answer
- One-shot backfill migration: compute for all existing threads.
- Render in single-thread template inside `<details>` or styled callout above OP. HTML structure:
  ```html
  <aside class="presszone-forum-tldr" aria-label="Thread summary">
    <strong class="presszone-forum-tldr__label">TL;DR</strong>
    <p class="presszone-forum-tldr__body">...</p>
  </aside>
  ```
- Schema (`DiscussionForumPosting` + `QAPage`) reuses summary as `description` field.
- Helper class `ThreadSummary` — `compute(thread, op, acceptedReply): string`, `extractSentences(string): array`, `truncateToSentence(string, int): string`.

**Files:**
- `includes/migrations/2026-04-tldr-column.php` (new + backfill)
- `includes/class-presszone-forum-thread-summary.php` (new)
- `includes/class-presszone-forum-query.php` — hydrate `tldr_summary` in `getThread()`
- `includes/class-presszone-forum-post-creator.php` — hooks
- `templates/presszone/single-thread.php` — render aside
- `assets/scss/components/_tldr.scss` (new)
- `includes/class-presszone-forum-schema.php` — use as description

### F7 — Person Schema Enrichment

**Problem:** Existing Person schema has only name + url. AI engines weight authority signals (`knowsAbout`, `memberOf`, post counts, role).

**Design:**
- Author Person object enriched with:
  - `description`: signature stripped to plain text (first 200 chars)
  - `image`: avatar URL (already in DB)
  - `url`: profile URL (existing)
  - `interactionStatistic`: `[{@type: InteractionCounter, interactionType: "https://schema.org/WriteAction", userInteractionCount: post_count}]`
  - `memberOf`: `{@type: Organization, name: site name}`
  - `roleName`: forum role label (Member, Moderator, Admin) — from `Roles`
  - `agentInteractionStatistic` mirror for engagement
- Helper: `Schema::generatePerson(int $userId, ?array $userData): array` — single source of truth, used by all schema emitters (DiscussionForumPosting, Comment, QAPage Answer).
- Cache per-user via `wp_cache_set` with group `presszone_forum_schema`, invalidate on user update / post count change.

**Files:**
- `includes/class-presszone-forum-schema.php` — add `generatePerson()`, refactor existing emitters to call it

### F8 — IndexNow Ping

**Problem:** Bing, Yandex, Naver (and via Bing → Copilot) accept IndexNow protocol — push notifications when content changes, eliminating crawl latency. Free, simple HTTP POST.

**Design:**
- Generate one-time site key on first activation: 32-char random hex stored in option `presszone_forum_indexnow_key`. Expose at `https://{site}/{key}.txt` containing the key (required by protocol).
- Endpoint: register rewrite rule for `^([a-f0-9]{32})\.txt$` → only respond if matches stored key, else 404.
- Ping conditions:
  - Thread published (visible to public)
  - Accepted answer set/changed (F1)
  - Thread approved out of moderation queue
  - Thread permanently deleted (POST with delete intent — IndexNow doesn't support delete; we just notify URL changed)
- Implementation: `IndexNow::ping(array $urls): void`. Batches up to 10000 URLs per request per spec. Uses `wp_remote_post`. Endpoint: `https://api.indexnow.org/indexnow`.
- Throttle: max 1 ping per URL per 5 minutes (transient lock).
- Admin setting: enable/disable (default ON), view key, regenerate key.
- Failures logged but never fatal.

**Files:**
- `includes/class-presszone-forum-indexnow.php` (new)
- `includes/class-presszone-forum-router.php` — key file rewrite rule
- `includes/class-presszone-forum-plugin.php` — hook to thread/post events
- Admin SEO tab (F5) — IndexNow settings panel

---

## 4. Cross-Cutting Concerns

### Caching Strategy
- All emit-time computation (schema, llms.txt, markdown view, TL;DR) cached in transients OR object cache.
- Invalidation hooks centralized in `Plugin.php` listening to: `presszone_forum_thread_created`, `presszone_forum_thread_updated`, `presszone_forum_post_created`, `presszone_forum_post_updated`, `presszone_forum_thread_deleted`, `presszone_forum_accepted_answer_changed` (new — fired by F1).
- Per-feature cache keys:
  - F3: `presszone_forum_llms_index`, `presszone_forum_llms_full`
  - F4: `presszone_forum_md_{thread_id}`
  - F7: object cache group `presszone_forum_schema`, key `person_{user_id}`

### Settings Surface
All new settings live under one admin tab "SEO & AI" (or extend existing Design/SEO tab):
- Section: AI Bot access (F5)
- Section: IndexNow (F8)
- Section: Generative engine settings (llms.txt thread limit for F3, TL;DR enable toggle for F6, markdown view enable for F4)

### Backwards Compatibility
- Existing schema output unchanged for non-Q&A threads.
- New DB columns nullable, no breaking schema changes.
- All new endpoints are additive (no replacement of existing routes).

### WordPress.org Compliance
Follows foundation skill rules (auto-applied):
- All prefixes ≥ 4 chars (`presszone_forum_*`, `PresszoneForum*`)
- Text domain `forum-press-zone` on all i18n calls
- Output escaped, input sanitized
- Nonce verification on F1 REST endpoints
- Permission callbacks (no `__return_true`)
- All SQL via `$wpdb->prepare()`

### Accessibility
- F1 button: button element, `aria-pressed` for toggle state, keyboard activation
- F6 TL;DR: semantic `<aside aria-label="">`, dismissible with keyboard
- New SCSS uses existing variables; both light + dark mode variants

### Verification
Per CLAUDE.md `verification` skill — Playwright tests required for:
- F1 UI: click "mark as answer", reload, badge present, schema in `<head>` includes `acceptedAnswer`
- F4: fetch `/forums/thread/{slug}/?format=md`, assert content-type + structure
- F5: load `/robots.txt`, assert disallow rules toggle correctly with admin settings
- F6: assert `<aside class="presszone-forum-tldr">` rendered, dark mode contrast OK
- F2: rich-results validation snapshot in test (`google-rich-results` not available in CI; use `schema-dts` JSON validation)

## 5. Out of Scope

- LLM-based abstractive summarization (deferred indefinitely)
- Multilingual schema variants (handled separately by translate-press-zone integration)
- AMP / Reader Mode
- Schema for private/messaging content (private by design)
- Custom JSON-LD authoring UI (admin override)

## 6. Success Criteria

- Google Rich Results Test passes for thread URLs (`DiscussionForumPosting` AND `QAPage` where applicable)
- `/llms.txt` reachable, valid format per llmstxt.org spec, lists ≥ N most-recent threads
- `/llms-full.txt` includes full thread bodies, generates within 2s for 200-thread limit
- `?format=md` returns valid markdown for any thread, content-type correct
- robots.txt updates within 60s of toggle change
- TL;DR present on every public thread after backfill
- Person schema validates in Schema.org validator
- IndexNow key file reachable, ping succeeds (200/202 from api.indexnow.org)

## 7. Implementation Plan (Parallel Sonnet Waves)

See `2026-04-27-geo-bundle-plan.md` (created next via writing-plans skill).

Wave 1 (5 parallel Sonnet agents, all independent foundations):
- A1 — F1 backend (migration + Query + REST + invalidation hook)
- A2 — F3 LlmsTxt class + Router rule + cache scaffolding
- A3 — F4 MarkdownRenderer class + Router rule + cache scaffolding
- A4 — F5 robots_txt filter + admin settings backend
- A5 — F8 IndexNow class + key file rewrite + admin settings backend
- A6 — F6 TL;DR migration + ThreadSummary class + backfill (independent of F1; F1 wiring in Wave 2)
- A7 — F7 Person schema helper refactor

Wave 2 (3 parallel Sonnet agents, depend on Wave 1 outputs):
- B1 — F1 frontend UI (template + JS + SCSS) + verification tests
- B2 — F2 QAPage schema (depends on F1 accepted_post_id from A1)
- B3 — F5 admin SPA UI (depends on A4 backend) + F8 admin UI (depends on A5)
- B4 — F6 wiring (template render + Schema description integration; depends on A6 + A7)

Wave 3 (single Opus reasoning pass):
- C1 — Cross-cutting review:
  - Schema graph validation (no duplicate IDs, valid `@graph`)
  - Rewrite rule conflicts (F3 + F4 + F8 + existing 40 rules)
  - robots.txt rule merging correctness (F5 vs WP core defaults)
  - Cache invalidation completeness (every mutation invalidates every dependent cache)
  - Performance budget (llms-full.txt for 200 threads, TL;DR backfill)
  - WordPress.org compliance final scan
  - Verification skill checklist completion

Each wave gates on green Playwright + PHP CodeSniffer + Psalm before next wave begins.
