WooCommerce Code Reference

class-post-template.php

Source code

<?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();
	}
}