- 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
205 lines
7.1 KiB
PHP
205 lines
7.1 KiB
PHP
<?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;
|
||
}
|
||
}
|