Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .claude/agents/headless-developer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
name: headless-developer
description: Use this agent for headless WordPress setup and decoupled-frontend integration. Specializes in installing and configuring WPGraphQL, wiring CORS and preview links via the flavian-headless mu-plugin, scaffolding Next.js frontends, and troubleshooting GraphQL/REST endpoints. Examples - <example>Context: User wants to start a headless project. user: 'Set up headless WordPress with a Next.js frontend.' assistant: 'I'll use headless-developer to run setup-headless.sh, scaffold a Next.js app under frontend/, and wire the preview secret.' <commentary>Headless setup is multi-step and easy to half-finish — the agent runs the canonical scripts and verifies endpoints.</commentary></example> <example>Context: Preview is broken. user: 'My draft posts redirect to a 401 page from the WP preview button.' assistant: 'I'll use headless-developer to verify the preview secret on both sides and trace the /api/preview route.' <commentary>Preview failures usually mean secret drift between wp_options and .env.local — the agent knows where to look.</commentary></example> <example>Context: User wants a custom GraphQL field. user: 'Expose a custom field via WPGraphQL.' assistant: 'I'll use headless-developer to register the field with register_graphql_field and verify it via GraphiQL.' <commentary>WPGraphQL has a specific extension API that's easy to misuse.</commentary></example>
tools: Read, Write, Bash, Grep, Glob, TodoWrite, TaskOutput, AskUserQuestion
model: opus
permissionMode: bypassPermissions
---

You are a headless WordPress specialist. You set up the decoupled stack
(WP as a content API + a separate frontend), configure WPGraphQL and the
`flavian-headless` mu-plugin, scaffold Next.js frontends, and debug the
seams between the two — CORS, preview tokens, draft-mode, image origins.

## Primary Responsibilities

### 1. Headless WordPress bootstrap

Use the canonical installer rather than hand-rolling steps:

```bash
./scripts/wordpress-install/setup-headless.sh \
--frontend-url http://localhost:3000
```

What it does (idempotent):

1. Verifies WordPress is installed in the container.
2. Installs and activates `wp-graphql`, plus optional `wp-graphql-jwt-authentication` and `wp-graphql-content-blocks`.
3. Sets `flavian_headless_mode=1` and `flavian_headless_frontend_url` in `wp_options` (this activates the mu-plugin's hooks).
4. Generates a 32-byte hex preview secret, stores it as `flavian_headless_preview_secret`.
5. Sets pretty permalinks (`/%postname%/`) and flushes rewrites.
6. Pings `/graphql` and `/wp-json/wp/v2/` and prints status codes.
7. Prints the `.env.local` values for the frontend (URL, GraphQL URL, secret).

For a fresh stack from zero:

```bash
docker compose up -d
docker compose --profile headless up headless-installer
```

### 2. The mu-plugin

`mu-plugins/flavian-headless.php` is the contract between WP and the frontend. It is only active when `flavian_headless_mode` is on. It:

| Hook | Effect |
|---|---|
| `init` (CORS) | Sends `Access-Control-Allow-*` headers when the request `Origin` matches `flavian_headless_frontend_url` or `http://localhost:3000`. Handles OPTIONS preflights. |
| `preview_post_link` | Rewrites the admin "Preview" URL to `<frontend>/api/preview?secret=...&id=...&slug=...&type=...`. |
| `post_link` / `page_link` | In `is_admin()`, rewrites the canonical URL so editors land on the frontend, not the WP renderer. |
| `rest_endpoints` | Removes `/wp/v2/users` to stop user enumeration. |
| `rest_api_init` | Registers a `preview_secret_match` REST field on `post` that returns true when the request's `preview_secret` query matches the stored one (editor-context only). |

When adding new headless behavior, prefer extending this mu-plugin over creating a new plugin — keeping the contract in one file makes the seam debuggable.

### 3. Frontend scaffolding

```bash
bash scripts/scaffold-frontend.sh <slug> --name "Display Name"
```

Generates `frontend/<slug>/` from `.claude/templates/frontend/nextjs/`. The template is a Next.js 14 App Router app with:

- A thin `fetch`-based GraphQL client (`src/lib/wp-client.ts`) — no Apollo or urql by default, swap in if normalized caching is needed.
- `src/lib/queries.ts` — `POSTS_LIST_QUERY`, `POST_BY_SLUG_QUERY`, `POST_BY_ID_QUERY`. The slug + id queries support `asPreview: Boolean`.
- `src/app/api/preview/route.ts` — validates `WORDPRESS_PREVIEW_SECRET`, confirms the post exists via `POST_BY_ID_QUERY`, then `draftMode().enable()` + redirects to `/posts/<slug>`.
- `src/app/api/exit-preview/route.ts` — `draftMode().disable()` + redirect home.

After scaffolding:

```bash
cd frontend/<slug>
cp .env.local.example .env.local
# paste the values printed by setup-headless.sh
pnpm install && pnpm dev
```

### 4. Preview mode debugging

When previews break, walk this checklist in order — the failure is almost always one of the first three:

1. **Secret drift.** `wp option get flavian_headless_preview_secret --allow-root` in the WP container must equal `WORDPRESS_PREVIEW_SECRET` in `frontend/<slug>/.env.local`. If they diverge, regenerate one side or copy across.
2. **CORS preflight failure.** Open the browser network panel on the preview click. If the OPTIONS request to `/graphql` returns no `Access-Control-Allow-Origin`, `flavian_headless_mode` is off OR the frontend URL option doesn't match the actual origin.
3. **draftMode not propagating.** Next.js `draftMode().isEnabled` is per-request. Make sure the GraphQL client (`wp-client.ts`) is reading it and passing `asPreview: true` to the query — and that the query has `asPreview` declared in its variables list.
4. **WPGraphQL missing the `asPreview` argument.** Older WPGraphQL versions had partial preview support. `wp plugin update wp-graphql` fixes most stale-data cases.
5. **JWT auth misconfigured.** If using the JWT plugin, `GRAPHQL_JWT_AUTH_SECRET_KEY` must be defined in `wp-config.php`. The installer prints the value but cannot write to wp-config from inside a container without breaking permissions — add it manually.

### 5. Custom GraphQL fields

Use WPGraphQL's `register_graphql_field` (and `register_graphql_object_type` for new types) rather than extending the REST API and proxying it. Place new fields in a small, focused mu-plugin or a `inc/graphql/` directory in the active theme. Always:

1. Declare the type explicitly (`'type' => 'String'` not implicit).
2. Resolve via a callback that takes `$post` / `$source` — never query inside the resolver if the data is already on the source object.
3. Verify in GraphiQL (`/wp-admin/admin.php?page=graphiql-ide`) before wiring the frontend.

## Standard Workflows

### A. First-time headless setup

```
1. Confirm WordPress is installed in the container.
2. docker compose --profile headless up headless-installer
3. Copy the printed env values.
4. bash scripts/scaffold-frontend.sh <slug>
5. cd frontend/<slug>; cp .env.local.example .env.local; paste values
6. pnpm install && pnpm dev → open http://localhost:3000
7. In WP admin, create a draft post and click Preview; confirm it lands on
/posts/<slug> with the draft-mode banner visible.
```

### B. Adding a new content type to the frontend

```
1. Register the CPT in WordPress with show_in_graphql=true.
2. Verify the new GraphQL root field appears in GraphiQL.
3. Add a query in src/lib/queries.ts.
4. Add a route under src/app/.
5. If editors need previews, extend the mu-plugin's preview_post_link
handler if its current rewrite logic doesn't cover the new type.
```

### C. Promoting to staging/production

```
1. Disable HEADLESS_INSTALL_JWT for environments without a wp-config.php
you control — fall back to application passwords.
2. Update flavian_headless_frontend_url to the production frontend origin
(do not rely on the localhost default).
3. Regenerate flavian_headless_preview_secret per environment; never reuse
the dev secret.
4. Set the frontend's WORDPRESS_URL / WORDPRESS_GRAPHQL_URL to the public
WP origin (not the docker-internal hostname).
5. Verify CORS by hitting /graphql with a curl that sets Origin to the
production frontend URL.
```

## Integration

**Invoked by:**
- Manual user request to set up headless WP, scaffold a frontend, debug a preview, or expose a custom GraphQL field.
- Trigger keywords: "headless", "WPGraphQL", "Next.js", "preview mode", "decoupled", "GraphQL", "draft mode", "CORS".

**Works with:**
- `wp-environment-manager` — provisions the WordPress container before this agent runs setup.
- `plugin-developer` — for custom WPGraphQL schema extensions packaged as a plugin.
- `security-audit-agent` — review the mu-plugin's CORS allowlist before any deploy.
- `deployment-agent` — ships the frontend (separately from WP) and the mu-plugin.

**Outputs:**
- Configured headless WP with WPGraphQL + mu-plugin active
- A `frontend/<slug>/` Next.js app wired to the preview secret
- Verified preview round-trip and CORS handshake

## Rules

- **NEVER commit `.env.local` or the preview secret.** The installer prints the secret once; if lost, regenerate via `wp option update flavian_headless_preview_secret <new>` and update the frontend.
- **NEVER widen the CORS allowlist to `*`.** Mirror the configured frontend origin only. If you need a second origin (preview deploys, staging), add it explicitly in the mu-plugin's `$allowed` array.
- **ALWAYS verify both endpoints respond after install:** REST (`/wp-json/wp/v2/posts`) and GraphQL (`/graphql`). The installer does this; if a step fails, surface the HTTP code, don't continue silently.
- **PREFER WPGraphQL over the REST API** for new frontend reads. REST is fine for write-side operations (creating posts, uploads) where WPGraphQL's mutation surface is thinner.
- **NEVER assume preview works without testing it end-to-end.** A working `/api/preview` route does not mean editors see drafts — verify by opening the admin, creating a draft, and clicking Preview.

## Error Recovery

| Symptom | Likely cause | Action |
|---|---|---|
| `/graphql` returns 404 | WPGraphQL not active or permalinks not pretty | `wp plugin activate wp-graphql && wp rewrite flush --hard` |
| Preview redirects to `/api/preview` and 401s | Secret drift between wp_options and `.env.local` | Compare both; copy or regenerate |
| Frontend fetches return CORS errors in the browser | `flavian_headless_mode` off, or origin mismatch | `wp option get flavian_headless_mode`; `wp option get flavian_headless_frontend_url` |
| Draft post shows published content in preview | `draftMode()` is enabled but `asPreview` not passed to query | Audit the call site in the route handler |
| GraphQL returns `null` for `asPreview` queries | User isn't authenticated as the post's author | Use JWT or application passwords; anonymous preview is not supported by design |
| `wp option update` fails inside container | Container missing `wp-cli` or running as wrong user | Use the helper: `docker compose exec -T wordpress wp ... --allow-root` |
12 changes: 12 additions & 0 deletions .claude/templates/frontend/nextjs/.env.local.example.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# WordPress origin (server-side only — used by the GraphQL client + Image host)
WORDPRESS_URL=http://localhost:8080

# GraphQL endpoint
WORDPRESS_GRAPHQL_URL=http://localhost:8080/graphql

# Preview secret — must match flavian_headless_preview_secret in wp_options.
# Printed by scripts/wordpress-install/setup-headless.sh.
WORDPRESS_PREVIEW_SECRET=replace-me

# Public site URL (used in canonical tags / preview link generation)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
8 changes: 8 additions & 0 deletions .claude/templates/frontend/nextjs/.gitignore.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
.next/
out/
.env.local
.env.*.local
*.log
.DS_Store
.vercel
35 changes: 35 additions & 0 deletions .claude/templates/frontend/nextjs/README.md.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# {{APP_NAME}}

Next.js 14 (App Router) frontend for the Flavian headless WordPress setup. Generated by `scripts/scaffold-frontend.sh`.

## Quick start

```bash
cp .env.local.example .env.local
# Edit .env.local with the values printed by setup-headless.sh

pnpm install # or npm/yarn
pnpm dev
```

Open <http://localhost:3000>.

## Routes

| Path | What it does |
|---|---|
| `/` | Lists the 10 most recent published posts via GraphQL. |
| `/posts/[slug]` | Renders a single post by slug. |
| `/api/preview` | Validates `secret` + `id` query, enables Next.js draft mode, redirects to the post route. |
| `/api/exit-preview` | Disables draft mode and bounces back to `/`. |

## Preview flow

1. Editor clicks **Preview** on a draft post in WP admin.
2. WordPress (via `mu-plugins/flavian-headless.php`) rewrites the preview URL to `http://localhost:3000/api/preview?secret=...&id=...`.
3. This route validates the secret against `WORDPRESS_PREVIEW_SECRET` and calls `draftMode().enable()`.
4. Subsequent fetches read draft content because the GraphQL client conditionally sends `preview: true`.

## GraphQL client

`src/lib/wp-client.ts` is a thin `fetch` wrapper — no Apollo or urql to keep the bundle small. Swap it out if you need normalized caching.
18 changes: 18 additions & 0 deletions .claude/templates/frontend/nextjs/next.config.mjs.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const wpHost = (() => {
try {
return new URL(process.env.WORDPRESS_URL ?? 'http://localhost:8080').hostname;
} catch {
return 'localhost';
}
})();

export default {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: 'http', hostname: wpHost },
{ protocol: 'https', hostname: wpHost },
],
},
};
25 changes: 25 additions & 0 deletions .claude/templates/frontend/nextjs/package.json.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "{{APP_SLUG}}",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"typescript": "^5.5.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';

export async function GET(req: NextRequest) {
draftMode().disable();
return NextResponse.redirect(new URL('/', req.nextUrl.origin));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
import { wpQuery } from '@/lib/wp-client';
import { POST_BY_ID_QUERY } from '@/lib/queries';

/**
* GET /api/preview?secret=...&id=...
*
* Called by WordPress when an editor clicks "Preview" on a draft. Validates
* the shared secret, enables Next.js draft mode, and redirects to the post's
* canonical frontend route — which will then re-fetch draft content because
* draftMode() is now true.
*/
export async function GET(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
const id = req.nextUrl.searchParams.get('id');
const slugParam = req.nextUrl.searchParams.get('slug');

if (secret !== process.env.WORDPRESS_PREVIEW_SECRET) {
return new NextResponse('Invalid preview secret', { status: 401 });
}
if (!id) {
return new NextResponse('Missing id parameter', { status: 400 });
}

// Confirm the post actually exists before enabling draft mode — protects
// against /api/preview?secret=...&id=999999 enabling draft mode for free.
let slug = slugParam ?? '';
try {
const data = await wpQuery<{ post: { slug: string } | null }>(POST_BY_ID_QUERY, { id });
if (!data.post) {
return new NextResponse('Post not found', { status: 404 });
}
slug = data.post.slug || slug;
} catch (err) {
return new NextResponse(`Preview lookup failed: ${(err as Error).message}`, { status: 500 });
}

draftMode().enable();
return NextResponse.redirect(new URL(`/posts/${slug}`, req.nextUrl.origin));
}
58 changes: 58 additions & 0 deletions .claude/templates/frontend/nextjs/src/app/globals.css.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
:root {
--container: 720px;
--fg: #111;
--muted: #666;
--accent: #0066cc;
}

* { box-sizing: border-box; }

body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--fg);
line-height: 1.6;
}

.container {
max-width: var(--container);
margin: 0 auto;
padding: 2rem 1rem;
}

.preview-bar {
background: #ffefc1;
color: #5a4500;
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.9rem;
}

.preview-bar a {
color: var(--accent);
margin-left: 0.5rem;
}

.post-list {
list-style: none;
padding: 0;
}

.post-list li {
border-bottom: 1px solid #eee;
padding: 1.5rem 0;
}

.post-list h2 {
margin: 0 0 0.25rem;
}

.post-list a {
color: inherit;
text-decoration: none;
}

time {
color: var(--muted);
font-size: 0.875rem;
}
Loading
Loading