options(); $defaults = [ 'allowEmpty' => false, 'paragraph_select_from_bottom' => isset( $placement_opts['start_from_bottom'] ) && $placement_opts['start_from_bottom'], 'position' => isset( $placement_opts['position'] ) ? $placement_opts['position'] : 'after', // only has before and after. 'before' => isset( $placement_opts['position'] ) && 'before' === $placement_opts['position'], // Whether to alter nodes, for example to prevent injecting ads into `a` tags. 'alter_nodes' => true, 'repeat' => false, ]; $defaults['paragraph_id'] = isset( $placement_opts['index'] ) ? $placement_opts['index'] : 1; $defaults['paragraph_id'] = max( 1, (int) $defaults['paragraph_id'] ); // if there are too few items at this level test nesting. $defaults['itemLimit'] = 'p' === $tag_option ? 2 : 1; // trigger such a high item limit that all elements will be considered. if ( ! empty( $plugin_options['content-injection-level-disabled'] ) ) { $defaults['itemLimit'] = 1000; } // Handle tags that are empty by definition or could be empty ("custom" option). if ( in_array( $tag_option, [ 'img', 'iframe', 'custom' ], true ) ) { $defaults['allowEmpty'] = true; } // Merge the options if possible. If there are common keys, we don't merge them to prevent overriding and unexpected behavior. $common_keys = array_intersect_key( $options, $placement_opts ); if ( empty( $common_keys ) ) { $options = array_merge( $options, $placement_opts ); } // allow hooks to change some options. $options = apply_filters( 'advanced-ads-placement-content-injection-options', wp_parse_args( $options, $defaults ), $tag_option ); $wp_charset = get_bloginfo( 'charset' ); // parse document as DOM (fragment - having only a part of an actual post given). $content_to_load = self::get_content_to_load( $content ); if ( ! $content_to_load ) { return $content; } $dom = new DOMDocument( '1.0', $wp_charset ); // may loose some fragments or add autop-like code. $libxml_use_internal_errors = libxml_use_internal_errors( true ); // avoid notices and warnings - html is most likely malformed. $success = $dom->loadHtml( '
' . $content_to_load ); libxml_use_internal_errors( $libxml_use_internal_errors ); if ( true !== $success ) { // -TODO handle cases were dom-parsing failed (at least inform user) return $content; } /** * Handle advanced tags. */ switch ( $tag_option ) { case 'p': // Exclude paragraphs within blockquote tags. $tag = 'p[not(parent::blockquote)]'; break; case 'pwithoutimg': // Convert option name into correct path, exclude paragraphs within blockquote tags. $tag = 'p[not(descendant::img) and not(parent::blockquote)]'; break; case 'img': /* * Handle: 1) "img" tags 2) "image" block 3) "gallery" block 4) "gallery shortcode" 5) "wp_caption" shortcode * Handle the gallery created by the block or the shortcode as one image. * Prevent injection of ads next to images in tables. */ // Default shortcodes, including non-HTML5 versions. $shortcodes = "@class and ( contains(concat(' ', normalize-space(@class), ' '), ' gallery-size') or contains(concat(' ', normalize-space(@class), ' '), ' wp-caption ') )"; $tag = "*[self::img or self::figure or self::div[$shortcodes]] [not(ancestor::table or ancestor::figure or ancestor::div[$shortcodes])]"; break; // Any headline. By default h2, h3, and h4. case 'headlines': $headlines = apply_filters( 'advanced-ads-headlines-for-ad-injection', [ 'h2', 'h3', 'h4' ] ); foreach ( $headlines as &$headline ) { $headline = 'self::' . $headline; } $tag = '*[' . implode( ' or ', $headlines ) . ']'; // /html/body/*[self::h2 or self::h3 or self::h4] break; // Any HTML element that makes sense in the content. case 'anyelement': $exclude = [ 'html', 'body', 'script', 'style', 'tr', 'td', // Inline tags. 'a', 'abbr', 'b', 'bdo', 'br', 'button', 'cite', 'code', 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'option', 'q', 'samp', 'select', 'small', 'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'tt', 'var', ]; $tag = '*[not(self::' . implode( ' or self::', $exclude ) . ')]'; break; case 'custom': // Get the path for the "custom" tag choice, use p as a fallback to prevent it from showing any ads if users left it empty. $tag = ! empty( $placement_opts['xpath'] ) ? stripslashes( $placement_opts['xpath'] ) : 'p'; break; } // select positions. $xpath = new DOMXPath( $dom ); if ( -1 !== $options['itemLimit'] ) { $items = $xpath->query( '/html/body/' . $tag ); if ( $items->length < $options['itemLimit'] ) { $items = $xpath->query( '/html/body/*/' . $tag ); } // try third level. if ( $items->length < $options['itemLimit'] ) { $items = $xpath->query( '/html/body/*/*/' . $tag ); } // try all levels as last resort. if ( $items->length < $options['itemLimit'] ) { $items = $xpath->query( '//' . $tag ); } } else { $items = $xpath->query( $tag ); } // allow to select other elements. $items = apply_filters( 'advanced-ads-placement-content-injection-items', $items, $xpath, $tag_option ); // filter empty tags from items. $whitespaces = json_decode( '"\t\n\r \u00A0"' ); $paragraphs = []; foreach ( $items as $item ) { if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar $paragraphs[] = $item; } } $ancestors_to_limit = self::get_ancestors_to_limit( $xpath ); $paragraphs = self::filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit ); $options['paragraph_count'] = count( $paragraphs ); if ( $options['paragraph_count'] >= $options['paragraph_id'] ) { $offset = $options['paragraph_select_from_bottom'] ? $options['paragraph_count'] - $options['paragraph_id'] : $options['paragraph_id'] - 1; $offsets = apply_filters( 'advanced-ads-placement-content-offsets', [ $offset ], $options, $placement_opts, $xpath, $paragraphs, $dom ); $did_inject = false; foreach ( $offsets as $offset ) { // inject. $node = apply_filters( 'advanced-ads-placement-content-injection-node', $paragraphs[ $offset ], $tag, $options['before'] ); if ( $options['alter_nodes'] ) { // Prevent injection into image caption and gallery. $parent = $node; for ( $i = 0; $i < 4; $i++ ) { $parent = $parent->parentNode; if ( ! $parent instanceof DOMElement ) { break; } if ( preg_match( '/\b(wp-caption|gallery-size)\b/', $parent->getAttribute( 'class' ) ) ) { $node = $parent; break; } } // Make sure that the ad is injected outside the link. if ( 'img' === $tag_option && 'a' === $node->parentNode->tagName ) { if ( $options['before'] ) { $node->parentNode; } else { // Go one level deeper if inserted after to not insert the ad into the link; probably after the paragraph. $node->parentNode->parentNode; } } } $ad_content = (string) get_the_placement( $placement_id, '', $placement_opts ); if ( trim( $ad_content, $whitespaces ) === '' ) { continue; } // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar $ad_content = self::filter_ad_content( $ad_content, $node->tagName, $options ); // convert HTML to XML! $ad_dom = new DOMDocument( '1.0', $wp_charset ); $libxml_use_internal_errors = libxml_use_internal_errors( true ); $ad_dom->loadHtml( '' . $ad_content ); switch ( $options['position'] ) { case 'append': $ref_node = $node; foreach ( $ad_dom->getElementsByTagName( 'body' )->item( 0 )->childNodes as $importedNode ) { $importedNode = $dom->importNode( $importedNode, true ); $ref_node->appendChild( $importedNode ); } break; case 'prepend': $ref_node = $node; foreach ( $ad_dom->getElementsByTagName( 'body' )->item( 0 )->childNodes as $importedNode ) { $importedNode = $dom->importNode( $importedNode, true ); $ref_node->insertBefore( $importedNode, $ref_node->firstChild ); } break; case 'before': $ref_node = $node; foreach ( $ad_dom->getElementsByTagName( 'body' )->item( 0 )->childNodes as $importedNode ) { $importedNode = $dom->importNode( $importedNode, true ); $ref_node->parentNode->insertBefore( $importedNode, $ref_node ); } break; case 'after': default: // append before next node or as last child to body. $ref_node = $node->nextSibling; if ( isset( $ref_node ) ) { foreach ( $ad_dom->getElementsByTagName( 'body' )->item( 0 )->childNodes as $importedNode ) { $importedNode = $dom->importNode( $importedNode, true ); $ref_node->parentNode->insertBefore( $importedNode, $ref_node ); } } else { // append to body; -TODO using here that we only select direct children of the body tag. foreach ( $ad_dom->getElementsByTagName( 'body' )->item( 0 )->childNodes as $importedNode ) { $importedNode = $dom->importNode( $importedNode, true ); $node->parentNode->appendChild( $importedNode ); } } } libxml_use_internal_errors( $libxml_use_internal_errors ); $did_inject = true; } if ( ! $did_inject ) { return $content; } $content_orig = $content; // convert to text-representation. $content = $dom->saveHTML(); $content = self::prepare_output( $content, $content_orig ); /** * Show a warning to ad admins in the Ad Health bar in the frontend, when * * * the level limitation was not disabled * * could not inject one ad (as by use of `elseif` here) * * but there are enough elements on the site, but just in sub-containers */ } elseif ( Conditional::user_can( 'advanced_ads_manage_options' ) && -1 !== $options['itemLimit'] && empty( $plugin_options['content-injection-level-disabled'] ) ) { // Check if there are more elements without limitation. $all_items = $xpath->query( '//' . $tag ); $paragraphs = []; foreach ( $all_items as $item ) { if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar $paragraphs[] = $item; } } $paragraphs = self::filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit ); if ( $options['paragraph_id'] <= count( $paragraphs ) ) { // Add a warning to ad health. add_filter( 'advanced-ads-ad-health-nodes', [ 'Advanced_Ads_In_Content_Injector', 'add_ad_health_node' ] ); } } // phpcs:enable return $content; } /** * Get content to load. * * @param string $content Original content. * * @return string $content Content to load. */ private static function get_content_to_load( $content ) { // Prevent removing closing tags in scripts. $content_to_load = preg_replace( '/