Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
WCEmailTemplateSelectiveApplier::merge │ private static │ WC 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() 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 ) ),
);
}