Created
August 28, 2026 13:10
-
-
Save jonathanbossenger/1592183b963f9019f1f85afb675d3121 to your computer and use it in GitHub Desktop.
simplecontactblockplan.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Build Plan: "Simple Contact Block" WordPress Plugin | |
| ## Decisions locked in for this build | |
| - **Scaffolding tool:** `@wordpress/create-block` (official WordPress package) | |
| - **Plugin slug / namespace:** `simple-contact-block` | |
| - **Block name:** `simple-contact-block/contact-form` | |
| - **Block render type:** Dynamic (server-side rendered via `render.php`) | |
| - **Form submission:** Custom WordPress REST API endpoint, called from JS using `@wordpress/api-fetch` (not raw `fetch()`) | |
| - **Storage:** Custom post type `contact_submission` | |
| - **Extras included:** email notification to the site admin on each submission; inline success/error messaging in the block (no page reload) | |
| - **Extras explicitly excluded (do not build):** custom admin list-table columns beyond WordPress defaults, honeypot/spam protection, CAPTCHA | |
| --- | |
| ## Step 0 — Prerequisites | |
| Confirm before starting: | |
| - Node.js ≥ 18 and npm installed | |
| - A local WordPress environment to test against (the create-block `--wp-env` flag can provision one) | |
| - PHP ≥ 7.4, WordPress ≥ 6.4 as the target minimum versions | |
| --- | |
| ## Step 1 — Scaffold the plugin with `@wordpress/create-block` | |
| Run: | |
| ```shell | |
| npx @wordpress/create-block@latest simple-contact-block \ | |
| --namespace simple-contact-block \ | |
| --variant dynamic \ | |
| --title "Simple Contact Block" \ | |
| --short-description "A block that renders a name/email/message contact form and stores submissions as a custom post type." \ | |
| --category widgets \ | |
| --wp-env | |
| ``` | |
| **Guidance:** | |
| - `--variant dynamic` is required — the block must be server-side rendered (via `render.php`) rather than saved as static markup, since the form needs a fresh nonce on every page load and PHP-side processing. | |
| - Review what the scaffold produces before writing anything new: the plugin's main PHP file, `src/block.json`, `src/edit.js`, `src/index.js`, `src/render.php`, `src/view.js`, `src/style.scss`, `src/editor.scss`. Understand what each file is responsible for before extending it. | |
| - Don't fight the generated build tooling (`wp-scripts`) — use the npm scripts it provides (`start`, `build`) rather than introducing a separate bundler. | |
| **Acceptance criteria:** | |
| - [ ] `npm start` runs without errors | |
| - [ ] The block is insertable in the block editor and shows the (unmodified) scaffold placeholder | |
| --- | |
| ## Step 2 — Register the `contact_submission` custom post type | |
| **Goal:** every form submission is stored as a post of a dedicated custom post type, visible to site admins in wp-admin but never exposed on the public-facing site or through the default REST API. | |
| **Guidance:** | |
| - Register the post type on the `init` hook, using `register_post_type()`. | |
| - Decide where this registration lives (a dedicated includes file is cleaner than cramming it into the main plugin file — use whatever structure the rest of the plugin follows). | |
| - Data mapping to decide and implement: | |
| - Submitter's **name** → the post title | |
| - Submitter's **message** → the post content | |
| - Submitter's **email** → post meta (not a core field) — think about why email specifically needs its own meta key rather than being another core field | |
| - Post type visibility requirements: it must **not** be publicly queryable, must **not** appear in search or have a frontend archive/single view, but **must** have an admin UI so submissions are reviewable by site owners in wp-admin. | |
| - The email meta field should not be exposed through the default `wp/v2` REST API to unauthenticated or under-privileged users — research `register_post_meta()`'s options for controlling REST visibility and read/write authorization per field. | |
| - Think about whether regular contributors/authors should be able to create, edit, or delete these posts by hand, versus submissions only ever being created programmatically by the plugin's own code. | |
| **Acceptance criteria:** | |
| - [ ] Activating the plugin adds a "Contact Submissions" admin menu item | |
| - [ ] A submission created programmatically appears correctly in that list (title \= name, content \= message) | |
| - [ ] The post type never appears anywhere on the public-facing site | |
| - [ ] The stored email is not retrievable via an anonymous request to the default `wp/v2` REST endpoints | |
| --- | |
| ## Step 3 — Register the REST API endpoint | |
| **Goal:** a dedicated, plugin-owned REST route that accepts a form submission and creates a `contact_submission` post. | |
| **Guidance:** | |
| - Register the route on `rest_api_init` using `register_rest_route()`, under the plugin's own namespace (never reuse or extend `wp/v2`) — e.g. something under `simple-contact-block/v1`. | |
| - Method should be the one appropriate for creating a new resource. | |
| - Think carefully about `permission_callback`: this endpoint must be reachable by anonymous site visitors, but that doesn't mean it should skip all protection — it still needs to verify the request actually originated from the site itself, via the standard `wp_rest` nonce that WordPress core's REST authentication already checks for cookie-authenticated requests (this is the same nonce the default `wp-api-fetch` nonce middleware attaches automatically — see Step 6, which pins the approach). Don't invent a custom nonce action for this; rely on the standard one so the default middleware "just works" without extra wiring. | |
| - Define the expected request arguments (name, email, message) with appropriate `sanitize_callback`s in the route's `args` — but remember sanitization is not the same as validation. | |
| - Server-side validation requirements, independent of anything the browser does: | |
| - All three fields are required and non-empty after trimming | |
| - The email must be a genuinely valid email format, not just sanitized text | |
| - Appropriate HTTP status codes and error responses (`WP_Error`) for each failure case, distinguishable enough that the frontend can show a useful message | |
| - On success: create the post (mapping fields per Step 2), store the email meta, trigger the notification email (Step 5), and return a response the frontend can use to show a success message. | |
| - On failure: return a `WP_Error` with a client-safe message and correct status code (don't leak internals). | |
| **Acceptance criteria:** | |
| - [ ] A valid submission creates a new `contact_submission` post with correct title/content/email meta and returns a success response | |
| - [ ] An invalid email format is rejected with a 4xx response and a clear message, no post created | |
| - [ ] A missing required field is rejected with a 4xx response, no post created | |
| - [ ] A request without a valid nonce is rejected (see Step 6\) | |
| --- | |
| ## Step 4 — Block editor experience (`edit.js`) | |
| **Goal:** authors get a reasonable in-editor preview of the form, plus a few configurable options. | |
| **Guidance:** | |
| - The editor view doesn't need to be a functioning form — it's a preview inside the editor canvas. Show something that clearly communicates "this is where a name/email/message form will appear." | |
| - Add block attributes (defined in `block.json`) that authors can configure via `InspectorControls`: | |
| - The submit button's label text | |
| - The success message shown after a successful submission | |
| - An optional override email address for where notifications get sent (falls back to the site admin email if left blank) | |
| - Pick sensible defaults for each attribute so the block works out of the box with zero configuration. | |
| - Validate the recipient-email override in the UI (don't let an author save an obviously malformed email into an attribute that PHP will later trust). | |
| **Acceptance criteria:** | |
| - [ ] Inserting the block shows a clear form preview in the editor | |
| - [ ] Each attribute is editable via the Inspector panel and persists after saving | |
| --- | |
| ## Step 5 — Dynamic render (`render.php`) | |
| **Goal:** output the real, functional form markup on the frontend for each request. | |
| **Guidance:** | |
| - Use the block's `$attributes` to fill in the submit button label and any other configurable text. | |
| - Use `get_block_wrapper_attributes()` for the outer wrapper, per standard dynamic-block conventions. | |
| - The form needs: labeled name/email/message inputs (proper `<label>`/`id` association, appropriate `type`/`required` attributes), a submit button, and a place for a status/response message to be shown by the frontend JS after submission. | |
| - Nonce data does not need to be manually passed to `view.js` — the default `wp-api-fetch` nonce middleware (pinned in Step 6\) handles that automatically once the script dependency is wired up correctly. The only thing `render.php` needs to make available to the frontend script is the REST route/path itself; think about the standard WordPress mechanism for passing that one piece of PHP-side data to an enqueued script, rather than hand-rolling `data-*` attributes if a more idiomatic option exists. | |
| - All dynamic output must be properly escaped for its context (attribute vs. HTML vs. URL). | |
| **Acceptance criteria:** | |
| - [ ] Viewing a page with the block shows a real form with all three fields and a properly labeled submit button | |
| - [ ] The submit button text reflects the block attribute set in the editor | |
| - [ ] View source shows no unescaped output | |
| --- | |
| ## Step 6 — Frontend interactivity using `@wordpress/api-fetch` | |
| **Goal:** progressively enhance the form so submission happens via a REST request instead of a full page reload, using the `@wordpress/api-fetch` package rather than the raw `fetch()` API. | |
| **Guidance:** | |
| - Research `@wordpress/api-fetch` ([https://developer.wordpress.org/block-editor/reference-guides/packages/packages-api-fetch/](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-api-fetch/)) before implementing this step — in particular how it wraps `fetch()`, how requests are described (`path`/`method`/`data` vs. a raw `url`), and how its middleware system works. | |
| - **Nonce handling is pinned to WordPress's default `wp-api-fetch` nonce middleware — do not build a custom nonce solution.** Concretely: | |
| - Rely on the standard behavior WordPress core provides whenever the `wp-api-fetch` script handle is enqueued as a dependency: core automatically adds the inline JavaScript that registers `apiFetch.createNonceMiddleware()` (using the standard `wp_rest` nonce) and `apiFetch.createRootURLMiddleware()`, with no manual `apiFetch.use(...)` calls or hand-written nonce headers needed in `view.js`. | |
| - This default wiring is normally set up automatically in wp-admin; on the public/logged-out frontend it depends on `wp-api-fetch` actually being present as a declared script dependency for `view.js` — verify the build correctly registers this dependency (check the generated asset file) and that it isn't silently missing from the frontend enqueue. | |
| - Do not create a custom nonce action, do not localize a nonce manually via `wp_localize_script`, and do not set the `X-WP-Nonce` header by hand — all of that should be unnecessary once the default middleware is correctly wired up. | |
| - `view.js` needs `@wordpress/api-fetch` as a build/script dependency — make sure the dependency is actually registered so it loads before the block's own script runs on the frontend. | |
| - Behavior to implement: | |
| - Intercept the form's submit event and prevent the default full-page submission | |
| - Disable the submit button while the request is in flight (avoid double-submits) | |
| - On success, replace/populate the status area with the success message and reset the form fields | |
| - On failure, show the specific error message returned by the REST endpoint (invalid email vs. missing field vs. server error should all be distinguishable to the user) | |
| - Handle the case where the request fails entirely (network error) separately from a request that completed but returned an error response | |
| - Re-enable the submit button once the request settles, regardless of outcome. | |
| **Acceptance criteria:** | |
| - [ ] Submitting valid data shows the success message inline with no page reload, and the form clears | |
| - [ ] Submitting invalid data (e.g. malformed email) shows the server's specific error message inline | |
| - [ ] The request is visibly made via `@wordpress/api-fetch` machinery (not a hand-rolled `fetch()` call) in the network tab, with the `X-WP-Nonce` header correctly attached by the default `wp-api-fetch` middleware — not by any custom header-setting code | |
| - [ ] The submit button is disabled during the request and re-enabled afterward in both success and failure cases | |
| --- | |
| ## Step 7 — Styling (`style.scss`, `editor.scss`) | |
| **Goal:** a usable, accessible baseline appearance that doesn't depend on the active theme. | |
| **Guidance:** | |
| - Stack fields vertically with clear labels, visible focus states, and adequate spacing. | |
| - Visually distinguish the success and error states of the response area (color plus something non-color-dependent, for accessibility) with sufficient contrast. | |
| - Style the disabled/in-flight submit button state distinctly from its normal state. | |
| **Acceptance criteria:** | |
| - [ ] Form is legible and usable with only the plugin's own CSS | |
| - [ ] Success and error states are visually distinct and meet basic contrast requirements | |
| --- | |
| ## Step 8 — Email notification on submission | |
| **Goal:** the site admin (or a per-block override address) receives an email whenever a submission comes in. | |
| **Guidance:** | |
| - Trigger this from the REST callback, after the post and its meta are successfully saved — not before, and not if saving failed. | |
| - Recipient resolution order: the block's configured override email (Step 4\) if present and valid, otherwise the site's admin email. | |
| - Provide a way for other code to override the recipient globally (a filter is the idiomatic WordPress mechanism for this). | |
| - Email content should include the submitter's name, email, and message, plus a way for the admin to easily get to the submission in wp-admin. | |
| - Set the reply-to address to the submitter's email so the admin can respond directly. | |
| - Consider what should happen if the email fails to send — should the API request still report success to the visitor? (Their message was still saved.) | |
| **Acceptance criteria:** | |
| - [ ] A local test submission produces an email to the site admin (or override address) containing name, email, and message | |
| - [ ] Replying to that email would go to the submitter's address | |
| - [ ] A failed email send does not prevent the submission from being saved or reported as successful to the visitor | |
| --- | |
| ## Step 9 — Security checklist (verify all before considering the plugin done) | |
| - [ ] The REST endpoint rejects requests without a valid nonce | |
| - [ ] All dynamic output in `render.php` is properly escaped for context | |
| - [ ] All REST input is both sanitized and independently validated (required fields, real email-format check) — not sanitization alone | |
| - [ ] The email post meta is not exposed via the default REST API to unauthorized requests | |
| - [ ] The custom post type is fully non-public (no archive, no single view, not searchable) | |
| - [ ] No hand-written SQL anywhere — all persistence goes through core WordPress data APIs | |
| - [ ] Text domain is consistent across all translation function calls and the plugin header | |
| --- | |
| ## Step 10 — Testing plan | |
| **Manual:** | |
| 1. Insert the block, publish, view on the frontend — form renders correctly. | |
| 2. Submit valid data — success message shown, submission appears correctly in wp-admin, email meta stored correctly, notification email received. | |
| 3. Submit an invalid email — inline error shown, nothing saved. | |
| 4. Submit with a required field empty, bypassing client-side validation — server-side validation still blocks it. | |
| 5. Send a direct request to the REST endpoint with no nonce — rejected. | |
| 6. Confirm the custom post type is never reachable on the public-facing site. | |
| **Automated (if the environment supports it):** | |
| - Tests confirming the custom post type is registered as non-public. | |
| - Tests hitting the REST route directly with valid, invalid-email, and missing-nonce payloads, asserting the correct response in each case. | |
| **Acceptance criteria:** | |
| - [ ] All manual steps pass | |
| - [ ] Any automated tests written pass in CI | |
| --- | |
| ## Step 11 — Build & packaging | |
| **Guidance:** | |
| - Produce a production build via the scaffolded build script. | |
| - Update the plugin's readme with an accurate description, minimum/tested-up-to WordPress versions, and an initial changelog entry. | |
| **Acceptance criteria:** | |
| - [ ] A fresh WordPress install with only this plugin active lets an editor insert the block, a visitor submit it successfully, an admin find the submission in wp-admin, and the admin receive a notification email. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment