Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 2 additions & 29 deletions .github/workflows/playwright-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,12 @@ jobs:
- name: Install Playwright
run: npx playwright install chromium

- name: Start WordPress Playground
run: |
npx @wp-playground/cli server \
--blueprint=blueprint.json \
--php=${{ matrix.php }} \
--wp=${{ matrix.wp }} \
--port=9400 \
--auto-mount &
echo "PLAYGROUND_PID=$!" >> $GITHUB_ENV
env:
CI: true

- name: Wait for WordPress Playground to be ready
run: |
timeout 60 bash -c 'until curl -sSf http://127.0.0.1:9400 > /dev/null 2>&1; do sleep 2; done' || echo "WordPress Playground may not be fully ready"
sleep 5

- name: Debug - Check WordPress Playground status
run: |
curl -I http://127.0.0.1:9400 || echo "WordPress Playground not responding"

- name: Run E2E tests
run: npm run test:e2e
env:
WP_BASE_URL: http://127.0.0.1:9400
CI: true
PLAYGROUND_PHP: ${{ matrix.php }}
PLAYGROUND_WP: ${{ matrix.wp }}

- name: Upload test results
if: always()
Expand All @@ -79,13 +59,6 @@ jobs:
path: playwright-report/
retention-days: 7

- name: Stop WordPress Playground
if: always()
run: |
if [ ! -z "$PLAYGROUND_PID" ]; then
kill $PLAYGROUND_PID || true
fi

- name: Comment test results on PR
if: github.event_name == 'pull_request' && always()
uses: daun/playwright-report-summary@v3
Expand Down
30 changes: 17 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ WordPress block plugin that embeds HubSpot Forms v4 directly in page content. Th
Single block (`hubspot/form`) that:
- Accepts Portal ID, region, Form ID, redirect URL, submit button text, GTM event name
- Supports optional inner blocks as a success message shown in place of the form after submission
- Emits a `<template>` element server-side containing the inner-block HTML; clones it on submission success via `view.js`
- Serves that success/gated content from a REST endpoint (`/hubspot-form-block/v1/unlock`) only after submission — the content is never embedded in the page. With a HubSpot private app token configured, the submission is verified server-side against HubSpot before the content is released.

## Architecture

Expand All @@ -16,26 +16,29 @@ src/ # Source (compiled by @wordpress/scripts → build/)
├── block.json # Block registration, attributes, supports
├── index.js # Block registration entry
├── edit.js # Block editor UI (InspectorControls + inner blocks)
├── save.js # Saves inner block content (used by render.php)
├── view.js # Frontend JS: form events, success message clone, GTM
├── render.php # Server render callback — outputs form container + <template>
├── save.js # Saves inner block content (re-rendered by the unlock endpoint)
├── view.js # Frontend JS: form events, success-message fetch/inject, GTM
├── render.php # Server render callback — outputs form container + config (no message HTML)
├── editor.scss # Editor-only styles
└── style.scss # Frontend + editor styles
inc/
└── inline-message.php # Unlock REST endpoint, block locating, submission verification, HMAC tokens
hubspot-form-block.php # Plugin entry: registers block, enqueues HubSpot loader script
```

### Key patterns

**Config injection (`render.php`):** Each form instance gets a unique `target` ID (`hubspot-form-{formId}-{n}`). A `<script>` block writes `window.hsForms[target] = {...config}` so `view.js` can pick it up when HubSpot fires `hs-form-event:on-ready`.
**Config injection (`render.php`):** Each form instance gets a unique `target` ID (`hubspot-form-{formId}-{n}`). A `<script>` block writes `window.hsForms[target] = {...config}` so `view.js` can pick it up when HubSpot fires `hs-form-event:on-ready`. When inner blocks are present, the config also carries `gated`, `postId`, `formId`, `instance`, `restUrl`, and `pendingMessage`.

**Success message (`render.php` → `view.js`):**
When inner blocks are present and no `redirectUrl` is set, `render.php` emits:
```html
<template id="{target}-inline-message">...inner block HTML...</template>
```
On `hs-form-event:on-submission:success`, `view.js` finds the template by ID and clones its content into the form container, replacing the form with the success message. No sanitization needed — content is server-rendered WordPress block output.
**Success message (server-fetched):**
The inner-block HTML is **never** emitted into the page. On `hs-form-event:on-submission:success`, `view.js` POSTs to `POST /wp-json/hubspot-form-block/v1/unlock`. The handler (`inc/inline-message.php`):
1. Validates the post is published/publicly viewable.
2. `locate_form_block()` — re-parses the post, walks blocks in document order (expanding `core/block` synced patterns), and matches the Nth `hubspot/form` instance for the form ID (mirrors render.php's per-formId counter). This makes the content **per-page and per-instance**.
3. Verifies the submission (see below), then `get_inline_message_html()` renders the located block's inner blocks and returns the HTML, which `view.js` injects. While unverified the endpoint returns `202` and the client polls with backoff; on failure it shows `pendingMessage`.

**Submission verification (strong vs best-effort):** `get_private_token()` resolves a HubSpot private app token from the `HUBSPOT_FORMS_PRIVATE_TOKEN` constant → `hubspot_form_block_private_token` filter → `hubspot_embed_private_app_token` option (never exposed via REST). When present (**strong mode**), `verify_hubspot_submission()` calls `https://api.hubapi.com/form-integrations/v1/submissions/forms/{formId}` (host is region-agnostic) and requires a recent submission matching the page URL and email — the conversionId is *not* in that API response, so matching is heuristic. The submissions list is transient-cached and the endpoint is per-IP rate-limited. Without a token (**best-effort mode**) content is returned directly.

**`WP_HTML_Tag_Processor` usage (`render.php`):** Used to rewrite the outer class on the inner-block wrapper div from `wp-block-hubspot-form` → `wp-block-hubspot-form__inline-message` before placing it in the template.
**Repeat visits / `persistSuccess`:** On first unlock the server mints an HMAC `unlockToken` (`mint_unlock_token()`, signed with `wp_salt()`); `view.js` stores `{ path, token }` in `localStorage` keyed `hs-form-submitted:{formId}`. On return visits a slim inline `<script>` in render.php hides the form (no content exposed) and `view.js` replays the token, which `verify_unlock_token()` validates with no HubSpot call. `.is-hubspot-form-first-submission` blocks are stripped client-side on repeat visits only.

## Dev workflow

Expand Down Expand Up @@ -66,5 +69,6 @@ The `--webpack-copy-php` flag on `build`/`start` is required — it copies `rend

- The HubSpot form renders **inline, not in an iframe** — it injects DOM directly into `<div id="{target}">`. Allow time for `hs-form-event:on-ready` before asserting form elements exist.
- `--webpack-copy-php` is required in `build`/`start` scripts so `render.php` is included in `build/`. Do not remove it.
- Block attributes are in `src/block.json`. The `inlineMessage` attribute in `block.json` is legacy (kept for backward-compat migration) — the live success-message mechanism now uses the `<template>` approach, not the `inlineMessage` string.
- Block attributes are in `src/block.json`. The `inlineMessage` attribute in `block.json` is legacy (kept for backward-compat migration) — the live success-message mechanism now fetches inner-block content from the unlock REST endpoint, not the `inlineMessage` string or the old `<template>`.
- Strong-mode verification can't be exercised in Playground (no HubSpot token, no outbound HTTP). The default E2E suite covers best-effort mode against the real endpoint and mocks the endpoint (`mockUnlockEndpoint` in `tests/helpers.js`) for polling/repeat-visit cases. To test strong mode, set the `hubspot_form_block_private_token` filter and short-circuit the HubSpot call via `pre_http_request`.
- `build/` is committed on the `release` branch (via GitHub Actions) but gitignored on `main`.
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ A WordPress block plugin that embeds HubSpot Forms v4 directly in page content.
## Features

- **HubSpot Forms v4 API** — uses the modern developer embed API
- **Inline success message** — add any WordPress blocks as the post-submission message; shown in place of the form after submission
- **Gated content** — optionally remember submissions in the browser (per page URL) and show the success content immediately on return visits, hiding the form permanently; supports a "First Submission Message" block for content only shown at the moment of first submission
- **Inline success message** — add any WordPress blocks as the post-submission message; shown in place of the form after submission. The content is never embedded in the page — it is fetched from a REST endpoint only after submitting, so it can't be read from the page source.
- **Gated content** — the success content is served only after a submission. With a HubSpot private app token configured, the submission is verified server-side against HubSpot before the content is released (see [Gated content security](#gated-content-security)). Supports a "First Submission Message" block for content only shown at the moment of first submission, and remembers returning visitors per page URL.
- **Global settings** — set Portal ID and region site-wide; override per block instance
- **Google Tag Manager** — fires a configurable dataLayer event on submission (default: `hubspot_form_submit`)
- **Deferred script loading** — HubSpot tracking JS loaded asynchronously in the footer
Expand All @@ -24,8 +24,8 @@ A WordPress block plugin that embeds HubSpot Forms v4 directly in page content.
| Redirect URL | Redirect to this URL after submission. Overrides the inline message if set. |
| Submit button text | Override the form's submit button label. |
| GTM event name | dataLayer event name pushed on submission. Defaults to `hubspot_form_submit`. |
| Success message (inner blocks) | WordPress blocks shown in place of the form after successful submission. Not shown if a redirect URL is set. |
| Enable gated content | When on, the browser remembers that this form has been submitted on this page (stored in `localStorage`). Returning visitors see the success message immediately instead of the form. Use the "Insert First Submission Message" button to add a block whose content is only shown at the moment of the first submission — stripped on subsequent visits. Disabled when a redirect URL is set. |
| Success message (inner blocks) | WordPress blocks shown in place of the form after successful submission. Fetched from the server after submitting (never embedded in the page). Not shown if a redirect URL is set. |
| Enable gated content | When on, the success content is treated as gated: it is fetched only after a submission and, returning visitors are remembered per page URL (an unlock token stored in `localStorage`) so they see it again without re-submitting. Use the "Insert First Submission Message" button to add a block whose content is only shown at the moment of the first submission — stripped on subsequent visits. Disabled when a redirect URL is set. |

## Global settings

Expand Down Expand Up @@ -68,18 +68,45 @@ src/
├── index.js # Block registration entry
├── edit.js # Block editor UI (InspectorControls + inner blocks)
├── save.js # Saves inner block content
├── view.js # Frontend JS: form events, success message, GTM
├── render.php # Server render: form container + <template> for success message
├── view.js # Frontend JS: form events, success-message fetch/inject, GTM
├── render.php # Server render: form container + config (no success-message HTML)
├── editor.scss # Editor-only styles
└── style.scss # Frontend + editor styles
inc/
└── inline-message.php # Unlock REST endpoint, block locating, submission verification
hubspot-form-block.php # Plugin entry: block registration, script enqueue, settings API
```

**Config injection:** Each form instance gets a unique target ID (`hubspot-form-{formId}-{n}`). A `<script>` block writes `window.hsForms[target] = {...config}` so `view.js` can pick it up when HubSpot fires `hs-form-event:on-ready`.
**Config injection:** Each form instance gets a unique target ID (`hubspot-form-{formId}-{n}`). A `<script>` block writes `window.hsForms[target] = {...config}` so `view.js` can pick it up when HubSpot fires `hs-form-event:on-ready`. When inner blocks are present the config also carries `gated`, `postId`, `formId`, `instance`, `restUrl` and `pendingMessage`.

**Success message:** When inner blocks are present and no redirect URL is set, `render.php` emits a `<template id="{target}-inline-message">` containing the server-rendered block HTML. On `hs-form-event:on-submission:success`, `view.js` clones the template content into the form container, replacing the form with the success message.
**Success message:** The inner-block HTML is never emitted into the page. On `hs-form-event:on-submission:success`, `view.js` POSTs to `POST /wp-json/hubspot-form-block/v1/unlock` with `postId`, `formId`, `instance` and the submitted email. The endpoint re-parses *that specific post*, locates *that specific form instance* (so the same form ID on different pages returns different content), renders its inner blocks, and returns the HTML, which `view.js` injects into the form container. While verification is in progress the endpoint replies `202` and the client retries with backoff; on repeated failure the `pendingMessage` is shown.

**Gated content (`persistSuccess`):** When enabled, `view.js` writes the current `window.location.pathname` into a `localStorage` entry keyed `hs-form-submitted:{formId}` (value is a JSON array of paths, so the same form on different URLs is tracked independently). A synchronous inline `<script>` emitted by `render.php` checks this array on page load and pre-swaps the container before paint if the current path is present, preventing HubSpot from rendering the form at all. Any inner `core/group` block with class `is-hubspot-form-first-submission` (the "First Submission Message" variation) is stripped from the clone during pre-swap but preserved for the fresh-submission path.
**Gated content (`persistSuccess`):** On first successful unlock the server mints a signed, expiring unlock token and returns it; `view.js` stores `{ path, token }` in a `localStorage` entry keyed `hs-form-submitted:{formId}`. On return visits a small inline `<script>` from `render.php` hides the form (no content is exposed) and `view.js` replays the token to the endpoint, which validates the HMAC and returns the content without contacting HubSpot. Any inner `core/group` block with class `is-hubspot-form-first-submission` (the "First Submission Message" variation) is stripped from the returned HTML on repeat visits but preserved for the fresh-submission path.

## Gated content security

Because HubSpot forms submit client-side, the plugin supports two modes for releasing gated content, selected automatically:

- **Strong mode (recommended)** — when a HubSpot private app access token is configured, the unlock endpoint verifies that a matching, recent submission actually exists in HubSpot (matched by page URL, recency and the submitted email) before returning any content. Content cannot be obtained without genuinely completing the form.
- **Best-effort mode (default, no token)** — the content is still kept out of the page and served from the endpoint, which defeats casual View-Source scraping and bots, but is not a hard guarantee against a determined client that knows the post/form identifiers.

### Configuring the private app token (strong mode)

1. In HubSpot, go to **Settings → Integrations → Private Apps → Create a private app**.
2. Under **Scopes**, grant the **Forms** (read) scope.
3. Create the app and copy the **access token**.
4. Add it to `wp-config.php`:

```php
define( 'HUBSPOT_FORMS_PRIVATE_TOKEN', 'pat-xxxxxxxx-...' );
```

Alternatively, return it from the `hubspot_form_block_private_token` filter. The token is never exposed via the REST API.

Notes:

- The HubSpot API host is always `api.hubapi.com` regardless of the `eu1`/`na1` region setting (region only affects the JS embed hosts).
- Forms placed in block-based theme template parts (outside the post content) can't be located by the unlock endpoint.

## Release workflow

Expand Down
14 changes: 14 additions & 0 deletions hubspot-form-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

namespace HM\HubspotFormBlock;

require_once __DIR__ . '/inc/inline-message.php';

/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
Expand Down Expand Up @@ -110,6 +112,18 @@ function rest_api() {
'show_in_rest' => true,
]
);
// HubSpot private app access token used to verify form submissions before
// releasing gated content. Intentionally NOT exposed via the REST API.
// Prefer defining the HUBSPOT_FORMS_PRIVATE_TOKEN constant in wp-config.php.
register_setting(
'hubspot_embed',
'hubspot_embed_private_app_token',
[
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => false,
]
);
}

add_action( 'rest_api_init', __NAMESPACE__ . '\\rest_api' );
Loading
Loading