fix: identical means identical bytes – text parts count, pages and posts only (ruling Q25)

- Fingerprint::content() includes non-whitespace text parts (raw HTML between
  blocks, inner HTML of core blocks) and opaque blocks with all their bytes;
  structure() unchanged; find-similar "identical" follows
- make-global mode 2 also compares Fingerprint::canonical() bytes of each node
- sources and targets: post types page and post only (POST_TYPES)
- a rolled-back page N>1 keeps DiviSaver's "Revision X bleibt erhalten"
- guide: escaped divi/global-layout re-expanded by Divi's do_blocks in
  shortcode-module and WooCommerce text output documented as residual risk

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 21:59:20 +02:00
co-authored by Claude Opus 5.5
parent e733065b60
commit 4e959833b9
6 changed files with 238 additions and 21 deletions
@@ -12,8 +12,10 @@ defined('ABSPATH') || exit;
* Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and
* lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two
* sections built alike with other texts share it. content(): every attribute but the admin label,
* builderVersion and globalParent, children included – "the same section". Whitespace between blocks never
* counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP.
* builderVersion and globalParent, children and every non-whitespace text part included – "the same section"
* (ruling Q25: raw HTML between blocks and the inner HTML of core blocks are visible too). Whitespace between
* blocks never counts; an opaque block counts with all its bytes (content) or by its effective type (structure).
* Pure PHP.
*/
final class Fingerprint
{
@@ -30,6 +32,17 @@ final class Fingerprint
return self::hash(self::body($block));
}
/**
* The normalized bytes content() hashes (ruling Q25): block names, attributes with sorted keys (without admin
* label, builderVersion, globalParent), every non-whitespace text part – raw HTML or shortcodes between blocks
* and the inner HTML of core blocks – and opaque blocks with all their bytes. Two nodes with the same
* canonical() look the same to visitors; lit/library-make-global compares these bytes, not only the hash.
*/
public static function canonical(Block $block): string
{
return self::body($block);
}
private static function shape(Block $block): string
{
if ($block->isOpaque()) {
@@ -47,7 +60,7 @@ final class Fingerprint
private static function body(Block $block): string
{
if ($block->isOpaque()) {
return 'opaque:' . (string) $block->rawOpen();
return 'opaque:' . self::text(Serializer::block($block));
}
$attrs = $block->attrs();
@@ -57,9 +70,22 @@ final class Fingerprint
if (is_array($attrs['module']['meta'] ?? null)) {
unset($attrs['module']['meta']['adminLabel']);
}
$children = array_map(self::body(...), $block->children());
$parts = [];
foreach ($block->isVoid() ? [] : $block->parts() as $part) {
if ($part instanceof Block) {
$parts[] = self::body($part);
} elseif (trim($part) !== '') {
$parts[] = 'text:' . Json::encode(self::text($part));
}
}
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']';
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $parts) . ']';
}
/** Text as visitors get it: line breaks unified, whitespace at both ends dropped. */
private static function text(string $text): string
{
return trim(str_replace("\r\n", "\n", $text));
}
/** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */