Post_Template
extends Abstract_Block_Renderer
in package
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}).
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).
Table of Contents
- CELL_PADDING = 8
- Per-cell padding (px) that stands in for the grid's `gap` between items.
- IMAGE_STYLE = 'border: 0; line-height: 100%; width: 100%; max-width: 100%; height: auto; display: block;'
- 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.
- MAX_COLUMNS = 16
- 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.
- $current_rendering_context : Rendering_Context|null
- Rendering context for calls using the legacy add_spacer() signature.
- render() : string
- Render the block.
- add_spacer() : string
- Add a spacer around the block for vertical spacing (margin-top).
- add_spacer_with_context() : string
- Add a spacer around the block with rendering context.
- compile_css() : string
- Compile objects containing CSS properties to a string.
- get_inner_content() : string
- Extract inner content from a wrapper element.
- get_styles_from_block() : array<string|int, mixed>
- Wrapper for wp_style_engine_get_styles which ensures all values are returned.
- render_content() : string
- Renders the post-template block content using a table-based grid layout.
- build_grid_row() : string
- Build a single grid row as its own fixed-layout table.
- build_grid_table() : string
- Build the grid as one `<table>` per row wrapped in a container table.
- count_media_descendants() : int
- Count the media elements (images and embeds) contained within an element.
- extract_list_items() : array<int, string>
- Extract the inner HTML of each direct-child `<li>` of the post-template list.
- extract_remaining_content() : string
- 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).
- find_image_removal_target() : DOMElement
- Choose which element to strip when hoisting an image, so the preserved remainder is clean.
- find_link_href() : string
- Return the href of the nearest ancestor `<a>` of the given image, or an empty string.
- find_post_template_list() : DOMElement|null
- Locate the post-template list within the rendered content.
- get_cell_width() : int
- Estimate the rendered pixel width of a single grid cell's content area.
- get_column_count() : int
- Determine how many columns the grid should render.
- normalize_image_for_email() : string
- Sanitize a raw `<img>` and normalize it for a grid cell.
- prepare_item_content() : string
- Turn a rendered `<li>`'s inner HTML into email-safe cell content.
- strip_unsafe_markup() : void
- Strip markup that has no place in an email from the preserved remainder: `<script>`/`<style>` elements and inline event-handler (`on*`) attributes.
Constants
CELL_PADDING
Per-cell padding (px) that stands in for the grid's `gap` between items.
private
mixed
CELL_PADDING
= 8
IMAGE_STYLE
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.
private
mixed
IMAGE_STYLE
= 'border: 0; line-height: 100%; width: 100%; max-width: 100%; height: auto; display: block;'
The explicit width attribute (set alongside this) is the Outlook fallback, since Outlook
ignores max-width.
MAX_COLUMNS
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
mixed
MAX_COLUMNS
= 16
Properties
$current_rendering_context
Rendering context for calls using the legacy add_spacer() signature.
protected
Rendering_Context|null
$current_rendering_context
= null
Methods
render()
Render the block.
public
render(string $block_content, array<string|int, mixed> $parsed_block, Rendering_Context $rendering_context) : string
Parameters
- $block_content : string
-
The block content.
- $parsed_block : array<string|int, mixed>
-
The parsed block.
- $rendering_context : Rendering_Context
-
The rendering context.
Return values
string —add_spacer()
Add a spacer around the block for vertical spacing (margin-top).
protected
add_spacer(string $content, array<string|int, mixed> $email_attrs) : string
Horizontal root padding is applied uniformly by Content_Renderer::render_block() so that all blocks — including those using render_email_callback without Abstract_Block_Renderer — receive consistent padding.
Parameters
- $content : string
-
The block content.
- $email_attrs : array<string|int, mixed>
-
The email attributes.
Return values
string —add_spacer_with_context()
Add a spacer around the block with rendering context.
protected
add_spacer_with_context(string $content, array<string|int, mixed> $email_attrs[, Rendering_Context|null $rendering_context = null ]) : string
Parameters
- $content : string
-
The block content.
- $email_attrs : array<string|int, mixed>
-
The email attributes.
- $rendering_context : Rendering_Context|null = null
-
Rendering context.
Return values
string —compile_css()
Compile objects containing CSS properties to a string.
protected
compile_css(array<string|int, mixed> ...$styles) : string
Parameters
- $styles : array<string|int, mixed>
-
Style arrays to compile.
Return values
string —get_inner_content()
Extract inner content from a wrapper element.
protected
get_inner_content(string $block_content[, string $tag_name = 'div' ]) : string
Removes the outer wrapper element (e.g., div) and returns only the inner HTML content. This is useful when you need to strip the wrapper and use only the inner content.
Parameters
- $block_content : string
-
Block content with wrapper element.
- $tag_name : string = 'div'
-
Tag name of the wrapper element (default: 'div').
Return values
string — Inner content without the wrapper element, or original content if wrapper not found.get_styles_from_block()
Wrapper for wp_style_engine_get_styles which ensures all values are returned.
protected
get_styles_from_block(array<string|int, mixed> $block_styles[, bool $skip_convert_vars = false ]) : array<string|int, mixed>
Parameters
- $block_styles : array<string|int, mixed>
-
Array of block styles.
- $skip_convert_vars : bool = false
-
If true, --wp_preset--spacing--x type values will be left in the original var:preset:spacing:x format.
Return values
array<string|int, mixed> —render_content()
Renders the post-template block content using a table-based grid layout.
protected
render_content(string $block_content, array<string|int, mixed> $parsed_block, Rendering_Context $rendering_context) : string
Parameters
- $block_content : string
-
Block content.
- $parsed_block : array<string|int, mixed>
-
Parsed block.
- $rendering_context : Rendering_Context
-
Rendering context.
Return values
string —build_grid_row()
Build a single grid row as its own fixed-layout table.
private
build_grid_row(array<int, string> $row_items, int $columns, int $cell_width) : string
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.
Parameters
- $row_items : array<int, string>
-
Inner HTML of the items in this row.
- $columns : int
-
Total number of columns.
- $cell_width : int
-
Cell content width in px.
Return values
string — Row table HTML.build_grid_table()
Build the grid as one `<table>` per row wrapped in a container table.
private
build_grid_table(array<int, string> $items, int $columns, Dom_Document_Helper $dom, DOMElement $list_element, int $layout_width) : string
Follows the tiled-gallery pattern ({@see}): 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.
Parameters
- $items : array<int, string>
-
Inner HTML of each list item.
- $columns : int
-
Number of columns.
- $dom : Dom_Document_Helper
-
Parsed block content.
- $list_element : DOMElement
-
The post-template list element (for wrapper classes).
- $layout_width : int
-
Available layout width in px.
Return values
string — Grid table HTML.count_media_descendants()
Count the media elements (images and embeds) contained within an element.
private
count_media_descendants(DOMElement $element) : int
Parameters
- $element : DOMElement
-
The element to inspect.
Return values
int —extract_list_items()
Extract the inner HTML of each direct-child `<li>` of the post-template list.
private
extract_list_items(Dom_Document_Helper $dom, DOMElement $list_element) : array<int, string>
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.
Parameters
- $dom : Dom_Document_Helper
-
Parsed block content.
- $list_element : DOMElement
-
The post-template list element.
Return values
array<int, string> — Inner HTML of each list item, in document order.extract_remaining_content()
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).
private
extract_remaining_content(Dom_Document_Helper $item_dom) : string
Parameters
- $item_dom : Dom_Document_Helper
-
The item DOM after image removal.
Return values
string —find_image_removal_target()
Choose which element to strip when hoisting an image, so the preserved remainder is clean.
private
find_image_removal_target(DOMElement $img_element) : DOMElement
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.
Parameters
- $img_element : DOMElement
-
The image element being hoisted.
Return values
DOMElement — The element to remove from the item.find_link_href()
Return the href of the nearest ancestor `<a>` of the given image, or an empty string.
private
find_link_href(DOMElement $img_element) : string
Parameters
- $img_element : DOMElement
-
The image element.
Return values
string —find_post_template_list()
Locate the post-template list within the rendered content.
private
find_post_template_list(Dom_Document_Helper $dom) : DOMElement|null
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.
Parameters
- $dom : Dom_Document_Helper
-
Parsed block content.
Return values
DOMElement|null —get_cell_width()
Estimate the rendered pixel width of a single grid cell's content area.
private
get_cell_width(int $layout_width, int $columns) : int
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.
Parameters
- $layout_width : int
-
Available layout width in px.
- $columns : int
-
Number of columns (>= 2; a grid is only built for multi-column layouts).
Return values
int — Cell content width in px (at least 1).get_column_count()
Determine how many columns the grid should render.
private
get_column_count(array<string|int, mixed> $parsed_block, Dom_Document_Helper $dom, DOMElement $list_element) : int
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.
Parameters
- $parsed_block : array<string|int, mixed>
-
Parsed block data.
- $dom : Dom_Document_Helper
-
Parsed block content.
- $list_element : DOMElement
-
The post-template list element.
Return values
int — Column count (at least 1).normalize_image_for_email()
Sanitize a raw `<img>` and normalize it for a grid cell.
private
normalize_image_for_email(string $img_html, int $cell_width) : string
Reuses {@see} 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}.
Parameters
- $img_html : string
-
Raw
<img>HTML. - $cell_width : int
-
Cell content width in px.
Return values
string — Normalized `` HTML, or an empty string when the image has no usable src.prepare_item_content()
Turn a rendered `<li>`'s inner HTML into email-safe cell content.
private
prepare_item_content(string $item_html, int $cell_width) : string
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.
Parameters
- $item_html : string
-
Inner HTML of a single list item.
- $cell_width : int
-
Cell content width in px.
Return values
string — Cell content HTML.strip_unsafe_markup()
Strip markup that has no place in an email from the preserved remainder: `<script>`/`<style>` elements and inline event-handler (`on*`) attributes.
private
strip_unsafe_markup(Dom_Document_Helper $item_dom) : void
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.
Parameters
- $item_dom : Dom_Document_Helper
-
The item DOM to clean in place.
