Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails

WCEmailTemplateSelectiveApplier::mergeprivate staticWC 1.0

Compute the merged block tree, starting from the merchant's post and layering on core's changes per the v1 algorithm.

When $precomputed_summary is provided (the caller's last_core_render meta was set, so the change-summary ran three-way attribution), the merge defers to the summary's classification:

  • Matched pairs whose path is in removed_blocks (yours-only) or added_blocks (core-only) are REJECTED — the summary correctly identified them as separate adds; the local LCS may have falsely paired them by name. The reject lets Pass 2 / Pass 3 handle them.
  • Matched pairs not in copy_changes are silent — Pass 1 skips them even if a use_core decision was passed (yours-only edit, no conflict to resolve).

Without $precomputed_summary (legacy two-way fallback), the existing behavior is preserved: every matched pair with differing inner_text is eligible for use_core, and the local LCS drives matched-set tracking.

Method of the class: WCEmailTemplateSelectiveApplier{}

No Hooks.

Returns

Array{content:String,. structural_skipped:bool, aliases_migrated:string[]}

Usage

$result = WCEmailTemplateSelectiveApplier::merge( $post_content, $core_content, $choices, ?array $precomputed_summary ): array;
$post_content(string) (required)
Merchant's current post_content.
$core_content(string) (required)
Canonical core render.
$choices(array) (required)
.
?array $precomputed_summary
.
Default: null

WCEmailTemplateSelectiveApplier::merge() code WC 10.9.1

private static function merge( string $post_content, string $core_content, array $choices, ?array $precomputed_summary = null ): array {
	$post_blocks = parse_blocks( $post_content );
	$core_blocks = parse_blocks( $core_content );

	if ( empty( $post_blocks ) || empty( $core_blocks ) ) {
		return array(
			'content'            => $post_content,
			'structural_skipped' => false,
			'aliases_migrated'   => array(),
		);
	}

	$post_records = WCEmailTemplateChangeSummary::flatten_blocks( $post_blocks );
	$core_records = WCEmailTemplateChangeSummary::flatten_blocks( $core_blocks );
	$matches      = WCEmailTemplateChangeSummary::lcs_matches( $core_records, $post_records );

	$choice_map = array();
	foreach ( $choices as $choice ) {
		if ( ! is_array( $choice ) || ! isset( $choice['path'] ) || ! is_array( $choice['path'] ) ) {
			continue;
		}
		$decision = (string) ( $choice['decision'] ?? 'keep_yours' );
		if ( 'use_core' !== $decision && 'keep_yours' !== $decision ) {
			continue;
		}
		$choice_map[ self::path_key( $choice['path'] ) ] = $decision;
	}

	// Three-way overrides derived from the precomputed summary. `null`
	// signals the legacy two-way path (no gating).
	$copy_change_paths = null;
	$added_path_keys   = array();
	$removed_path_keys = array();
	if ( null !== $precomputed_summary ) {
		$copy_change_paths = array();
		foreach ( $precomputed_summary['copy_changes'] ?? array() as $cc ) {
			if ( isset( $cc['path'] ) && is_array( $cc['path'] ) ) {
				$copy_change_paths[ self::path_key( $cc['path'] ) ] = true;
			}
		}
		foreach ( $precomputed_summary['added_blocks'] ?? array() as $ab ) {
			if ( isset( $ab['path'] ) && is_array( $ab['path'] ) ) {
				$added_path_keys[ self::path_key( $ab['path'] ) ] = true;
			}
		}
		foreach ( $precomputed_summary['removed_blocks'] ?? array() as $rb ) {
			if ( isset( $rb['path'] ) && is_array( $rb['path'] ) ) {
				$removed_path_keys[ self::path_key( $rb['path'] ) ] = true;
			}
		}
	}

	// Pass 1: matched pairs. Apply use_core decisions on copy changes;
	// detect parent-name diffs (structural punted, but we still flag
	// `structural_skipped` so the caller can surface it).
	$structural_skipped = false;
	$matched_core_set   = array();
	$matched_post_set   = array();
	foreach ( $matches as $pair ) {
		$core_rec = $core_records[ $pair[0] ];
		$post_rec = $post_records[ $pair[1] ];
		$core_key = self::path_key( $core_rec['path'] );
		$post_key = self::path_key( $post_rec['path'] );

		// Three-way reject: applier's LCS paired these but the summary
		// classified them as separate add+remove. Don't track as matched
		// (so Pass 2 / Pass 3 will handle them) and don't apply.
		if ( null !== $precomputed_summary
			&& ( isset( $added_path_keys[ $core_key ] ) || isset( $removed_path_keys[ $post_key ] ) )
		) {
			continue;
		}

		$matched_core_set[ $pair[0] ] = true;
		$matched_post_set[ $pair[1] ] = true;

		if ( $core_rec['parent_name'] !== $post_rec['parent_name'] ) {
			$structural_skipped = true;
		}

		if ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
			continue;
		}

		// Three-way gate: only paths the summary surfaced as `copy_changes`
		// are eligible for `use_core`. Yours-only edits are silently
		// preserved — they aren't conflicts.
		if ( null !== $copy_change_paths && ! isset( $copy_change_paths[ $post_key ] ) ) {
			continue;
		}

		$decision = $choice_map[ $post_key ] ?? 'keep_yours';
		if ( 'use_core' !== $decision ) {
			continue;
		}

		$core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
		if ( null === $core_block ) {
			continue;
		}
		$post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block );
	}//end foreach

	// Pass 2: unmatched core records. Insert non-structural blocks at
	// the equivalent path; flag structural wrappers as skipped.
	$insertions = array();
	foreach ( $core_records as $i => $rec ) {
		if ( isset( $matched_core_set[ $i ] ) ) {
			continue;
		}
		if ( self::is_structural_block( $rec['name'] ) ) {
			$structural_skipped = true;
			continue;
		}
		$core_block = self::block_at_path( $core_blocks, $rec['path'] );
		if ( null === $core_block ) {
			continue;
		}
		$insertions[] = array(
			'path'  => $rec['path'],
			'block' => $core_block,
		);
	}

	// Insert in order of decreasing path-depth+index so each insert's
	// target index isn't shifted by a prior insert at the same level.
	usort(
		$insertions,
		static function ( array $a, array $b ): int {
			$path_a    = $a['path'];
			$path_b    = $b['path'];
			$depth_cmp = count( $path_b ) - count( $path_a );
			if ( 0 !== $depth_cmp ) {
				return $depth_cmp;
			}
			$last_a = end( $path_a );
			$last_b = end( $path_b );
			return ( (int) $last_b ) - ( (int) $last_a );
		}
	);
	foreach ( $insertions as $insertion ) {
		$post_blocks = self::insert_block_at_path( $post_blocks, $insertion['path'], $insertion['block'] );
	}

	// Pass 3: unmatched post records (`removed_blocks`). Auto-resolved
	// as Keep yours — no change. Detect structural wrappers solely so
	// we can flag `structural_skipped` honestly.
	foreach ( $post_records as $i => $rec ) {
		if ( isset( $matched_post_set[ $i ] ) ) {
			continue;
		}
		if ( self::is_structural_block( $rec['name'] ) ) {
			$structural_skipped = true;
		}
	}

	// Final pass: explicit deprecated-namespace migration. Whenever a
	// `wp:woo/email-content` block is found in the merged tree, rewrite
	// it to the canonical `wp:woocommerce/email-content` form, including
	// the `wp-block-{old}` CSS class on the inner div so the comment and
	// class stay consistent. The block's `attrs` and inner content are
	// preserved — only the namespace label changes. This is unconditional
	// (independent of `choices`) because `woo/email-content` is a known
	// alias of the canonical core block, not a customisation worth
	// preserving.
	$aliases_migrated = array();
	$post_blocks      = self::migrate_woo_email_content_namespace( $post_blocks, $aliases_migrated );

	return array(
		// $post_blocks originates from parse_blocks() and our mutations only
		// rewrite well-typed fields; serialize_blocks accepts the same shape.
		// PHPStan can't follow the mutation chain, so the explicit ignore.
		// @phpstan-ignore-next-line argument.type
		'content'            => serialize_blocks( $post_blocks ),
		'structural_skipped' => $structural_skipped,
		'aliases_migrated'   => array_values( array_unique( $aliases_migrated ) ),
	);
}