Files
WP-LIT-MCP/src/Integrations/Divi/Library/UsageMatcher.php
T
janniclas.loosenandClaude Opus 5.5 44c1804c4c feat: lit/library-make-global – node to global module, all or nothing (E10)
- NewLibraryEntry: spec and creation of a new library entry, shared by
  lit/library-save and lit/library-make-global (NewDraft, P7, StoredNodeGuard)
- UsageMatcher::referencesAny() (ruling Q7): a global entry never names any
  entry in any spelling Divi renders (P23/P24 forms, Divi 4 shortcodes inside
  Divi 5 values, values casting to 0); used by library-save scope global and
  make-global
- make-global: entry created, node replaced by GlobalInstance::of, page through
  DiviWriter::commit; refused or rolled-back page deletes the entry, a partial
  page write keeps it (the page may show it); sources are pages and posts only

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 21:35:48 +02:00

205 lines
7.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* Whether post content shows library entry $id (rulings P23, P24), fail closed:
* - Divi 5: the textual "globalModule":"<digits>" Divi's render takes (leading zeros = intval), and
* every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a
* globalModule with that ID at any depth;
* - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an
* opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module
* cast with (int) like Divi; a non-empty value other than "0" that casts to 0 makes Divi render the
* newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry;
* - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content
* the regex engine cannot scan, counts as a possible reference.
* referencesAny() asks the same for every entry at once (ruling Q7): content that becomes a global entry
* must not render another entry – nor, through a value that casts to 0, the newest layout, which may be
* that entry itself.
* Pure PHP.
*/
final class UsageMatcher
{
/** A block opener and its attribute JSON (the Parser's delimiter grammar). */
private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s';
/** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */
private const TAG = '/\[\[?([\w-]+)(?![\w-])([^\]\/]*(?:\/(?!\])[^\]\/]*)*?)\/?\]/';
/** WordPress' get_shortcode_atts_regex(). */
private const ATTS = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*'
. '([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/';
public static function shows(string $content, int $id): bool
{
return self::matches($content, $id);
}
/**
* Whether $content renders any library entry, in every spelling shows() knows (ruling Q7): a
* globalModule key with any value, a global_module attribute Divi does not skip; fail closed.
*/
public static function referencesAny(string $content): bool
{
return self::matches($content, null);
}
/** shows() for entry $id, or for any entry when $id is null. */
private static function matches(string $content, ?int $id): bool
{
$textual = preg_match(
$id === null ? '/"globalModule"\s*:/' : '/"globalModule":"0*' . $id . '"/',
$content
);
if ($textual !== 0) {
return true; // A match, or a regex failure (fail closed).
}
$found = preg_match_all(self::OPENER, $content, $matches);
if ($found === false) {
return true;
}
// Shortcodes outside the openers (Divi 4 pages, free text between blocks).
$outside = $found === 0 ? $content : str_replace($matches[1], '', $content);
if (self::shortcodeShows($outside, $id)) {
return true;
}
$escape = chr(92) . 'u';
foreach ($matches[1] as $json) {
if (
! str_contains($json, $escape)
&& ! str_contains($json, 'globalModule')
&& stripos($json, 'global_module') === false
) {
continue;
}
$attrs = json_decode($json, true);
if (! is_array($attrs) || self::names($attrs, $id)) {
return true;
}
foreach (self::strings($attrs) as $value) {
if (self::shortcodeShows($value, $id)) {
return true;
}
}
}
return false;
}
/** Whether a Divi 4 shortcode in $text renders entry $id (null: any entry); a value casting to 0 renders any. */
private static function shortcodeShows(string $text, ?int $id): bool
{
if (stripos($text, 'global_module') === false) {
return false;
}
$found = preg_match_all(self::TAG, $text, $tags);
if ($found === false) {
return true;
}
foreach ($tags[2] as $attributes) {
$atts = self::parseAtts($attributes);
if ($atts === null) {
return true;
}
$value = $atts['global_module'] ?? null;
// Divi: ! empty( $global_module_id ), then WP_Query p => (int) $global_module_id.
if (! is_string($value) || $value === '' || $value === '0') {
continue;
}
$cast = (int) $value;
if ($id === null || $cast === 0 || $cast === $id) {
return true;
}
}
return false;
}
/**
* shortcode_parse_atts() of WordPress 6.9 (named attributes only); null when PCRE fails.
*
* @return array<string, string>|null
*/
private static function parseAtts(string $text): ?array
{
$text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text);
if (! is_string($text)) {
return null;
}
$found = preg_match_all(self::ATTS, $text, $matches, PREG_SET_ORDER);
if ($found === false) {
return null;
}
$atts = [];
foreach ($matches as $m) {
foreach ([1, 3, 5] as $key) {
if (! empty($m[$key])) {
$atts[strtolower($m[$key])] = stripcslashes($m[$key + 1]);
break;
}
}
}
// WordPress rejects values with unclosed HTML elements.
foreach ($atts as $name => $value) {
if (str_contains($value, '<') && preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value) !== 1) {
$atts[$name] = '';
}
}
return $atts;
}
/**
* @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id (null: any
* globalModule key, whatever its value – fail closed).
*/
private static function names(array $attrs, ?int $id): bool
{
foreach ($attrs as $key => $value) {
if ($id === null && $key === 'globalModule') {
return true;
}
if (
$key === 'globalModule'
&& (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1))
&& (int) $value === $id
) {
return true;
}
if (is_array($value) && self::names($value, $id)) {
return true;
}
}
return false;
}
/**
* @param array<array-key, mixed> $attrs
* @return list<string> Every string value at any depth.
*/
private static function strings(array $attrs): array
{
$strings = [];
array_walk_recursive($attrs, static function (mixed $value) use (&$strings): void {
if (is_string($value)) {
$strings[] = $value;
}
});
return $strings;
}
}