- 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
112 lines
3.8 KiB
PHP
112 lines
3.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
use stdClass;
|
||
|
||
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 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
|
||
{
|
||
/** Top-level attributes that say nothing about what visitors see. */
|
||
private const IGNORED = ['builderVersion', 'globalParent'];
|
||
|
||
public static function structure(Block $block): string
|
||
{
|
||
return self::hash(self::shape($block));
|
||
}
|
||
|
||
public static function content(Block $block): string
|
||
{
|
||
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()) {
|
||
return 'opaque:' . GlobalInstance::effectiveName($block);
|
||
}
|
||
|
||
$presets = NodeSummary::presetIds($block);
|
||
sort($presets);
|
||
$children = array_map(self::shape(...), $block->children());
|
||
|
||
return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets)
|
||
. '[' . implode(';', $children) . ']';
|
||
}
|
||
|
||
private static function body(Block $block): string
|
||
{
|
||
if ($block->isOpaque()) {
|
||
return 'opaque:' . self::text(Serializer::block($block));
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
foreach (self::IGNORED as $key) {
|
||
unset($attrs[$key]);
|
||
}
|
||
if (is_array($attrs['module']['meta'] ?? null)) {
|
||
unset($attrs['module']['meta']['adminLabel']);
|
||
}
|
||
$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(';', $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. */
|
||
private static function sorted(mixed $value): mixed
|
||
{
|
||
if ($value instanceof stdClass) {
|
||
$value = (array) $value;
|
||
}
|
||
if (! is_array($value)) {
|
||
return $value;
|
||
}
|
||
if (! array_is_list($value)) {
|
||
ksort($value);
|
||
}
|
||
|
||
return array_map(self::sorted(...), $value);
|
||
}
|
||
|
||
private static function hash(string $value): string
|
||
{
|
||
return substr(hash('sha256', $value), 0, 16);
|
||
}
|
||
}
|