@hostinger/mcp 3.4.1 → 3.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/steps/02-refine/SKILL.md +7 -0
  6. package/skills/headless/steps/03-provision/wordpress/SKILL.md +63 -0
  7. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +58 -5
  8. package/skills/headless/steps/04-content-model/wordpress/contact-form.md +227 -0
  9. package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +481 -0
  10. package/skills/headless/steps/06-frontend/SKILL.md +15 -0
  11. package/skills/headless/steps/07-validate/SKILL.md +13 -0
  12. package/skills/headless/steps/09-done/SKILL.md +5 -0
  13. package/src/core/skills.js +32 -18
  14. package/src/core/skills.ts +32 -18
  15. package/src/servers/agency-hosting.js +1 -1
  16. package/src/servers/agency-hosting.ts +1 -1
  17. package/src/servers/agent-readiness.js +1 -1
  18. package/src/servers/agent-readiness.ts +1 -1
  19. package/src/servers/all.js +1 -1
  20. package/src/servers/all.ts +1 -1
  21. package/src/servers/billing.js +1 -1
  22. package/src/servers/billing.ts +1 -1
  23. package/src/servers/dns.js +1 -1
  24. package/src/servers/dns.ts +1 -1
  25. package/src/servers/domains.js +1 -1
  26. package/src/servers/domains.ts +1 -1
  27. package/src/servers/ecommerce.js +1 -1
  28. package/src/servers/ecommerce.ts +1 -1
  29. package/src/servers/horizons.js +1 -1
  30. package/src/servers/horizons.ts +1 -1
  31. package/src/servers/hosting.js +1 -1
  32. package/src/servers/hosting.ts +1 -1
  33. package/src/servers/mail.js +1 -1
  34. package/src/servers/mail.ts +1 -1
  35. package/src/servers/reach.js +1 -1
  36. package/src/servers/reach.ts +1 -1
  37. package/src/servers/vps.js +1 -1
  38. package/src/servers/vps.ts +1 -1
  39. package/src/servers/wordpress.js +1 -1
  40. package/src/servers/wordpress.ts +1 -1
@@ -0,0 +1,481 @@
1
+ # headless — WordPress custom plugins cookbook (structure the backend in code)
2
+
3
+ A companion recipe to `SKILL.md` (WordPress content model). Use it when the content model needs **more
4
+ than posts, pages, categories and media** — when the brief calls for **structured content types**,
5
+ **global site settings the frontend reads**, or small **site-specific helpers**. Those cannot be created
6
+ through the per-site WP MCP (it has no endpoint to register a post type, a taxonomy, or an options page);
7
+ they need PHP, which means a **small custom plugin** deployed into the headless `/cms`.
8
+
9
+ **When to reach for this (and when not to).** The per-site WP MCP already covers content *data* through
10
+ dedicated abilities — `posts-*`, `pages-*`, `categories-*`, `tags-*`, `media-*`, `menus-*`, and `cpt-*` for
11
+ custom post types — but there is **no generic `wp/v2` passthrough** (no `RestEndpointTool`). A plugin is for
12
+ *structure and behaviour* that data sits in, and for anything those abilities can't reach:
13
+
14
+ | Need | Plugin? | Do it with |
15
+ |---|---|---|
16
+ | Blog posts, static pages, categories, media, nav | **no** | per-site WP MCP (`SKILL.md` Step 4) |
17
+ | A **custom post type** — Team, Services, Properties, Testimonials, a restaurant's dishes | **yes** | *Custom post type* below |
18
+ | A **custom taxonomy** — Property Type, Cuisine, Service Area | **yes** | *Custom taxonomy* below |
19
+ | **Global site content** the frontend needs — footer text, phone, address, opening hours, social links, a CTA line | **yes** | *Settings page + public settings route* below |
20
+ | Extra **fields on a post/CPT** exposed in REST (price, subtitle, map coords) | **yes** | *Custom fields in REST* below |
21
+ | A small **helper** — a shortcode, a computed REST value, a cron tidy-up | **yes** | *Helpers* below |
22
+ | Payment, membership, page-builder, SEO, caching, or anything a maintained plugin already does well | **no** | install the real plugin at provision — don't reinvent it |
23
+
24
+ > **Plugins carry structure, not catalog or content data.** You deploy a plugin you've written into the
25
+ > headless `/cms` with `hosting_deploy { type: "wordpress-plugin", headless: true }` (see *Deploy* below).
26
+ > Author one only for the *structure and behaviour* the per-site MCP can't express — types, taxonomies,
27
+ > settings, helpers. Don't write a **seed/config plugin** to load WooCommerce catalog data or page content;
28
+ > that goes through the per-site MCP's native abilities (`woocommerce.md`, `SKILL.md` Step 4), never a plugin.
29
+
30
+ ---
31
+
32
+ ## 1 — The one rule: everything the frontend needs must be in the public REST API
33
+
34
+ The Step 6 frontend is a separate app that reads the backend over the **public `wp/v2` REST API** (open
35
+ CORS on a headless install, no auth for reads). So a custom post type the frontend never sees in REST is
36
+ useless to it. **Register everything headless-visible with REST on:**
37
+
38
+ - **Post types** → `'show_in_rest' => true` and an explicit `'rest_base'` → the frontend reads
39
+ `GET {urls.rest_api}/<rest_base>`.
40
+ - **Taxonomies** → `'show_in_rest' => true` and an explicit `'rest_base'` → readable, and embeddable on
41
+ their objects with `?_embed`.
42
+ - **Meta fields** → `register_post_meta(..., [ 'show_in_rest' => true ])`, or `register_rest_field` for a
43
+ computed value → appears under the object's `meta` (or your field) in REST.
44
+ - **Global settings** → a **public read-only REST route** the frontend fetches once for footer/contact/etc.
45
+
46
+ This is the whole reason the plugin exists in a headless build: it is a **schema + settings layer the
47
+ frontend consumes**, not an admin experience. Everything below keeps `show_in_rest` front and centre.
48
+
49
+ > **Register on the right hooks.** Post types and taxonomies register on `init`; meta on `init`; REST routes
50
+ > on `rest_api_init`; settings/admin pages on `admin_init` / `admin_menu`. Registering a post type outside
51
+ > `init` silently misbehaves.
52
+
53
+ ---
54
+
55
+ ## 2 — Plugin anatomy and WordPress standards
56
+
57
+ Keep it a **single, small, self-contained plugin per site** — one folder, prefixed, headless-focused. Ship
58
+ it as a directory (not a zip); the deploy tool uploads the folder's files and installs from them.
59
+
60
+ ```
61
+ <site>-headless/ slug = folder name = plugin slug, e.g. "acme-headless"
62
+ ├── <site>-headless.php main file with the plugin header (same base name as the folder)
63
+ ├── inc/
64
+ │ ├── post-types.php
65
+ │ ├── taxonomies.php
66
+ │ ├── settings.php
67
+ │ └── rest.php
68
+ ├── uninstall.php optional — clean up options on delete
69
+ └── readme.txt optional
70
+ ```
71
+
72
+ **Main file** — header, the `ABSPATH` guard, a constant for the version, and the activation flush:
73
+
74
+ ```php
75
+ <?php
76
+ /**
77
+ * Plugin Name: Acme Headless
78
+ * Description: Content types, taxonomies and site settings for the Acme headless frontend.
79
+ * Version: 1.0.0
80
+ * Requires at least: 6.4
81
+ * Requires PHP: 7.4
82
+ * Author: Acme
83
+ * License: GPL-2.0-or-later
84
+ * Text Domain: acme-headless
85
+ */
86
+
87
+ // No direct access — every PHP file in the plugin starts with this.
88
+ if ( ! defined( 'ABSPATH' ) ) {
89
+ exit;
90
+ }
91
+
92
+ define( 'ACME_HEADLESS_VERSION', '1.0.0' );
93
+
94
+ require_once __DIR__ . '/inc/post-types.php';
95
+ require_once __DIR__ . '/inc/taxonomies.php';
96
+ require_once __DIR__ . '/inc/settings.php';
97
+ require_once __DIR__ . '/inc/rest.php';
98
+
99
+ // Post types/taxonomies are registered on `init` by the files above. Rewrite rules for their
100
+ // permalinks must be flushed once, on activation only (never on every load — it is expensive).
101
+ register_activation_hook( __FILE__, function () {
102
+ acme_headless_register_post_types();
103
+ acme_headless_register_taxonomies();
104
+ flush_rewrite_rules();
105
+ } );
106
+ register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );
107
+ ```
108
+
109
+ **Standards that matter (every snippet below follows them):**
110
+
111
+ - **Prefix everything** — functions, options, the text domain, the REST namespace — with a per-site token
112
+ (`acme_headless_`, `acme-headless`). Two plugins must never collide.
113
+ - **Guard every file** with `if ( ! defined( 'ABSPATH' ) ) { exit; }`.
114
+ - **Sanitize on input, escape on output.** `sanitize_text_field`, `sanitize_textarea_field`, `esc_url_raw`,
115
+ `absint` on the way in; `esc_html`, `esc_attr`, `esc_url`, `wp_kses_post` on the way out.
116
+ - **Check capability + nonce** on any admin write (`current_user_can( 'manage_options' )`; the Settings API
117
+ handles the nonce for you). Set `permission_callback` on every REST route — `__return_true` only for
118
+ genuinely public reads, a capability check for anything that writes.
119
+ - **Never hardcode a secret** in the plugin, and never expose one over a public route. Site settings the
120
+ frontend reads are public content (footer text, phone) — API keys and the like are not; keep those out.
121
+ - **i18n:** wrap user-facing strings in `__( 'text', 'acme-headless' )`. Headless admin labels are low-stakes
122
+ but costs nothing to do right.
123
+
124
+ > **Flushing rewrite rules headlessly.** The activation hook above runs when the plugin is activated, so CPT
125
+ > *permalinks* resolve. But the frontend reads **REST**, and REST routes work the moment the type is
126
+ > registered — they don't depend on a rewrite flush. So even if activation timing is odd, `GET /<rest_base>`
127
+ > is live as soon as the plugin is active. Don't block the build on permalink flushing.
128
+
129
+ ---
130
+
131
+ ## 3 — Custom post type
132
+
133
+ One `register_post_type` per type, on `init`, with REST on and an explicit `rest_base`:
134
+
135
+ ```php
136
+ // inc/post-types.php
137
+ if ( ! defined( 'ABSPATH' ) ) { exit; }
138
+
139
+ function acme_headless_register_post_types() {
140
+ register_post_type( 'acme_service', array(
141
+ 'labels' => array(
142
+ 'name' => __( 'Services', 'acme-headless' ),
143
+ 'singular_name' => __( 'Service', 'acme-headless' ),
144
+ ),
145
+ 'public' => true,
146
+ 'has_archive' => true,
147
+ 'menu_icon' => 'dashicons-hammer',
148
+ 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
149
+ 'show_in_rest' => true, // REQUIRED for the headless frontend
150
+ 'rest_base' => 'services', // GET {rest_api}/services
151
+ 'rewrite' => array( 'slug' => 'services' ),
152
+ ) );
153
+ }
154
+ add_action( 'init', 'acme_headless_register_post_types' );
155
+ ```
156
+
157
+ - Pick a **prefixed internal key** (`acme_service`, ≤ 20 chars) but a **clean `rest_base`** (`services`) —
158
+ the frontend route reads naturally and won't clash with core.
159
+ - `'supports' => [ 'thumbnail' ]` enables the featured image; `'custom-fields'` lets meta ride along.
160
+ - After deploy, the per-site WP MCP can **populate entries** into this type with its **native
161
+ `cpt-*` abilities** — `cpt-list-types` (confirm the type is registered, with its `rest_base`),
162
+ then `cpt-create { post_type: "acme_service", title, content, excerpt?, status: "publish" }`
163
+ (and `cpt-search` / `cpt-get` / `cpt-update` / `cpt-delete`). Deploy the plugin first (the schema),
164
+ then seed entries through the MCP. Note `cpt-create` takes **no taxonomy/term fields** — see §4 for how
165
+ custom-taxonomy terms are handled.
166
+
167
+ ## 4 — Custom taxonomy
168
+
169
+ ```php
170
+ // inc/taxonomies.php
171
+ if ( ! defined( 'ABSPATH' ) ) { exit; }
172
+
173
+ function acme_headless_register_taxonomies() {
174
+ register_taxonomy( 'acme_service_area', array( 'acme_service' ), array(
175
+ 'labels' => array(
176
+ 'name' => __( 'Service Areas', 'acme-headless' ),
177
+ 'singular_name' => __( 'Service Area', 'acme-headless' ),
178
+ ),
179
+ 'public' => true,
180
+ 'hierarchical' => true, // true = categories, false = tags
181
+ 'show_in_rest' => true, // REQUIRED for the frontend
182
+ 'rest_base' => 'service-areas',
183
+ 'rewrite' => array( 'slug' => 'service-areas' ),
184
+ ) );
185
+ }
186
+ add_action( 'init', 'acme_headless_register_taxonomies' );
187
+ ```
188
+
189
+ **Seeding terms is a PHP job, not an MCP one.** The per-site MCP has no custom-taxonomy ability
190
+ (`categories-*` / `tags-*` cover only the built-in `category` / `post_tag`, and there is no generic REST
191
+ passthrough), and `cpt-create` takes no term fields. So seed the taxonomy's starter terms **in the plugin**,
192
+ on `init`, **gated by a version option** — not the activation hook, which doesn't re-run when you redeploy
193
+ an already-active plugin, so later-added terms would never seed. Bump `ACME_HEADLESS_VERSION` and redeploy
194
+ to re-run the seed:
195
+
196
+ ```php
197
+ add_action( 'init', function () {
198
+ if ( get_option( 'acme_headless_seed_version' ) === ACME_HEADLESS_VERSION ) {
199
+ return; // already seeded for this version
200
+ }
201
+ foreach ( array( 'Downtown', 'Suburbs', 'Coast' ) as $area ) {
202
+ if ( ! term_exists( $area, 'acme_service_area' ) ) {
203
+ wp_insert_term( $area, 'acme_service_area' ); // idempotent via the term_exists guard
204
+ }
205
+ }
206
+ update_option( 'acme_headless_seed_version', ACME_HEADLESS_VERSION );
207
+ }, 11 ); // after register_taxonomy (init, priority 10)
208
+ ```
209
+
210
+ Terms then read over REST (`GET {rest_api}/service-areas`) and embed on their objects with
211
+ `GET {rest_api}/services?_embed`. **Pre-assigning a term to an entry is separate** — `cpt-create` can't set
212
+ terms. Either the owner assigns them in wp-admin, or the same versioned seed attaches them **by entry slug
213
+ once the entries exist** (`wp_set_object_terms( get_page_by_path( '<slug>', OBJECT, 'acme_service' )->ID,
214
+ '<term>', 'acme_service_area' )`) — seed the entries first, then bump `ACME_HEADLESS_VERSION` and redeploy
215
+ so the seed re-runs with the entries in place.
216
+
217
+ ## 5 — Settings page + a public settings route (footer, phone, generic text)
218
+
219
+ The classic headless need: **one place the owner edits global content** (footer blurb, phone, email,
220
+ address, opening hours, social links, a CTA line) and **one REST route the frontend fetches** to render it
221
+ site-wide. Store it as a **single option holding an array**, edited with the Settings API, and exposed
222
+ read-only over REST.
223
+
224
+ ```php
225
+ // inc/settings.php
226
+ if ( ! defined( 'ABSPATH' ) ) { exit; }
227
+
228
+ const ACME_HEADLESS_OPTION = 'acme_headless_settings';
229
+
230
+ // --- Admin page (wp-admin → Settings → Acme Headless): where the owner edits global content. ---
231
+ add_action( 'admin_menu', function () {
232
+ add_options_page(
233
+ __( 'Acme Headless', 'acme-headless' ),
234
+ __( 'Acme Headless', 'acme-headless' ),
235
+ 'manage_options', // capability check
236
+ 'acme-headless',
237
+ 'acme_headless_settings_page'
238
+ );
239
+ } );
240
+
241
+ add_action( 'admin_init', function () {
242
+ // register_setting wires in the nonce + capability check and runs our sanitizer on save.
243
+ register_setting( 'acme_headless', ACME_HEADLESS_OPTION, array(
244
+ 'type' => 'object',
245
+ 'sanitize_callback' => 'acme_headless_sanitize_settings',
246
+ 'default' => array(),
247
+ ) );
248
+ } );
249
+
250
+ // Sanitize on input — never trust the posted array.
251
+ function acme_headless_sanitize_settings( $input ) {
252
+ return array(
253
+ 'phone' => sanitize_text_field( $input['phone'] ?? '' ),
254
+ 'email' => sanitize_email( $input['email'] ?? '' ),
255
+ 'address' => sanitize_textarea_field( $input['address'] ?? '' ),
256
+ 'footer_text' => sanitize_textarea_field( $input['footer_text'] ?? '' ),
257
+ 'facebook' => esc_url_raw( $input['facebook'] ?? '' ),
258
+ 'instagram' => esc_url_raw( $input['instagram'] ?? '' ),
259
+ );
260
+ }
261
+
262
+ function acme_headless_settings_page() {
263
+ if ( ! current_user_can( 'manage_options' ) ) { return; }
264
+ $o = get_option( ACME_HEADLESS_OPTION, array() );
265
+ ?>
266
+ <div class="wrap">
267
+ <h1><?php esc_html_e( 'Acme Headless', 'acme-headless' ); ?></h1>
268
+ <form method="post" action="options.php">
269
+ <?php settings_fields( 'acme_headless' ); // nonce + option group ?>
270
+ <table class="form-table" role="presentation">
271
+ <tr>
272
+ <th><label for="ah_phone"><?php esc_html_e( 'Phone', 'acme-headless' ); ?></label></th>
273
+ <td><input name="<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[phone]" id="ah_phone"
274
+ type="text" value="<?php echo esc_attr( $o['phone'] ?? '' ); ?>" class="regular-text"></td>
275
+ </tr>
276
+ <tr>
277
+ <th><label for="ah_footer"><?php esc_html_e( 'Footer text', 'acme-headless' ); ?></label></th>
278
+ <td><textarea name="<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[footer_text]" id="ah_footer"
279
+ rows="3" class="large-text"><?php echo esc_textarea( $o['footer_text'] ?? '' ); ?></textarea></td>
280
+ </tr>
281
+ <!-- email, address, social … same pattern -->
282
+ </table>
283
+ <?php submit_button(); ?>
284
+ </form>
285
+ </div>
286
+ <?php
287
+ }
288
+ ```
289
+
290
+ Now expose it to the frontend as a **public, read-only** route. This is global site chrome — public content
291
+ by definition — so the read is open. The values were **sanitized on save** (the Settings API sanitizer
292
+ above), so return them as **plain JSON** — the frontend escapes on render — and never add a secret to it.
293
+ Don't HTML-escape here: `esc_html()`/`esc_url()` in a JSON response double-encode `&`:
294
+
295
+ ```php
296
+ // inc/rest.php
297
+ if ( ! defined( 'ABSPATH' ) ) { exit; }
298
+
299
+ add_action( 'rest_api_init', function () {
300
+ register_rest_route( 'acme/v1', '/settings', array(
301
+ 'methods' => WP_REST_Server::READABLE, // GET
302
+ 'permission_callback' => '__return_true', // public read — global chrome only
303
+ 'callback' => function () {
304
+ $o = get_option( ACME_HEADLESS_OPTION, array() );
305
+ return array(
306
+ // Already sanitized on save (see the Settings API sanitizer above); return as plain
307
+ // JSON strings — the frontend escapes on render. Don't esc_html()/esc_url() here, or
308
+ // a JSON client double-encodes "&" (e.g. "Tea & Co" → "Tea &amp; Co").
309
+ 'phone' => $o['phone'] ?? '',
310
+ 'email' => sanitize_email( $o['email'] ?? '' ),
311
+ 'address' => $o['address'] ?? '',
312
+ 'footer_text' => wp_kses_post( $o['footer_text'] ?? '' ),
313
+ 'social' => array(
314
+ 'facebook' => esc_url_raw( $o['facebook'] ?? '' ),
315
+ 'instagram' => esc_url_raw( $o['instagram'] ?? '' ),
316
+ ),
317
+ );
318
+ },
319
+ ) );
320
+ } );
321
+ ```
322
+
323
+ The frontend fetches `GET {urls.cms}/wp-json/acme/v1/settings` once (it's cacheable) and renders the
324
+ footer, header contact bar, etc. Record this route in `content-model.md` so Step 6 wires it in.
325
+
326
+ > **Custom namespace base vs `urls.rest_api`.** `site.json → urls.rest_api` points at the `…/wp-json/wp/v2`
327
+ > base (core content). A custom namespace lives one level up, off `{urls.cms}/wp-json` — e.g.
328
+ > `{urls.cms}/wp-json/acme/v1/settings`. Record the full custom URL explicitly so the frontend doesn't try
329
+ > to hang it off the `wp/v2` base.
330
+
331
+ ## 6 — Custom fields in REST (meta on a post or CPT)
332
+
333
+ For a simple stored field, `register_post_meta` with `show_in_rest` — it then reads/writes under the
334
+ object's `meta`:
335
+
336
+ ```php
337
+ add_action( 'init', function () {
338
+ register_post_meta( 'acme_service', 'price_from', array(
339
+ 'type' => 'string',
340
+ 'single' => true,
341
+ 'show_in_rest' => true, // appears in REST under meta.price_from
342
+ 'auth_callback' => function () { return current_user_can( 'edit_posts' ); },
343
+ 'sanitize_callback' => 'sanitize_text_field',
344
+ ) );
345
+ } );
346
+ ```
347
+
348
+ For a **computed/read-only** value (not stored, derived at response time), use `register_rest_field`:
349
+
350
+ ```php
351
+ add_action( 'rest_api_init', function () {
352
+ register_rest_field( 'acme_service', 'read_time', array(
353
+ 'get_callback' => function ( $post ) {
354
+ return max( 1, (int) ceil( str_word_count( wp_strip_all_tags( get_post( $post['id'] )->post_content ) ) / 200 ) );
355
+ },
356
+ 'schema' => array( 'type' => 'integer' ),
357
+ ) );
358
+ } );
359
+ ```
360
+
361
+ ## 7 — Helpers (shortcodes, computed values, small cron)
362
+
363
+ Keep helpers tiny and single-purpose, in `inc/`. A headless frontend rarely renders WordPress HTML, so
364
+ prefer a **REST value over a shortcode**. If a cron job is genuinely needed, schedule it on activation and
365
+ clear it on deactivation (don't leave orphaned events):
366
+
367
+ ```php
368
+ register_activation_hook( __FILE__, function () {
369
+ if ( ! wp_next_scheduled( 'acme_headless_daily' ) ) {
370
+ wp_schedule_event( time(), 'daily', 'acme_headless_daily' );
371
+ }
372
+ } );
373
+ register_deactivation_hook( __FILE__, function () {
374
+ wp_clear_scheduled_hook( 'acme_headless_daily' );
375
+ } );
376
+ add_action( 'acme_headless_daily', 'acme_headless_run_daily' );
377
+ ```
378
+
379
+ > **The two `register_*_hook` lines must sit in the main `<site>-headless.php` file** (beside the plugin's
380
+ > existing activation hook), not in an `inc/` include: `__FILE__` has to resolve to the **main plugin file**
381
+ > or the hooks never fire. The schedule check and the callback (`add_action( 'acme_headless_daily', … )` and
382
+ > `acme_headless_run_daily()`) can live in `inc/`.
383
+
384
+ Clean up on uninstall so deleting the plugin leaves no rows behind:
385
+
386
+ ```php
387
+ // uninstall.php
388
+ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; }
389
+ delete_option( 'acme_headless_settings' );
390
+ ```
391
+
392
+ ---
393
+
394
+ ## 8 — Deploy the plugin into the headless `/cms`
395
+
396
+ Deploy the **plugin directory** with the connector's one-shot `hosting_deploy`, passing **`headless: true`**
397
+ so it lands in `cms/wp-content/plugins/` (not the document-root `wp-content/`):
398
+
399
+ ```json
400
+ { "operation": "hosting_deploy",
401
+ "params": {
402
+ "domain": "<domain>",
403
+ "type": "wordpress-plugin",
404
+ "path": "~/.hostinger/<domain>/plugins/<site>-headless",
405
+ "slug": "<site>-headless",
406
+ "headless": true } }
407
+ ```
408
+
409
+ - **`path`** is the plugin **folder** (it contains the main `*.php` with the plugin header). The tool scans
410
+ the directory and uploads every file; it skips `node_modules`, `.git`, OS junk and `.env*` on its own —
411
+ don't zip it yourself, don't pass a git repo (`headless` is path-only).
412
+ - **`slug`** becomes the install directory name (lower-case letters, digits, `-`, `_`). Keep it the same
413
+ across re-runs; the tool appends a random suffix to the *upload* dir, then installs from it.
414
+ - `type` is optional — the tool detects a WordPress plugin from the header — but pass it to be explicit.
415
+ - A plugin/theme deploy is **done when the call returns** (unlike a Node.js build). The result echoes
416
+ `{ type: "wordpress-plugin", status: "deployed", slug, headless: true }`.
417
+ - It **overwrites** a previous deploy of the same plugin — re-run freely to ship a fix. Confirm the domain
418
+ first, as with any deploy.
419
+
420
+ **The deploy activates the plugin for you — then verify over public REST:**
421
+
422
+ 1. **Confirm it's active** (the headless plugin deploy activates it on install — you normally don't
423
+ call activate yourself). Check the per-site MCP's `plugin-list` ability for your slug, or just
424
+ rely on the REST check below. Only if it comes back inactive, activate it with
425
+ `wordpress_plugins_activate { username, software, plugin: "<slug>/<slug>.php" }` (or the bare
426
+ `"<slug>"` form) and re-check.
427
+ 2. **Verify the REST surface is live** (public GET, no auth) — this is the real proof the plugin is
428
+ active and registered:
429
+ - CPT: `GET {urls.rest_api}/services` returns `200` (empty array before you seed entries — that's fine).
430
+ - Taxonomy: `GET {urls.rest_api}/service-areas` returns `200`.
431
+ - Settings: `GET {urls.cms}/wp-json/acme/v1/settings` returns your JSON shape.
432
+ - Namespace: `GET {urls.cms}/wp-json/` lists your `acme/v1` under `namespaces`.
433
+ A `404` on a route means the plugin isn't active (or the `rest_base`/namespace differs) — fix before moving on.
434
+
435
+ > **Order matters.** Deploy the plugin **before** the content push seeds entries into the new types — the
436
+ > type must exist in REST for the MCP's `cpt-create` to accept entries. Default **Settings** values are
437
+ > seeded by the plugin itself (in the same versioned `init` seed, via `update_option`) or left for the owner
438
+ > to fill in wp-admin — record which in `content-model.md`. The MCP can't write the custom option
439
+ > (`wp-settings-update` only touches standard WordPress settings, and there's no generic REST passthrough).
440
+
441
+ ---
442
+
443
+ ## 9 — Record and hand off
444
+
445
+ Write what you built into `content-model.md` under a `## Custom plugin` section so Step 6 (frontend data)
446
+ and any re-run resolve it without re-discovery:
447
+
448
+ - Plugin **slug** and version, and that it's deployed + active.
449
+ - Each **post type**: internal key → `rest_base` → the full read URL the frontend uses.
450
+ - Each **taxonomy**: key → `rest_base`, and which types it's attached to.
451
+ - Each **meta / REST field** exposed, and under what key it appears.
452
+ - The **settings route** full URL and its JSON shape (so the frontend knows the footer/contact fields).
453
+
454
+ The frontend then reads these routes exactly like core `wp/v2` content — same host, same open CORS, no auth.
455
+
456
+ ---
457
+
458
+ ## 10 — Checklist & further reading
459
+
460
+ - [ ] One prefixed plugin folder; main file carries the plugin header; every PHP file guards `ABSPATH`.
461
+ - [ ] Post types & taxonomies: `show_in_rest: true` + explicit `rest_base`, registered on `init`.
462
+ - [ ] Settings stored in one option, Settings API for the admin page (capability + nonce), **sanitized in**.
463
+ - [ ] Public settings **read** route with `permission_callback`, values returned as **plain JSON** (sanitized on save, not HTML-escaped), no secrets.
464
+ - [ ] Rewrite flush on activation; cron (if any) cleared on deactivation; `uninstall.php` tidies options.
465
+ - [ ] Deployed with `hosting_deploy { type: "wordpress-plugin", headless: true }`, confirmed **active**,
466
+ every REST route returns `200`.
467
+ - [ ] Routes recorded in `content-model.md` for the frontend.
468
+
469
+ **References** — this cookbook is self-contained, but for depth follow the maintained sources (don't invent
470
+ APIs; confirm a function against these):
471
+
472
+ - WordPress **Plugin Handbook** — <https://developer.wordpress.org/plugins/> (headers, hooks, activation,
473
+ Settings API, security: nonces/capabilities/sanitizing/escaping).
474
+ - WordPress **REST API Handbook** — <https://developer.wordpress.org/rest-api/> (`show_in_rest`,
475
+ `register_rest_route`, `register_rest_field`, authentication).
476
+ - Post types & taxonomies — <https://developer.wordpress.org/reference/functions/register_post_type/> ·
477
+ <https://developer.wordpress.org/reference/functions/register_taxonomy/>.
478
+ - **WordPress/agent-skills** (expert WordPress knowledge for AI assistants — blocks, themes, plugins, best
479
+ practices) — <https://github.com/WordPress/agent-skills>. If your coding agent exposes WordPress skills
480
+ (e.g. plugin-development, rest-api), prefer them for generating and reviewing the PHP; this cookbook is the
481
+ headless wiring around them.
@@ -103,6 +103,21 @@ Read the backend from `site.json`:
103
103
  `yoast_head_json` into the page `<head>` (title, description, Open Graph, JSON-LD schema) — Yoast is
104
104
  installed by default at provision (`site.json → seo.enabled: true`). If `seo.enabled` is `false`
105
105
  (Yoast absent), fall back to generating per-page title/description/OG tags from each item's content.
106
+ - **Contact form** (`site.json → contact_form.enabled: true`, any WordPress backend) → render the
107
+ contact page's form and submit it **client-side** to Contact Form 7's public feedback endpoint. Put a
108
+ `submitContactForm(data)` helper in the one data module: build a `FormData` with the field names from
109
+ `contact_form.fields` (e.g. `your-name`, `your-email`, `your-subject`, `your-message`) **plus CF7's
110
+ hidden fields** — `_wpcf7` = `contact_form.form_id`, `_wpcf7_version`, `_wpcf7_locale` (e.g. `en_US`),
111
+ `_wpcf7_unit_tag` (any stable string), `_wpcf7_container_post` = `0` — and `POST` it to
112
+ `contact_form.feedback_url` **without setting `Content-Type`** (let the browser add the multipart
113
+ boundary). `/cms` is same-origin, so no CORS and no secret. Branch on the JSON `status`: `mail_sent`
114
+ → success state; `validation_failed` → map `invalid_fields[].field` to per-input errors;
115
+ `spam`/`aborted`/`mail_failed` → a generic "couldn't send, try again" message. Include the honeypot
116
+ decoy field named in `contact_form.honeypot_field` (e.g. `your_website`), rendered hidden/off-screen,
117
+ **empty**, `autocomplete="off"`, `tabindex="-1"` — the deployed spam guard rejects it if filled. A user
118
+ validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is `false` or absent, render the contact page with
119
+ plain contact details and **no form** — graceful, like disabled checkout; never half-build. Full
120
+ contract: `../04-content-model/wordpress/contact-form.md` §3.
106
121
  - **WordPress + WooCommerce** (`site.json → woocommerce.enabled: true`) → the WordPress bullet above **plus**
107
122
  the store. Read the catalog over the **public WooCommerce Store API** (`site.json → urls.store_api`,
108
123
  `/cms/wp-json/wc/store/v1`, no auth, open CORS): `GET /products` (prices are **integer minor units** on
@@ -33,6 +33,19 @@ with an empty catalog means the push didn't land:
33
33
  count matches the plan. Don't test checkout while `woocommerce.payment_enabled` is `false`.
34
34
  - **Ecommerce:** query `urls.storefront_api` for the sales channel → 200 with the seeded products and
35
35
  prices *(confirm against the store)*.
36
+ - **Contact form** (`site.json → contact_form.enabled`): POST one throwaway test submission (dummy
37
+ name/email/subject/message + CF7's hidden `_wpcf7*` fields — see
38
+ `../04-content-model/wordpress/contact-form.md` §3) to `contact_form.feedback_url` and assert the JSON
39
+ `status` is **`mail_sent`**. This is the one place a real send happens, so one test email reaches the
40
+ owner — not several. `mail_failed` is a real finding: surface it to the user (CF7 is wired, but
41
+ `wp_mail` delivery/SPF needs attention — a Hostinger mailbox on the domain is the most reliable
42
+ recipient) and log it as an issue. A `validation_failed` means the test payload didn't match the form's
43
+ fields — fix the field names in `site.json`, not the plugin.
44
+ **Then check the honeypot:** POST a **second** throwaway submission with the same valid fields **plus**
45
+ `contact_form.honeypot_field` **filled**, and assert the `status` is **`spam`** (no mail sent). Keep the
46
+ real fields valid — CF7 validates **before** running the spam filter, so an invalid payload would return
47
+ `validation_failed` and never reach the honeypot check. A `mail_sent` here means the
48
+ `cf7-headless-spamguard` plugin isn't active — surface it and re-check its deploy.
36
49
 
37
50
  Fail or counts off → fix content-model push before building against live data.
38
51
 
@@ -20,6 +20,11 @@ already checked this — this is a quick re-confirm, not a full re-run):
20
20
  - **Backend API:** `GET {urls.rest_api}/posts?per_page=1` → 200 (WordPress); for a **WooCommerce** store
21
21
  (`site.json → woocommerce.enabled`) also `GET {urls.store_api}/products?per_page=1` → 200; or the
22
22
  storefront API for the sales channel → 200 (Ecommerce).
23
+ - **Contact form** (`site.json → contact_form.enabled`): validate already sent a test submission, so
24
+ don't send another here. In the handover, tell the owner the contact page is live and enquiries email
25
+ the **WordPress admin address** (via `wp_mail`) — and that for the most reliable delivery that address
26
+ should be a Hostinger mailbox on the domain. If validate reported `mail_failed`, say so plainly instead
27
+ of claiming the form sends.
23
28
 
24
29
  If a check fails, say so in the outcome paragraph and don't claim the site is live.
25
30