wp-mcp-gateway 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # MCP Server (TypeScript)
2
+
3
+ Local MCP server that connects Claude to a scoped WordPress plugin gateway.
4
+
5
+ ## Run
6
+
7
+ 1. Copy `.env.example` to `.env` and fill credentials.
8
+ 2. Install dependencies:
9
+ - `npm install`
10
+ 3. Start development server:
11
+ - `npm run dev`
12
+
13
+ ## Test
14
+
15
+ - `npm test`
16
+
17
+ ## Key constraints
18
+
19
+ - Allowed content types default to: `post,page,featured_item`
20
+ - Permanent delete is intentionally not exposed in v1
21
+ - Yoast operations are limited to allowlisted Tier A/B routes
@@ -0,0 +1,150 @@
1
+ import { ToolError } from "../utils/errors.js";
2
+ export class PluginApiClient {
3
+ config;
4
+ authHeader;
5
+ constructor(config) {
6
+ this.config = config;
7
+ const raw = `${config.wpUsername}:${config.wpAppPassword}`;
8
+ this.authHeader = `Basic ${Buffer.from(raw).toString("base64")}`;
9
+ }
10
+ async siteInfo() {
11
+ return this.request("GET", "/site-info");
12
+ }
13
+ async listContentTypes() {
14
+ return this.request("GET", "/content-types");
15
+ }
16
+ async findContent(query) {
17
+ return this.request("GET", "/content", { query: query });
18
+ }
19
+ async getContent(id) {
20
+ return this.request("GET", `/content/${id}`);
21
+ }
22
+ async createContent(body) {
23
+ return this.request("POST", "/content", { body });
24
+ }
25
+ async updateContent(id, body) {
26
+ return this.request("PATCH", `/content/${id}`, { body });
27
+ }
28
+ async publishContent(id, body) {
29
+ return this.request("POST", `/content/${id}/publish`, { body });
30
+ }
31
+ async trashContent(id) {
32
+ return this.request("POST", `/content/${id}/trash`, { body: {} });
33
+ }
34
+ async restoreContent(id) {
35
+ return this.request("POST", `/content/${id}/restore`, { body: {} });
36
+ }
37
+ async listRevisions(id) {
38
+ return this.request("GET", `/content/${id}/revisions`);
39
+ }
40
+ async restoreRevision(id, revisionId) {
41
+ return this.request("POST", `/content/${id}/revisions/${revisionId}/restore`, { body: {} });
42
+ }
43
+ async listAuthors() {
44
+ return this.request("GET", "/authors");
45
+ }
46
+ async assignAuthor(id, authorId) {
47
+ return this.request("POST", `/content/${id}/author`, { body: { author_id: authorId } });
48
+ }
49
+ async listTerms(taxonomy, query) {
50
+ return this.request("GET", `/taxonomies/${encodeURIComponent(taxonomy)}/terms`, {
51
+ query: query,
52
+ });
53
+ }
54
+ async createTerm(taxonomy, body) {
55
+ return this.request("POST", `/taxonomies/${encodeURIComponent(taxonomy)}/terms`, { body });
56
+ }
57
+ async assignTerms(id, body) {
58
+ return this.request("POST", `/content/${id}/terms`, { body });
59
+ }
60
+ async uploadMedia(body) {
61
+ return this.request("POST", "/media", { body });
62
+ }
63
+ async searchMedia(query) {
64
+ return this.request("GET", "/media", { query: query });
65
+ }
66
+ async updateMedia(id, body) {
67
+ return this.request("PATCH", `/media/${id}`, { body });
68
+ }
69
+ async setFeaturedImage(id, mediaId) {
70
+ return this.request("POST", `/content/${id}/featured-image`, { body: { media_id: mediaId } });
71
+ }
72
+ async insertInlineImage(id, body) {
73
+ return this.request("POST", `/content/${id}/inline-image/insert`, { body });
74
+ }
75
+ async replaceInlineImage(id, body) {
76
+ return this.request("POST", `/content/${id}/inline-image/replace`, { body });
77
+ }
78
+ async removeInlineImage(id, body) {
79
+ return this.request("POST", `/content/${id}/inline-image/remove`, { body });
80
+ }
81
+ async getYoastAnalysis(id) {
82
+ return this.request("GET", `/yoast/analysis/${id}`);
83
+ }
84
+ async updateYoastMetadata(id, body) {
85
+ return this.request("PATCH", `/yoast/metadata/${id}`, { body });
86
+ }
87
+ async getYoastHeadPreview(id) {
88
+ return this.request("GET", `/yoast/head/${id}`);
89
+ }
90
+ async getPreviewLink(id) {
91
+ return this.request("POST", `/content/${id}/preview-link`, { body: {} });
92
+ }
93
+ async getMedia(mediaId) {
94
+ return this.request("GET", `/media/${mediaId}`);
95
+ }
96
+ async uploadMediaFromUrl(params) {
97
+ return this.request("POST", "/media/from-url", params);
98
+ }
99
+ async cloneContent(id, params) {
100
+ return this.request("POST", `/content/${id}/clone`, params);
101
+ }
102
+ async request(method, path, options = {}) {
103
+ const url = new URL(`${this.config.wpPluginBaseUrl}${path}`);
104
+ for (const [key, value] of Object.entries(options.query ?? {})) {
105
+ if (value === null || value === undefined || value === "") {
106
+ continue;
107
+ }
108
+ url.searchParams.set(key, String(value));
109
+ }
110
+ const controller = new AbortController();
111
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
112
+ try {
113
+ const response = await fetch(url, {
114
+ method,
115
+ headers: {
116
+ Authorization: this.authHeader,
117
+ "Content-Type": "application/json",
118
+ },
119
+ body: options.body ? JSON.stringify(options.body) : undefined,
120
+ signal: controller.signal,
121
+ });
122
+ const payload = (await response.json().catch(() => ({})));
123
+ if (!response.ok || !payload.success) {
124
+ const error = payload.error;
125
+ throw new ToolError(error?.code ?? "PLUGIN_REQUEST_FAILED", error?.message ?? "Plugin request failed", {
126
+ details: error?.details ?? payload,
127
+ httpStatus: error?.http_status ?? response.status,
128
+ retryable: Boolean(error?.retryable),
129
+ });
130
+ }
131
+ return payload.data;
132
+ }
133
+ catch (error) {
134
+ if (error instanceof ToolError) {
135
+ throw error;
136
+ }
137
+ if (error instanceof DOMException && error.name === "AbortError") {
138
+ throw new ToolError("PLUGIN_REQUEST_TIMEOUT", `Request timed out after ${this.config.requestTimeoutMs}ms`, {
139
+ retryable: true,
140
+ });
141
+ }
142
+ throw new ToolError("PLUGIN_REQUEST_FAILED", "Failed to call WordPress plugin API", {
143
+ details: error,
144
+ });
145
+ }
146
+ finally {
147
+ clearTimeout(timeout);
148
+ }
149
+ }
150
+ }
package/dist/config.js ADDED
@@ -0,0 +1,71 @@
1
+ const DEFAULT_ALLOWED_CONTENT_TYPES = ["post", "page", "featured_item"];
2
+ const DEFAULT_ALLOWED_TAXONOMIES = [
3
+ "category",
4
+ "post_tag",
5
+ "featured_item_category",
6
+ "featured_item_tag",
7
+ ];
8
+ const DEFAULT_YOAST_ALLOWED_PATHS = ["/yoast/analysis", "/yoast/metadata", "/yoast/head"];
9
+ const DEFAULT_MEDIA_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
10
+ function readList(raw, fallback) {
11
+ if (!raw || !raw.trim()) {
12
+ return [...fallback];
13
+ }
14
+ return raw
15
+ .split(",")
16
+ .map((item) => item.trim())
17
+ .filter(Boolean);
18
+ }
19
+ function readNumber(raw, fallback) {
20
+ if (!raw || !raw.trim()) {
21
+ return fallback;
22
+ }
23
+ const parsed = Number(raw);
24
+ if (!Number.isFinite(parsed)) {
25
+ return fallback;
26
+ }
27
+ return parsed;
28
+ }
29
+ function readBoolean(raw, fallback) {
30
+ if (!raw || !raw.trim()) {
31
+ return fallback;
32
+ }
33
+ const lowered = raw.toLowerCase();
34
+ if (["true", "1", "yes", "y", "on"].includes(lowered)) {
35
+ return true;
36
+ }
37
+ if (["false", "0", "no", "n", "off"].includes(lowered)) {
38
+ return false;
39
+ }
40
+ return fallback;
41
+ }
42
+ function ensure(name, value) {
43
+ if (!value || !value.trim()) {
44
+ throw new Error(`Missing required environment variable: ${name}`);
45
+ }
46
+ return value;
47
+ }
48
+ export function loadConfig(env = process.env) {
49
+ const allowedAuthorIds = readList(env.ALLOWED_AUTHOR_IDS, [])
50
+ .map((raw) => Number(raw))
51
+ .filter((value) => Number.isInteger(value) && value > 0);
52
+ return {
53
+ wpPluginBaseUrl: ensure("WP_PLUGIN_BASE_URL", env.WP_PLUGIN_BASE_URL).replace(/\/$/, ""),
54
+ wpUsername: ensure("WP_USERNAME", env.WP_USERNAME),
55
+ wpAppPassword: ensure("WP_APP_PASSWORD", env.WP_APP_PASSWORD),
56
+ allowedContentTypes: readList(env.ALLOWED_CONTENT_TYPES, DEFAULT_ALLOWED_CONTENT_TYPES),
57
+ allowedTaxonomies: readList(env.ALLOWED_TAXONOMIES, DEFAULT_ALLOWED_TAXONOMIES),
58
+ allowedAuthorIds,
59
+ solutionsRootSlug: env.SOLUTIONS_ROOT_SLUG?.trim() || "solutions",
60
+ yoastAllowedPaths: readList(env.YOAST_ALLOWED_PATHS, DEFAULT_YOAST_ALLOWED_PATHS),
61
+ mediaAllowedMimeTypes: readList(env.MEDIA_ALLOWED_MIME_TYPES, DEFAULT_MEDIA_ALLOWED_MIME_TYPES),
62
+ mediaMaxSizeMb: readNumber(env.MEDIA_MAX_SIZE_MB, 10),
63
+ mediaRequireAltText: readBoolean(env.MEDIA_REQUIRE_ALT_TEXT, true),
64
+ mediaAllowedImportDomains: readList(env.MEDIA_ALLOWED_IMPORT_DOMAINS, []),
65
+ previewTokenTtlMinutes: readNumber(env.PREVIEW_TOKEN_TTL_MINUTES, 1440),
66
+ confirmationTtlSeconds: readNumber(env.CONFIRMATION_TTL_SECONDS, 300),
67
+ requestTimeoutMs: readNumber(env.REQUEST_TIMEOUT_MS, 30_000),
68
+ rateLimitMaxBurst: readNumber(env.RATE_LIMIT_MAX_BURST, 30),
69
+ rateLimitRefillPerSecond: readNumber(env.RATE_LIMIT_REFILL_PER_SECOND, 1),
70
+ };
71
+ }
@@ -0,0 +1,204 @@
1
+ # Generic WordPress Content Format Guide
2
+
3
+ ## Overview
4
+ For standard WordPress sites without custom shortcodes, use plain HTML or Gutenberg block markup. No special theme-specific shortcuts—just semantic HTML.
5
+
6
+ ## Content Formats Supported
7
+
8
+ ### Option 1: HTML (Recommended for Simplicity)
9
+ ```html
10
+ <p>Regular paragraph text.</p>
11
+
12
+ <h2>Heading</h2>
13
+
14
+ <p>More paragraph text with <strong>bold</strong> and <em>italic</em>.</p>
15
+
16
+ <ul>
17
+ <li>List item 1</li>
18
+ <li>List item 2</li>
19
+ </ul>
20
+
21
+ <img src="https://..." alt="Image description">
22
+
23
+ <a href="https://...">Link text</a>
24
+
25
+ <blockquote>Quote text</blockquote>
26
+ ```
27
+
28
+ Use `content_format: "html"` with this approach.
29
+
30
+ ### Option 2: Markdown (Auto-Converted to HTML)
31
+ ```markdown
32
+ # Heading 1
33
+
34
+ ## Heading 2
35
+
36
+ Regular paragraph text with **bold** and *italic*.
37
+
38
+ - List item 1
39
+ - List item 2
40
+
41
+ [Link text](https://...)
42
+
43
+ > Blockquote text
44
+
45
+ ![Image description](https://...)
46
+ ```
47
+
48
+ Use `content_format: "markdown"` with this approach.
49
+
50
+ ### Option 3: Gutenberg Block Markup
51
+ ```html
52
+ <!-- wp:paragraph -->
53
+ <p>Paragraph content</p>
54
+ <!-- /wp:paragraph -->
55
+
56
+ <!-- wp:heading {"level":2} -->
57
+ <h2>Heading</h2>
58
+ <!-- /wp:heading -->
59
+
60
+ <!-- wp:image {"id":123,"sizeSlug":"large"} -->
61
+ <figure class="wp-block-image size-large">
62
+ <img src="https://..." alt=""/>
63
+ </figure>
64
+ <!-- /wp:image -->
65
+
66
+ <!-- wp:list -->
67
+ <ul>
68
+ <li>Item 1</li>
69
+ <li>Item 2</li>
70
+ </ul>
71
+ <!-- /wp:list -->
72
+ ```
73
+
74
+ Use `content_format: "html"` with this approach.
75
+
76
+ ## Common HTML Elements
77
+
78
+ ### Headings
79
+ ```html
80
+ <h1>Page Title</h1>
81
+ <h2>Section Heading</h2>
82
+ <h3>Subsection</h3>
83
+ <h4>Small Heading</h4>
84
+ ```
85
+
86
+ ### Text Formatting
87
+ ```html
88
+ <p>Regular paragraph.</p>
89
+ <strong>Bold text</strong>
90
+ <em>Italic text</em>
91
+ <u>Underlined text</u>
92
+ <code>Code snippet</code>
93
+ ```
94
+
95
+ ### Lists
96
+ ```html
97
+ <ul>
98
+ <li>Unordered item 1</li>
99
+ <li>Unordered item 2</li>
100
+ </ul>
101
+
102
+ <ol>
103
+ <li>Ordered item 1</li>
104
+ <li>Ordered item 2</li>
105
+ </ol>
106
+ ```
107
+
108
+ ### Images
109
+ ```html
110
+ <img src="https://example.com/image.jpg" alt="Image description">
111
+
112
+ <!-- Or with figure element -->
113
+ <figure>
114
+ <img src="https://example.com/image.jpg" alt="">
115
+ <figcaption>Optional image caption</figcaption>
116
+ </figure>
117
+ ```
118
+
119
+ ### Links
120
+ ```html
121
+ <a href="https://example.com">Link text</a>
122
+ <a href="https://example.com" target="_blank">External link</a>
123
+ ```
124
+
125
+ ### Blockquote
126
+ ```html
127
+ <blockquote>
128
+ <p>Quote text here</p>
129
+ <cite>— Author Name</cite>
130
+ </blockquote>
131
+ ```
132
+
133
+ ### Separator/Divider
134
+ ```html
135
+ <hr>
136
+ ```
137
+
138
+ ## Image Workflow
139
+
140
+ ### Selecting images from the media library
141
+ 1. Call `wp_search_media` with relevant keywords
142
+ 2. **Display the thumbnail URLs visually** — show the images inline so the user can see their options
143
+ 3. Present as numbered choices with titles and dimensions
144
+ 4. The user picks by number, or drops a new image into the chat
145
+
146
+ ### Uploading new images
147
+ - **User drops image in chat**: Read the file, base64-encode it, call `wp_upload_media`
148
+ - **Image at a URL**: Call `wp_upload_media_from_url` (server-side download)
149
+ - Use the returned source_url in `<img src="RETURNED_URL" alt="description">`
150
+
151
+ ### Featured image
152
+ - Call `wp_set_featured_image` with the media ID (separate from inline content images)
153
+
154
+ ## Simple Content Template
155
+
156
+ ```html
157
+ <h1>Post Title</h1>
158
+
159
+ <p>Opening paragraph introducing the topic.</p>
160
+
161
+ <h2>First Section</h2>
162
+
163
+ <p>Section content goes here. Use natural paragraphs.</p>
164
+
165
+ <img src="https://..." alt="Relevant image">
166
+
167
+ <h2>Second Section</h2>
168
+
169
+ <p>More content.</p>
170
+
171
+ <ul>
172
+ <li>Key point 1</li>
173
+ <li>Key point 2</li>
174
+ <li>Key point 3</li>
175
+ </ul>
176
+
177
+ <h2>Conclusion</h2>
178
+
179
+ <p>Closing thoughts.</p>
180
+ ```
181
+
182
+ ## Content Format Selection
183
+
184
+ - **`content_format: "html"`** — Use for HTML, Gutenberg blocks, or mixed content
185
+ - **`content_format: "markdown"`** — Use for markdown-formatted content (will auto-convert to HTML)
186
+ - **`content_format: "auto"`** — WordPress auto-detects format (usually works fine)
187
+
188
+ ## Pro Tips
189
+
190
+ - **Keep it semantic**: Use proper HTML tags (h1-h6 for headings, p for paragraphs, etc.)
191
+ - **Alt text required**: Always include `alt` attribute on images for accessibility
192
+ - **Link target**: Use `target="_blank"` for external links only
193
+ - **No inline styles**: Let WordPress/theme handle styling via CSS classes
194
+ - **Use lists**: Break up text with lists for readability
195
+ - **Short paragraphs**: 2-4 sentences per paragraph is ideal for web reading
196
+
197
+ ## When to Use Custom Theme Guides
198
+
199
+ If the WordPress site uses:
200
+ - **XMPro + Flatsome theme** → Use `xmpro-post-guide.md` or `xmpro-page-guide.md`
201
+ - **Custom shortcodes** → Ask the user for theme/plugin documentation
202
+ - **Gutenberg patterns** → This generic guide works fine
203
+
204
+ Otherwise, this generic HTML approach works for any WordPress site.
@@ -0,0 +1,204 @@
1
+ # Generic WordPress Content Format Guide
2
+
3
+ ## Overview
4
+ For standard WordPress sites without custom shortcodes, use plain HTML or Gutenberg block markup. No special theme-specific shortcuts—just semantic HTML.
5
+
6
+ ## Content Formats Supported
7
+
8
+ ### Option 1: HTML (Recommended for Simplicity)
9
+ ```html
10
+ <p>Regular paragraph text.</p>
11
+
12
+ <h2>Heading</h2>
13
+
14
+ <p>More paragraph text with <strong>bold</strong> and <em>italic</em>.</p>
15
+
16
+ <ul>
17
+ <li>List item 1</li>
18
+ <li>List item 2</li>
19
+ </ul>
20
+
21
+ <img src="https://..." alt="Image description">
22
+
23
+ <a href="https://...">Link text</a>
24
+
25
+ <blockquote>Quote text</blockquote>
26
+ ```
27
+
28
+ Use `content_format: "html"` with this approach.
29
+
30
+ ### Option 2: Markdown (Auto-Converted to HTML)
31
+ ```markdown
32
+ # Heading 1
33
+
34
+ ## Heading 2
35
+
36
+ Regular paragraph text with **bold** and *italic*.
37
+
38
+ - List item 1
39
+ - List item 2
40
+
41
+ [Link text](https://...)
42
+
43
+ > Blockquote text
44
+
45
+ ![Image description](https://...)
46
+ ```
47
+
48
+ Use `content_format: "markdown"` with this approach.
49
+
50
+ ### Option 3: Gutenberg Block Markup
51
+ ```html
52
+ <!-- wp:paragraph -->
53
+ <p>Paragraph content</p>
54
+ <!-- /wp:paragraph -->
55
+
56
+ <!-- wp:heading {"level":2} -->
57
+ <h2>Heading</h2>
58
+ <!-- /wp:heading -->
59
+
60
+ <!-- wp:image {"id":123,"sizeSlug":"large"} -->
61
+ <figure class="wp-block-image size-large">
62
+ <img src="https://..." alt=""/>
63
+ </figure>
64
+ <!-- /wp:image -->
65
+
66
+ <!-- wp:list -->
67
+ <ul>
68
+ <li>Item 1</li>
69
+ <li>Item 2</li>
70
+ </ul>
71
+ <!-- /wp:list -->
72
+ ```
73
+
74
+ Use `content_format: "html"` with this approach.
75
+
76
+ ## Common HTML Elements
77
+
78
+ ### Headings
79
+ ```html
80
+ <h1>Page Title</h1>
81
+ <h2>Section Heading</h2>
82
+ <h3>Subsection</h3>
83
+ <h4>Small Heading</h4>
84
+ ```
85
+
86
+ ### Text Formatting
87
+ ```html
88
+ <p>Regular paragraph.</p>
89
+ <strong>Bold text</strong>
90
+ <em>Italic text</em>
91
+ <u>Underlined text</u>
92
+ <code>Code snippet</code>
93
+ ```
94
+
95
+ ### Lists
96
+ ```html
97
+ <ul>
98
+ <li>Unordered item 1</li>
99
+ <li>Unordered item 2</li>
100
+ </ul>
101
+
102
+ <ol>
103
+ <li>Ordered item 1</li>
104
+ <li>Ordered item 2</li>
105
+ </ol>
106
+ ```
107
+
108
+ ### Images
109
+ ```html
110
+ <img src="https://example.com/image.jpg" alt="Image description">
111
+
112
+ <!-- Or with figure element -->
113
+ <figure>
114
+ <img src="https://example.com/image.jpg" alt="">
115
+ <figcaption>Optional image caption</figcaption>
116
+ </figure>
117
+ ```
118
+
119
+ ### Links
120
+ ```html
121
+ <a href="https://example.com">Link text</a>
122
+ <a href="https://example.com" target="_blank">External link</a>
123
+ ```
124
+
125
+ ### Blockquote
126
+ ```html
127
+ <blockquote>
128
+ <p>Quote text here</p>
129
+ <cite>— Author Name</cite>
130
+ </blockquote>
131
+ ```
132
+
133
+ ### Separator/Divider
134
+ ```html
135
+ <hr>
136
+ ```
137
+
138
+ ## Image Workflow
139
+
140
+ ### Selecting images from the media library
141
+ 1. Call `wp_search_media` with relevant keywords
142
+ 2. **Display the thumbnail URLs visually** — show the images inline so the user can see their options
143
+ 3. Present as numbered choices with titles and dimensions
144
+ 4. The user picks by number, or drops a new image into the chat
145
+
146
+ ### Uploading new images
147
+ - **User drops image in chat**: Read the file, base64-encode it, call `wp_upload_media`
148
+ - **Image at a URL**: Call `wp_upload_media_from_url` (server-side download)
149
+ - Use the returned source_url in `<img src="RETURNED_URL" alt="description">`
150
+
151
+ ### Featured image
152
+ - Call `wp_set_featured_image` with the media ID (separate from inline content images)
153
+
154
+ ## Simple Content Template
155
+
156
+ ```html
157
+ <h1>Post Title</h1>
158
+
159
+ <p>Opening paragraph introducing the topic.</p>
160
+
161
+ <h2>First Section</h2>
162
+
163
+ <p>Section content goes here. Use natural paragraphs.</p>
164
+
165
+ <img src="https://..." alt="Relevant image">
166
+
167
+ <h2>Second Section</h2>
168
+
169
+ <p>More content.</p>
170
+
171
+ <ul>
172
+ <li>Key point 1</li>
173
+ <li>Key point 2</li>
174
+ <li>Key point 3</li>
175
+ </ul>
176
+
177
+ <h2>Conclusion</h2>
178
+
179
+ <p>Closing thoughts.</p>
180
+ ```
181
+
182
+ ## Content Format Selection
183
+
184
+ - **`content_format: "html"`** — Use for HTML, Gutenberg blocks, or mixed content
185
+ - **`content_format: "markdown"`** — Use for markdown-formatted content (will auto-convert to HTML)
186
+ - **`content_format: "auto"`** — WordPress auto-detects format (usually works fine)
187
+
188
+ ## Pro Tips
189
+
190
+ - **Keep it semantic**: Use proper HTML tags (h1-h6 for headings, p for paragraphs, etc.)
191
+ - **Alt text required**: Always include `alt` attribute on images for accessibility
192
+ - **Link target**: Use `target="_blank"` for external links only
193
+ - **No inline styles**: Let WordPress/theme handle styling via CSS classes
194
+ - **Use lists**: Break up text with lists for readability
195
+ - **Short paragraphs**: 2-4 sentences per paragraph is ideal for web reading
196
+
197
+ ## When to Use Custom Theme Guides
198
+
199
+ If the WordPress site uses:
200
+ - **XMPro + Flatsome theme** → Use `xmpro-post-guide.md` or `xmpro-page-guide.md`
201
+ - **Custom shortcodes** → Ask the user for theme/plugin documentation
202
+ - **Gutenberg patterns** → This generic guide works fine
203
+
204
+ Otherwise, this generic HTML approach works for any WordPress site.