Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@
add_filter( 'the_content', 'convert_smilies', 20 );
add_filter( 'the_content', 'wpautop' );
add_filter( 'the_content', 'shortcode_unautop' );
// Fix for #50863: [playlist] + trailing text produces a </p> with no matching <p>.
add_filter( 'the_content', 'wp_split_paragraphs_around_block_shortcodes' );
add_filter( 'the_content', 'prepend_attachment' );
add_filter( 'the_content', 'wp_replace_insecure_home_url' );
add_filter( 'the_content', 'do_shortcode', 11 ); // AFTER wpautop().
Expand Down
88 changes: 88 additions & 0 deletions src/wp-includes/formatting.php
Original file line number Diff line number Diff line change
Expand Up @@ -6403,3 +6403,91 @@ function maybe_hash_hex_color( $color ) {

return $color;
}

/**
* Shortcode tags known to produce block-level markup when expanded.
*
* @since 7.2.0
*
* @return string[] Registered block-level shortcode tag names.
*/
function wp_get_block_level_shortcode_tags() {
/**
* Filters the shortcode tags treated as producing block-level markup, so
* their paragraphs get split instead of left wrapping the expanded output.
*
* @since 7.2.0
*
* @param string[] $tags Shortcode tag names. Default array( 'playlist' ).
*/
return apply_filters( 'wp_block_level_shortcode_tags', array( 'playlist' ) );
}

/**
* Splits paragraphs around block-level shortcodes that aren't alone in them.
*
* @since 7.2.0
*
* @param string $content Content that has been through wpautop() and shortcode_unautop().
* @return string
*/
function wp_split_paragraphs_around_block_shortcodes( $content ) {
$tags = wp_get_block_level_shortcode_tags();

if ( empty( $tags ) || false === strpos( $content, '[' ) ) {
return $content;
}

$shortcode_regex = get_shortcode_regex( $tags );

return preg_replace_callback(
'/<p\b[^>]*>(.*?)<\/p>/s',
static function ( $matches ) use ( $shortcode_regex ) {
return _wp_split_paragraph_around_shortcode_matches( $matches[0], $matches[1], $shortcode_regex );
},
$content
);
}

/**
* Helper for wp_split_paragraphs_around_block_shortcodes().
*
* @since 7.2.0
*
* @param string $whole_p The full <p>...</p> match.
* @param string $inner The paragraph's inner content.
* @param string $shortcode_regex Regex from get_shortcode_regex() for the block-level tags.
* @return string
*/
function _wp_split_paragraph_around_shortcode_matches( $whole_p, $inner, $shortcode_regex ) {
if ( ! preg_match_all( '/' . $shortcode_regex . '/s', $inner, $all, PREG_OFFSET_CAPTURE ) ) {
return $whole_p;
}

// A shortcode that's already the paragraph's sole content is shortcode_unautop()'s job.
if ( 1 === count( $all[0] ) && trim( $inner ) === trim( $all[0][0][0] ) ) {
return $whole_p;
}

$output = '';
$cursor = 0;

foreach ( $all[0] as $match ) {
list( $shortcode_text, $offset ) = $match;

$before = trim( substr( $inner, $cursor, $offset - $cursor ) );
if ( '' !== $before ) {
$output .= '<p>' . $before . '</p>';
}

$output .= $shortcode_text;
$cursor = $offset + strlen( $shortcode_text );
}

$after = trim( substr( $inner, $cursor ) );
if ( '' !== $after ) {
$output .= '<p>' . $after . '</p>';
}

return $output;
}
127 changes: 127 additions & 0 deletions tests/phpunit/tests/formatting/unwrapblockcontent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

/**
* @group formatting
* @covers ::wp_split_paragraphs_around_block_shortcodes
*/
class Tests_Formatting_WpSplitParagraphsAroundBlockShortcodes extends WP_UnitTestCase {

public function set_up() {
parent::set_up();
add_shortcode(
'playlist',
static function () {
return '<div class="wp-playlist wp-video-playlist wp-playlist-dark">'
. '<div class="wp-playlist-next"></div>'
. '<div class="wp-playlist-prev"></div>'
. '<script type="application/json" class="wp-playlist-script">{"tracks":[]}</script>'
. '</div>';
}
);
}

public function tear_down() {
remove_shortcode( 'playlist' );
parent::tear_down();
}

/**
* Reproduces #50863 end to end through the real content pipeline.
*/
public function test_playlist_with_trailing_text_produces_valid_nesting() {
$raw = '[playlist ids="1,2,3"] Hey everyone, check out my new songs!';

$content = wpautop( $raw );
$content = shortcode_unautop( $content );
$content = wp_split_paragraphs_around_block_shortcodes( $content );
$content = do_shortcode( $content );

$this->assertStringNotContainsString( '<p><div', $content );
$this->assertStringNotContainsString( '<p></p>', $content );
$this->assertStringContainsString( '<p>Hey everyone, check out my new songs!</p>', $content );
$this->assertStringContainsString( '<div class="wp-playlist-next"></div>', $content );
}

/**
* Leading text before the shortcode should get its own paragraph too.
*/
public function test_playlist_with_leading_text_produces_valid_nesting() {
$raw = 'Check this out: [playlist ids="1,2,3"]';

$content = wpautop( $raw );
$content = shortcode_unautop( $content );
$content = wp_split_paragraphs_around_block_shortcodes( $content );
$content = do_shortcode( $content );

$this->assertStringContainsString( '<p>Check this out:</p>', $content );
$this->assertStringNotContainsString( '<p>Check this out: <div', $content );
}

/**
* A shortcode alone in its paragraph is shortcode_unautop()'s job; the new
* function must be a no-op there, not double-handle it.
*/
public function test_standalone_playlist_shortcode_is_left_to_shortcode_unautop() {
$raw = '[playlist ids="1,2,3"]';

$content = wpautop( $raw );
$after_unautop = shortcode_unautop( $content );

// wpautop() emits each paragraph as "<p>...</p>\n"; shortcode_unautop() only
// replaces the matched <p>...</p> span, so that trailing newline is expected
// and irrelevant to what's being tested here.
$this->assertSame( '[playlist ids="1,2,3"]', trim( $after_unautop ) );
$this->assertSame( $after_unautop, wp_split_paragraphs_around_block_shortcodes( $after_unautop ) );
}

/**
* A non-block-level shortcode (not in the registered tag list) must be left alone,
* even with surrounding text, since it isn't known to expand into block markup.
*/
public function test_non_block_level_shortcode_is_untouched() {
$content = '<p>Some text with [caption]a normal caption[/caption] inline.</p>';

$this->assertSame( $content, wp_split_paragraphs_around_block_shortcodes( $content ) );
}

/**
* Raw HTML a user typed directly into a paragraph, with no shortcode involved
* at all, must never be touched by this filter.
*/
public function test_raw_user_html_with_no_shortcode_is_untouched() {
$content = '<p>Some text with a <div>raw div a user pasted</div> inside it.</p>';

$this->assertSame( $content, wp_split_paragraphs_around_block_shortcodes( $content ) );
}

/**
* The wp_block_level_shortcode_tags filter lets other block-producing
* shortcodes (core's own [gallery], or a plugin's) opt in.
*/
public function test_block_level_tag_list_is_filterable() {
add_shortcode(
'stub_gallery',
static function () {
return '<div class="gallery"><div class="gallery-item"></div></div>';
}
);

$add_gallery = static function ( $tags ) {
$tags[] = 'stub_gallery';
return $tags;
};
add_filter( 'wp_block_level_shortcode_tags', $add_gallery );

$raw = '[stub_gallery] Look at these!';
$content = wpautop( $raw );
$content = shortcode_unautop( $content );
$content = wp_split_paragraphs_around_block_shortcodes( $content );
$content = do_shortcode( $content );

remove_filter( 'wp_block_level_shortcode_tags', $add_gallery );
remove_shortcode( 'stub_gallery' );

$this->assertStringNotContainsString( '<p><div', $content );
$this->assertStringContainsString( '<p>Look at these!</p>', $content );
}
}
Loading