" with a string function, which is what breaks lists, tables and shortcodes
* in older related-post plugins.
*
* @package Stunning_Related
*/
defined( 'ABSPATH' ) || exit;
/**
* Placement engine.
*/
class SRPP_Placement {
/**
* Re-entrancy guard.
*
* @var bool
*/
protected $running = false;
/**
* Constructor.
*/
public function __construct() {
add_filter( 'the_content', array( $this, 'filter_content' ), 12 );
}
/**
* All in-content placement choices.
*
* @return array
*/
public static function content_placements() {
return array(
'before_content' => __( 'Before content', 'advanced-css3-related-posts-widget' ),
'after_title' => __( 'After title (top of content)', 'advanced-css3-related-posts-widget' ),
'after_content' => __( 'After content', 'advanced-css3-related-posts-widget' ),
'after_paragraph' => __( 'After paragraph X', 'advanced-css3-related-posts-widget' ),
'before_paragraph' => __( 'Before paragraph X', 'advanced-css3-related-posts-widget' ),
'after_percent' => __( 'After X% of the article', 'advanced-css3-related-posts-widget' ),
'middle' => __( 'Middle of article', 'advanced-css3-related-posts-widget' ),
'before_first_heading' => __( 'Before first heading', 'advanced-css3-related-posts-widget' ),
'after_first_heading' => __( 'After first heading', 'advanced-css3-related-posts-widget' ),
'before_last_heading' => __( 'Before last heading', 'advanced-css3-related-posts-widget' ),
'before_conclusion' => __( 'Before conclusion', 'advanced-css3-related-posts-widget' ),
'every_paragraphs' => __( 'Every X paragraphs', 'advanced-css3-related-posts-widget' ),
'every_words' => __( 'Every X words', 'advanced-css3-related-posts-widget' ),
);
}
/**
* Placements that do not touch the_content.
*
* @return array
*/
public static function manual_placements() {
return array(
'manual' => __( 'Manual only (shortcode / block / widget / PHP)', 'advanced-css3-related-posts-widget' ),
);
}
/**
* Every placement choice, grouped for the admin select.
*
* @return array group => [ value => label ]
*/
public static function grouped_placements() {
$groups = array(
__( 'Content', 'advanced-css3-related-posts-widget' ) => self::content_placements(),
);
if ( srpp_is_woo_active() ) {
$groups[ __( 'WooCommerce', 'advanced-css3-related-posts-widget' ) ] = SRPP_Woo::placements();
}
$groups[ __( 'Manual', 'advanced-css3-related-posts-widget' ) ] = self::manual_placements();
return $groups;
}
/**
* Is this placement handled by the content filter?
*
* @param string $placement Placement key.
* @return bool
*/
public static function is_content_placement( $placement ) {
return array_key_exists( $placement, self::content_placements() );
}
/**
* Displays that want to run inside the content of the current post.
*
* @param int $post_id Post ID.
* @return SRPP_Display[]
*/
public static function displays_for_content( $post_id ) {
$out = array();
foreach ( SRPP_Display::active() as $display ) {
if ( ! self::is_content_placement( $display->opt( 'placement' ) ) ) {
continue;
}
if ( ! SRPP_Conditions::passes( $display, $post_id, 'singular' ) ) {
continue;
}
$out[] = $display;
}
usort( $out, array( __CLASS__, 'sort_by_priority' ) );
return $out;
}
/**
* Sort helper.
*
* @param SRPP_Display $a Display.
* @param SRPP_Display $b Display.
* @return int
*/
public static function sort_by_priority( $a, $b ) {
$pa = (int) $a->opt( 'priority' );
$pb = (int) $b->opt( 'priority' );
if ( $pa === $pb ) {
return 0;
}
return ( $pa < $pb ) ? -1 : 1;
}
/**
* The content filter.
*
* @param string $content Post content.
* @return string
*/
public function filter_content( $content ) {
if ( $this->running || is_admin() || is_feed() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
if ( doing_filter( 'get_the_excerpt' ) || post_password_required() ) {
return $content;
}
$post_id = get_the_ID();
if ( ! $post_id ) {
return $content;
}
$displays = self::displays_for_content( $post_id );
if ( empty( $displays ) ) {
return $content;
}
$this->running = true;
$blocks = array();
$before = '';
$after = '';
foreach ( $displays as $display ) {
$placement = $display->opt( 'placement' );
if ( in_array( $placement, array( 'before_content', 'after_title' ), true ) ) {
$before .= srpp()->render->render( $display, array( 'post_id' => $post_id, 'placement' => $placement ) );
continue;
}
if ( 'after_content' === $placement ) {
$after .= srpp()->render->render( $display, array( 'post_id' => $post_id, 'placement' => $placement ) );
continue;
}
$blocks[] = $display;
}
if ( $blocks ) {
$content = $this->inject( $content, $blocks, $post_id );
}
$this->running = false;
return $before . $content . $after;
}
/**
* Insert one or more Displays inside the content.
*
* @param string $content Content HTML.
* @param array $displays Displays to place.
* @param int $post_id Post ID.
* @return string
*/
public function inject( $content, $displays, $post_id ) {
if ( '' === trim( $content ) ) {
return $content;
}
if ( ! class_exists( 'DOMDocument' ) ) {
return $this->inject_fallback( $content, $displays, $post_id );
}
$dom = $this->load_dom( $content );
if ( ! $dom ) {
return $this->inject_fallback( $content, $displays, $post_id );
}
$container = $this->find_container( $dom );
$nodes = $this->block_nodes( $container );
if ( count( $nodes ) < 2 ) {
return $this->inject_fallback( $content, $displays, $post_id );
}
$index = $this->build_index( $nodes );
$slots = array();
$slot_html = array();
$slot_count = 0;
foreach ( $displays as $display ) {
$positions = $this->resolve_positions( $display, $index );
foreach ( $positions as $position ) {
if ( isset( $slots[ $position ] ) ) {
// Two blocks landing on the same spot: nudge the second one down.
$position = $this->next_free( $position, $slots, count( $nodes ) );
}
$html = srpp()->render->render(
$display,
array(
'post_id' => $post_id,
'placement' => $display->opt( 'placement' ),
)
);
if ( '' === $html ) {
continue;
}
$token = 'SRPP_SLOT_' . ( ++$slot_count );
$slots[ $position ] = $token;
$slot_html[ $token ] = $html;
}
}
if ( empty( $slots ) ) {
return $content;
}
ksort( $slots );
foreach ( $slots as $position => $token ) {
$comment = $dom->createComment( $token );
if ( $position >= count( $nodes ) ) {
$container->appendChild( $comment );
} else {
$container->insertBefore( $comment, $nodes[ $position ] );
}
}
$html = $this->save_dom( $dom, $container );
foreach ( $slot_html as $token => $markup ) {
$html = str_replace( '', $markup, $html );
}
return $html;
}
/**
* Find the next unused slot position.
*
* @param int $position Wanted position.
* @param array $slots Taken positions.
* @param int $max Node count.
* @return int
*/
protected function next_free( $position, $slots, $max ) {
while ( isset( $slots[ $position ] ) && $position <= $max ) {
$position++;
}
return $position;
}
/**
* Parse content into a DOM document.
*
* @param string $content HTML.
* @return DOMDocument|false
*/
protected function load_dom( $content ) {
$dom = new DOMDocument( '1.0', 'UTF-8' );
$previous = libxml_use_internal_errors( true );
$wrapped = '
' . $content . '
';
$loaded = $dom->loadHTML( $wrapped, LIBXML_NOWARNING | LIBXML_NOERROR );
libxml_clear_errors();
libxml_use_internal_errors( $previous );
return $loaded ? $dom : false;
}
/**
* Serialize the container's inner HTML.
*
* @param DOMDocument $dom Document.
* @param DOMNode $container Container node.
* @return string
*/
protected function save_dom( $dom, $container ) {
$out = '';
foreach ( $container->childNodes as $child ) {
$out .= $dom->saveHTML( $child );
}
return $out;
}
/**
* Walk past single-child wrappers so paragraph counting works inside
* builder markup (a page builder often wraps everything in one div).
*
* @param DOMDocument $dom Document.
* @return DOMNode
*/
protected function find_container( $dom ) {
$bodies = $dom->getElementsByTagName( 'body' );
$body = $bodies->length ? $bodies->item( 0 ) : $dom->documentElement;
$root = $body;
foreach ( $this->element_children( $body ) as $child ) {
if ( $child->hasAttribute( 'id' ) && 'srpp-root' === $child->getAttribute( 'id' ) ) {
$root = $child;
break;
}
}
$node = $root;
$depth = 0;
while ( $depth < 6 ) {
$children = $this->element_children( $node );
if ( 1 !== count( $children ) ) {
break;
}
$only = $children[0];
$tag = strtolower( $only->nodeName );
if ( ! in_array( $tag, array( 'div', 'section', 'article', 'main' ), true ) ) {
break;
}
if ( count( $this->element_children( $only ) ) < 2 ) {
break;
}
$node = $only;
$depth++;
}
return $node;
}
/**
* Element children of a node.
*
* @param DOMNode $node Node.
* @return array
*/
protected function element_children( $node ) {
$out = array();
foreach ( $node->childNodes as $child ) {
if ( XML_ELEMENT_NODE === $child->nodeType ) {
$out[] = $child;
}
}
return $out;
}
/**
* Top-level block nodes we may insert between.
*
* @param DOMNode $container Container.
* @return array
*/
protected function block_nodes( $container ) {
$skip = array( 'br', 'span', 'a', 'em', 'strong', 'b', 'i', 'small', 'code' );
$out = array();
foreach ( $container->childNodes as $child ) {
if ( XML_ELEMENT_NODE !== $child->nodeType ) {
continue;
}
if ( in_array( strtolower( $child->nodeName ), $skip, true ) ) {
continue;
}
$out[] = $child;
}
return $out;
}
/**
* Build positional metadata for the node list.
*
* @param array $nodes Block nodes.
* @return array
*/
protected function build_index( $nodes ) {
$index = array(
'count' => count( $nodes ),
'paragraphs' => array(),
'headings' => array(),
'words' => array(),
'chars' => array(),
'total_words' => 0,
'total_chars' => 0,
);
$words = 0;
$chars = 0;
foreach ( $nodes as $i => $node ) {
$tag = strtolower( $node->nodeName );
$text = trim( (string) $node->textContent );
if ( 'p' === $tag && '' !== $text ) {
$index['paragraphs'][] = $i;
}
if ( preg_match( '/^h[1-6]$/', $tag ) ) {
$index['headings'][] = $i;
}
$words += srpp_word_count( $text );
$chars += mb_strlen( $text );
$index['words'][ $i ] = $words;
$index['chars'][ $i ] = $chars;
}
$index['total_words'] = $words;
$index['total_chars'] = $chars;
return $index;
}
/**
* Turn a Display's placement settings into node indexes.
*
* @param SRPP_Display $display Display.
* @param array $index Index data.
* @return array Insertion positions (insert *before* node at this index).
*/
protected function resolve_positions( $display, $index ) {
$placement = $display->opt( 'placement' );
$value = (int) $display->opt( 'placement_value' );
$count = $index['count'];
$paras = $index['paragraphs'];
$heads = $index['headings'];
$positions = array();
switch ( $placement ) {
case 'after_paragraph':
$n = max( 1, $value );
if ( isset( $paras[ $n - 1 ] ) ) {
$positions[] = $paras[ $n - 1 ] + 1;
} elseif ( $paras ) {
$positions[] = end( $paras ) + 1;
}
break;
case 'before_paragraph':
$n = max( 1, $value );
if ( isset( $paras[ $n - 1 ] ) ) {
$positions[] = $paras[ $n - 1 ];
}
break;
case 'after_percent':
case 'middle':
$pct = ( 'middle' === $placement ) ? 50 : max( 1, min( 99, $value ) );
$positions[] = $this->position_at_percent( $index, $pct );
break;
case 'before_first_heading':
if ( $heads ) {
$positions[] = $heads[0];
}
break;
case 'after_first_heading':
if ( $heads ) {
$positions[] = $heads[0] + 1;
}
break;
case 'before_last_heading':
case 'before_conclusion':
if ( count( $heads ) > 1 ) {
$positions[] = end( $heads );
} elseif ( $heads ) {
$positions[] = $heads[0];
} else {
$positions[] = $this->position_at_percent( $index, 80 );
}
break;
case 'every_paragraphs':
$step = max( 1, (int) $display->opt( 'repeat_every' ) );
$max = max( 1, (int) $display->opt( 'repeat_max' ) );
$start = max( 1, $value );
for ( $i = $start; $i <= count( $paras ); $i += $step ) {
if ( count( $positions ) >= $max ) {
break;
}
$positions[] = $paras[ $i - 1 ] + 1;
}
break;
case 'every_words':
$step = max( 50, (int) $display->opt( 'repeat_every' ) );
$max = max( 1, (int) $display->opt( 'repeat_max' ) );
for ( $target = $step; $target < $index['total_words']; $target += $step ) {
if ( count( $positions ) >= $max ) {
break;
}
$positions[] = $this->position_at_words( $index, $target );
}
break;
}
$positions = array_values( array_unique( array_filter( $positions, 'is_int' ) ) );
foreach ( $positions as $key => $position ) {
$positions[ $key ] = max( 0, min( $count, (int) $position ) );
}
return array_values( array_unique( $positions ) );
}
/**
* Node index at a percentage through the article.
*
* @param array $index Index data.
* @param int $pct Percentage.
* @return int
*/
protected function position_at_percent( $index, $pct ) {
if ( $index['total_chars'] < 1 ) {
return (int) floor( $index['count'] / 2 );
}
$target = ( $index['total_chars'] * $pct ) / 100;
foreach ( $index['chars'] as $i => $chars ) {
if ( $chars >= $target ) {
return $i + 1;
}
}
return $index['count'];
}
/**
* Node index after N words.
*
* @param array $index Index data.
* @param int $target Word target.
* @return int
*/
protected function position_at_words( $index, $target ) {
foreach ( $index['words'] as $i => $words ) {
if ( $words >= $target ) {
return $i + 1;
}
}
return $index['count'];
}
/**
* Paragraph-split fallback for environments without DOM support.
*
* @param string $content Content.
* @param array $displays Displays.
* @param int $post_id Post ID.
* @return string
*/
protected function inject_fallback( $content, $displays, $post_id ) {
$parts = preg_split( '/(?<=<\/p>)/i', $content );
if ( ! $parts || count( $parts ) < 2 ) {
foreach ( $displays as $display ) {
$content .= srpp()->render->render( $display, array( 'post_id' => $post_id, 'placement' => $display->opt( 'placement' ) ) );
}
return $content;
}
$total = count( $parts );
foreach ( $displays as $display ) {
$placement = $display->opt( 'placement' );
$value = max( 1, (int) $display->opt( 'placement_value' ) );
if ( 'after_percent' === $placement || 'middle' === $placement ) {
$pct = ( 'middle' === $placement ) ? 50 : min( 99, $value );
$after = (int) max( 1, round( ( $total * $pct ) / 100 ) );
} else {
$after = min( $total, $value );
}
$html = srpp()->render->render( $display, array( 'post_id' => $post_id, 'placement' => $placement ) );
if ( '' !== $html && isset( $parts[ $after - 1 ] ) ) {
$parts[ $after - 1 ] .= $html;
}
}
return implode( '', $parts );
}
}