feat(email): bind post content to Core blocks through one read-only binding source - #94
Merged
Merged
Conversation
…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>
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: `&lt;` decoded to `<` 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 `<script>` 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 `&lt;strong&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>
This was referenced Sep 20, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
campaignbridge/post-titlecore/headingbound totitle/titleLinkcampaignbridge/post-excerptcore/paragraphbound toexcerpt/contentcampaignbridge/post-buttoncore/buttons›core/buttonbound tourlcampaignbridge/post-linkghoststyleCustom blocks drop from 12 to 8.
campaignbridge/post-imagestays: WordPress'get_block_bindings_supported_attributes()makes onlyid,url,title,altandcaptionbindable oncore/image, so link-to-post and the snapshot's intrinsic dimensions have no bindable path.Binding contract
includes/Email_Blocks/email-blocks.jsongains apostBindingssection naming the one source —campaignbridge/post-data— and the only supported block + attribute + field + argument combinations. BothCore_Block_Normalizerand 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 optionallink(the snapshot field the value links to). Bindings therefore resolve data; renderers own presentation.linkToPostsurvives as thetitleLinkfield rather than a block attribute, andHeading_Renderercomposes the anchor with the heading's own resolved colour, because email clients recolour bare links.Read-only by construction
The source defines neither
setValuesnorcanUserEditValue, so WordPress itself disables editing of every bound attribute and discards writes to it. It is deliberately not registered in PHP, sorender_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_Normalizervalidatesmetadata.bindingsagainst the contract and emits a canonicalpostBindingsattribute; 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
contentsnapshot field exposing the post body as plain text, reduced without invokingthe_content, shortcodes or dynamic blocks. Block boundaries are preserved as word boundaries: plainstrip_tagswould turn…epsilon</p><!-- wp:paragraph --><p>zeta…into one word.maxWordsargument (10–500) shared by theexcerptandcontentfields.editor.BlockEditfilter. Core's own bindings panel reads one source-wide field list and cannot be scoped per attribute, so it would offerurlon a paragraph.color, so a theme palette slug with no email equivalent points at the control that produced it.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
pnpm qapnpm qa:fastpnpm lint:phppnpm test:e2epnpm build/release:package/release:verifyOne e2e run had
native-editorandpreview-personalizationfail, 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