| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\BlockRecovery; |
| 4 |
|
| 5 |
/** |
| 6 |
* Keeps the attributes a rebuild would otherwise delete. |
| 7 |
* |
| 8 |
* Regenerating a block does NOT rewrite only its markup — it rewrites the block comment too. |
| 9 |
* `parse` drops every attribute the installed block type does not declare, `createBlock` builds |
| 10 |
* from what survived, and `serialize` writes the comment from that. Measured on the SaaStark |
| 11 |
* pack with Essential Blocks Pro inactive: a rebuild took `data-gsap` 13 -> 0 in the markup AND |
| 12 |
* `"ebGsap"` 13 -> 0 in the comments. The animation config was simply gone, and activating Pro |
| 13 |
* afterwards could not bring it back. |
| 14 |
* |
| 15 |
* So the rebuild happens in two halves: |
| 16 |
* |
| 17 |
* 1. SNAPSHOT (here, before the editor saves) — read the stored comment JSON and remember |
| 18 |
* every attribute, keyed by `blockId`. |
| 19 |
* 2. RESTORE (here, after the editor saves) — merge back any key the rebuild dropped. |
| 20 |
* |
| 21 |
* The result is exactly "regenerate the rendered HTML, leave the attributes alone": the markup |
| 22 |
* is what THIS site's `save()` produces, so the block validates, while the comment still carries |
| 23 |
* the Pro/newer-plugin configuration for the day that plugin shows up. PHP's `serialize_block()` |
| 24 |
* writes whatever is in `attrs` — unlike the JS serializer, it does not filter against the |
| 25 |
* registered block type — which is why the restore has to happen on this side. |
| 26 |
*/ |
| 27 |
class AttributeSnapshot { |
| 28 |
|
| 29 |
/** Where a post's pre-rebuild attributes live between the two halves. */ |
| 30 |
const META_KEY = '_templately_block_attr_snapshot'; |
| 31 |
|
| 32 |
/** |
| 33 |
* Record every block's stored attributes, keyed by `blockId`. |
| 34 |
* |
| 35 |
* `blockId` rather than document position: it is the only identifier shared between the |
| 36 |
* stored markup and the editor's parsed tree that survives a deprecation migration, and |
| 37 |
* every block that can carry an unknown attribute (Essential Blocks and friends) has one. |
| 38 |
* A block without one is not snapshotted, and the gate keeps refusing to rebuild it. |
| 39 |
* |
| 40 |
* @param int $post_id |
| 41 |
* @return int Number of blocks recorded. |
| 42 |
*/ |
| 43 |
public static function capture( int $post_id ): int { |
| 44 |
$post = get_post( $post_id ); |
| 45 |
|
| 46 |
if ( ! $post ) { |
| 47 |
return 0; |
| 48 |
} |
| 49 |
|
| 50 |
$map = self::attributes_by_block_id( (string) $post->post_content ); |
| 51 |
|
| 52 |
if ( empty( $map ) ) { |
| 53 |
delete_post_meta( $post_id, self::META_KEY ); |
| 54 |
|
| 55 |
return 0; |
| 56 |
} |
| 57 |
|
| 58 |
// wp_slash: update_post_meta unslashes, and these values are JSON-ish arrays that can |
| 59 |
// legitimately contain backslashes (unicode escapes in EB's generated CSS). |
| 60 |
update_post_meta( $post_id, self::META_KEY, wp_slash( wp_json_encode( $map ) ) ); |
| 61 |
|
| 62 |
return count( $map ); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* Merge back whatever the rebuild dropped, then rewrite the post if anything changed. |
| 67 |
* |
| 68 |
* Only ADDS keys that are missing. A key the rebuild kept is left exactly as the editor |
| 69 |
* wrote it — the editor is authoritative for anything this site understands; the snapshot is |
| 70 |
* authoritative only for what it could not see. |
| 71 |
* |
| 72 |
* @param int $post_id |
| 73 |
* @return array{restored:int,blocks:int} Attributes restored, and how many blocks got them. |
| 74 |
*/ |
| 75 |
public static function restore( int $post_id ): array { |
| 76 |
$result = [ 'restored' => 0, 'blocks' => 0 ]; |
| 77 |
|
| 78 |
$post = get_post( $post_id ); |
| 79 |
$raw = get_post_meta( $post_id, self::META_KEY, true ); |
| 80 |
|
| 81 |
if ( ! $post || empty( $raw ) ) { |
| 82 |
return $result; |
| 83 |
} |
| 84 |
|
| 85 |
$snapshot = json_decode( (string) $raw, true ); |
| 86 |
|
| 87 |
if ( ! is_array( $snapshot ) || empty( $snapshot ) ) { |
| 88 |
delete_post_meta( $post_id, self::META_KEY ); |
| 89 |
|
| 90 |
return $result; |
| 91 |
} |
| 92 |
|
| 93 |
$blocks = parse_blocks( (string) $post->post_content ); |
| 94 |
$changed = false; |
| 95 |
|
| 96 |
$walk = function ( array &$list ) use ( &$walk, $snapshot, &$result, &$changed ) { |
| 97 |
foreach ( $list as &$block ) { |
| 98 |
$block_id = $block['attrs']['blockId'] ?? null; |
| 99 |
|
| 100 |
if ( is_string( $block_id ) && isset( $snapshot[ $block_id ] ) && is_array( $snapshot[ $block_id ] ) ) { |
| 101 |
$missing = array_diff_key( $snapshot[ $block_id ], $block['attrs'] ); |
| 102 |
|
| 103 |
if ( ! empty( $missing ) ) { |
| 104 |
$block['attrs'] = array_merge( $block['attrs'], $missing ); |
| 105 |
$result['restored'] += count( $missing ); |
| 106 |
$result['blocks']++; |
| 107 |
$changed = true; |
| 108 |
} |
| 109 |
} |
| 110 |
|
| 111 |
if ( ! empty( $block['innerBlocks'] ) ) { |
| 112 |
$walk( $block['innerBlocks'] ); |
| 113 |
} |
| 114 |
} |
| 115 |
}; |
| 116 |
$walk( $blocks ); |
| 117 |
|
| 118 |
if ( $changed ) { |
| 119 |
// Bypass kses for this write. On MULTISITE `unfiltered_html` belongs to super admins |
| 120 |
// only, so an ordinary site administrator running the pass would have their own |
| 121 |
// already-stored content re-filtered on the way back in — and kses can only ever |
| 122 |
// remove from it. This write adds nothing new: every byte is either what the editor |
| 123 |
// just saved or what was already in the post when we snapshotted it, so re-sanitising |
| 124 |
// it buys no safety and risks corrupting block markup. |
| 125 |
$kses_was_active = has_filter( 'content_save_pre', 'wp_filter_post_kses' ); |
| 126 |
|
| 127 |
if ( $kses_was_active ) { |
| 128 |
kses_remove_filters(); |
| 129 |
} |
| 130 |
|
| 131 |
wp_update_post( |
| 132 |
[ |
| 133 |
'ID' => $post_id, |
| 134 |
'post_content' => wp_slash( serialize_blocks( $blocks ) ), |
| 135 |
] |
| 136 |
); |
| 137 |
|
| 138 |
if ( $kses_was_active ) { |
| 139 |
kses_init_filters(); |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
delete_post_meta( $post_id, self::META_KEY ); |
| 144 |
|
| 145 |
return $result; |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Attributes of every block comment that carries a `blockId`, keyed by it. |
| 150 |
* |
| 151 |
* Reads the RAW comments rather than `parse_blocks()` output: `parse_blocks` hands back |
| 152 |
* whatever the JSON decoded to, which is what we want here — but going through the parser |
| 153 |
* would tie this to WP's block registry, and the whole point is to capture attributes the |
| 154 |
* registry knows nothing about. |
| 155 |
* |
| 156 |
* @param string $content |
| 157 |
* @return array<string,array<string,mixed>> |
| 158 |
*/ |
| 159 |
public static function attributes_by_block_id( string $content ): array { |
| 160 |
$map = []; |
| 161 |
|
| 162 |
if ( '' === $content ) { |
| 163 |
return $map; |
| 164 |
} |
| 165 |
|
| 166 |
// Block comments are single-line and never nest, so a non-greedy match to the closing |
| 167 |
// delimiter cannot swallow the following block. |
| 168 |
if ( ! preg_match_all( '#<!--\s+wp:[a-z0-9-]+/[a-z0-9-]+\s+(\{.*?\})\s*/?-->#s', $content, $matches ) ) { |
| 169 |
return $map; |
| 170 |
} |
| 171 |
|
| 172 |
$seen = []; |
| 173 |
|
| 174 |
foreach ( $matches[1] as $json ) { |
| 175 |
$attrs = json_decode( $json, true ); |
| 176 |
|
| 177 |
if ( ! is_array( $attrs ) || ! isset( $attrs['blockId'] ) || ! is_string( $attrs['blockId'] ) ) { |
| 178 |
continue; |
| 179 |
} |
| 180 |
|
| 181 |
$block_id = $attrs['blockId']; |
| 182 |
|
| 183 |
// A duplicated blockId (copy/paste in the editor, or a pack that repeats a section) |
| 184 |
// would make the restore apply one block's attributes to a different block. There is |
| 185 |
// no way to tell them apart afterwards, so drop the id entirely rather than guess — |
| 186 |
// those blocks keep the conservative treatment and are left as imported. |
| 187 |
if ( isset( $seen[ $block_id ] ) ) { |
| 188 |
unset( $map[ $block_id ] ); |
| 189 |
continue; |
| 190 |
} |
| 191 |
|
| 192 |
$seen[ $block_id ] = true; |
| 193 |
$map[ $block_id ] = $attrs; |
| 194 |
} |
| 195 |
|
| 196 |
return $map; |
| 197 |
} |
| 198 |
} |
| 199 |
|