" 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 ); } }