@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.
- package/CHANGELOG.md +11 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/steps/02-refine/SKILL.md +7 -0
- package/skills/headless/steps/03-provision/wordpress/SKILL.md +63 -0
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +58 -5
- package/skills/headless/steps/04-content-model/wordpress/contact-form.md +227 -0
- package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +481 -0
- package/skills/headless/steps/06-frontend/SKILL.md +15 -0
- package/skills/headless/steps/07-validate/SKILL.md +13 -0
- package/skills/headless/steps/09-done/SKILL.md +5 -0
- package/src/core/skills.js +32 -18
- package/src/core/skills.ts +32 -18
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- 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 & 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
|
|