Skip to content

feat(email): bind post content to Core blocks through one read-only binding source - #94

Merged
TheAggressive merged 5 commits into
masterfrom
feat/post-data-block-bindings
Sep 20, 2026
Merged

TheAggressive merged 5 commits into
masterfrom
feat/post-data-block-bindings

Conversation

@TheAggressive

Copy link
Copy Markdown
Owner

Replaces the snapshot-bound post blocks with real WordPress Core blocks connected to a single CampaignBridge Block Bindings source, without weakening the deterministic, immutable, snapshot-only compiler.

What changed

Removed Replacement
campaignbridge/post-title core/heading bound to title / titleLink
campaignbridge/post-excerpt core/paragraph bound to excerpt / content
campaignbridge/post-button core/buttonscore/button bound to url
campaignbridge/post-link the same button with the native ghost style

Custom blocks drop from 12 to 8. campaignbridge/post-image stays: WordPress' get_block_bindings_supported_attributes() makes only id, url, title, alt and caption bindable on core/image, so link-to-post and the snapshot's intrinsic dimensions have no bindable path.

Binding contract

includes/Email_Blocks/email-blocks.json gains a postBindings section naming the one source — campaignbridge/post-data — and the only supported block + attribute + field + argument combinations. Both Core_Block_Normalizer and the editor read that file, so the editor cannot author a binding the compiler will not accept.

A field declares reads (the snapshot field supplying its value) and an optional link (the snapshot field the value links to). Bindings therefore resolve data; renderers own presentation. linkToPost survives as the titleLink field rather than a block attribute, and Heading_Renderer composes the anchor with the heading's own resolved colour, because email clients recolour bare links.

Read-only by construction

The source defines neither setValues nor canUserEditValue, so WordPress itself disables editing of every bound attribute and discards writes to it. It is deliberately not registered in PHP, so render_block() cannot substitute live post data either. Editing an email template can never modify the article it displays — asserted in a browser against real Gutenberg.

Compiler

Core_Block_Normalizer validates metadata.bindings against the contract and emits a canonical postBindings attribute; it never executes the source. Renderer_Interface::resolve_post_bindings() substitutes values from the Post Card's immutable snapshot after token resolution — so snapshot text can never be read as an authored provider token — and before validation, so a bound value passes the same rules an authored one does. No post is read during a compile.

Also included

  • New optional content snapshot field exposing the post body as plain text, reduced without invoking the_content, shortcodes or dynamic blocks. Block boundaries are preserved as word boundaries: plain strip_tags would turn …epsilon</p><!-- wp:paragraph --><p>zeta… into one word.
  • Bounded maxWords argument (10–500) shared by the excerpt and content fields.
  • A contract-aware Post content inspector panel added through the public editor.BlockEdit filter. Core's own bindings panel reads one source-wide field list and cannot be scoped per attribute, so it would offer url on a paragraph.
  • Colour diagnostics now name the attribute the author set instead of a generic color, so a theme palette slug with no email equivalent points at the control that produced it.
  • Removes link-hover style support orphaned by the replaced blocks.

Compatibility

Templates saved with the four removed blocks no longer compile and must be rebuilt with the Core equivalents. No BREAKING CHANGE: footer was added, so semantic-release will not cut a major — say if you want that instead.

Verification

Command Result
pnpm qa OK — 1071 tests, 4777 assertions
pnpm qa:fast OK — 122 JS tests, prettier, PHPStan clean
pnpm lint:php clean
pnpm test:e2e 9 passed
pnpm build / release:package / release:verify succeeded

One e2e run had native-editor and preview-personalization fail, then both passed in isolation and the full suite passed on re-run — parallel contention on a single local site, not a regression.

🤖 Generated with Claude Code

…inding source

Replace the snapshot-bound post blocks with real WordPress Core blocks
connected to a single CampaignBridge Block Bindings source, keeping the
deterministic, immutable, snapshot-only compiler intact.

Removed in favour of bound Core blocks:

- campaignbridge/post-title  -> core/heading
- campaignbridge/post-excerpt -> core/paragraph
- campaignbridge/post-button -> core/buttons > core/button
- campaignbridge/post-link   -> the same button with the native ghost style

campaignbridge/post-image stays custom: WordPress makes only id, url, title,
alt and caption bindable on core/image, so link-to-post and the snapshot's
intrinsic dimensions have no bindable path.

Contract

includes/Email_Blocks/email-blocks.json gains a postBindings section naming
the one source, campaignbridge/post-data, and the only supported block,
attribute, field and argument combinations. A field declares `reads` (the
snapshot field supplying its value) and an optional `link` (the snapshot field
the value links to), so bindings resolve data while renderers own
presentation. linkToPost survives as the titleLink field rather than a block
attribute, and Heading_Renderer composes the anchor with the heading's own
resolved colour.

Read-only

The source defines neither setValues nor canUserEditValue, so WordPress
disables editing of every bound attribute and discards writes to it. It is
deliberately not registered in PHP, so render_block() cannot substitute live
post data either. Editing a template can never modify the post it displays.

Compiler

Core_Block_Normalizer validates metadata.bindings against the contract and
emits a canonical postBindings attribute; it never executes the source.
Renderer_Interface::resolve_post_bindings() substitutes values from the Post
Card's immutable snapshot after token resolution, so snapshot text can never
be read as an authored provider token, and before validation, so bound values
pass the same rules as authored ones.

Also

- new optional `content` snapshot field exposing the post body as plain text,
  reduced without invoking the_content, shortcodes or dynamic blocks, with
  block boundaries preserved as word boundaries
- bounded maxWords argument (10-500) shared by the excerpt and content fields
- contract-aware "Post content" inspector panel, added through the public
  editor.BlockEdit filter, offering only the combinations the compiler accepts
- colour diagnostics now name the attribute the author set instead of a
  generic "color", so a theme palette slug with no email equivalent points at
  the control that produced it
- removes dead link-hover style support orphaned by the replaced blocks

Templates saved with the removed blocks no longer compile and must be
rebuilt with the Core equivalents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added area:blocks WordPress email blocks area:email Email compiler or campaign workflow area:frontend Frontend source or Node tooling area:php PHP or Composer code area:tests Automated tests or test configuration risk:high Security or release sensitive; never auto-merged type:feature New capability needs-attention Automation stopped; maintainer action required labels Sep 20, 2026
Comment thread src/blocks/shared/post-bindings.ts Fixed
TheAggressive and others added 4 commits September 20, 2026 10:53
The editor's excerpt preview replaced one entity kind at a time, so a
replacement's own output was scanned again by the next replacement:
`&amp;lt;` decoded to `&lt;` and then to `<`, inventing markup the source
never contained. CodeQL flags this as double unescaping.

Match every named, decimal, and hexadecimal entity in one scan instead, so
text is decoded exactly one level, the way `html_entity_decode()` already
does on the compiler side. Tags are still stripped before decoding, so an
escaped `&lt;script&gt;` stays inert text.

Each new expectation is the exact output of
`Renderer_Support::truncate_words()` for the same input, so the editor
preview and the compiled email cannot drift apart on entity handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new Post Card led with the post's summary. Lead with the post body instead,
reduced to plain text and capped by the same bounded maxWords argument, so a
card shows real content the moment it is inserted.

The field stays a per-block choice in the Post content inspector panel; only
the seeded default changes, and every existing template keeps whatever field
it already stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Post Card exposed its composition as a `template` property on the block
type, which Core never reads, so `useInnerBlocksProps()` was called without a
template and a newly inserted card stayed empty. The browser suite caught it
in CI, where nothing incidentally delayed the first assertion.

Pass the template to `useInnerBlocksProps()` instead, which is the mechanism
Core provides: it applies the template in its own layout effect, only while
the card has no children, and marks the change non-persistent. Cards inserted
from a variation, or recomposed by the Layout control, already have children,
so the template is a no-op for them. No effect, store, or post-render
insertion of our own, and templateLock stays false.

Also fixes what that change exposed and what CodeQL flagged:

- `core/heading` offers Core's Appearance control, which writes `fontStyle`
  alongside `fontWeight`. The old post-title block had no weight support, so
  the pair never reached the compiler before. Both properties are now bounded
  (`normal`/`italic`, 100-900) and rendered, and `core/paragraph` normalizes a
  string weight the way `core/heading` already did.
- The editor's text truncation replaced entities one kind at a time, so
  `&amp;lt;strong&amp;gt;` decoded twice into real markup. It now decodes once
  through `@wordpress/html-entities`, which the browser resolves in a detached
  textarea.
- Connecting a binding cleared nothing, so an authored paragraph could keep a
  literal the compiler refuses. The panel now clears exactly the bound
  attribute in the same action, leaving a button's authored label intact.
- `getFieldsList()` is no longer published. WordPress reads one field list per
  source and cannot scope it per attribute, so it would offer `url` on a
  paragraph in Core's generic panel.
- The TypeScript binding contract models the JSON it actually reads, dropping
  the `as unknown as` escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new Post Card led with the post body, which changed the familiar
post-summary composition without cause. Seed the excerpt again, so a card
starts as image, title, summary, call to action.

Binding to the body remains fully supported and stays one selection away in
the Post content inspector, with the same bounded word cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TheAggressive
TheAggressive merged commit 2403e61 into master Sep 20, 2026
21 checks passed
@TheAggressive
TheAggressive deleted the feat/post-data-block-bindings branch September 20, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:blocks WordPress email blocks area:email Email compiler or campaign workflow area:frontend Frontend source or Node tooling area:php PHP or Composer code area:tests Automated tests or test configuration needs-attention Automation stopped; maintainer action required risk:high Security or release sensitive; never auto-merged type:feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants