Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jonathanbossenger/1592183b963f9019f1f85afb675d3121 to your computer and use it in GitHub Desktop.

Select an option

Save jonathanbossenger/1592183b963f9019f1f85afb675d3121 to your computer and use it in GitHub Desktop.
simplecontactblockplan.md
# 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