From aa36048e6569b67037ecd05b5ff58bfb45db997a Mon Sep 17 00:00:00 2001 From: Subodh Rajpopat Date: Wed, 21 Oct 2026 16:08:52 +0530 Subject: [PATCH 1/2] Keep page-builder shortcode text out of the video sitemap description The sitemap is served at parse_request priority 1, before WPBakery and Divi register their shortcodes, so core's strip_shortcodes() left tags such as [vc_row] in the fallback description taken from the page text. Add the tag names found in the text to strip_shortcodes()'s own list so unregistered shortcodes are removed the same way registered ones are, which gives the same description in the sitemap and on the front end. The strip_shortcodes() test stub removed every [tag], which hid the bug. It now follows core: only registered shortcodes are removed. --- inc/classes/class-seo.php | 45 +++++++++++- tests/php/VideoSeoSchemaTest.php | 103 ++++++++++++++++++++++++++++ tests/php/VideoSitemapTest.php | 56 +++++++++++++++ tests/stubs/hooks.php | 23 +++++++ tests/stubs/video-seo-functions.php | 67 +++++++++++++++++- 5 files changed, 292 insertions(+), 2 deletions(-) diff --git a/inc/classes/class-seo.php b/inc/classes/class-seo.php index 63c8c39ea..0f8be874d 100644 --- a/inc/classes/class-seo.php +++ b/inc/classes/class-seo.php @@ -31,6 +31,15 @@ class Seo { */ const DESCRIPTION_MAX_LENGTH = 160; + /** + * Most unregistered shortcode names removed from one page text when + * building its fallback description. Bounds the size of the pattern a page + * full of bracketed words would otherwise build. + * + * @since 2.5.0 + */ + const FALLBACK_UNREGISTERED_SHORTCODES_LIMIT = 100; + /** * Seconds before a failed transcript fetch is retried. * @@ -1605,7 +1614,7 @@ private function get_post_excerpt_fallback( $post_id ) { $text = has_excerpt( $post ) ? $post->post_excerpt : $post->post_content; $text = preg_replace( '//s', '', (string) $text ); - $text = wp_strip_all_tags( strip_shortcodes( $text ) ); + $text = wp_strip_all_tags( $this->strip_all_shortcodes( $text ) ); // Decode entities, then strip again: text stored as "</script>" // must not come back as a real tag. $text = wp_strip_all_tags( html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); @@ -1613,6 +1622,40 @@ private function get_post_excerpt_fallback( $post_id ) { return self::trim_to_length( $text, self::DESCRIPTION_MAX_LENGTH ); } + /** + * Remove every shortcode from a text, registered or not, together with the + * text they enclose. + * + * Core's strip_shortcodes() only removes the shortcodes registered at the + * moment it runs. The video sitemap is served at parse_request priority 1, + * before a page builder such as WPBakery or Divi registers its own, so + * their tags would stay in the description as raw `[vc_row]` text while a + * front-end page (wp_head, after the builder loaded) drops them. Adding + * the tag names found in the text to core's list, through its own + * `strip_shortcodes_tagnames` filter, gives the same result at both points. + * + * @since 2.5.0 + * + * @param string $text Page text. + * @return string Text without shortcodes. + */ + private function strip_all_shortcodes( $text ) { + $add_found_tag_names = static function ( $tags, $content ) { + $tags = (array) $tags; + + preg_match_all( '@\[([^<>&/\[\]\x00-\x20=]++)@', (string) $content, $matches ); + $unregistered = array_diff( array_unique( $matches[1] ), $tags ); + + return array_merge( $tags, array_slice( $unregistered, 0, self::FALLBACK_UNREGISTERED_SHORTCODES_LIMIT ) ); + }; + + add_filter( 'strip_shortcodes_tagnames', $add_found_tag_names, 10, 2 ); + $stripped = strip_shortcodes( $text ); + remove_filter( 'strip_shortcodes_tagnames', $add_found_tag_names, 10 ); + + return (string) $stripped; + } + /** * Whether the current request is a front-end page render where render-time * video SEO should be collected and emitted. diff --git a/tests/php/VideoSeoSchemaTest.php b/tests/php/VideoSeoSchemaTest.php index a7637c2a7..e4584e62f 100644 --- a/tests/php/VideoSeoSchemaTest.php +++ b/tests/php/VideoSeoSchemaTest.php @@ -45,6 +45,14 @@ class VideoSeoSchemaTest extends TestCase { */ private $temp_dirs = array(); + /** + * Registered shortcodes as they were before register_shortcodes() replaced + * them, put back in tearDown(). + * + * @var array|null + */ + private $saved_shortcode_tags = null; + protected function setUp(): void { parent::setUp(); $GLOBALS['rtgodam_stub'] = array(); @@ -53,6 +61,10 @@ protected function setUp(): void { } protected function tearDown(): void { + if ( null !== $this->saved_shortcode_tags ) { + $GLOBALS['shortcode_tags'] = $this->saved_shortcode_tags; + $this->saved_shortcode_tags = null; + } foreach ( $this->temp_dirs as $dir ) { $this->remove_dir( $dir ); } @@ -239,6 +251,97 @@ public function test_page_content_fallback_strips_blocks_and_shortcodes() { $this->assertSame( 'Hello & welcome.', $schema['description'] ); } + /** + * Replace the registered shortcodes, as they stand at a given point of a + * request. The sitemap is built at parse_request priority 1, before a page + * builder registers its shortcodes; a front-end page (wp_head) is built after. + * + * @param string[] $tags Shortcode tags that are registered. + * @return void + */ + private function register_shortcodes( array $tags ) { + if ( null === $this->saved_shortcode_tags ) { + $this->saved_shortcode_tags = $GLOBALS['shortcode_tags']; + } + + $GLOBALS['shortcode_tags'] = array_fill_keys( $tags, '__return_empty_string' ); + } + + /** + * Description a page with the given content gets, or '' when it has none. + * + * @param string $content Page content. + * @return string + */ + private function page_description( $content ) { + $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => '', + 'post_content' => $content, + ); + + $schema = $this->seo->build_video_object_schema( $this->legacy_entry(), 7 ); + + return $schema['description'] ?? ''; + } + + public function test_page_fallback_does_not_show_builder_shortcodes_that_are_not_registered_yet() { + // The sitemap runs before WPBakery registers vc_row, vc_column and vc_column_text. + $this->register_shortcodes( array( 'gallery', 'godam_video' ) ); + + $description = $this->page_description( '[vc_row][vc_column][vc_column_text]Learn to brew filter coffee at home.[/vc_column_text][godam_video id="265"][/vc_column][/vc_row]' ); + + $this->assertStringNotContainsString( '[', $description ); + $this->assertStringNotContainsString( 'vc_', $description ); + } + + public function test_page_fallback_is_the_same_before_and_after_the_builder_registers_its_shortcodes() { + $content = 'Intro text. [vc_row][vc_column][vc_column_text]Inside the builder.[/vc_column_text][/vc_column][/vc_row] Outro text.'; + + $this->register_shortcodes( array( 'gallery', 'godam_video' ) ); + $at_sitemap_time = $this->page_description( $content ); + + $this->register_shortcodes( array( 'gallery', 'godam_video', 'vc_row', 'vc_column', 'vc_column_text' ) ); + $at_page_time = $this->page_description( $content ); + + $this->assertSame( $at_page_time, $at_sitemap_time ); + $this->assertSame( 'Intro text. Outro text.', $at_sitemap_time ); + } + + public function test_page_fallback_removes_divi_shortcodes_that_are_not_registered() { + $this->register_shortcodes( array( 'gallery' ) ); + + $description = $this->page_description( 'Before. [et_pb_section][et_pb_row][et_pb_text]Divi copy.[/et_pb_text][/et_pb_row][/et_pb_section] After.' ); + + $this->assertSame( 'Before. After.', $description ); + } + + public function test_page_fallback_removes_a_self_closing_shortcode_that_is_not_registered() { + $this->register_shortcodes( array( 'gallery' ) ); + + $this->assertSame( 'Hello world.', $this->page_description( 'Hello [unknown_tag foo="bar" /]world.' ) ); + } + + public function test_page_fallback_leaves_text_outside_shortcodes_alone_when_none_are_used() { + $this->register_shortcodes( array( 'gallery' ) ); + + $this->assertSame( 'Plain page text with no shortcodes.', $this->page_description( '

Plain page text with no shortcodes.

' ) ); + } + + public function test_page_fallback_survives_a_page_full_of_distinct_bracketed_words() { + $this->register_shortcodes( array( 'gallery' ) ); + + $content = 'Start. '; + for ( $i = 0; $i < 3000; $i++ ) { + $content .= '[word' . $i . '] '; + } + + $description = $this->page_description( $content ); + + $this->assertStringStartsWith( 'Start.', $description ); + $this->assertLessThanOrEqual( Seo::DESCRIPTION_MAX_LENGTH, mb_strlen( $description ) ); + } + public function test_page_text_cannot_close_the_json_ld_script_tag() { // An Author's paragraph, stored with entities (kses allows this). $GLOBALS['rtgodam_stub']['posts'][7] = (object) array( diff --git a/tests/php/VideoSitemapTest.php b/tests/php/VideoSitemapTest.php index 23435d37e..a4682659f 100644 --- a/tests/php/VideoSitemapTest.php +++ b/tests/php/VideoSitemapTest.php @@ -257,6 +257,62 @@ public function test_description_falls_back_to_title_because_google_requires_one $this->assertSame( 'Video 1', $video['description'] ); } + /** + * The sitemap's texts for a page whose content is the + * given string and whose video has no description of its own, with only the + * given shortcodes registered (the sitemap is served at parse_request + * priority 1, before WPBakery registers vc_row, vc_column and the rest). + * + * @param string $content Page content. + * @param string[] $registered_shorts Shortcode tags registered at that point. + * @return string[] + */ + private function sitemap_descriptions_for_page( $content, array $registered_shorts ) { + $saved = $GLOBALS['shortcode_tags']; + $GLOBALS['shortcode_tags'] = array_fill_keys( $registered_shorts, '__return_empty_string' ); + + try { + $GLOBALS['rtgodam_stub']['posts'][10] = (object) array( + 'post_type' => 'page', + 'post_excerpt' => '', + 'post_content' => $content, + ); + + $sitemap = $this->sitemap_with_pages( array( 10 => array( $this->video( 1, array( 'description' => '' ) ) ) ) ); + $xml = Video_Sitemap::build_urlset_xml( $sitemap->get_url_entries( 1 ) ); + } finally { + $GLOBALS['shortcode_tags'] = $saved; + } + + $xpath = $this->parse( $xml ); + $descriptions = array(); + $count = $xpath->query( '//video:description' )->length; + for ( $position = 1; $position <= $count; $position++ ) { + $descriptions[] = $xpath->evaluate( "string(//video:description[{$position}])" ); + } + + return $descriptions; + } + + public function test_wpbakery_page_does_not_leak_shortcode_text_into_the_description() { + $descriptions = $this->sitemap_descriptions_for_page( + '[vc_row][vc_column][vc_column_text]Learn to brew filter coffee at home, step by step.[/vc_column_text][godam_video id="265"][/vc_column][/vc_row]', + array( 'gallery', 'godam_video' ) + ); + + // Nothing is left of the page text, so the sitemap uses the title, as it does when the builder is active. + $this->assertSame( array( 'Video 1' ), $descriptions ); + } + + public function test_page_text_outside_the_builder_shortcodes_is_used_as_the_description() { + $descriptions = $this->sitemap_descriptions_for_page( + 'Learn to brew filter coffee at home. [vc_row][vc_column][godam_video id="265"][/vc_column][/vc_row]', + array( 'gallery', 'godam_video' ) + ); + + $this->assertSame( array( 'Learn to brew filter coffee at home.' ), $descriptions ); + } + public function test_missing_thumbnail_falls_back_to_featured_image() { $GLOBALS['rtgodam_stub']['featured_image'][10] = 'https://example.test/featured.jpg'; diff --git a/tests/stubs/hooks.php b/tests/stubs/hooks.php index 8780aac74..f307cedfc 100644 --- a/tests/stubs/hooks.php +++ b/tests/stubs/hooks.php @@ -36,6 +36,29 @@ function add_filter( $hook, $callback, $priority = 10, $accepted_args = 1 ) { // } } +if ( ! function_exists( 'remove_filter' ) ) { + + /** + * Remove a callback that add_filter() recorded. + * + * @param string $hook Hook name. + * @param callable $callback Callback, compared by identity. + * @param int $priority Priority it was added at. + * + * @return bool Whether a callback was removed. + */ + function remove_filter( $hook, $callback, $priority = 10 ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- stub mirroring the WP function. + foreach ( $GLOBALS['rtgodam_hooks'][ $hook ][ $priority ] ?? array() as $index => $registered ) { + if ( $registered === $callback ) { + unset( $GLOBALS['rtgodam_hooks'][ $hook ][ $priority ][ $index ] ); + return true; + } + } + + return false; + } +} + if ( ! function_exists( 'add_action' ) ) { /** diff --git a/tests/stubs/video-seo-functions.php b/tests/stubs/video-seo-functions.php index a196d8bdb..7c1c49f75 100644 --- a/tests/stubs/video-seo-functions.php +++ b/tests/stubs/video-seo-functions.php @@ -14,6 +14,9 @@ namespace { + // get_shortcode_regex(), used by the strip_shortcodes() stub below. + require_once __DIR__ . '/shortcode-functions.php'; + /* * The HLS URL is read through rtgodam_get_hls_transcoded_url_from_attachment() * when admin/godam-transcoder-functions.php is loaded (EarlyTranslationHooksTest @@ -124,14 +127,76 @@ function has_excerpt( $post = 0 ) { } } + /* + * Registered shortcodes. Core fills $shortcode_tags as plugins call + * add_shortcode(); strip_shortcodes() below only removes tags found in it, + * which is the behaviour the sitemap description depends on. These are the + * shortcodes core registers itself, plus GoDAM's. A test that needs another + * set assigns $GLOBALS['shortcode_tags'] and restores it in tearDown(). + */ + if ( ! isset( $GLOBALS['shortcode_tags'] ) ) { + $GLOBALS['shortcode_tags'] = array_fill_keys( + array( 'wp_caption', 'caption', 'gallery', 'playlist', 'audio', 'video', 'embed', 'godam_video' ), + '__return_empty_string' + ); + } + + if ( ! function_exists( 'strip_shortcode_tag' ) ) { + + /** + * WordPress core's strip_shortcode_tag(), copied. + * + * @param array $m Regex match. + * @return string + */ + function strip_shortcode_tag( $m ) { + // Allow [[foo]] syntax for escaping a tag. + if ( '[' === $m[1] && ']' === $m[6] ) { + return substr( $m[0], 1, -1 ); + } + + return $m[1] . $m[6]; + } + } + if ( ! function_exists( 'strip_shortcodes' ) ) { /** + * WordPress core's strip_shortcodes(): removes the registered shortcodes + * found in $content, together with the text they enclose, and leaves + * every other [tag] as plain text. Copied from wp-includes/shortcodes.php + * except for do_shortcodes_in_html_tags() / unescape_invalid_shortcodes(), + * which only matter for shortcodes inside HTML attributes. + * * @param string $content Content. * @return string */ function strip_shortcodes( $content ) { - return preg_replace( '/\[[^\]]*\]/', '', (string) $content ); + global $shortcode_tags; + + $content = (string) $content; + + if ( false === strpos( $content, '[' ) ) { + return $content; + } + + if ( empty( $shortcode_tags ) || ! is_array( $shortcode_tags ) ) { + return $content; + } + + // Find all tag names in $content. + preg_match_all( '@\[([^<>&/\[\]\x00-\x20=]++)@', $content, $matches ); + + $tags_to_remove = apply_filters( 'strip_shortcodes_tagnames', array_keys( $shortcode_tags ), $content ); + $tagnames = array_intersect( $tags_to_remove, $matches[1] ); + + if ( empty( $tagnames ) ) { + return $content; + } + + $pattern = get_shortcode_regex( $tagnames ); + + return preg_replace_callback( "/$pattern/", 'strip_shortcode_tag', $content ); } } From c600aa950783387369938e479b9443d8738c4d19 Mon Sep 17 00:00:00 2001 From: Subodh Rajpopat Date: Wed, 21 Oct 2026 16:31:14 +0530 Subject: [PATCH 2/2] Only strip builder-style unregistered shortcode names from the fallback description Review of the first version found that it also removed ordinary bracketed text ([sic], [1], [b]bold[/b]) from the front-end description, that one very long bracketed word made core's pattern too large and the description empty, that a tag another filter had taken off the strip list was re-added, and that the filter stayed registered if stripping threw. Only names that look like a builder tag are added now (a letter first, an underscore or hyphen in the name, 64 characters at most), tags that are registered are left to core, and the filter is removed in a finally block. New tests pin the cap, the length limit, the filter removal and the ordinary bracketed text. --- inc/classes/class-seo.php | 62 ++++++++++++++++++++------ tests/php/VideoSeoSchemaTest.php | 74 ++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/inc/classes/class-seo.php b/inc/classes/class-seo.php index 0f8be874d..409f32539 100644 --- a/inc/classes/class-seo.php +++ b/inc/classes/class-seo.php @@ -32,14 +32,26 @@ class Seo { const DESCRIPTION_MAX_LENGTH = 160; /** - * Most unregistered shortcode names removed from one page text when - * building its fallback description. Bounds the size of the pattern a page - * full of bracketed words would otherwise build. + * Most unregistered builder-style shortcode names removed from one page + * text when building its fallback description. Together with the name + * length limit below, it keeps the pattern core builds from them far below + * PCRE's size limit, whatever a page contains. * * @since 2.5.0 */ const FALLBACK_UNREGISTERED_SHORTCODES_LIMIT = 100; + /** + * Which unregistered tag names the fallback description treats as page-builder + * shortcodes (`vc_row`, `et_pb_section`, `fusion_builder_container`, + * `contact-form-7`): 64 characters at most, starting with a letter, with an + * underscore or hyphen in them. Plain bracketed words are common in ordinary + * text and stay. + * + * @since 2.5.0 + */ + const FALLBACK_SHORTCODE_NAME_PATTERN = '/^(?=.{1,64}$)(?=[A-Za-z0-9]*[_-])[A-Za-z][A-Za-z0-9_-]*$/D'; + /** * Seconds before a failed transcript fetch is retried. * @@ -1623,16 +1635,22 @@ private function get_post_excerpt_fallback( $post_id ) { } /** - * Remove every shortcode from a text, registered or not, together with the - * text they enclose. + * Remove shortcodes from a text, including page-builder shortcodes that are + * not registered yet, together with the text they enclose. * * Core's strip_shortcodes() only removes the shortcodes registered at the * moment it runs. The video sitemap is served at parse_request priority 1, * before a page builder such as WPBakery or Divi registers its own, so * their tags would stay in the description as raw `[vc_row]` text while a * front-end page (wp_head, after the builder loaded) drops them. Adding - * the tag names found in the text to core's list, through its own - * `strip_shortcodes_tagnames` filter, gives the same result at both points. + * the unregistered builder-style tag names found in the text to core's list, + * through its own `strip_shortcodes_tagnames` filter, gives the same result + * at both points. + * + * Only names that look like a builder tag are added (see + * {@see self::FALLBACK_SHORTCODE_NAME_PATTERN}): ordinary bracketed text + * such as `[sic]`, `[1]` or `[b]bold[/b]` is left alone, so the description + * of a page whose shortcodes are all registered does not change. * * @since 2.5.0 * @@ -1641,17 +1659,37 @@ private function get_post_excerpt_fallback( $post_id ) { */ private function strip_all_shortcodes( $text ) { $add_found_tag_names = static function ( $tags, $content ) { - $tags = (array) $tags; + global $shortcode_tags; + + $tags = (array) $tags; + $registered = is_array( $shortcode_tags ) ? $shortcode_tags : array(); + $found = array(); preg_match_all( '@\[([^<>&/\[\]\x00-\x20=]++)@', (string) $content, $matches ); - $unregistered = array_diff( array_unique( $matches[1] ), $tags ); - return array_merge( $tags, array_slice( $unregistered, 0, self::FALLBACK_UNREGISTERED_SHORTCODES_LIMIT ) ); + foreach ( array_unique( $matches[1] ) as $name ) { + if ( count( $found ) >= self::FALLBACK_UNREGISTERED_SHORTCODES_LIMIT ) { + break; + } + + // A tag another filter took off the list is registered: leave it be. + if ( array_key_exists( $name, $registered ) || ! preg_match( self::FALLBACK_SHORTCODE_NAME_PATTERN, $name ) ) { + continue; + } + + $found[] = $name; + } + + return array_merge( $tags, $found ); }; add_filter( 'strip_shortcodes_tagnames', $add_found_tag_names, 10, 2 ); - $stripped = strip_shortcodes( $text ); - remove_filter( 'strip_shortcodes_tagnames', $add_found_tag_names, 10 ); + + try { + $stripped = strip_shortcodes( $text ); + } finally { + remove_filter( 'strip_shortcodes_tagnames', $add_found_tag_names, 10 ); + } return (string) $stripped; } diff --git a/tests/php/VideoSeoSchemaTest.php b/tests/php/VideoSeoSchemaTest.php index e4584e62f..04d323411 100644 --- a/tests/php/VideoSeoSchemaTest.php +++ b/tests/php/VideoSeoSchemaTest.php @@ -328,12 +328,40 @@ public function test_page_fallback_leaves_text_outside_shortcodes_alone_when_non $this->assertSame( 'Plain page text with no shortcodes.', $this->page_description( '

Plain page text with no shortcodes.

' ) ); } - public function test_page_fallback_survives_a_page_full_of_distinct_bracketed_words() { + public function test_page_fallback_leaves_ordinary_bracketed_text_alone() { $this->register_shortcodes( array( 'gallery' ) ); + $text = 'He said [sic] hello, see [1] and [citation needed], [b]bold[/b] and a[0].'; + + $this->assertSame( $text, $this->page_description( $text ) ); + } + + public function test_page_fallback_removes_a_builder_tag_with_a_hyphen_in_its_name() { + $this->register_shortcodes( array( 'gallery' ) ); + + $this->assertSame( 'Hello world.', $this->page_description( 'Hello [contact-form-7 id="1"]world.' ) ); + } + + public function test_page_fallback_leaves_a_tag_another_filter_took_off_the_strip_list() { + $this->register_shortcodes( array( 'gallery', 'my_plugin_tag' ) ); + add_filter( + 'strip_shortcodes_tagnames', + static function ( $tags ) { + return array_diff( $tags, array( 'my_plugin_tag' ) ); + }, + 5 + ); + + $this->assertSame( 'See [my_plugin_tag id="1"] done.', $this->page_description( 'See [my_plugin_tag id="1"] done.' ) ); + } + + public function test_page_fallback_survives_a_page_full_of_distinct_builder_style_tags() { + $this->register_shortcodes( array( 'gallery' ) ); + + // Far more names than fit in one pattern: core's regex fails to compile past a few thousand. $content = 'Start. '; - for ( $i = 0; $i < 3000; $i++ ) { - $content .= '[word' . $i . '] '; + for ( $i = 0; $i < 20000; $i++ ) { + $content .= '[tag_' . $i . '] '; } $description = $this->page_description( $content ); @@ -342,6 +370,46 @@ public function test_page_fallback_survives_a_page_full_of_distinct_bracketed_wo $this->assertLessThanOrEqual( Seo::DESCRIPTION_MAX_LENGTH, mb_strlen( $description ) ); } + public function test_page_fallback_survives_a_very_long_tag_name() { + $this->register_shortcodes( array( 'gallery' ) ); + + // A name this long would make the pattern too large for PCRE and the whole description empty. + $content = 'Start. [long_' . str_repeat( 'x', 70000 ) . '] End.'; + + $description = $this->page_description( $content ); + + $this->assertStringStartsWith( 'Start.', $description ); + } + + public function test_page_fallback_does_not_leave_its_filter_registered() { + $this->register_shortcodes( array( 'gallery' ) ); + + $this->page_description( '[vc_row]Text.[/vc_row]' ); + + $this->assertEmpty( array_filter( $GLOBALS['rtgodam_hooks']['strip_shortcodes_tagnames'] ?? array() ) ); + } + + public function test_page_fallback_does_not_leave_its_filter_registered_when_stripping_throws() { + $this->register_shortcodes( array( 'gallery' ) ); + add_filter( + 'strip_shortcodes_tagnames', + static function () { + throw new \RuntimeException( 'another plugin failed' ); + }, + 20 + ); + + try { + $this->page_description( '[vc_row]Text.[/vc_row]' ); + $this->fail( 'The exception from the other filter should have reached the caller.' ); + } catch ( \RuntimeException $e ) { + $this->assertSame( 'another plugin failed', $e->getMessage() ); + } + + // Only the throwing callback (priority 20) is left; the one this fix added (priority 10) is gone. + $this->assertEmpty( $GLOBALS['rtgodam_hooks']['strip_shortcodes_tagnames'][10] ?? array() ); + } + public function test_page_text_cannot_close_the_json_ld_script_tag() { // An Author's paragraph, stored with entities (kses allows this). $GLOBALS['rtgodam_stub']['posts'][7] = (object) array(