From b6d791d46a93736a384435c235fcf80bd4725525 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:53:54 +0200 Subject: [PATCH] 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) --- docs/guides/divi-library.md | 8 +- .../Divi/Library/GlobalUsages.php | 9 +- .../Divi/Library/UsageMatcher.php | 138 +++++++++++++++--- .../Divi/Library/GlobalUsagesTest.php | 5 +- .../Divi/Library/UsageMatcherTest.php | 45 ++++++ 5 files changed, 172 insertions(+), 33 deletions(-) diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index a8bb292..6fb9c54 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -13,9 +13,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen - (`affected`, `affected_count`, `hidden_count`). Gezählt wird jeder Verweis, den Divi darstellt: Divi-5-Instanzen - in jeder Schreibweise und Divi-4-Shortcodes mit `global_module`; ein Verweis, der sich nicht eindeutig lesen - lässt, zählt vorsichtshalber mit. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint + (`affected`, `affected_count`, `hidden_count`). Erkannt werden Divi-5-Instanzen (auch mit maskierten Zeichen) + und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite + mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist + (Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen + können trotzdem unerkannt bleiben. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst. - `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie. diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 9fd86eb..adb5e66 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -127,7 +127,7 @@ class GlobalUsages /** * Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The * candidates come from one query (ID, status and type only): content with "globalModule", a - * Divi 4 "global_module" or a JSON escape of a letter (an escaped key such as globalModule + * Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule * always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by * UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1). * @@ -158,10 +158,11 @@ class GlobalUsages // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $db->get_results( $db->prepare( - 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (post_content LIKE %s OR ' - . "post_content LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " + // Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation. + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR ' + . "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', - '%' . $db->esc_like('globalModule') . '%', + '%' . $db->esc_like('globalmodule') . '%', '%' . $db->esc_like('global_module') . '%', $escapedLetter ), diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php index 6b8e912..96c47c0 100644 --- a/src/Integrations/Divi/Library/UsageMatcher.php +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -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":"" 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= 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+\/?-->/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|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 $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 $attrs + * @return list 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; + } } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 87a5f31..92b09da 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -50,8 +50,9 @@ final class GlobalUsagesTest extends TestCase self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed'); self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); self::assertStringNotContainsString('post_content,', $queries[0]); - self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]); - self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]); + // Ruling P24: case-insensitive whatever the column collation. + self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]); + self::assertStringContainsString("LOWER(post_content) LIKE '%global" . $bs . "_module%'", $queries[0]); self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php index ed861dc..dbac8cd 100644 --- a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -61,9 +61,54 @@ final class UsageMatcherTest extends TestCase false, ], 'plain page' => ['', false], + // Ruling P24 G1: a Divi 4 shortcode inside a Divi 5 attribute value (quotes serialized as escapes). + 'shortcode in a divi 5 text value' => [self::inText('[et_pb_section global_module=' . self::u('0022') . '123' + . self::u('0022') . '][/et_pb_section]'), true], + 'shortcode in a divi 5 text value, another entry' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . '124' . self::u('0022') . '][/et_pb_section]'), false], + 'shortcode in a divi 5 text value, escaped digits' => [self::inText('[et_pb_section global_module=' + . self::u('0022') . self::u('0031') . self::u('0032') . '3' . self::u('0022') . '][/et_pb_section]'), true], + // G2: parsed like shortcode_parse_atts, cast with (int) like Divi. + 'divi 4 leading space' => ['[et_pb_section global_module=" 123"][/et_pb_section]', true], + 'divi 4 exponent' => ['[et_pb_section global_module="1.23e2"][/et_pb_section]', true], + 'divi 4 upper-case attribute' => ['[et_pb_section GLOBAL_MODULE="123"][/et_pb_section]', true], + 'divi 4 zero renders nothing' => ['[et_pb_section global_module="0"][/et_pb_section]', false], + 'divi 4 empty renders nothing' => ['[et_pb_section global_module=""][/et_pb_section]', false], + 'global_module as plain text' => ['

global_module="123" steht hier nur als Text

', false], ]; } + /** A Divi 5 text module whose content value holds $text, JSON-encoded like WordPress does. */ + private static function inText(string $text): string + { + return ''; + } + + /** + * Ruling P24 G3: a global_module Divi casts to 0 renders the newest library or TB layout + * (et_pb_load_global_module, WP_Query p=0) – a reference to every entry. + * + * @return array + */ + public static function wildcards(): array + { + return [ + 'entity before the id' => ['[et_pb_section global_module=""123"][/et_pb_section]'], + 'letters' => ['[et_pb_row global_module="abc"][/et_pb_row]'], + 'typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]'], + 'double zero' => ['[et_pb_row global_module="00"][/et_pb_row]'], + 'inside a divi 5 value' => [self::inText('[et_pb_text global_module=' . self::u('0022') . 'x' + . self::u('0022') . ']a[/et_pb_text]')], + ]; + } + + /** @dataProvider wildcards */ + public function testAValueThatCastsToZeroShowsEveryEntry(string $content): void + { + self::assertTrue(UsageMatcher::shows($content, 123)); + self::assertTrue(UsageMatcher::shows($content, 98765)); + } + /** @dataProvider contents */ public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void {