fix: UsageMatcher reads Divi 4 shortcodes like WordPress and Divi, everywhere (ruling P24)

- G1: every decoded string value of a Divi 5 opener is scanned for shortcodes;
  outside the openers only the remaining content is.
- G2: attributes are parsed like shortcode_parse_atts() and global_module is
  cast with (int), as Divi does.
- G3: a non-empty global_module other than "0" that casts to 0 makes Divi
  render the newest layout, so it counts for every entry (fail closed).
- The prefilter LIKEs use LOWER(post_content), so they are case-insensitive
  under any collation.
- The guide describes the fail-closed rule without an absolute claim.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:53:54 +02:00
co-authored by Claude Opus 5.5
parent 8f87ec6163
commit b6d791d46a
5 changed files with 172 additions and 33 deletions
+114 -24
View File
@@ -7,13 +7,16 @@ namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* Whether post content shows library entry $id (ruling P23, re-review F1), fail closed:
* 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: a shortcode attribute global_module=<digits> in any quoting (intval);
* - an opener with JSON escapes (or a globalModule) that cannot be decoded, or content the regex
* engine cannot scan, counts as a possible reference.
* - 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.
* Pure PHP.
*/
final class UsageMatcher
@@ -22,8 +25,12 @@ final class UsageMatcher
private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s';
/** Divi 4: global_module=, any quote characters (straight, typographic, entities) before the digits. */
private const SHORTCODE = '/\bglobal_module\s*=\s*[^\d\s\]]{0,12}(\d+)/i';
/** 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
{
@@ -32,40 +39,107 @@ final class UsageMatcher
return true; // A match, or a regex failure (fail closed).
}
if (stripos($content, 'global_module') !== false) {
$found = preg_match_all(self::SHORTCODE, $content, $matches);
if ($found === false) {
return true;
}
foreach ($matches[1] as $value) {
if ((int) $value === $id) {
return true;
}
}
}
$escape = chr(92) . 'u';
if (! str_contains($content, 'globalModule') && ! str_contains($content, $escape)) {
return false;
}
$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')) {
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 (or, casting to 0, any entry). */
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 ($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. */
private static function names(array $attrs, int $id): bool
{
@@ -84,4 +158,20 @@ final class UsageMatcher
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;
}
}