Commit 30a8bcea829 for woocommerce
commit 30a8bcea829b72b241e6a7e5311baba0e06c329f
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Mon Jul 27 16:13:54 2026 -0400
feat(email-editor): render grid-layout Query Loop post-templates as email-safe columns (#66993)
* feat(email-editor): render Query Loop post-template as an email-safe column grid
WordPress renders a Query Loop's `core/post-template` as a `<ul>` laid out
with CSS grid/flex. Email clients (Outlook especially) don't support those,
so a multi-column grid — e.g. the WordCamp US sponsor logo grids — collapsed
to a single stacked column in the newsletter.
Add a `Post_Template` renderer that re-flows the already-rendered `<li>`
items into a table-based column layout, mirroring the existing Gallery
renderer's row/column table pattern. The renderer reads the column count from
the block's `layout.columnCount` attribute (falling back to the `columns-N`
class WordPress stamps on the list) and pads partial final rows with empty
cells so items stay aligned to their column. Single-column and non-grid
layouts are left untouched.
Because `core/post-template` is server-rendered, the items arrive already
rendered; the renderer only rearranges them and never re-runs the query.
Registered as a render-only block (not inserted from the editor); `core/query`
continues to flow through Fallback unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDBTFmiUd42JznZy2Jmqfd
* Harden post-template renderer after adversarial review
Address two findings from an independent adversarial review:
- Match the post-template list by its `wp-block-post-template` class instead
of taking the first `<ul>` in the content, so a sibling list that appears
earlier can't defeat the guard and cause a silent no-op. Adds a reusable
`Dom_Document_Helper::find_elements()` and consolidates the block content to
a single DOM parse threaded through the helpers (was parsed up to 3×).
- Strengthen the test cell/row counters: match the renderer's full cell and
row style strings rather than the bare `text-align: center;` /
`table-layout: fixed;` substrings, which item content can legitimately
contain. Add tests for the decoy-list case and for item content that mimics
the cell/row styles.
No behavior change for real post-template content (its root element is the
post-template `<ul>`); this only makes list identification and the tests
robust against hostile/edge markup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDBTFmiUd42JznZy2Jmqfd
* Fix PHPStan cast.string error on layout type
Guard the layout `type` with is_string() instead of casting mixed to string,
resolving the level-9 `cast.string` error reported by CI. Behavior is
unchanged for real content (layout.type is always a string); a non-string
value now falls through to the column-count/class detection instead of being
coerced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDBTFmiUd42JznZy2Jmqfd
* Fix post-template grid images collapsing to a few pixels in email
The Query Loop post-template renderer previously passed each rendered
`<li>` through verbatim. WordPress wraps a post's image in fixed-width,
auto-layout tables (`<td width="520">`) carrying a web-only `width: 100%`
that has no resolvable basis once the CSS grid is gone — so in email
(Gmail confirmed, reproduced in Chromium at ~17px) the image collapses to
a few pixels, shrinking further as the column count grows until the
widest grids render blank. The deep passthrough markup also bloated the
email past Gmail's clipping threshold, hiding trailing sections.
Mirror the Gallery renderer instead: hoist each item's image out of the
wrapper tables and rebuild it as a clean, responsive `<img>` sitting
directly in its grid cell, with a concrete pixel `width` (the Outlook
fallback) scaled from the intrinsic dimensions to preserve aspect ratio.
The image now fills its column and scales down on mobile. Items without an
image fall back to their original markup unchanged (text stacks fine).
Verified across 3/4/5-column grids and desktop/mobile widths in Chromium:
images render at a uniform cell width instead of collapsing, and the
output is an order of magnitude smaller.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Preserve non-image card content in post-template grids
The grid renderer hoisted each item's featured image out of core's
collapsing wrapper tables but discarded everything else, so a post-card
grid (image + title + date) rendered as bare images. Sponsor-logo grids
were unaffected only because their cards contain nothing but the image.
Keep whatever the card actually holds: after hoisting the image, climb
from it through any ancestors that wrap only that single image (no text,
no other media) and remove that empty shell in one go, then append the
remaining content (title, date, excerpt, and any figcaption, since the
climb stops at caption text) below the rebuilt image. Cards that are
image-only leave nothing behind, so logo grids stay byte-for-byte the
clean output already verified in Gmail.
Adds Dom_Document_Helper::remove_element() and get_root_html() to support
the in-place edit; get_root_html() skips the `<?xml>` PI that the UTF-8
load hack prepends (otherwise wp_strip_all_tags treats the unterminated
`<?` as a tag and swallows the string, which masked the preserved text).
Verified in Chromium: a 3-column post grid renders featured image +
date + title per cell, images filling the column responsively; logo
grids unchanged. Post_Template + Dom_Document_Helper + Gallery suites
green (44 tests), PHPStan level 9 and package PHPCS clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Raise post-template grid column cap to 16 to match the editor
MAX_COLUMNS was 8, copied from the core Gallery block — but post-template
uses the grid layout control, whose Columns range tops out at 16 (the
typed field is unbounded). Clamping to 8 silently reduced any author
choice of 9–16 columns, changing the layout from what the editor shows.
Raise the cap to 16 so every column count an author can pick is honored;
the clamp remains only as a sanity bound against a runaway hand-edited
value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Drop vestigial column clamp in get_cell_width
get_cell_width() is only reached after render_content() returns early on
columns < 2, so columns is always >= 2 there; the max( 1, $columns )
guard never clamped and the division could never divide by zero. Remove
it and document the >= 2 precondition on the parameter instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Drop unrenderable images instead of leaking them through the remainder
When a card held a valid image alongside a second image whose src was
rejected by sanitizing, the renderer hoisted the valid one but `continue`d
past the invalid one without removing it — so the original, unsanitized
tag (e.g. an unsafe URL) survived in the preserved remainder and shipped
in the email. Strip every image found up front, whether it is rebuilt or
dropped as unrenderable, so none can linger. Also add a Gallery-parity
empty-input guard to normalize_image_for_email(). Found in self-review by
an independent adversarial reader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Address CodeRabbit review: exact class token, strip-all-failed images
- find_post_template_list: match `wp-block-post-template` as a whole class
token (preg_split + in_array) instead of a substring, so an unrelated
list like `my-wp-block-post-template-wrapper` isn't rebuilt as a grid.
- prepare_item_content: when no image can be rebuilt, still strip the
images instead of restoring the original markup, so an unrenderable
`<img>` carrying a dangerous attribute (e.g. `onerror`) can't pass
through the remainder. Guard the no-real-image case on the collected
targets rather than the rebuilt list.
- Use `stripos` for the `<img>` fast-path as belt-and-suspenders (the item
HTML is already lowercased by DOM serialization in practice).
Adds regression tests for the substring-class and all-images-fail cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Strip scripts and event handlers from preserved card content
The grid reconstruct path sanitizes the images it hoists but re-emitted a
card's non-image content verbatim, so a <script> or an inline on* handler
in the card would pass through to the email. Add a scoped, in-place pass
that removes <script>/<style> elements and on* attributes from the
remainder before serializing, keeping the reconstructed cell consistent
with its sanitized images.
Deliberately narrow: it runs only on this renderer's grid path, operates
on the local item DOM (no shared-helper change), and leaves style
attributes and structural markup intact — so legitimate card content
(title/date/excerpt, which never carries scripts/handlers) renders
unchanged. Found while sweeping the reconstruct path's emit branches for
siblings of the earlier image-leak fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
* Fix phpcs alignment warning in Post_Template_Test
Whole-package phpcs (which CI runs, treating warnings as failures) flagged
two unaligned `=` in the new test; phpcbf-corrected. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPyvsAwXHtBcRdyk7FaUXd
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/packages/php/email-editor/changelog/add-email-post-template-grid-renderer b/packages/php/email-editor/changelog/add-email-post-template-grid-renderer
new file mode 100644
index 00000000000..0fb5223cd79
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-email-post-template-grid-renderer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Render a Query Loop's post-template grid as an email-safe table of columns. Grid layouts (e.g. a 3-column sponsor logo grid) previously collapsed to a single stacked column in email clients because CSS grid isn't supported; the post-template's items are now re-flowed into table columns matching the author's column count.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-template.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-template.php
new file mode 100644
index 00000000000..138b8ea24f4
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-template.php
@@ -0,0 +1,510 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
+
+/**
+ * Renders a `core/post-template` block (the repeater inside a Query Loop) for email.
+ *
+ * WordPress renders `core/post-template` as a `<ul class="wp-block-post-template">` whose grid is
+ * laid out with CSS grid/flex. Email clients (Outlook especially) don't support those, so the grid
+ * collapses to a single stacked column. This renderer re-flows the already-rendered `<li>` items
+ * into an email-safe, table-based column layout — mirroring how the Gallery renderer arranges
+ * images (see {@see Gallery::build_gallery_table()}).
+ *
+ * The list items arrive already rendered (post-template is a dynamic block, so `$block_content`
+ * holds the final `<ul><li>…</li></ul>`), so this renderer never re-runs the query. Each item's
+ * image is extracted and rebuilt as a clean, responsive `<img>` sitting directly in its grid cell —
+ * the same shape the Gallery renderer emits. This is deliberate: the images WordPress renders inside
+ * a post-template `<li>` are wrapped in fixed-width, auto-layout tables (`<td width="520">`) that
+ * were never sized for email, and a nested `width: 100%` image inside them collapses to a few pixels
+ * in Gmail (the width has no resolvable basis). Hoisting the image into the grid cell gives it a
+ * definite basis so it fills its column and scales down on mobile. An item with no image falls back
+ * to its original markup untouched (text content stacks correctly on its own).
+ */
+class Post_Template extends Abstract_Block_Renderer {
+ /**
+ * Upper bound on grid columns, matching the maximum the core grid layout control allows (its
+ * Columns range control tops out at 16). Honors any column count an author can pick in the editor,
+ * while still bounding an out-of-range hand-edited value so it can't emit a runaway number of cells.
+ */
+ private const MAX_COLUMNS = 16;
+
+ /**
+ * Per-cell padding (px) that stands in for the grid's `gap` between items.
+ */
+ private const CELL_PADDING = 8;
+
+ /**
+ * Responsive image style applied to every rebuilt grid image. `width: 100%` fills the column,
+ * `max-width: 100%` lets it scale down on narrow viewports, and `height: auto` keeps the ratio.
+ * The explicit `width` attribute (set alongside this) is the Outlook fallback, since Outlook
+ * ignores `max-width`.
+ */
+ private const IMAGE_STYLE = 'border: 0; line-height: 100%; width: 100%; max-width: 100%; height: auto; display: block;';
+
+ /**
+ * Renders the post-template block content using a table-based grid layout.
+ *
+ * @param string $block_content Block content.
+ * @param array $parsed_block Parsed block.
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string
+ */
+ protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
+ if ( '' === trim( $block_content ) ) {
+ return $block_content;
+ }
+
+ $dom = new Dom_Document_Helper( $block_content );
+ $list_element = $this->find_post_template_list( $dom );
+
+ // If we can't find the post-template list, leave the original content untouched so we never
+ // degrade output for markup shapes we don't recognize.
+ if ( null === $list_element ) {
+ return $block_content;
+ }
+
+ $items = $this->extract_list_items( $dom, $list_element );
+ if ( empty( $items ) ) {
+ return $block_content;
+ }
+
+ $columns = $this->get_column_count( $parsed_block, $dom, $list_element );
+
+ // Single-column (list/flow/constrained) layouts already stack correctly in email; only the
+ // multi-column grid/flex layouts need to be rebuilt as a table.
+ if ( $columns < 2 ) {
+ return $block_content;
+ }
+
+ // The layout width (minus the email's root padding) is what each cell's images are sized to,
+ // so an image CDN / Outlook get a concrete pixel width rather than the intrinsic file width.
+ $layout_width = (int) Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
+
+ return $this->build_grid_table( $items, $columns, $dom, $list_element, $layout_width );
+ }
+
+ /**
+ * Locate the post-template list within the rendered content.
+ *
+ * The list is matched by its `wp-block-post-template` class rather than by being the first
+ * `<ul>`, so a sibling list that happens to appear earlier in the markup can't be mistaken for
+ * the repeater. Returns null when no such list is present.
+ *
+ * @param Dom_Document_Helper $dom Parsed block content.
+ * @return \DOMElement|null
+ */
+ private function find_post_template_list( Dom_Document_Helper $dom ): ?\DOMElement {
+ foreach ( $dom->find_elements( 'ul' ) as $list_element ) {
+ // Match `wp-block-post-template` as a whole class token, not a substring, so an unrelated
+ // list whose class merely contains the string (e.g. `my-wp-block-post-template-wrapper`)
+ // isn't mistaken for the repeater and rebuilt.
+ $classes = preg_split( '/\s+/', trim( $dom->get_attribute_value( $list_element, 'class' ) ) );
+ if ( is_array( $classes ) && in_array( 'wp-block-post-template', $classes, true ) ) {
+ return $list_element;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Extract the inner HTML of each direct-child `<li>` of the post-template list.
+ *
+ * Only direct children are collected, so a nested list inside a post's content (e.g. a
+ * `core/list` in an excerpt) contributes its markup to the item it lives in rather than being
+ * mistaken for additional repeater items.
+ *
+ * @param Dom_Document_Helper $dom Parsed block content.
+ * @param \DOMElement $list_element The post-template list element.
+ * @return array<int, string> Inner HTML of each list item, in document order.
+ */
+ private function extract_list_items( Dom_Document_Helper $dom, \DOMElement $list_element ): array {
+ $items = array();
+ foreach ( $list_element->childNodes as $node ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ if ( $node instanceof \DOMElement && 'li' === $node->tagName ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ $items[] = $dom->get_element_inner_html( $node );
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * Determine how many columns the grid should render.
+ *
+ * Prefers the block's own `layout.columnCount` attribute and falls back to the `columns-N` class
+ * WordPress core stamps on the rendered list, so it still works when the parsed attributes are
+ * sparse. Non-grid/flex layouts always resolve to a single (stacked) column.
+ *
+ * @param array $parsed_block Parsed block data.
+ * @param Dom_Document_Helper $dom Parsed block content.
+ * @param \DOMElement $list_element The post-template list element.
+ * @return int Column count (at least 1).
+ */
+ private function get_column_count( array $parsed_block, Dom_Document_Helper $dom, \DOMElement $list_element ): int {
+ $layout = $parsed_block['attrs']['layout'] ?? array();
+ $type = is_array( $layout ) && isset( $layout['type'] ) && is_string( $layout['type'] ) ? $layout['type'] : '';
+
+ // A layout that is neither grid nor flex (default, constrained, flow) stacks in one column.
+ if ( '' !== $type && 'grid' !== $type && 'flex' !== $type ) {
+ return 1;
+ }
+
+ $columns = 0;
+ if ( is_array( $layout ) && isset( $layout['columnCount'] ) && is_numeric( $layout['columnCount'] ) ) {
+ $columns = (int) $layout['columnCount'];
+ }
+
+ // Fallback: read the `columns-N` class WordPress core adds to the list wrapper.
+ if ( $columns < 1 && preg_match( '/(?:^|\s)columns-(\d+)(?:\s|$)/', $dom->get_attribute_value( $list_element, 'class' ), $matches ) ) {
+ $columns = (int) $matches[1];
+ }
+
+ if ( $columns < 1 ) {
+ return 1;
+ }
+
+ return min( self::MAX_COLUMNS, $columns );
+ }
+
+ /**
+ * Build the grid as one `<table>` per row wrapped in a container table.
+ *
+ * Follows the tiled-gallery pattern ({@see Gallery::build_gallery_table()}): items are chunked
+ * into rows of `$columns` and each row is its own fixed-layout table, so every cell keeps a
+ * consistent width regardless of how many items the final (possibly partial) row holds.
+ *
+ * @param array<int, string> $items Inner HTML of each list item.
+ * @param int $columns Number of columns.
+ * @param Dom_Document_Helper $dom Parsed block content.
+ * @param \DOMElement $list_element The post-template list element (for wrapper classes).
+ * @param int $layout_width Available layout width in px.
+ * @return string Grid table HTML.
+ */
+ private function build_grid_table( array $items, int $columns, Dom_Document_Helper $dom, \DOMElement $list_element, int $layout_width ): string {
+ $cell_width = $this->get_cell_width( $layout_width, $columns );
+
+ $rows = array();
+ $item_count = count( $items );
+ for ( $i = 0; $i < $item_count; $i += $columns ) {
+ $rows[] = $this->build_grid_row( array_slice( $items, $i, $columns ), $columns, $cell_width );
+ }
+ $grid_content = implode( '', $rows );
+
+ $original_class = $dom->get_attribute_value( $list_element, 'class' );
+
+ $table_attrs = array(
+ 'class' => trim( 'email-block-post-template ' . Html_Processing_Helper::clean_css_classes( $original_class ) ),
+ 'style' => 'width: 100%; border-collapse: collapse;',
+ 'width' => '100%',
+ );
+
+ return Table_Wrapper_Helper::render_table_wrapper( $grid_content, $table_attrs );
+ }
+
+ /**
+ * Estimate the rendered pixel width of a single grid cell's content area.
+ *
+ * The layout width is split evenly across the columns and the per-cell padding is removed from
+ * both sides. Used to give each rebuilt image a concrete `width` attribute (the Outlook fallback)
+ * instead of the intrinsic file width, which Outlook would otherwise honor literally and blow the
+ * cell open.
+ *
+ * @param int $layout_width Available layout width in px.
+ * @param int $columns Number of columns (>= 2; a grid is only built for multi-column layouts).
+ * @return int Cell content width in px (at least 1).
+ */
+ private function get_cell_width( int $layout_width, int $columns ): int {
+ $cell_width = (int) floor( $layout_width / $columns ) - ( 2 * self::CELL_PADDING );
+ return max( 1, $cell_width );
+ }
+
+ /**
+ * Build a single grid row as its own fixed-layout table.
+ *
+ * Every cell is a fixed `100 / $columns` percent wide and a partial final row is padded with
+ * empty cells, so items stay aligned to their column and keep a uniform width across rows (unlike
+ * the gallery, which stretches a partial row to fill the width). This matches how a CSS grid keeps
+ * column tracks consistent — important when the items are logos that shouldn't change size row to
+ * row.
+ *
+ * @param array<int, string> $row_items Inner HTML of the items in this row.
+ * @param int $columns Total number of columns.
+ * @param int $cell_width Cell content width in px.
+ * @return string Row table HTML.
+ */
+ private function build_grid_row( array $row_items, int $columns, int $cell_width ): string {
+ $cell_width_percent = 100 / $columns;
+ $cells = '';
+
+ for ( $col = 0; $col < $columns; $col++ ) {
+ $cell_content = isset( $row_items[ $col ] ) ? $this->prepare_item_content( $row_items[ $col ], $cell_width ) : '';
+ $cell_attrs = array(
+ 'style' => sprintf(
+ 'width: %s; padding: %dpx; vertical-align: top; text-align: center;',
+ Html_Processing_Helper::sanitize_css_value( sprintf( '%.4f%%', $cell_width_percent ) ),
+ self::CELL_PADDING
+ ),
+ 'valign' => 'top',
+ );
+ $cells .= Table_Wrapper_Helper::render_table_cell( $cell_content, $cell_attrs );
+ }
+
+ return sprintf(
+ '<table role="presentation" style="width: %s; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
+ Html_Processing_Helper::sanitize_css_value( '100%' ),
+ $cells
+ );
+ }
+
+ /**
+ * Turn a rendered `<li>`'s inner HTML into email-safe cell content.
+ *
+ * Each image in the item is rebuilt as a clean, responsive `<img>` (preserving its link) sitting
+ * directly in the cell, so its width resolves against the grid column instead of collapsing inside
+ * the fixed-width wrapper tables WordPress renders around it. Any non-image content the card holds
+ * (post title, date, excerpt) is kept below the image, so a post grid isn't reduced to bare images.
+ *
+ * When the card is image-only (e.g. a featured-image sponsor grid) the leftover is nothing but the
+ * now-empty wrapper shells, which are dropped so the output stays a clean logo grid. An item with no
+ * image at all is returned unchanged — its text stacks correctly without intervention.
+ *
+ * @param string $item_html Inner HTML of a single list item.
+ * @param int $cell_width Cell content width in px.
+ * @return string Cell content HTML.
+ */
+ private function prepare_item_content( string $item_html, int $cell_width ): string {
+ // `stripos` (not `strpos`) so an uppercase `<IMG>` fast-path isn't skipped. In practice the
+ // item HTML arrives lowercased by DOM serialization, but this keeps the guard correct if a
+ // caller ever passes raw markup.
+ if ( false === stripos( $item_html, '<img' ) ) {
+ return $item_html;
+ }
+
+ $item_dom = new Dom_Document_Helper( $item_html );
+ $images = array();
+ $remove_targets = array();
+
+ foreach ( $item_dom->find_elements( 'img' ) as $img_element ) {
+ // Record every image up front so none can linger in the preserved remainder — whether we
+ // rebuild it below or drop it as unrenderable. Targets are computed now (before any removal)
+ // so the media counts stay accurate.
+ $remove_targets[] = $this->find_image_removal_target( $img_element );
+
+ $normalized_img = $this->normalize_image_for_email( $item_dom->get_outer_html( $img_element ), $cell_width );
+ if ( '' === $normalized_img ) {
+ // The image had no usable src (e.g. an unsafe URL esc_url rejected); drop it rather than
+ // leak the original unsanitized tag through the remainder.
+ continue;
+ }
+
+ $href = $this->find_link_href( $img_element );
+ if ( '' !== $href ) {
+ $images[] = '<a href="' . esc_url( $href ) . '">' . $normalized_img . '</a>';
+ } else {
+ $images[] = $normalized_img;
+ }
+ }
+
+ // The `<img` match was not a real image element (e.g. it sat inside a comment); leave the
+ // content untouched.
+ if ( empty( $remove_targets ) ) {
+ return $item_html;
+ }
+
+ // Strip every image found — including any we couldn't rebuild — so an unrenderable, unsanitized
+ // `<img>` (e.g. one carrying `onerror`) can never survive through the remainder, then keep
+ // whatever real content remains (title/date/excerpt). Empty wrapper shells are dropped.
+ foreach ( $remove_targets as $target ) {
+ $item_dom->remove_element( $target );
+ }
+
+ return implode( '', $images ) . $this->extract_remaining_content( $item_dom );
+ }
+
+ /**
+ * Choose which element to strip when hoisting an image, so the preserved remainder is clean.
+ *
+ * Climbs from the image through every ancestor that wraps nothing but that single image — no text,
+ * no other media — and returns the outermost such wrapper. This removes the whole empty
+ * `<figure>`/`<a>`/layout-table shell WordPress renders around a featured image in one go, rather
+ * than leaving hollow, padded cells behind. Climbing stops as soon as an ancestor holds real
+ * content, so a sibling title/date (outside the image's wrapper) and a `<figcaption>` (whose text
+ * lives on the figure) are both preserved.
+ *
+ * @param \DOMElement $img_element The image element being hoisted.
+ * @return \DOMElement The element to remove from the item.
+ */
+ private function find_image_removal_target( \DOMElement $img_element ): \DOMElement {
+ $target = $img_element;
+ $parent = $img_element->parentNode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ while ( $parent instanceof \DOMElement ) {
+ // Stop once the ancestor carries text (e.g. a caption or a sibling title) or wraps more
+ // than just this one image — removing it would take real content with it.
+ if ( '' !== trim( $parent->textContent ) || 1 !== $this->count_media_descendants( $parent ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ break;
+ }
+ $target = $parent;
+ $parent = $parent->parentNode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ }
+ return $target;
+ }
+
+ /**
+ * Count the media elements (images and embeds) contained within an element.
+ *
+ * @param \DOMElement $element The element to inspect.
+ * @return int
+ */
+ private function count_media_descendants( \DOMElement $element ): int {
+ $count = $element->getElementsByTagName( 'img' )->length;
+ foreach ( array( 'video', 'audio', 'iframe', 'svg' ) as $tag_name ) {
+ $count += $element->getElementsByTagName( $tag_name )->length;
+ }
+ return $count;
+ }
+
+ /**
+ * Read back the card content left after the images were removed, or an empty string when nothing
+ * but structural wrapper shells remain (so image-only cards stay a clean grid).
+ *
+ * @param Dom_Document_Helper $item_dom The item DOM after image removal.
+ * @return string
+ */
+ private function extract_remaining_content( Dom_Document_Helper $item_dom ): string {
+ $this->strip_unsafe_markup( $item_dom );
+
+ $remainder = $item_dom->get_root_html();
+
+ // Treat the remainder as empty unless it carries visible text or embedded media — otherwise it
+ // is just the leftover wrapper markup (empty figures/tables) the image used to live in.
+ if ( '' === trim( str_replace( "\xc2\xa0", '', wp_strip_all_tags( $remainder ) ) )
+ && ! preg_match( '/<(img|video|audio|iframe|svg)\b/i', $remainder ) ) {
+ return '';
+ }
+
+ return $remainder;
+ }
+
+ /**
+ * Strip markup that has no place in an email from the preserved remainder: `<script>`/`<style>`
+ * elements and inline event-handler (`on*`) attributes.
+ *
+ * The images beside this content are already sanitized when they're rebuilt, so this keeps the
+ * reconstructed cell internally consistent. It intentionally leaves `style` attributes and all
+ * structural markup in place, so legitimate card content (title/date/excerpt) renders unchanged —
+ * core never emits scripts or handlers there, making this a no-op for real content. Scoped to this
+ * renderer's grid path only; it operates on the local item DOM and touches no shared helper.
+ *
+ * @param Dom_Document_Helper $item_dom The item DOM to clean in place.
+ */
+ private function strip_unsafe_markup( Dom_Document_Helper $item_dom ): void {
+ foreach ( array( 'script', 'style' ) as $tag_name ) {
+ foreach ( $item_dom->find_elements( $tag_name ) as $element ) {
+ $item_dom->remove_element( $element );
+ }
+ }
+
+ foreach ( $item_dom->find_elements( '*' ) as $element ) {
+ $attributes = $element->attributes;
+ if ( null === $attributes ) {
+ continue;
+ }
+ // Collect handler attribute names first, then remove — mutating the live attribute map
+ // mid-iteration would skip entries.
+ $handler_attributes = array();
+ foreach ( $attributes as $attribute ) {
+ if ( 0 === stripos( $attribute->name, 'on' ) ) {
+ $handler_attributes[] = $attribute->name;
+ }
+ }
+ foreach ( $handler_attributes as $attribute_name ) {
+ $element->removeAttribute( $attribute_name );
+ }
+ }
+ }
+
+ /**
+ * Return the href of the nearest ancestor `<a>` of the given image, or an empty string.
+ *
+ * @param \DOMElement $img_element The image element.
+ * @return string
+ */
+ private function find_link_href( \DOMElement $img_element ): string {
+ $parent = $img_element->parentNode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ while ( $parent instanceof \DOMElement ) {
+ if ( 'a' === $parent->tagName ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ return $parent->getAttribute( 'href' );
+ }
+ $parent = $parent->parentNode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ }
+ return '';
+ }
+
+ /**
+ * Sanitize a raw `<img>` and normalize it for a grid cell.
+ *
+ * Reuses {@see Html_Processing_Helper::sanitize_image_html()} for the security pass (attribute
+ * allowlist, URL/style sanitizing), then pins the display width to the cell and replaces the
+ * web-only styling with the responsive email style. WordPress stores the intrinsic file width
+ * (e.g. `width="1024"`) which Outlook honors literally, and the core web style carries a
+ * `width: 100%` that collapses once the image is out of a CSS grid — so both are overwritten with
+ * a concrete cell width plus {@see self::IMAGE_STYLE}.
+ *
+ * @param string $img_html Raw `<img>` HTML.
+ * @param int $cell_width Cell content width in px.
+ * @return string Normalized `<img>` HTML, or an empty string when the image has no usable src.
+ */
+ private function normalize_image_for_email( string $img_html, int $cell_width ): string {
+ if ( '' === $img_html ) {
+ return '';
+ }
+
+ $sanitized = Html_Processing_Helper::sanitize_image_html( $img_html );
+
+ $html = new \WP_HTML_Tag_Processor( $sanitized );
+ if ( ! $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+ return '';
+ }
+
+ $src = $html->get_attribute( 'src' );
+ if ( ! is_string( $src ) || '' === $src ) {
+ return '';
+ }
+
+ // Scale the stored height to the cell width so the image keeps its aspect ratio in clients
+ // that read the attributes (Outlook). A missing/oversized/non-numeric dimension just leaves
+ // the height to `height: auto` in the style.
+ $raw_width = $html->get_attribute( 'width' );
+ $raw_height = $html->get_attribute( 'height' );
+ $width = is_string( $raw_width ) && is_numeric( $raw_width ) ? (int) $raw_width : 0;
+ $height = is_string( $raw_height ) && is_numeric( $raw_height ) ? (int) $raw_height : 0;
+ if ( $width > 0 && $height > 0 ) {
+ $scaled_height = max( 1, (int) round( $height * ( $cell_width / $width ) ) );
+ $html->set_attribute( 'height', esc_attr( (string) $scaled_height ) );
+ } else {
+ $html->remove_attribute( 'height' );
+ }
+
+ $html->set_attribute( 'width', esc_attr( (string) $cell_width ) );
+
+ // Drop the web-only class (harmless in email, and the core/image renderer strips it too) and
+ // replace the web styling with the responsive email style.
+ $html->remove_attribute( 'class' );
+ $html->set_attribute( 'style', esc_attr( self::IMAGE_STYLE ) );
+
+ return $html->get_updated_html();
+ }
+}
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index bf9bf19ca09..a99bc41be24 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -26,6 +26,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Bl
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\List_Item;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Media_Text;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Content;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Template;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Quote;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Link;
@@ -76,6 +77,7 @@ class Initializer {
'core/cover',
'core/video',
'core/post-title',
+ 'core/post-template',
);
/**
@@ -247,6 +249,9 @@ class Initializer {
case 'core/gallery':
$renderer = new Gallery();
break;
+ case 'core/post-template':
+ $renderer = new Post_Template();
+ break;
case 'core/media-text':
$renderer = new Media_Text();
break;
diff --git a/packages/php/email-editor/src/Integrations/Utils/class-dom-document-helper.php b/packages/php/email-editor/src/Integrations/Utils/class-dom-document-helper.php
index db2151647bf..5b64d1f0a8e 100644
--- a/packages/php/email-editor/src/Integrations/Utils/class-dom-document-helper.php
+++ b/packages/php/email-editor/src/Integrations/Utils/class-dom-document-helper.php
@@ -53,6 +53,22 @@ class Dom_Document_Helper {
return $elements->item( 0 ) ? $elements->item( 0 ) : null;
}
+ /**
+ * Returns every element matching the given tag name, in document order.
+ *
+ * @param string $tag_name The tag name to search for.
+ * @return array<int, \DOMElement>
+ */
+ public function find_elements( string $tag_name ): array {
+ $elements = array();
+ foreach ( $this->dom->getElementsByTagName( $tag_name ) as $element ) {
+ if ( $element instanceof \DOMElement ) {
+ $elements[] = $element;
+ }
+ }
+ return $elements;
+ }
+
/**
* Returns the value of the given attribute from the given element.
*
@@ -86,6 +102,44 @@ class Dom_Document_Helper {
return (string) $this->dom->saveHTML( $element );
}
+ /**
+ * Removes the given element from the document.
+ *
+ * A no-op when the element has already been detached (its parent is null), so removing the same
+ * node twice — e.g. two images sharing one wrapper — is safe.
+ *
+ * @param \DOMElement $element The element to remove.
+ */
+ public function remove_element( \DOMElement $element ): void {
+ $parent = $element->parentNode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ if ( $parent instanceof \DOMNode ) {
+ $parent->removeChild( $element );
+ }
+ }
+
+ /**
+ * Serializes every top-level node of the loaded fragment back to HTML.
+ *
+ * The document is loaded with LIBXML_HTML_NOIMPLIED (no implicit html/body wrapper), so the
+ * top-level nodes are the fragment's own roots. Useful for reading back what remains after
+ * elements have been removed.
+ *
+ * @return string
+ */
+ public function get_root_html(): string {
+ $html = '';
+ foreach ( $this->dom->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ // Skip the `<?xml encoding="UTF-8">` processing instruction that load_html() prepends to
+ // force UTF-8; serializing it back would corrupt callers (e.g. strip_tags treats the
+ // unterminated `<?` as a tag and swallows the rest of the string).
+ if ( XML_PI_NODE === $child->nodeType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ continue;
+ }
+ $html .= (string) $this->dom->saveHTML( $child );
+ }
+ return $html;
+ }
+
/**
* Returns the inner HTML of the given element.
*
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Template_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Template_Test.php
new file mode 100644
index 00000000000..757b8727354
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Post_Template_Test.php
@@ -0,0 +1,686 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Tests\Integration\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Template;
+
+/**
+ * Integration test for the Post_Template renderer.
+ *
+ * The renderer receives the already-rendered `<ul class="wp-block-post-template">…</ul>` (a Query
+ * Loop's repeater output) and re-flows its list items into an email-safe table grid.
+ */
+class Post_Template_Test extends \Email_Editor_Integration_Test_Case {
+ /**
+ * Renderer under test.
+ *
+ * @var Post_Template
+ */
+ private $renderer;
+
+ /**
+ * Rendering context instance.
+ *
+ * @var Rendering_Context
+ */
+ private $rendering_context;
+
+ /**
+ * Set up before each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->di_container->get( Email_Editor::class )->initialize();
+ $this->renderer = new Post_Template();
+ $theme_controller = $this->di_container->get( Theme_Controller::class );
+ $this->rendering_context = new Rendering_Context( $theme_controller->get_theme() );
+ }
+
+ /**
+ * Build a rendered post-template list from a set of item HTML fragments.
+ *
+ * @param array<int, string> $items Inner HTML for each `<li>`.
+ * @param int $columns Column count (drives the `columns-N` class core stamps).
+ * @param bool $is_grid Whether to mark the list as a grid layout.
+ * @return string
+ */
+ private function build_list( array $items, int $columns, bool $is_grid = true ): string {
+ $layout_class = $is_grid ? 'is-layout-grid wp-block-post-template-is-layout-grid' : 'is-layout-flow wp-block-post-template-is-layout-flow';
+ $lis = '';
+ foreach ( $items as $item ) {
+ $lis .= '<li class="wp-block-post">' . $item . '</li>';
+ }
+ return sprintf(
+ '<ul class="wp-block-post-template %s columns-%d">%s</ul>',
+ $layout_class,
+ $columns,
+ $lis
+ );
+ }
+
+ /**
+ * A featured-image `<li>` like the WordCamp sponsor grids use.
+ *
+ * @param string $src Image URL.
+ * @return string
+ */
+ private function featured_image( string $src ): string {
+ return '<figure class="wp-block-post-featured-image"><a href="https://example.com/sponsor"><img src="' . esc_url( $src ) . '" alt="Sponsor"/></a></figure>';
+ }
+
+ /**
+ * A featured-image `<li>` whose `<img>` carries the intrinsic width/height WordPress stores,
+ * wrapped in the fixed-width tables the block editor renders around a post-template image. This is
+ * the shape whose nested `width: 100%` collapses in email, so the renderer must hoist and rebuild
+ * the image.
+ *
+ * @param string $src Image URL.
+ * @param int $width Intrinsic image width.
+ * @param int $height Intrinsic image height.
+ * @return string
+ */
+ private function nested_featured_image( string $src, int $width, int $height ): string {
+ return '<div><table width="100%" style="border-collapse:separate"><tbody><tr>'
+ . '<td width="520px" style="padding:30px">'
+ . '<figure class="wp-block-post-featured-image size-full">'
+ . '<a href="https://example.com/sponsor">'
+ . '<img src="' . esc_url( $src ) . '" alt="Sponsor" width="' . $width . '" height="' . $height . '" class="wp-image-1" style="width:100%;max-width:100%;object-fit:contain"/>'
+ . '</a></figure></td></tr></tbody></table></div>';
+ }
+
+ /**
+ * Count the grid cells produced.
+ *
+ * Matches the renderer's full cell style tail (not just `text-align: center;`), so passed-through
+ * item content that happens to contain a centered element can't inflate the count.
+ *
+ * @param string $rendered Rendered HTML.
+ * @return int
+ */
+ private function count_cells( string $rendered ): int {
+ return substr_count( $rendered, 'padding: 8px; vertical-align: top; text-align: center;' );
+ }
+
+ /**
+ * Count the per-row tables produced.
+ *
+ * Matches the renderer's full row-table style (the container table uses `border-collapse:
+ * collapse;` without `table-layout: fixed;`), so item content that contains a fixed-layout table
+ * can't inflate the count.
+ *
+ * @param string $rendered Rendered HTML.
+ * @return int
+ */
+ private function count_rows( string $rendered ): int {
+ return substr_count( $rendered, 'border-collapse: collapse; table-layout: fixed;' );
+ }
+
+ /**
+ * A 3-column grid re-flows its items into a table with three cells per row.
+ */
+ public function testItRendersGridAsTableColumnsFromLayoutAttribute(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/s1.png' ),
+ $this->featured_image( 'https://example.com/s2.png' ),
+ $this->featured_image( 'https://example.com/s3.png' ),
+ $this->featured_image( 'https://example.com/s4.png' ),
+ ),
+ 3
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // Every sponsor image survives the transform.
+ $this->assertStringContainsString( 's1.png', $rendered );
+ $this->assertStringContainsString( 's4.png', $rendered );
+ // It is now a table grid, not the original list.
+ $this->assertStringContainsString( 'email-block-post-template', $rendered );
+ $this->assertStringNotContainsString( '<ul', $rendered );
+ // 4 items over 3 columns => 2 rows, and each row is padded to 3 cells (6 total).
+ $this->assertSame( 2, $this->count_rows( $rendered ) );
+ $this->assertSame( 6, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * When the layout attribute is missing, the `columns-N` class on the list drives the column count.
+ */
+ public function testItFallsBackToColumnsClassWhenAttributeMissing(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/a.png' ),
+ $this->featured_image( 'https://example.com/b.png' ),
+ $this->featured_image( 'https://example.com/c.png' ),
+ $this->featured_image( 'https://example.com/d.png' ),
+ ),
+ 2
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // 4 items over 2 columns => 2 rows of 2 cells (4 total).
+ $this->assertSame( 2, $this->count_rows( $rendered ) );
+ $this->assertSame( 4, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * Column counts above the Gallery block's 8 are honored: the grid layout control allows up to 16,
+ * so the renderer must not silently reduce a 12-column author choice.
+ */
+ public function testItHonorsColumnCountsAboveEight(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 12,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $items = array();
+ for ( $i = 1; $i <= 12; $i++ ) {
+ $items[] = $this->featured_image( "https://example.com/img{$i}.png" );
+ }
+ $content = $this->build_list( $items, 12 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // 12 items over 12 columns => a single row of 12 cells (not clamped down to 8).
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 12, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * A column count beyond the editor's maximum (e.g. a hand-edited value) is clamped to 16 so it
+ * can't emit a runaway number of cells.
+ */
+ public function testItClampsColumnCountToMaximum(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 99,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $items = array();
+ for ( $i = 1; $i <= 18; $i++ ) {
+ $items[] = $this->featured_image( "https://example.com/img{$i}.png" );
+ }
+ $content = $this->build_list( $items, 99 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // Clamped to 16 columns: 18 items => a full row of 16 + a partial row padded to 16 (32 cells).
+ $this->assertSame( 2, $this->count_rows( $rendered ) );
+ $this->assertSame( 32, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * A partial final row is padded with empty cells so items stay aligned to their column.
+ */
+ public function testItPadsPartialRowToKeepColumnsAligned(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 4,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // 6 items over 4 columns => rows of 4 and 2; the second row is padded to 4 cells.
+ $items = array();
+ for ( $i = 1; $i <= 6; $i++ ) {
+ $items[] = $this->featured_image( "https://example.com/img{$i}.png" );
+ }
+ $content = $this->build_list( $items, 4 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ $this->assertSame( 2, $this->count_rows( $rendered ) );
+ $this->assertSame( 8, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * An empty list item (a sponsor tier slot with no featured image) is preserved as an empty cell
+ * rather than dropped, so the remaining items keep their column positions.
+ */
+ public function testItKeepsEmptyItemAsCellToPreserveAlignment(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/one.png' ),
+ '', // A post with no featured image renders an empty <li>.
+ $this->featured_image( 'https://example.com/three.png' ),
+ ),
+ 3
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // 3 items (one empty) over 3 columns => a single row of 3 cells; nothing is dropped.
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 3, $this->count_cells( $rendered ) );
+ $this->assertStringContainsString( 'one.png', $rendered );
+ $this->assertStringContainsString( 'three.png', $rendered );
+ }
+
+ /**
+ * A single-column (non-grid) layout already stacks correctly, so the original list is left as-is.
+ */
+ public function testItLeavesSingleColumnLayoutUntouched(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array( 'layout' => array( 'type' => 'default' ) ),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/x.png' ),
+ $this->featured_image( 'https://example.com/y.png' ),
+ ),
+ 1,
+ false
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // No grid table is built; the original list is preserved.
+ $this->assertStringContainsString( 'wp-block-post-template', $rendered );
+ $this->assertStringContainsString( '<ul', $rendered );
+ $this->assertSame( 0, $this->count_rows( $rendered ) );
+ }
+
+ /**
+ * Content without the expected post-template list is returned untouched.
+ */
+ public function testItReturnsUnrecognizedContentUntouched(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $content = '<div class="something-else"><p>Not a post-template list.</p></div>';
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ $this->assertStringContainsString( 'Not a post-template list.', $rendered );
+ $this->assertSame( 0, $this->count_rows( $rendered ) );
+ }
+
+ /**
+ * The post-template list is matched by class, not by being the first `<ul>`: a sibling list that
+ * appears earlier in the markup must not be mistaken for the repeater.
+ */
+ public function testItFindsPostTemplateListEvenWhenAnotherListPrecedesIt(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $post_template = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/real1.png' ),
+ $this->featured_image( 'https://example.com/real2.png' ),
+ $this->featured_image( 'https://example.com/real3.png' ),
+ ),
+ 3
+ );
+ // A decoy list (e.g. an unrelated block) rendered before the post-template list.
+ $content = '<ul class="wp-block-list"><li>Decoy item</li></ul>' . $post_template;
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The real list is gridded (1 row of 3 cells); the decoy item is not pulled in as a cell.
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 3, $this->count_cells( $rendered ) );
+ $this->assertStringContainsString( 'real1.png', $rendered );
+ $this->assertStringNotContainsString( 'Decoy item', $rendered );
+ }
+
+ /**
+ * Item content that mimics the renderer's own cell/row style strings must not inflate the grid
+ * structure. This guards the test counters (and the grid) against passed-through markup such as a
+ * centered paragraph or a fixed-layout table inside a post.
+ */
+ public function testItCountsGridStructureIgnoringContentThatMimicsCellStyles(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // Each item embeds a centered paragraph and a fixed-layout table — the exact substrings the
+ // naive counters keyed on.
+ $decoy_markup = '<p style="text-align: center;">Centered copy</p><table style="table-layout: fixed;"><tr><td>x</td></tr></table>';
+ $content = $this->build_list(
+ array(
+ $this->featured_image( 'https://example.com/p.png' ) . $decoy_markup,
+ $this->featured_image( 'https://example.com/q.png' ) . $decoy_markup,
+ ),
+ 2
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // 2 items over 2 columns => exactly 1 row of 2 cells, regardless of the mimicking content.
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 2, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * Each item's image is hoisted out of its fixed-width wrapper tables and rebuilt as a clean,
+ * responsive `<img>` with a concrete pixel width — otherwise its `width: 100%` collapses to a few
+ * pixels in email once the CSS grid is gone.
+ */
+ public function testItRebuildsNestedImagesAsResponsiveImages(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ $this->nested_featured_image( 'https://example.com/logo1.png', 1024, 210 ),
+ $this->nested_featured_image( 'https://example.com/logo2.png', 1024, 210 ),
+ $this->nested_featured_image( 'https://example.com/logo3.png', 1024, 210 ),
+ ),
+ 3
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The collapsing web markup is gone: no fixed-width wrapper cell, no figure passthrough.
+ $this->assertStringNotContainsString( 'width="520px"', $rendered );
+ $this->assertStringNotContainsString( '<figure', $rendered );
+ // The images are rebuilt with the responsive email style, not the web `width: 100%` alone.
+ $this->assertStringContainsString( 'max-width: 100%; height: auto; display: block;', $rendered );
+ // Each image keeps its link and gets a concrete (non-percentage) pixel width for Outlook.
+ $this->assertSame( 3, substr_count( $rendered, 'href="https://example.com/sponsor"' ) );
+ $this->assertSame( 3, preg_match_all( '/<img[^>]*\swidth="\d+"/', $rendered ) );
+ $this->assertStringContainsString( 'logo1.png', $rendered );
+ $this->assertStringContainsString( 'logo3.png', $rendered );
+ }
+
+ /**
+ * The rebuilt image's height is scaled from the intrinsic dimensions to the cell width, so it keeps
+ * its aspect ratio in clients that size by the width/height attributes (Outlook).
+ */
+ public function testItScalesImageHeightToPreserveAspectRatio(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // A 1000x500 image (2:1) in a 2-column grid: whatever width the cell resolves to, the height
+ // attribute must stay half of it.
+ $content = $this->build_list(
+ array(
+ $this->nested_featured_image( 'https://example.com/wide.png', 1000, 500 ),
+ $this->nested_featured_image( 'https://example.com/wide2.png', 1000, 500 ),
+ ),
+ 2
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ $this->assertSame( 1, preg_match( '/<img[^>]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/', $rendered, $matches ) );
+ $width = (int) $matches[1];
+ $height = (int) $matches[2];
+ $this->assertGreaterThan( 0, $width );
+ // 2:1 ratio preserved (allow +/-1px for rounding).
+ $this->assertEqualsWithDelta( $width / 2, $height, 1 );
+ }
+
+ /**
+ * A post-card grid (featured image + date + title) keeps its text: the image is hoisted and
+ * rebuilt, and the title/date are preserved below it rather than dropped.
+ */
+ public function testItPreservesCardTextAlongsideHoistedImage(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $card = function ( string $src, string $title ) {
+ return $this->nested_featured_image( $src, 1024, 512 )
+ . '<div class="wp-block-post-date"><time datetime="2026-07-24T14:41:24-04:00">July 24, 2026</time></div>'
+ . '<h2 class="wp-block-post-title">' . $title . '</h2>';
+ };
+ $content = $this->build_list(
+ array(
+ $card( 'https://example.com/a.png', 'First post' ),
+ $card( 'https://example.com/b.png', 'Second post' ),
+ ),
+ 2
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // Image is rebuilt (responsive style, no collapsing wrapper) AND the card text survives.
+ $this->assertStringContainsString( 'max-width: 100%; height: auto; display: block;', $rendered );
+ $this->assertStringNotContainsString( 'width="520px"', $rendered );
+ $this->assertStringContainsString( 'First post', $rendered );
+ $this->assertStringContainsString( 'Second post', $rendered );
+ $this->assertStringContainsString( 'July 24, 2026', $rendered );
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 2, $this->count_cells( $rendered ) );
+ }
+
+ /**
+ * When a card's preserved (non-image) content carries unsafe markup — a `<script>` element or an
+ * inline event handler — it is stripped from the rebuilt cell, while legitimate content and styles
+ * are kept. Guards the reconstruct path against passing through markup that has no place in email.
+ */
+ public function testItStripsScriptsAndEventHandlersFromPreservedContent(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $card = $this->nested_featured_image( 'https://example.com/a.png', 800, 400 )
+ . '<h2 class="wp-block-post-title" style="color:#111" onmouseover="alert(1)">Card Title</h2>'
+ . '<script>alert(2)</script>';
+ $content = $this->build_list( array( $card, $card ), 2 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The image is rebuilt and the legitimate title (and its style) survive...
+ $this->assertStringContainsString( 'a.png', $rendered );
+ $this->assertStringContainsString( 'Card Title', $rendered );
+ $this->assertStringContainsString( 'color:#111', $rendered );
+ // ...but the handler and script are gone.
+ $this->assertStringNotContainsString( 'onmouseover', $rendered );
+ $this->assertStringNotContainsString( '<script', $rendered );
+ $this->assertStringNotContainsString( 'alert(2)', $rendered );
+ }
+
+ /**
+ * A list whose class merely contains `wp-block-post-template` as a substring (not a whole token)
+ * must not be mistaken for the repeater and rebuilt — the content is left untouched.
+ */
+ public function testItIgnoresListsWhoseClassOnlyContainsTheTokenAsSubstring(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 3,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // `my-wp-block-post-template-wrapper` contains the token as a substring but is not the token.
+ $content = '<ul class="my-wp-block-post-template-wrapper columns-3">'
+ . '<li>' . $this->featured_image( 'https://example.com/a.png' ) . '</li>'
+ . '<li>' . $this->featured_image( 'https://example.com/b.png' ) . '</li>'
+ . '</ul>';
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // Not recognized as a post-template list: no grid is built and the original list survives.
+ $this->assertSame( 0, $this->count_rows( $rendered ) );
+ $this->assertStringContainsString( '<ul', $rendered );
+ }
+
+ /**
+ * When every image in a card fails normalization, the images are still stripped (never restored)
+ * so an unrenderable tag with a dangerous attribute can't reach the email through the remainder.
+ */
+ public function testItStripsAllImagesWhenNoneCanBeRebuilt(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // The card's only image has an unsafe src (esc_url rejects it) plus a dangerous handler.
+ $card = '<figure class="wp-block-image"><img src="javascript:alert(1)" alt="x" onerror="alert(1)"/></figure>';
+ $content = $this->build_list( array( $card, $card ), 2 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The grid is built, but the unrenderable image (and its handler / unsafe URL) is gone.
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertStringNotContainsString( 'javascript:', $rendered );
+ $this->assertStringNotContainsString( 'onerror', $rendered );
+ }
+
+ /**
+ * An image that can't be rebuilt (its src was rejected by sanitizing) is dropped rather than left
+ * behind in the preserved remainder, so its original unsanitized tag never reaches the email.
+ */
+ public function testItDropsUnrenderableImageInsteadOfLeakingIt(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ // Each card has a valid image plus a second image whose src esc_url() empties, in one figure.
+ $card = '<figure class="wp-block-image">'
+ . '<img src="https://example.com/good.png" alt="Good" width="800" height="400"/>'
+ . '<img src="javascript:alert(1)" alt="Bad"/>'
+ . '</figure>';
+ $content = $this->build_list( array( $card, $card ), 2 );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The valid image is rebuilt; the unrenderable one is gone entirely — no raw src leaks through.
+ $this->assertStringContainsString( 'good.png', $rendered );
+ $this->assertStringNotContainsString( 'javascript:', $rendered );
+ $this->assertStringNotContainsString( 'Bad', $rendered );
+ }
+
+ /**
+ * An item with no image (e.g. a title/excerpt-only card) is passed through unchanged, since text
+ * content stacks correctly on its own and needs no rebuilding.
+ */
+ public function testItPassesThroughItemsWithoutImages(): void {
+ $parsed_block = array(
+ 'blockName' => 'core/post-template',
+ 'attrs' => array(
+ 'layout' => array(
+ 'type' => 'grid',
+ 'columnCount' => 2,
+ ),
+ ),
+ 'innerBlocks' => array(),
+ );
+ $content = $this->build_list(
+ array(
+ '<h3 class="wp-block-post-title">First post</h3>',
+ '<h3 class="wp-block-post-title">Second post</h3>',
+ ),
+ 2
+ );
+
+ $rendered = $this->renderer->render( $content, $parsed_block, $this->rendering_context );
+
+ // The grid is still built, and the text content survives verbatim in its cell.
+ $this->assertSame( 1, $this->count_rows( $rendered ) );
+ $this->assertSame( 2, $this->count_cells( $rendered ) );
+ $this->assertStringContainsString( 'First post', $rendered );
+ $this->assertStringContainsString( 'Second post', $rendered );
+ }
+}