claude-plugin-wordpress-manager 1.5.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +27 -13
  4. package/agents/wp-accessibility-auditor.md +206 -0
  5. package/agents/wp-content-strategist.md +18 -0
  6. package/agents/wp-deployment-engineer.md +34 -2
  7. package/agents/wp-performance-optimizer.md +12 -0
  8. package/agents/wp-security-auditor.md +20 -0
  9. package/agents/wp-security-hardener.md +266 -0
  10. package/agents/wp-site-manager.md +14 -0
  11. package/agents/wp-test-engineer.md +207 -0
  12. package/docs/guides/INDEX.md +46 -0
  13. package/docs/guides/wp-blog.md +590 -0
  14. package/docs/guides/wp-design-system.md +976 -0
  15. package/docs/guides/wp-ecommerce.md +786 -0
  16. package/docs/guides/wp-landing-page.md +762 -0
  17. package/docs/guides/wp-portfolio.md +713 -0
  18. package/docs/plans/2026-02-27-design-system-guide-design.md +30 -0
  19. package/docs/plans/2026-02-27-site-type-guides-design.md +44 -0
  20. package/package.json +2 -2
  21. package/skills/wordpress-router/references/decision-tree.md +12 -2
  22. package/skills/wp-accessibility/SKILL.md +170 -0
  23. package/skills/wp-accessibility/references/a11y-audit-tools.md +248 -0
  24. package/skills/wp-accessibility/references/a11y-testing.md +222 -0
  25. package/skills/wp-accessibility/references/block-a11y.md +247 -0
  26. package/skills/wp-accessibility/references/interactive-a11y.md +272 -0
  27. package/skills/wp-accessibility/references/media-a11y.md +254 -0
  28. package/skills/wp-accessibility/references/theme-a11y.md +309 -0
  29. package/skills/wp-audit/SKILL.md +4 -0
  30. package/skills/wp-block-development/SKILL.md +5 -0
  31. package/skills/wp-block-themes/SKILL.md +4 -0
  32. package/skills/wp-e2e-testing/SKILL.md +186 -0
  33. package/skills/wp-e2e-testing/references/ci-integration.md +174 -0
  34. package/skills/wp-e2e-testing/references/jest-wordpress.md +114 -0
  35. package/skills/wp-e2e-testing/references/phpunit-wordpress.md +141 -0
  36. package/skills/wp-e2e-testing/references/playwright-wordpress.md +108 -0
  37. package/skills/wp-e2e-testing/references/test-data-generation.md +127 -0
  38. package/skills/wp-e2e-testing/references/visual-regression.md +107 -0
  39. package/skills/wp-e2e-testing/references/wp-env-setup.md +97 -0
  40. package/skills/wp-e2e-testing/scripts/test_inspect.mjs +375 -0
  41. package/skills/wp-headless/SKILL.md +168 -0
  42. package/skills/wp-headless/references/api-layer-choice.md +160 -0
  43. package/skills/wp-headless/references/cors-config.md +245 -0
  44. package/skills/wp-headless/references/frontend-integration.md +331 -0
  45. package/skills/wp-headless/references/headless-auth.md +286 -0
  46. package/skills/wp-headless/references/webhooks.md +277 -0
  47. package/skills/wp-headless/references/wpgraphql.md +331 -0
  48. package/skills/wp-headless/scripts/headless_inspect.mjs +321 -0
  49. package/skills/wp-i18n/SKILL.md +170 -0
  50. package/skills/wp-i18n/references/js-i18n.md +201 -0
  51. package/skills/wp-i18n/references/multilingual-setup.md +219 -0
  52. package/skills/wp-i18n/references/php-i18n.md +196 -0
  53. package/skills/wp-i18n/references/rtl-support.md +206 -0
  54. package/skills/wp-i18n/references/translation-workflow.md +178 -0
  55. package/skills/wp-i18n/references/wpcli-i18n.md +177 -0
  56. package/skills/wp-i18n/scripts/i18n_inspect.mjs +330 -0
  57. package/skills/wp-interactivity-api/SKILL.md +4 -0
  58. package/skills/wp-plugin-development/SKILL.md +6 -0
  59. package/skills/wp-rest-api/SKILL.md +4 -0
  60. package/skills/wp-security/SKILL.md +179 -0
  61. package/skills/wp-security/references/api-restriction.md +147 -0
  62. package/skills/wp-security/references/authentication-hardening.md +105 -0
  63. package/skills/wp-security/references/filesystem-hardening.md +105 -0
  64. package/skills/wp-security/references/http-headers.md +105 -0
  65. package/skills/wp-security/references/incident-response.md +144 -0
  66. package/skills/wp-security/references/user-capabilities.md +115 -0
  67. package/skills/wp-security/references/wp-config-security.md +129 -0
  68. package/skills/wp-security/scripts/security_inspect.mjs +393 -0
@@ -0,0 +1,245 @@
1
+ # CORS Configuration
2
+
3
+ Use this file when configuring Cross-Origin Resource Sharing for headless WordPress.
4
+
5
+ ## Why CORS matters for headless
6
+
7
+ In a headless setup, the frontend (e.g., `app.example.com`) and WordPress backend (`wp.example.com`) are on different origins. Browsers block cross-origin requests unless the server explicitly allows them via CORS headers.
8
+
9
+ ## WordPress CORS via PHP
10
+
11
+ ### Allow specific origin (recommended)
12
+
13
+ ```php
14
+ add_action('init', function() {
15
+ $allowed_origins = [
16
+ 'https://app.example.com',
17
+ 'https://staging.example.com',
18
+ ];
19
+
20
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
21
+
22
+ if (in_array($origin, $allowed_origins, true)) {
23
+ header("Access-Control-Allow-Origin: $origin");
24
+ header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
25
+ header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
26
+ header('Access-Control-Allow-Credentials: true');
27
+ header('Access-Control-Max-Age: 86400');
28
+ }
29
+
30
+ // Handle preflight
31
+ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
32
+ status_header(204);
33
+ exit;
34
+ }
35
+ });
36
+ ```
37
+
38
+ ### REST API specific filter
39
+
40
+ ```php
41
+ add_action('rest_api_init', function() {
42
+ remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
43
+
44
+ add_filter('rest_pre_serve_request', function($value) {
45
+ $allowed_origins = [
46
+ 'https://app.example.com',
47
+ 'https://staging.example.com',
48
+ ];
49
+
50
+ $origin = get_http_origin();
51
+
52
+ if ($origin && in_array($origin, $allowed_origins, true)) {
53
+ header("Access-Control-Allow-Origin: $origin");
54
+ header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
55
+ header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
56
+ header('Access-Control-Allow-Credentials: true');
57
+ }
58
+
59
+ return $value;
60
+ });
61
+ });
62
+ ```
63
+
64
+ ### WPGraphQL CORS
65
+
66
+ ```php
67
+ add_action('graphql_response_headers_to_send', function($headers) {
68
+ $allowed_origins = [
69
+ 'https://app.example.com',
70
+ ];
71
+
72
+ $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
73
+
74
+ if (in_array($origin, $allowed_origins, true)) {
75
+ $headers['Access-Control-Allow-Origin'] = $origin;
76
+ $headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type';
77
+ $headers['Access-Control-Allow-Credentials'] = 'true';
78
+ }
79
+
80
+ return $headers;
81
+ });
82
+ ```
83
+
84
+ ## Server-level CORS
85
+
86
+ ### Nginx
87
+
88
+ ```nginx
89
+ # /etc/nginx/conf.d/cors.conf or within server block
90
+ map $http_origin $cors_origin {
91
+ default "";
92
+ "https://app.example.com" "https://app.example.com";
93
+ "https://staging.example.com" "https://staging.example.com";
94
+ }
95
+
96
+ server {
97
+ # ... existing config
98
+
99
+ location /wp-json/ {
100
+ if ($cors_origin != "") {
101
+ add_header Access-Control-Allow-Origin $cors_origin always;
102
+ add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
103
+ add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-WP-Nonce" always;
104
+ add_header Access-Control-Allow-Credentials "true" always;
105
+ add_header Access-Control-Max-Age 86400 always;
106
+ }
107
+
108
+ if ($request_method = OPTIONS) {
109
+ return 204;
110
+ }
111
+
112
+ # ... proxy or fastcgi config
113
+ }
114
+
115
+ location /graphql {
116
+ # Same CORS headers as above
117
+ if ($cors_origin != "") {
118
+ add_header Access-Control-Allow-Origin $cors_origin always;
119
+ add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
120
+ add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
121
+ add_header Access-Control-Allow-Credentials "true" always;
122
+ }
123
+
124
+ if ($request_method = OPTIONS) {
125
+ return 204;
126
+ }
127
+
128
+ # ... proxy or fastcgi config
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### Apache (.htaccess)
134
+
135
+ ```apache
136
+ <IfModule mod_headers.c>
137
+ SetEnvIf Origin "^https://(app|staging)\.example\.com$" CORS_ORIGIN=$0
138
+ Header always set Access-Control-Allow-Origin %{CORS_ORIGIN}e env=CORS_ORIGIN
139
+ Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
140
+ Header always set Access-Control-Allow-Headers "Authorization, Content-Type, X-WP-Nonce"
141
+ Header always set Access-Control-Allow-Credentials "true"
142
+ Header always set Access-Control-Max-Age "86400"
143
+ </IfModule>
144
+
145
+ # Handle preflight
146
+ RewriteEngine On
147
+ RewriteCond %{REQUEST_METHOD} OPTIONS
148
+ RewriteRule ^(.*)$ $1 [R=204,L]
149
+ ```
150
+
151
+ ## CORS headers explained
152
+
153
+ | Header | Purpose |
154
+ |--------|---------|
155
+ | `Access-Control-Allow-Origin` | Which origins can access the resource |
156
+ | `Access-Control-Allow-Methods` | Which HTTP methods are allowed |
157
+ | `Access-Control-Allow-Headers` | Which request headers are allowed |
158
+ | `Access-Control-Allow-Credentials` | Whether cookies/auth can be sent |
159
+ | `Access-Control-Max-Age` | How long preflight results can be cached (seconds) |
160
+ | `Access-Control-Expose-Headers` | Which response headers the client can read |
161
+
162
+ ## Common issues
163
+
164
+ ### "No 'Access-Control-Allow-Origin' header"
165
+
166
+ The server doesn't include the CORS header. Fix: add the headers as shown above.
167
+
168
+ ### "The value of 'Access-Control-Allow-Origin' is not equal to the supplied origin"
169
+
170
+ Origin mismatch. Check:
171
+ - Trailing slashes (`https://app.example.com` vs `https://app.example.com/`)
172
+ - Protocol (`http` vs `https`)
173
+ - Port numbers (`localhost:3000` vs `localhost`)
174
+
175
+ ### "Credentials flag is true but Access-Control-Allow-Origin is '*'"
176
+
177
+ Cannot use `*` with `credentials: true`. Must specify the exact origin:
178
+ ```
179
+ Access-Control-Allow-Origin: https://app.example.com ✓
180
+ Access-Control-Allow-Origin: * ✗ (with credentials)
181
+ ```
182
+
183
+ ### Preflight (OPTIONS) returns 401/403
184
+
185
+ WordPress or a security plugin is blocking OPTIONS requests. Fix:
186
+ ```php
187
+ add_action('init', function() {
188
+ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
189
+ // Send CORS headers and exit before WordPress auth runs
190
+ header('Access-Control-Allow-Origin: https://app.example.com');
191
+ header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
192
+ header('Access-Control-Allow-Headers: Authorization, Content-Type');
193
+ header('Access-Control-Allow-Credentials: true');
194
+ status_header(204);
195
+ exit;
196
+ }
197
+ });
198
+ ```
199
+
200
+ ## Development CORS
201
+
202
+ ```php
203
+ // ONLY for local development — never in production
204
+ if (defined('WP_DEBUG') && WP_DEBUG) {
205
+ add_action('init', function() {
206
+ header('Access-Control-Allow-Origin: http://localhost:3000');
207
+ header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
208
+ header('Access-Control-Allow-Headers: Authorization, Content-Type');
209
+ header('Access-Control-Allow-Credentials: true');
210
+
211
+ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
212
+ status_header(204);
213
+ exit;
214
+ }
215
+ });
216
+ }
217
+ ```
218
+
219
+ ## Security rules
220
+
221
+ 1. **Never use `*` in production** — always whitelist specific origins
222
+ 2. **Never reflect the Origin header blindly** — validate against a whitelist
223
+ 3. **Use `Credentials: true` only when needed** — for authenticated requests
224
+ 4. **Set `Max-Age` for caching** — reduces preflight requests (86400 = 24 hours)
225
+ 5. **Expose only needed headers** — minimize `Access-Control-Expose-Headers`
226
+
227
+ ## Verification
228
+
229
+ ```bash
230
+ # Test CORS headers
231
+ curl -I -X OPTIONS https://wp.example.com/wp-json/wp/v2/posts \
232
+ -H "Origin: https://app.example.com" \
233
+ -H "Access-Control-Request-Method: GET" \
234
+ -H "Access-Control-Request-Headers: Authorization"
235
+
236
+ # Check response headers include:
237
+ # Access-Control-Allow-Origin: https://app.example.com
238
+ # Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
239
+ # Access-Control-Allow-Headers: Authorization, Content-Type
240
+
241
+ # Test from frontend
242
+ fetch('https://wp.example.com/wp-json/wp/v2/posts', {
243
+ credentials: 'include',
244
+ }).then(r => console.log('CORS OK:', r.ok));
245
+ ```
@@ -0,0 +1,331 @@
1
+ # Frontend Integration
2
+
3
+ Use this file when connecting a JavaScript frontend framework to a headless WordPress backend.
4
+
5
+ ## Next.js integration
6
+
7
+ ### Data fetching (App Router)
8
+
9
+ ```js
10
+ // lib/wordpress.js
11
+ const WP_URL = process.env.NEXT_PUBLIC_WP_URL || 'https://wp.example.com';
12
+
13
+ export async function getPosts(page = 1, perPage = 10) {
14
+ const res = await fetch(
15
+ `${WP_URL}/wp-json/wp/v2/posts?page=${page}&per_page=${perPage}&_embed`,
16
+ { next: { revalidate: 60 } } // ISR: revalidate every 60 seconds
17
+ );
18
+ if (!res.ok) throw new Error('Failed to fetch posts');
19
+ return {
20
+ posts: await res.json(),
21
+ totalPages: Number(res.headers.get('X-WP-TotalPages')),
22
+ };
23
+ }
24
+
25
+ export async function getPost(slug) {
26
+ const res = await fetch(
27
+ `${WP_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed`,
28
+ { next: { revalidate: 60 } }
29
+ );
30
+ const posts = await res.json();
31
+ return posts[0] || null;
32
+ }
33
+
34
+ export async function getPage(slug) {
35
+ const res = await fetch(
36
+ `${WP_URL}/wp-json/wp/v2/pages?slug=${slug}&_embed`,
37
+ { next: { revalidate: 300 } }
38
+ );
39
+ const pages = await res.json();
40
+ return pages[0] || null;
41
+ }
42
+ ```
43
+
44
+ ### Page components
45
+
46
+ ```jsx
47
+ // app/blog/page.js
48
+ import { getPosts } from '@/lib/wordpress';
49
+
50
+ export default async function BlogPage() {
51
+ const { posts } = await getPosts();
52
+
53
+ return (
54
+ <main>
55
+ <h1>Blog</h1>
56
+ {posts.map((post) => (
57
+ <article key={post.id}>
58
+ <h2>
59
+ <a href={`/blog/${post.slug}`}>{post.title.rendered}</a>
60
+ </h2>
61
+ <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
62
+ </article>
63
+ ))}
64
+ </main>
65
+ );
66
+ }
67
+
68
+ // app/blog/[slug]/page.js
69
+ import { getPost, getPosts } from '@/lib/wordpress';
70
+ import { notFound } from 'next/navigation';
71
+
72
+ export async function generateStaticParams() {
73
+ const { posts } = await getPosts(1, 100);
74
+ return posts.map((post) => ({ slug: post.slug }));
75
+ }
76
+
77
+ export default async function PostPage({ params }) {
78
+ const post = await getPost(params.slug);
79
+ if (!post) notFound();
80
+
81
+ return (
82
+ <article>
83
+ <h1>{post.title.rendered}</h1>
84
+ <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
85
+ </article>
86
+ );
87
+ }
88
+ ```
89
+
90
+ ### ISR (Incremental Static Regeneration)
91
+
92
+ ```js
93
+ // Revalidate on demand via webhook (see webhooks.md)
94
+ // app/api/revalidate/route.js
95
+ import { revalidatePath } from 'next/cache';
96
+
97
+ export async function POST(request) {
98
+ const secret = request.headers.get('x-revalidate-secret');
99
+ if (secret !== process.env.REVALIDATE_SECRET) {
100
+ return Response.json({ error: 'Invalid secret' }, { status: 401 });
101
+ }
102
+
103
+ const { path } = await request.json();
104
+ revalidatePath(path);
105
+ return Response.json({ revalidated: true });
106
+ }
107
+ ```
108
+
109
+ ## Nuxt 3 integration
110
+
111
+ ### Composable
112
+
113
+ ```js
114
+ // composables/useWordPress.js
115
+ export function useWordPress() {
116
+ const config = useRuntimeConfig();
117
+ const wpUrl = config.public.wpUrl;
118
+
119
+ async function getPosts(page = 1) {
120
+ return useFetch(`${wpUrl}/wp-json/wp/v2/posts`, {
121
+ params: { page, per_page: 10, _embed: true },
122
+ });
123
+ }
124
+
125
+ async function getPost(slug) {
126
+ const { data } = await useFetch(`${wpUrl}/wp-json/wp/v2/posts`, {
127
+ params: { slug, _embed: true },
128
+ });
129
+ return data.value?.[0] || null;
130
+ }
131
+
132
+ return { getPosts, getPost };
133
+ }
134
+ ```
135
+
136
+ ### nuxt.config.ts
137
+
138
+ ```ts
139
+ export default defineNuxtConfig({
140
+ runtimeConfig: {
141
+ public: {
142
+ wpUrl: process.env.WP_URL || 'https://wp.example.com',
143
+ },
144
+ },
145
+ routeRules: {
146
+ '/blog/**': { isr: 60 }, // Revalidate every 60s
147
+ '/': { isr: 300 },
148
+ },
149
+ });
150
+ ```
151
+
152
+ ## Astro integration
153
+
154
+ ### Data fetching
155
+
156
+ ```astro
157
+ ---
158
+ // src/pages/blog/[slug].astro
159
+ import Layout from '@/layouts/Layout.astro';
160
+
161
+ export async function getStaticPaths() {
162
+ const res = await fetch(`${import.meta.env.WP_URL}/wp-json/wp/v2/posts?per_page=100`);
163
+ const posts = await res.json();
164
+
165
+ return posts.map((post) => ({
166
+ params: { slug: post.slug },
167
+ props: { post },
168
+ }));
169
+ }
170
+
171
+ const { post } = Astro.props;
172
+ ---
173
+
174
+ <Layout title={post.title.rendered}>
175
+ <article>
176
+ <h1 set:html={post.title.rendered} />
177
+ <div set:html={post.content.rendered} />
178
+ </article>
179
+ </Layout>
180
+ ```
181
+
182
+ ## WPGraphQL with frontend frameworks
183
+
184
+ ### Apollo Client (React/Next.js)
185
+
186
+ ```js
187
+ // lib/apollo.js
188
+ import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
189
+
190
+ const client = new ApolloClient({
191
+ link: new HttpLink({
192
+ uri: `${process.env.NEXT_PUBLIC_WP_URL}/graphql`,
193
+ }),
194
+ cache: new InMemoryCache(),
195
+ });
196
+
197
+ export default client;
198
+ ```
199
+
200
+ ### urql (lightweight alternative)
201
+
202
+ ```js
203
+ import { Client, cacheExchange, fetchExchange } from 'urql';
204
+
205
+ const client = new Client({
206
+ url: `${process.env.NEXT_PUBLIC_WP_URL}/graphql`,
207
+ exchanges: [cacheExchange, fetchExchange],
208
+ });
209
+ ```
210
+
211
+ ## Rendering WordPress content
212
+
213
+ ### Sanitizing HTML content
214
+
215
+ ```jsx
216
+ // React: dangerouslySetInnerHTML (ensure source is trusted)
217
+ <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
218
+
219
+ // With DOMPurify for extra safety
220
+ import DOMPurify from 'isomorphic-dompurify';
221
+
222
+ function WPContent({ html }) {
223
+ const clean = DOMPurify.sanitize(html, {
224
+ ADD_TAGS: ['iframe'],
225
+ ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'],
226
+ });
227
+ return <div dangerouslySetInnerHTML={{ __html: clean }} />;
228
+ }
229
+ ```
230
+
231
+ ### Handling WordPress images
232
+
233
+ ```jsx
234
+ // Replace WordPress image URLs with optimized versions
235
+ function optimizeImages(html, wpUrl) {
236
+ // Replace with Next.js Image optimization
237
+ return html.replace(
238
+ /src="([^"]*wp-content\/uploads\/[^"]*)"/g,
239
+ (match, url) => `src="/_next/image?url=${encodeURIComponent(url)}&w=1200&q=80"`
240
+ );
241
+ }
242
+ ```
243
+
244
+ ### WordPress blocks in React
245
+
246
+ ```jsx
247
+ // Parse block content into React components
248
+ function renderBlock(block) {
249
+ switch (block.blockName) {
250
+ case 'core/paragraph':
251
+ return <p dangerouslySetInnerHTML={{ __html: block.innerHTML }} />;
252
+ case 'core/heading':
253
+ return <h2 dangerouslySetInnerHTML={{ __html: block.innerHTML }} />;
254
+ case 'core/image':
255
+ return <figure dangerouslySetInnerHTML={{ __html: block.innerHTML }} />;
256
+ default:
257
+ return <div dangerouslySetInnerHTML={{ __html: block.innerHTML }} />;
258
+ }
259
+ }
260
+ ```
261
+
262
+ ## SEO in headless WordPress
263
+
264
+ ### Yoast SEO integration
265
+
266
+ ```js
267
+ // Fetch Yoast data from REST API
268
+ // Requires Yoast SEO plugin (adds yoast_head to REST responses)
269
+ export async function getPostSEO(slug) {
270
+ const res = await fetch(
271
+ `${WP_URL}/wp-json/wp/v2/posts?slug=${slug}&_fields=yoast_head_json`
272
+ );
273
+ const posts = await res.json();
274
+ return posts[0]?.yoast_head_json || {};
275
+ }
276
+ ```
277
+
278
+ ```jsx
279
+ // app/blog/[slug]/page.js
280
+ export async function generateMetadata({ params }) {
281
+ const seo = await getPostSEO(params.slug);
282
+ return {
283
+ title: seo.title,
284
+ description: seo.description,
285
+ openGraph: {
286
+ title: seo.og_title,
287
+ description: seo.og_description,
288
+ images: seo.og_image ? [{ url: seo.og_image[0].url }] : [],
289
+ },
290
+ };
291
+ }
292
+ ```
293
+
294
+ ## Preview mode
295
+
296
+ ```js
297
+ // app/api/preview/route.js (Next.js)
298
+ import { draftMode } from 'next/headers';
299
+ import { redirect } from 'next/navigation';
300
+
301
+ export async function GET(request) {
302
+ const { searchParams } = new URL(request.url);
303
+ const secret = searchParams.get('secret');
304
+ const slug = searchParams.get('slug');
305
+
306
+ if (secret !== process.env.WP_PREVIEW_SECRET) {
307
+ return Response.json({ error: 'Invalid secret' }, { status: 401 });
308
+ }
309
+
310
+ draftMode().enable();
311
+ redirect(`/blog/${slug}`);
312
+ }
313
+ ```
314
+
315
+ ## Verification
316
+
317
+ ```bash
318
+ # Test REST API from frontend origin
319
+ curl -H "Origin: https://app.example.com" \
320
+ https://wp.example.com/wp-json/wp/v2/posts?per_page=1
321
+
322
+ # Test _embed returns featured images
323
+ curl -s "https://wp.example.com/wp-json/wp/v2/posts?_embed&per_page=1" | \
324
+ jq '.[0]._embedded["wp:featuredmedia"][0].source_url'
325
+
326
+ # Test ISR revalidation
327
+ curl -X POST https://app.example.com/api/revalidate \
328
+ -H "Content-Type: application/json" \
329
+ -H "x-revalidate-secret: YOUR_SECRET" \
330
+ -d '{"path": "/blog/hello-world"}'
331
+ ```