From 09e680fe4e98f1c31086c0e083579b4178ec681b Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Wed, 20 May 2026 19:38:16 -0400 Subject: [PATCH 1/2] feat: add headless WordPress + Next.js frontend scaffold (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #22. Ships headless WP as an opt-in compose profile alongside the existing stack — `docker compose --profile headless up headless-installer` installs WPGraphQL (+ optional JWT and Content Blocks), generates a preview secret, and prints the env values the frontend needs. The CORS, preview-link rewriting, and REST hardening live in a single mu-plugin (`mu-plugins/flavian-headless.php`) that short-circuits when `flavian_headless_mode` is off, so the default stack is unchanged. `scripts/scaffold-frontend.sh` generates a self-contained Next.js 14 app under `frontend//` with a fetch-based WPGraphQL client and working `/api/preview` + `/api/exit-preview` routes. Nuxt and Astro are tracked as follow-ups in `docs/headless-wordpress/README.md`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/headless-developer.md | 171 ++++++++++++++++ .../frontend/nextjs/.env.local.example.tmpl | 12 ++ .../templates/frontend/nextjs/.gitignore.tmpl | 8 + .../templates/frontend/nextjs/README.md.tmpl | 35 ++++ .../frontend/nextjs/next.config.mjs.tmpl | 18 ++ .../frontend/nextjs/package.json.tmpl | 25 +++ .../src/app/api/exit-preview/route.ts.tmpl | 7 + .../nextjs/src/app/api/preview/route.ts.tmpl | 41 ++++ .../frontend/nextjs/src/app/globals.css.tmpl | 58 ++++++ .../frontend/nextjs/src/app/layout.tsx.tmpl | 25 +++ .../frontend/nextjs/src/app/page.tsx.tmpl | 36 ++++ .../nextjs/src/app/posts/[slug]/page.tsx.tmpl | 34 ++++ .../frontend/nextjs/src/lib/queries.ts.tmpl | 48 +++++ .../frontend/nextjs/src/lib/wp-client.ts.tmpl | 32 +++ .../frontend/nextjs/tsconfig.json.tmpl | 21 ++ .env.example | 6 + CLAUDE.md | 20 +- docker-compose.yml | 34 ++++ docs/headless-wordpress/README.md | 73 +++++++ frontend/README.md | 11 ++ mu-plugins/flavian-headless.php | 184 ++++++++++++++++++ scripts/scaffold-frontend.sh | 137 +++++++++++++ scripts/wordpress-install/setup-headless.sh | 173 ++++++++++++++++ 23 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 .claude/agents/headless-developer.md create mode 100644 .claude/templates/frontend/nextjs/.env.local.example.tmpl create mode 100644 .claude/templates/frontend/nextjs/.gitignore.tmpl create mode 100644 .claude/templates/frontend/nextjs/README.md.tmpl create mode 100644 .claude/templates/frontend/nextjs/next.config.mjs.tmpl create mode 100644 .claude/templates/frontend/nextjs/package.json.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/globals.css.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/page.tsx.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/app/posts/[slug]/page.tsx.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/lib/queries.ts.tmpl create mode 100644 .claude/templates/frontend/nextjs/src/lib/wp-client.ts.tmpl create mode 100644 .claude/templates/frontend/nextjs/tsconfig.json.tmpl create mode 100644 docs/headless-wordpress/README.md create mode 100644 frontend/README.md create mode 100644 mu-plugins/flavian-headless.php create mode 100644 scripts/scaffold-frontend.sh create mode 100644 scripts/wordpress-install/setup-headless.sh diff --git a/.claude/agents/headless-developer.md b/.claude/agents/headless-developer.md new file mode 100644 index 0000000..c849116 --- /dev/null +++ b/.claude/agents/headless-developer.md @@ -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 - 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.' Headless setup is multi-step and easy to half-finish — the agent runs the canonical scripts and verifies endpoints. 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.' Preview failures usually mean secret drift between wp_options and .env.local — the agent knows where to look. 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.' WPGraphQL has a specific extension API that's easy to misuse. +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 `/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 --name "Display Name" +``` + +Generates `frontend//` 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/`. +- `src/app/api/exit-preview/route.ts` — `draftMode().disable()` + redirect home. + +After scaffolding: + +```bash +cd frontend/ +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//.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 +5. cd frontend/; 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/ 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//` 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 ` 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` | diff --git a/.claude/templates/frontend/nextjs/.env.local.example.tmpl b/.claude/templates/frontend/nextjs/.env.local.example.tmpl new file mode 100644 index 0000000..ffef3f4 --- /dev/null +++ b/.claude/templates/frontend/nextjs/.env.local.example.tmpl @@ -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 diff --git a/.claude/templates/frontend/nextjs/.gitignore.tmpl b/.claude/templates/frontend/nextjs/.gitignore.tmpl new file mode 100644 index 0000000..bb9346e --- /dev/null +++ b/.claude/templates/frontend/nextjs/.gitignore.tmpl @@ -0,0 +1,8 @@ +node_modules/ +.next/ +out/ +.env.local +.env.*.local +*.log +.DS_Store +.vercel diff --git a/.claude/templates/frontend/nextjs/README.md.tmpl b/.claude/templates/frontend/nextjs/README.md.tmpl new file mode 100644 index 0000000..86e31b9 --- /dev/null +++ b/.claude/templates/frontend/nextjs/README.md.tmpl @@ -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 . + +## 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. diff --git a/.claude/templates/frontend/nextjs/next.config.mjs.tmpl b/.claude/templates/frontend/nextjs/next.config.mjs.tmpl new file mode 100644 index 0000000..8988f10 --- /dev/null +++ b/.claude/templates/frontend/nextjs/next.config.mjs.tmpl @@ -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 }, + ], + }, +}; diff --git a/.claude/templates/frontend/nextjs/package.json.tmpl b/.claude/templates/frontend/nextjs/package.json.tmpl new file mode 100644 index 0000000..b31aac6 --- /dev/null +++ b/.claude/templates/frontend/nextjs/package.json.tmpl @@ -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" + } +} diff --git a/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl b/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl new file mode 100644 index 0000000..0208647 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/api/exit-preview/route.ts.tmpl @@ -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)); +} diff --git a/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl b/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl new file mode 100644 index 0000000..ed6eabe --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/api/preview/route.ts.tmpl @@ -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)); +} diff --git a/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl b/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl new file mode 100644 index 0000000..4624cc5 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/globals.css.tmpl @@ -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; +} diff --git a/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl b/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl new file mode 100644 index 0000000..0a61a09 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/layout.tsx.tmpl @@ -0,0 +1,25 @@ +import type { Metadata } from 'next'; +import { draftMode } from 'next/headers'; +import './globals.css'; + +export const metadata: Metadata = { + title: '{{APP_NAME}}', + description: 'Headless WordPress frontend', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const { isEnabled: isDraft } = draftMode(); + + return ( + + + {isDraft && ( +
+ Draft mode is on. Exit preview +
+ )} +
{children}
+ + + ); +} diff --git a/.claude/templates/frontend/nextjs/src/app/page.tsx.tmpl b/.claude/templates/frontend/nextjs/src/app/page.tsx.tmpl new file mode 100644 index 0000000..bdf0e5d --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/page.tsx.tmpl @@ -0,0 +1,36 @@ +import Link from 'next/link'; +import { wpQuery } from '@/lib/wp-client'; +import { POSTS_LIST_QUERY } from '@/lib/queries'; + +type PostsListResponse = { + posts: { + nodes: Array<{ + id: string; + title: string; + slug: string; + date: string; + excerpt: string; + }>; + }; +}; + +export default async function HomePage() { + const data = await wpQuery(POSTS_LIST_QUERY); + + return ( + <> +

{{APP_NAME}}

+
    + {data.posts.nodes.map(post => ( +
  • + +

    {post.title}

    + + +
    +
  • + ))} +
+ + ); +} diff --git a/.claude/templates/frontend/nextjs/src/app/posts/[slug]/page.tsx.tmpl b/.claude/templates/frontend/nextjs/src/app/posts/[slug]/page.tsx.tmpl new file mode 100644 index 0000000..1170856 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/app/posts/[slug]/page.tsx.tmpl @@ -0,0 +1,34 @@ +import { notFound } from 'next/navigation'; +import { draftMode } from 'next/headers'; +import { wpQuery } from '@/lib/wp-client'; +import { POST_BY_SLUG_QUERY } from '@/lib/queries'; + +type PostResponse = { + post: { + title: string; + date: string; + content: string; + } | null; +}; + +export default async function PostPage({ params }: { params: { slug: string } }) { + const { isEnabled: isDraft } = draftMode(); + const data = await wpQuery(POST_BY_SLUG_QUERY, { + slug: params.slug, + asPreview: isDraft, + }); + + if (!data.post) { + notFound(); + } + + return ( +
+

{data.post.title}

+ +
+
+ ); +} diff --git a/.claude/templates/frontend/nextjs/src/lib/queries.ts.tmpl b/.claude/templates/frontend/nextjs/src/lib/queries.ts.tmpl new file mode 100644 index 0000000..41c4681 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/lib/queries.ts.tmpl @@ -0,0 +1,48 @@ +export const POSTS_LIST_QUERY = /* GraphQL */ ` + query PostsList { + posts(first: 10, where: { status: PUBLISH }) { + nodes { + id + databaseId + title + slug + date + excerpt + featuredImage { + node { + sourceUrl + altText + } + } + } + } + } +`; + +export const POST_BY_SLUG_QUERY = /* GraphQL */ ` + query PostBySlug($slug: ID!, $asPreview: Boolean = false) { + post(id: $slug, idType: SLUG, asPreview: $asPreview) { + id + databaseId + title + slug + date + content + featuredImage { + node { + sourceUrl + altText + } + } + } + } +`; + +export const POST_BY_ID_QUERY = /* GraphQL */ ` + query PostById($id: ID!) { + post(id: $id, idType: DATABASE_ID, asPreview: true) { + slug + status + } + } +`; diff --git a/.claude/templates/frontend/nextjs/src/lib/wp-client.ts.tmpl b/.claude/templates/frontend/nextjs/src/lib/wp-client.ts.tmpl new file mode 100644 index 0000000..a4247b1 --- /dev/null +++ b/.claude/templates/frontend/nextjs/src/lib/wp-client.ts.tmpl @@ -0,0 +1,32 @@ +import { draftMode } from 'next/headers'; + +const endpoint = process.env.WORDPRESS_GRAPHQL_URL ?? 'http://localhost:8080/graphql'; + +type GraphQLResponse = { data?: T; errors?: Array<{ message: string }> }; + +export async function wpQuery( + query: string, + variables: Record = {}, +): Promise { + const { isEnabled: isDraft } = draftMode(); + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + next: isDraft ? { revalidate: 0 } : { revalidate: 60 }, + }); + + if (!res.ok) { + throw new Error(`WPGraphQL ${res.status}: ${await res.text()}`); + } + + const json = (await res.json()) as GraphQLResponse; + if (json.errors?.length) { + throw new Error(json.errors.map(e => e.message).join('; ')); + } + if (!json.data) { + throw new Error('WPGraphQL response had no data field'); + } + return json.data; +} diff --git a/.claude/templates/frontend/nextjs/tsconfig.json.tmpl b/.claude/templates/frontend/nextjs/tsconfig.json.tmpl new file mode 100644 index 0000000..8e358bd --- /dev/null +++ b/.claude/templates/frontend/nextjs/tsconfig.json.tmpl @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/.env.example b/.env.example index 688532c..db5a874 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,9 @@ WC_INSTALL_SAMPLE_DATA=true WC_DEFAULT_THEME=flavian-shop WC_DEFAULT_CURRENCY=USD WC_DEFAULT_COUNTRY=US:CA + +# Headless WordPress installer (compose profile: headless) +# Run: docker compose --profile headless up headless-installer +HEADLESS_FRONTEND_URL=http://localhost:3000 +HEADLESS_INSTALL_JWT=true +HEADLESS_INSTALL_BLOCKS=true diff --git a/CLAUDE.md b/CLAUDE.md index 70aacc1..5ecd39c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,9 +279,9 @@ This project uses a lean, WordPress-optimized plugin configuration with 6 plugin --- -### Custom Agents (51 Total) +### Custom Agents (52 Total) -51 specialized agents: 26 WordPress-focused development agents plus 25 generic cross-domain agents (meta/ops, business, marketing/social, engineering). Key WordPress agents include `frontend-developer`, `plugin-developer`, `test-writer-fixer`, `ui-designer`, `figma-fse-converter`, `canva-fse-converter`, `security-audit-agent`, `deployment-agent`, `woocommerce-agent`. Key generic agents include `agent-expert`, `backend-architect`, `migration-specialist`, `content-creator`, `devops-automator`. +52 specialized agents: 27 WordPress-focused development agents plus 25 generic cross-domain agents (meta/ops, business, marketing/social, engineering). Key WordPress agents include `frontend-developer`, `plugin-developer`, `test-writer-fixer`, `ui-designer`, `figma-fse-converter`, `canva-fse-converter`, `security-audit-agent`, `deployment-agent`, `woocommerce-agent`, `headless-developer`. Key generic agents include `agent-expert`, `backend-architect`, `migration-specialist`, `content-creator`, `devops-automator`. Agents are invoked automatically based on task context. @@ -463,7 +463,7 @@ Result: themes/[theme-name]/ ready for WordPress - All plugins serve WordPress development **Agent Philosophy:** -- 51 custom agents available (26 WordPress-focused + 25 generic cross-domain) +- 52 custom agents available (27 WordPress-focused + 25 generic cross-domain) - Agents invoked contextually by Claude Code - No action required - automatic selection @@ -548,6 +548,18 @@ gh auth status # Check authentication ``` Configs live in `.claude/config/deployment/.yml` (gitignored). Templates: `*.example.yml`. +**Headless WordPress (decoupled frontends):** +```bash +# One-shot install: WPGraphQL + CORS + preview secret +docker compose --profile headless up headless-installer + +# Scaffold a Next.js 14 frontend under frontend// +bash scripts/scaffold-frontend.sh my-app --name "My Site" +``` +Mu-plugin: `mu-plugins/flavian-headless.php` (toggled by `flavian_headless_mode` option). +Frontend templates: `.claude/templates/frontend/nextjs/`. +Full guide: `docs/headless-wordpress/README.md`. + **WooCommerce (e-commerce scaffold):** ```bash # One-shot install via compose profile @@ -587,4 +599,4 @@ Defaults configurable via `.env`: `WC_INSTALL_SAMPLE_DATA`, `WC_DEFAULT_THEME`, --- **Last Updated:** 2026-05-06 -**Architecture Status:** ✅ Lean, WordPress-optimized configuration (6 plugins + 51 agents) \ No newline at end of file +**Architecture Status:** ✅ Lean, WordPress-optimized configuration (6 plugins + 52 agents) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4e280ba..14b3a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,40 @@ services: [ -n "$$WC_DEFAULT_COUNTRY" ] && ARGS="$$ARGS --country $$WC_DEFAULT_COUNTRY" bash /scripts/setup-woocommerce.sh $$ARGS + # One-shot headless WordPress installer. + # + # Run with the 'headless' compose profile to install WPGraphQL, configure + # CORS + preview routing, and print the env values your frontend needs: + # + # docker compose --profile headless up headless-installer + # + # The container exits when setup-headless.sh completes. Re-running is safe — + # every step is idempotent. + headless-installer: + image: docker:cli + container_name: flavian-headless-installer + profiles: ["headless"] + depends_on: + wordpress: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./scripts/wordpress-install:/scripts:ro + working_dir: /scripts + environment: + HEADLESS_FRONTEND_URL: ${HEADLESS_FRONTEND_URL:-http://localhost:3000} + HEADLESS_INSTALL_JWT: ${HEADLESS_INSTALL_JWT:-true} + HEADLESS_INSTALL_BLOCKS: ${HEADLESS_INSTALL_BLOCKS:-true} + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + apk add --no-cache bash docker-cli-compose >/dev/null + ARGS="--frontend-url $$HEADLESS_FRONTEND_URL" + [ "$$HEADLESS_INSTALL_JWT" = "false" ] && ARGS="$$ARGS --no-jwt" + [ "$$HEADLESS_INSTALL_BLOCKS" = "false" ] && ARGS="$$ARGS --no-blocks" + bash /scripts/setup-headless.sh $$ARGS + # Optional: phpMyAdmin for database management phpmyadmin: image: phpmyadmin:latest diff --git a/docs/headless-wordpress/README.md b/docs/headless-wordpress/README.md new file mode 100644 index 0000000..3c9f9c8 --- /dev/null +++ b/docs/headless-wordpress/README.md @@ -0,0 +1,73 @@ +# Headless WordPress + +Run WordPress as a content API (REST + WPGraphQL) and consume it from a decoupled frontend. The MVP ships a Next.js 14 (App Router) scaffold; Nuxt and Astro are tracked as follow-ups. + +## What's in the box + +| Piece | Path | Purpose | +|---|---|---| +| Installer script | `scripts/wordpress-install/setup-headless.sh` | Installs WPGraphQL, sets pretty permalinks, generates the preview secret, prints the values your frontend needs. | +| Compose profile | `docker-compose.yml` → `headless-installer` | Wraps the installer in a one-shot container. Run with `docker compose --profile headless up headless-installer`. | +| Mu-plugin | `mu-plugins/flavian-headless.php` | CORS, preview-URL rewriting, REST hardening, REST `preview_secret_match` field. Toggled by `flavian_headless_mode` option. | +| Scaffold script | `scripts/scaffold-frontend.sh` | Generates `frontend//` from `.claude/templates/frontend/nextjs/`. | +| Agent | `.claude/agents/headless-developer.md` | Domain expert; invoke for headless workflows. | + +## Five-minute setup + +```bash +# 1. Bring up WordPress (if not already running) +docker compose up -d + +# 2. Install WPGraphQL + configure headless mode +docker compose --profile headless up headless-installer +# → prints WORDPRESS_PREVIEW_SECRET and JWT secret + +# 3. Scaffold a Next.js frontend +bash scripts/scaffold-frontend.sh my-app --name "My Site" + +# 4. Configure and run the frontend +cd frontend/my-app +cp .env.local.example .env.local +# paste the values from step 2 into .env.local +pnpm install && pnpm dev +# → http://localhost:3000 +``` + +## How preview mode works + +1. Editor clicks **Preview** on a draft in `/wp-admin`. +2. `flavian-headless.php` hooks `preview_post_link` and rewrites the URL to: + ``` + http://localhost:3000/api/preview?secret=&id=&slug=&type=post + ``` +3. The Next.js `/api/preview` route validates `secret` against `WORDPRESS_PREVIEW_SECRET`, confirms the post exists via `POST_BY_ID_QUERY`, then calls `draftMode().enable()` and redirects to `/posts/`. +4. The post page detects `draftMode().isEnabled` and passes `asPreview: true` to the GraphQL query, which returns the unpublished revision. +5. The `` shows a "Draft mode is on" banner with an exit link to `/api/exit-preview`. + +## Endpoints + +- **REST**: `http://localhost:8080/wp-json/wp/v2/` +- **GraphQL**: `http://localhost:8080/graphql` +- **GraphiQL IDE**: `http://localhost:8080/wp-admin/admin.php?page=graphiql-ide` + +## Disabling headless mode + +```bash +docker compose exec wordpress wp option update flavian_headless_mode 0 --allow-root +``` + +The mu-plugin's hooks short-circuit when the option is off, so WordPress reverts to standard behavior immediately — no plugin deactivation needed. + +## Troubleshooting + +See the **Error Recovery** table in `.claude/agents/headless-developer.md` for the canonical list. The three failures that cover ~90% of issues: + +1. **Secret drift** between `wp_options.flavian_headless_preview_secret` and `frontend//.env.local`'s `WORDPRESS_PREVIEW_SECRET`. +2. **CORS preflight rejection** — `flavian_headless_mode` is off, or `flavian_headless_frontend_url` doesn't match the actual frontend origin. +3. **`asPreview` not propagating** — the GraphQL client must read `draftMode().isEnabled` and forward it as a variable; the query must declare `asPreview: Boolean = false`. + +## Follow-ups + +- Nuxt scaffold (`.claude/templates/frontend/nuxt/`) +- Astro scaffold (`.claude/templates/frontend/astro/`) +- Webhook-based ISR revalidation on post publish diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7e28772 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,11 @@ +# Frontend apps + +Decoupled frontend apps for the headless WordPress setup live here. Scaffold a new one with: + +```bash +bash scripts/scaffold-frontend.sh my-app +``` + +Each scaffolded app is self-contained: its own `package.json`, `.env.local`, and dependencies under `node_modules/` (gitignored at repo root). + +See `docs/headless-wordpress/README.md` for the end-to-end setup. diff --git a/mu-plugins/flavian-headless.php b/mu-plugins/flavian-headless.php new file mode 100644 index 0000000..649dab5 --- /dev/null +++ b/mu-plugins/flavian-headless.php @@ -0,0 +1,184 @@ + $secret, + 'id' => $post->ID, + 'slug' => $post->post_name, + 'type' => $post->post_type, + ) ); + + return frontend_url() . '/api/preview?' . $query; +} + +/** + * Replace the "View Post" permalink in the admin row actions so editors + * land on the frontend rendering, not the WP single template. + */ +add_filter( 'post_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); +add_filter( 'page_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); +function rewrite_permalink( string $permalink, $post ): string { + if ( ! is_enabled() || ! is_admin() ) { + return $permalink; + } + $post = get_post( $post ); + if ( ! $post || $post->post_status !== 'publish' ) { + return $permalink; + } + + $path = wp_parse_url( $permalink, PHP_URL_PATH ) ?: '/'; + return frontend_url() . $path; +} + +/** + * REST API: trim a handful of endpoints that leak data unnecessarily for a + * headless setup (user enumeration is the classic one). Apps that actually + * need the users endpoint can reinstate it. + */ +add_filter( 'rest_endpoints', __NAMESPACE__ . '\\restrict_rest_endpoints' ); +function restrict_rest_endpoints( array $endpoints ): array { + if ( ! is_enabled() ) { + return $endpoints; + } + unset( + $endpoints['/wp/v2/users'], + $endpoints['/wp/v2/users/(?P[\d]+)'] + ); + return $endpoints; +} + +/** + * Expose the preview secret to GraphQL/REST consumers via a custom field + * so a frontend can validate incoming preview requests against the same + * secret WordPress just signed them with. Returned only to authenticated + * editors+ — never publicly. + */ +add_action( 'rest_api_init', __NAMESPACE__ . '\\register_preview_field' ); +function register_preview_field(): void { + if ( ! is_enabled() ) { + return; + } + register_rest_field( 'post', 'preview_secret_match', array( + 'get_callback' => static function ( $object, $field, $request ) { + if ( ! current_user_can( 'edit_post', $object['id'] ) ) { + return null; + } + $incoming = (string) $request->get_param( 'preview_secret' ); + return hash_equals( preview_secret(), $incoming ); + }, + 'schema' => array( + 'description' => 'True when the incoming preview_secret query arg matches the stored secret.', + 'type' => 'boolean', + 'context' => array( 'edit' ), + ), + ) ); +} + +/** + * Admin notice with the current frontend URL so editors know where Preview + * is going to land them. + */ +add_action( 'admin_notices', __NAMESPACE__ . '\\admin_notice_headless_mode' ); +function admin_notice_headless_mode(): void { + if ( ! is_enabled() || ! current_user_can( 'manage_options' ) ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( $screen && $screen->id !== 'dashboard' ) { + return; + } + printf( + '

%s %s %s

', + esc_html__( 'Headless mode is active.', 'flavian' ), + esc_html__( 'Previews and post links route to:', 'flavian' ), + esc_html( frontend_url() ) + ); +} diff --git a/scripts/scaffold-frontend.sh b/scripts/scaffold-frontend.sh new file mode 100644 index 0000000..e874302 --- /dev/null +++ b/scripts/scaffold-frontend.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# scripts/scaffold-frontend.sh +# +# Generate a Next.js 14 (App Router) headless frontend under frontend// +# from the templates in .claude/templates/frontend/nextjs/. +# +# The generated app talks to the headless WordPress configured by +# scripts/wordpress-install/setup-headless.sh — same preview secret, same +# GraphQL endpoint. +# +# Usage: +# bash scripts/scaffold-frontend.sh [options] +# +# Options: +# --name "Human Name" Display name (default: slug title-cased) +# --framework nextjs Frontend framework (default: nextjs; only option today) +# --force Overwrite an existing frontend// directory +# --dry-run Print actions but do not write any files +# +# Exit codes: 0 = success, 1 = validation/IO error, 2 = bad usage. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FRONTEND_DIR="$PROJECT_ROOT/frontend" + +SLUG="" +NAME="" +FRAMEWORK="nextjs" +FORCE=0 +DRY_RUN=0 + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-2}" +} + +die() { + printf '✗ %s\n' "$*" >&2 + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --framework) FRAMEWORK="$2"; shift 2 ;; + --force) FORCE=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage 0 ;; + --*) die "Unknown flag: $1" ;; + *) + if [ -z "$SLUG" ]; then SLUG="$1"; shift + else die "Unexpected positional arg: $1" + fi + ;; + esac +done + +[ -n "$SLUG" ] || usage 2 + +# Only nextjs ships today; nuxt/astro are tracked as follow-ups. +if [ "$FRAMEWORK" != "nextjs" ]; then + die "Framework '$FRAMEWORK' is not yet implemented. Use --framework nextjs." +fi + +TEMPLATE_DIR="$PROJECT_ROOT/.claude/templates/frontend/$FRAMEWORK" +[ -d "$TEMPLATE_DIR" ] || die "Template directory not found: $TEMPLATE_DIR" + +# --- Slug validation --- +if ! [[ "$SLUG" =~ ^[a-z][a-z0-9-]*$ ]]; then + die "Invalid slug '$SLUG'. Must match ^[a-z][a-z0-9-]*\$ (lowercase, digits, hyphens; must start with a letter)." +fi + +if [ -z "$NAME" ]; then + NAME="$(printf '%s' "$SLUG" | sed -E 's/(^|-)([a-z])/\1\u\2/g' | tr '-' ' ')" +fi + +TARGET_DIR="$FRONTEND_DIR/$SLUG" + +if [ -e "$TARGET_DIR" ]; then + if [ "$FORCE" -eq 1 ]; then + [ "$DRY_RUN" -eq 1 ] || rm -rf "$TARGET_DIR" + else + die "$TARGET_DIR already exists. Re-run with --force to overwrite." + fi +fi + +# --- Token substitution helper --- +sed_escape() { + printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' +} + +render_file() { + local src="$1" dst="$2" + local slug_e name_e + slug_e="$(sed_escape "$SLUG")" + name_e="$(sed_escape "$NAME")" + sed \ + -e "s|{{APP_SLUG}}|${slug_e}|g" \ + -e "s|{{APP_NAME}}|${name_e}|g" \ + "$src" > "$dst" +} + +printf '▸ Scaffolding %s frontend: %s\n' "$FRAMEWORK" "$SLUG" +printf ' Name: %s\n' "$NAME" +printf ' Target: %s\n' "$TARGET_DIR" +[ "$DRY_RUN" -eq 1 ] && printf ' Dry-run: yes\n' +echo "" + +COUNT=0 +while IFS= read -r src; do + rel="${src#$TEMPLATE_DIR/}" + rel="${rel%.tmpl}" + dst="$TARGET_DIR/$rel" + + if [ "$DRY_RUN" -eq 1 ]; then + printf ' + %s\n' "$rel" + else + mkdir -p "$(dirname "$dst")" + render_file "$src" "$dst" + fi + COUNT=$((COUNT + 1)) +done < <(find "$TEMPLATE_DIR" -type f -name '*.tmpl' | LC_ALL=C sort) + +echo "" +if [ "$DRY_RUN" -eq 1 ]; then + printf '✓ Dry run: would write %d files to %s\n' "$COUNT" "$TARGET_DIR" +else + printf '✓ Wrote %d files to %s\n' "$COUNT" "$TARGET_DIR" + printf '\nNext steps:\n' + printf ' 1. docker compose --profile headless up headless-installer\n' + printf ' (installs WPGraphQL + prints WORDPRESS_PREVIEW_SECRET)\n' + printf ' 2. cd frontend/%s\n' "$SLUG" + printf ' cp .env.local.example .env.local # paste the printed values\n' + printf ' pnpm install && pnpm dev\n' + printf ' 3. Open http://localhost:3000\n' +fi diff --git a/scripts/wordpress-install/setup-headless.sh b/scripts/wordpress-install/setup-headless.sh new file mode 100644 index 0000000..c60950d --- /dev/null +++ b/scripts/wordpress-install/setup-headless.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Install and configure headless WordPress inside the Docker WP container. +# +# What it does, in order: +# 1. Verifies WordPress is installed; bails out cleanly if not. +# 2. Installs and activates WPGraphQL (+ optional companions). +# 3. Sets HEADLESS_MODE option so flavian-headless.php mu-plugin activates. +# 4. Generates a preview secret and stores it in wp_options. +# 5. Configures pretty permalinks (required for REST + WPGraphQL routes). +# 6. Pings /graphql and /wp-json/wp/v2/ to confirm both APIs respond. +# +# Usage: +# ./scripts/wordpress-install/setup-headless.sh [--frontend-url URL] +# [--no-jwt] +# [--no-blocks] +# [--preview-secret VALUE] +# +# Requires: docker compose, the project's WordPress container running. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +FRONTEND_URL="${HEADLESS_FRONTEND_URL:-http://localhost:3000}" +INSTALL_JWT=true +INSTALL_BLOCKS=true +PREVIEW_SECRET="" + +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +log() { printf '\n[setup-headless] %s\n' "$*"; } + +WP_CONTAINER="${WP_CONTAINER:-flavian-wp}" +if [[ -f /.dockerenv ]] || [[ "${WP_USE_DOCKER_EXEC:-}" == "true" ]]; then + wp_in_container() { + docker exec -i "$WP_CONTAINER" wp "$@" --allow-root + } +else + wp_in_container() { + docker compose exec -T wordpress wp "$@" --allow-root + } +fi + +# 0. Prerequisites ----------------------------------------------------------- +if ! command -v docker >/dev/null 2>&1; then + echo "[ERROR] docker not found in PATH" >&2 + exit 1 +fi + +if [[ ! -f /.dockerenv ]]; then + if ! docker compose ps --status running --services 2>/dev/null | grep -q '^wordpress$'; then + log "WordPress container not running — starting it" + docker compose up -d wordpress db + log "Waiting for WordPress to become healthy..." + for _ in $(seq 1 30); do + if docker compose exec -T wordpress curl -sf http://localhost/ >/dev/null 2>&1; then + break + fi + sleep 2 + done + fi +fi + +if ! wp_in_container core is-installed >/dev/null 2>&1; then + echo "[ERROR] WordPress is not installed yet. Run the WP install step first" >&2 + exit 1 +fi + +# 1. Install WPGraphQL + companions ------------------------------------------ +install_plugin() { + local slug="$1" + if wp_in_container plugin is-installed "$slug" >/dev/null 2>&1; then + log "$slug already installed" + else + log "Installing $slug" + wp_in_container plugin install "$slug" --activate + fi + if ! wp_in_container plugin is-active "$slug" >/dev/null 2>&1; then + wp_in_container plugin activate "$slug" + fi +} + +install_plugin wp-graphql + +# wp-graphql-jwt-authentication: token-based auth for the frontend's +# preview/draft fetches. Optional — apps can also use WP application passwords. +if $INSTALL_JWT; then + install_plugin wp-graphql-jwt-authentication || \ + log "JWT plugin install failed — continuing without it" +fi + +# wp-graphql-content-blocks: exposes the FSE block tree as a queryable field +# (editorBlocks). Critical for any frontend that wants to render block content. +if $INSTALL_BLOCKS; then + install_plugin wp-graphql-content-blocks || \ + log "Content Blocks plugin install failed — continuing without it" +fi + +# 2. Headless mode toggle + secrets ------------------------------------------ +log "Enabling HEADLESS_MODE option" +wp_in_container option update flavian_headless_mode 1 >/dev/null +wp_in_container option update flavian_headless_frontend_url "$FRONTEND_URL" >/dev/null + +if [[ -z "$PREVIEW_SECRET" ]]; then + # 32-byte hex secret. wp eval avoids needing openssl in the container. + PREVIEW_SECRET="$(wp_in_container eval 'echo bin2hex(random_bytes(32));' 2>/dev/null | tr -d '\r\n')" +fi +wp_in_container option update flavian_headless_preview_secret "$PREVIEW_SECRET" >/dev/null +log "Preview secret stored in wp_options (key: flavian_headless_preview_secret)" + +# JWT signing key — required by wp-graphql-jwt-authentication. The plugin +# reads GRAPHQL_JWT_AUTH_SECRET_KEY from wp-config.php OR a filter. We can't +# write to wp-config from here safely, so we surface the value to the user. +if $INSTALL_JWT; then + JWT_SECRET="$(wp_in_container eval 'echo bin2hex(random_bytes(32));' 2>/dev/null | tr -d '\r\n')" + wp_in_container option update flavian_headless_jwt_secret "$JWT_SECRET" >/dev/null +fi + +# 3. Permalinks -------------------------------------------------------------- +log "Setting pretty permalinks" +wp_in_container option update permalink_structure '/%postname%/' >/dev/null +wp_in_container rewrite flush --hard >/dev/null + +# 4. Verify endpoints -------------------------------------------------------- +log "Verifying API endpoints" +if wp_in_container option get permalink_structure >/dev/null 2>&1; then + REST_STATUS=$(docker exec "$WP_CONTAINER" curl -s -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/posts 2>/dev/null || echo "000") + log "REST API /wp-json/wp/v2/posts → HTTP $REST_STATUS" + + GQL_STATUS=$(docker exec "$WP_CONTAINER" curl -s -o /dev/null -w '%{http_code}' \ + -X POST -H 'Content-Type: application/json' \ + -d '{"query":"{ generalSettings { title } }"}' \ + http://localhost/graphql 2>/dev/null || echo "000") + log "GraphQL /graphql → HTTP $GQL_STATUS" +fi + +# 5. Summary ----------------------------------------------------------------- +echo "" +log "Headless WordPress configured. Endpoints (host):" +log " REST API: http://localhost:8080/wp-json/wp/v2/" +log " GraphQL: http://localhost:8080/graphql" +log " GraphiQL UI: http://localhost:8080/wp-admin/admin.php?page=graphiql-ide" +echo "" +log "Frontend env values to copy into frontend/.env.local:" +echo " WORDPRESS_URL=http://localhost:8080" +echo " WORDPRESS_GRAPHQL_URL=http://localhost:8080/graphql" +echo " WORDPRESS_PREVIEW_SECRET=$PREVIEW_SECRET" +if $INSTALL_JWT; then + echo " # Add the following line to wp-config.php for JWT auth:" + echo " # define( 'GRAPHQL_JWT_AUTH_SECRET_KEY', '$JWT_SECRET' );" +fi From d79722837888676a242db77807b50483266bc99c Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Wed, 20 May 2026 19:43:47 -0400 Subject: [PATCH 2/2] style(headless): satisfy WordPress-Extra + WordPress-Docs PHPCS rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI flagged the mu-plugin against WordPress-Extra/Docs. Changes are mechanical — same behaviour: - Add @package tag to the file docblock. - Add @return/@param docblocks to every function (closure aside). - Yoda all comparisons (null === $x, '' === $secret, etc). - Reformat multi-line array_filter / http_build_query / register_rest_field so opening parens close the line and each argument sits on its own line. - Drop the `?:` short ternary in rewrite_permalink in favour of an explicit empty() check. - Rename the register_rest_field callback's reserved `$object` parameter to `$post_arr`; ignore the unused `$field` arg. - Align `=>` columns in the schema array. - Sanitize $_SERVER['REQUEST_METHOD'] via wp_unslash + sanitize_text_field before comparing it. Verified with the exact CI command (`phpcs --standard=WordPress-Extra, WordPress-Docs --extensions=php --ignore=*/index.php mu-plugins/`): 1 file scanned, 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- mu-plugins/flavian-headless.php | 163 +++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 57 deletions(-) diff --git a/mu-plugins/flavian-headless.php b/mu-plugins/flavian-headless.php index 649dab5..1d72e04 100644 --- a/mu-plugins/flavian-headless.php +++ b/mu-plugins/flavian-headless.php @@ -7,6 +7,8 @@ * License: GPL-2.0-or-later * * Configured by scripts/wordpress-install/setup-headless.sh. + * + * @package Flavian\Headless */ namespace Flavian\Headless; @@ -17,46 +19,65 @@ /** * Cached check so the toggle doesn't hit the options table on every hook. + * + * @return bool */ function is_enabled(): bool { static $enabled = null; - if ( $enabled === null ) { + if ( null === $enabled ) { $enabled = (bool) get_option( 'flavian_headless_mode', false ); } return $enabled; } +/** + * Configured frontend origin without a trailing slash. + * + * @return string + */ function frontend_url(): string { $url = (string) get_option( 'flavian_headless_frontend_url', '' ); - return $url !== '' ? rtrim( $url, '/' ) : 'http://localhost:3000'; + return '' !== $url ? rtrim( $url, '/' ) : 'http://localhost:3000'; } +/** + * Stored preview secret used to authenticate Preview-button clicks. + * + * @return string + */ function preview_secret(): string { return (string) get_option( 'flavian_headless_preview_secret', '' ); } /** - * CORS for the frontend origin. + * Emit CORS headers for the configured frontend origin. * * Wide-open headers would defeat the purpose; we mirror the configured - * frontend origin (or echo the Origin header if it matches one of a small + * frontend origin (or echo the Origin header when it matches one of a small * allowlist) and let WP's standard REST/GraphQL responses pass through. + * + * @return void */ -add_action( 'init', __NAMESPACE__ . '\\send_cors_headers' ); function send_cors_headers(): void { if ( ! is_enabled() ) { return; } - $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; - if ( $origin === '' ) { + $origin = isset( $_SERVER['HTTP_ORIGIN'] ) + ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) + : ''; + if ( '' === $origin ) { return; } - $allowed = array_filter( array_unique( array( - frontend_url(), - 'http://localhost:3000', - ) ) ); + $allowed = array_filter( + array_unique( + array( + frontend_url(), + 'http://localhost:3000', + ) + ) + ); if ( ! in_array( $origin, $allowed, true ) ) { return; @@ -68,62 +89,82 @@ function send_cors_headers(): void { header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' ); header( 'Vary: Origin' ); - if ( ( $_SERVER['REQUEST_METHOD'] ?? '' ) === 'OPTIONS' ) { + $method = isset( $_SERVER['REQUEST_METHOD'] ) + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) + : ''; + if ( 'OPTIONS' === $method ) { status_header( 204 ); exit; } } +add_action( 'init', __NAMESPACE__ . '\\send_cors_headers' ); /** - * Rewrite the "Preview" link in the admin to point at the frontend's - * /api/preview route. The route validates the secret + post id, sets a - * Next.js draft mode cookie, then redirects to the post's frontend URL. + * Rewrite the admin "Preview" link to the frontend's /api/preview route. + * + * The route validates the secret + post id, sets a Next.js draft-mode + * cookie, then redirects to the post's frontend URL. + * + * @param string $link Current preview URL. + * @param \WP_Post $post Post being previewed. + * @return string */ -add_filter( 'preview_post_link', __NAMESPACE__ . '\\rewrite_preview_link', 10, 2 ); function rewrite_preview_link( string $link, \WP_Post $post ): string { if ( ! is_enabled() ) { return $link; } $secret = preview_secret(); - if ( $secret === '' ) { + if ( '' === $secret ) { return $link; } - $query = http_build_query( array( - 'secret' => $secret, - 'id' => $post->ID, - 'slug' => $post->post_name, - 'type' => $post->post_type, - ) ); + $query = http_build_query( + array( + 'secret' => $secret, + 'id' => $post->ID, + 'slug' => $post->post_name, + 'type' => $post->post_type, + ) + ); return frontend_url() . '/api/preview?' . $query; } +add_filter( 'preview_post_link', __NAMESPACE__ . '\\rewrite_preview_link', 10, 2 ); /** - * Replace the "View Post" permalink in the admin row actions so editors - * land on the frontend rendering, not the WP single template. + * Rewrite "View Post" permalinks shown in the admin to the frontend origin. + * + * @param string $permalink Current permalink. + * @param \WP_Post|int|null $post Post object or ID passed by the filter. + * @return string */ -add_filter( 'post_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); -add_filter( 'page_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); function rewrite_permalink( string $permalink, $post ): string { if ( ! is_enabled() || ! is_admin() ) { return $permalink; } - $post = get_post( $post ); - if ( ! $post || $post->post_status !== 'publish' ) { + $post_obj = get_post( $post ); + if ( ! $post_obj || 'publish' !== $post_obj->post_status ) { return $permalink; } - $path = wp_parse_url( $permalink, PHP_URL_PATH ) ?: '/'; + $path = wp_parse_url( $permalink, PHP_URL_PATH ); + if ( empty( $path ) ) { + $path = '/'; + } return frontend_url() . $path; } +add_filter( 'post_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); +add_filter( 'page_link', __NAMESPACE__ . '\\rewrite_permalink', 10, 2 ); /** - * REST API: trim a handful of endpoints that leak data unnecessarily for a - * headless setup (user enumeration is the classic one). Apps that actually - * need the users endpoint can reinstate it. + * Remove REST endpoints that leak data unnecessarily for a headless setup. + * + * Apps that actually need the users endpoint can reinstate it on the + * `rest_endpoints` filter with a later priority. + * + * @param array $endpoints REST endpoints map. + * @return array */ -add_filter( 'rest_endpoints', __NAMESPACE__ . '\\restrict_rest_endpoints' ); function restrict_rest_endpoints( array $endpoints ): array { if ( ! is_enabled() ) { return $endpoints; @@ -134,45 +175,52 @@ function restrict_rest_endpoints( array $endpoints ): array { ); return $endpoints; } +add_filter( 'rest_endpoints', __NAMESPACE__ . '\\restrict_rest_endpoints' ); /** - * Expose the preview secret to GraphQL/REST consumers via a custom field - * so a frontend can validate incoming preview requests against the same - * secret WordPress just signed them with. Returned only to authenticated - * editors+ — never publicly. + * Register a REST field that confirms the incoming preview_secret matches. + * + * Returned only to authenticated editors+; anonymous callers get null. + * + * @return void */ -add_action( 'rest_api_init', __NAMESPACE__ . '\\register_preview_field' ); function register_preview_field(): void { if ( ! is_enabled() ) { return; } - register_rest_field( 'post', 'preview_secret_match', array( - 'get_callback' => static function ( $object, $field, $request ) { - if ( ! current_user_can( 'edit_post', $object['id'] ) ) { - return null; - } - $incoming = (string) $request->get_param( 'preview_secret' ); - return hash_equals( preview_secret(), $incoming ); - }, - 'schema' => array( - 'description' => 'True when the incoming preview_secret query arg matches the stored secret.', - 'type' => 'boolean', - 'context' => array( 'edit' ), - ), - ) ); + register_rest_field( + 'post', + 'preview_secret_match', + array( + 'get_callback' => static function ( $post_arr, $field, $request ) { + unset( $field ); + if ( ! current_user_can( 'edit_post', $post_arr['id'] ) ) { + return null; + } + $incoming = (string) $request->get_param( 'preview_secret' ); + return hash_equals( preview_secret(), $incoming ); + }, + 'schema' => array( + 'description' => 'True when the incoming preview_secret query arg matches the stored secret.', + 'type' => 'boolean', + 'context' => array( 'edit' ), + ), + ) + ); } +add_action( 'rest_api_init', __NAMESPACE__ . '\\register_preview_field' ); /** - * Admin notice with the current frontend URL so editors know where Preview - * is going to land them. + * Surface an admin notice on the dashboard showing the current frontend URL. + * + * @return void */ -add_action( 'admin_notices', __NAMESPACE__ . '\\admin_notice_headless_mode' ); function admin_notice_headless_mode(): void { if ( ! is_enabled() || ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( $screen && $screen->id !== 'dashboard' ) { + if ( $screen && 'dashboard' !== $screen->id ) { return; } printf( @@ -182,3 +230,4 @@ function admin_notice_headless_mode(): void { esc_html( frontend_url() ) ); } +add_action( 'admin_notices', __NAMESPACE__ . '\\admin_notice_headless_mode' );