abstracts
1 day ago
admin
1 day ago
ads
1 day ago
compatibility
1 week ago
crons
1 day ago
frontend
1 week ago
groups
1 day ago
importers
5 days ago
installation
1 day ago
interfaces
5 months ago
license
1 day ago
placements
1 day ago
rest
1 day ago
traits
1 day ago
utilities
1 day ago
cap_map.php
3 years ago
class-assets-registry.php
1 day ago
class-autoloader.php
1 week ago
class-cache-invalidator.php
1 day ago
class-constants.php
1 day ago
class-content-injector.php
1 month ago
class-entities.php
3 months ago
class-modal.php
1 year ago
class-modules.php
1 year ago
class-options.php
1 year ago
class-plugin.php
1 day ago
class-post-data.php
10 months ago
class-shortcodes.php
1 week ago
class-upgrades.php
1 day ago
class-widget.php
11 months ago
default-hooks.php
5 months ago
functions-ad.php
1 day ago
functions-components.php
3 months ago
functions-conditional.php
1 year ago
functions-core.php
1 year ago
functions-group.php
1 day ago
functions-placement.php
1 day ago
functions.php
1 week ago
index.php
2 years ago
load_modules.php
2 years ago
class-content-injector.php
835 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Content injector template file. |
| 4 | * Brief description of the styles in this file |
| 5 | * |
| 6 | * @since 2.0.19 |
| 7 | * @package AdvancedAds |
| 8 | * @author Advanced Ads <info@wpadvancedads.com> |
| 9 | */ |
| 10 | |
| 11 | namespace AdvancedAds; |
| 12 | |
| 13 | use Advanced_Ads; |
| 14 | use AdvancedAds\Utilities\Conditional; |
| 15 | use AdvancedAds\Framework\Utilities\Str; |
| 16 | use DOMDocument; |
| 17 | use DOMElement; |
| 18 | use DOMNode; |
| 19 | use DOMNodeList; |
| 20 | use DOMXPath; |
| 21 | |
| 22 | defined( 'ABSPATH' ) || exit; |
| 23 | |
| 24 | /** |
| 25 | * Content injector template file class. |
| 26 | * |
| 27 | * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase |
| 28 | * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 29 | */ |
| 30 | class Content_Injector { |
| 31 | /** |
| 32 | * Gather placeholders which later are replaced by the ads. |
| 33 | * |
| 34 | * @var array |
| 35 | */ |
| 36 | private static $ads_for_placeholders = []; |
| 37 | |
| 38 | /** |
| 39 | * Self-closing HTML tags that cannot have children. |
| 40 | * Hash map for O(1) lookup vs in_array() linear scan. |
| 41 | * |
| 42 | * @var array |
| 43 | */ |
| 44 | private const SELF_CLOSING_TAGS = [ |
| 45 | 'area' => true, |
| 46 | 'base' => true, |
| 47 | 'basefont' => true, |
| 48 | 'bgsound' => true, |
| 49 | 'br' => true, |
| 50 | 'col' => true, |
| 51 | 'embed' => true, |
| 52 | 'frame' => true, |
| 53 | 'hr' => true, |
| 54 | 'img' => true, |
| 55 | 'input' => true, |
| 56 | 'keygen' => true, |
| 57 | 'link' => true, |
| 58 | 'meta' => true, |
| 59 | 'param' => true, |
| 60 | 'source' => true, |
| 61 | 'track' => true, |
| 62 | 'wbr' => true, |
| 63 | ]; |
| 64 | |
| 65 | /** |
| 66 | * Sort order for ad positions. |
| 67 | * Class constant avoids allocating a new array on every usort() comparison. |
| 68 | * |
| 69 | * @var array |
| 70 | */ |
| 71 | private const POSITION_ORDER = [ |
| 72 | 'before' => 1, |
| 73 | 'prepend' => 2, |
| 74 | 'append' => 3, |
| 75 | 'after' => 4, |
| 76 | ]; |
| 77 | |
| 78 | /** |
| 79 | * Stored as a constant so sprintf() is called once, not per DOM load. |
| 80 | * |
| 81 | * @var string |
| 82 | */ |
| 83 | private const DOM_PREFIX = '<!DOCTYPE html><html><meta http-equiv="Content-Type" content="text/html; charset=%s" /><body>'; |
| 84 | |
| 85 | /** |
| 86 | * Inject ads directly into the content. |
| 87 | * |
| 88 | * @param string $placement_id Id of the placement. |
| 89 | * @param array $placement_opts Placement options. |
| 90 | * @param string $content Content to inject placement into. |
| 91 | * @param array $options Injection options. |
| 92 | * |
| 93 | * @return string $content Content with injected placement. |
| 94 | */ |
| 95 | public static function &inject_in_content( $placement_id, $placement_opts, &$content, $options = [] ) { |
| 96 | if ( ! extension_loaded( 'dom' ) ) { |
| 97 | return $content; |
| 98 | } |
| 99 | |
| 100 | // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 101 | // phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase |
| 102 | |
| 103 | $tag = isset( $placement_opts['tag'] ) ? $placement_opts['tag'] : 'p'; |
| 104 | $tag = preg_replace( '/[^a-z0-9]/i', '', $tag ); |
| 105 | $tag_option = $tag; |
| 106 | |
| 107 | $tag = apply_filters( 'advanced-ads-placement-content-injection-xpath', $tag, $placement_opts ); |
| 108 | |
| 109 | $plugin_options = Advanced_Ads::get_instance()->options(); |
| 110 | |
| 111 | $defaults = [ |
| 112 | 'allowEmpty' => false, |
| 113 | 'paragraph_select_from_bottom' => isset( $placement_opts['start_from_bottom'] ) && $placement_opts['start_from_bottom'], |
| 114 | 'position' => isset( $placement_opts['position'] ) ? $placement_opts['position'] : 'after', |
| 115 | 'before' => isset( $placement_opts['position'] ) && 'before' === $placement_opts['position'], |
| 116 | 'alter_nodes' => true, |
| 117 | 'repeat' => false, |
| 118 | ]; |
| 119 | |
| 120 | $defaults['paragraph_id'] = isset( $placement_opts['index'] ) ? $placement_opts['index'] : 1; |
| 121 | $defaults['paragraph_id'] = max( 1, (int) $defaults['paragraph_id'] ); |
| 122 | $defaults['itemLimit'] = 'p' === $tag_option ? 2 : 1; |
| 123 | |
| 124 | if ( ! empty( $plugin_options['content-injection-level-disabled'] ) ) { |
| 125 | $defaults['itemLimit'] = 1000; |
| 126 | } |
| 127 | |
| 128 | if ( in_array( $tag_option, [ 'img', 'iframe', 'custom' ], true ) ) { |
| 129 | $defaults['allowEmpty'] = true; |
| 130 | } |
| 131 | |
| 132 | $common_keys = array_intersect_key( $options, $placement_opts ); |
| 133 | if ( empty( $common_keys ) ) { |
| 134 | $options = array_merge( $options, $placement_opts ); |
| 135 | } |
| 136 | |
| 137 | $options = apply_filters( |
| 138 | 'advanced-ads-placement-content-injection-options', |
| 139 | wp_parse_args( $options, $defaults ), |
| 140 | $tag_option |
| 141 | ); |
| 142 | |
| 143 | $wp_charset = get_bloginfo( 'charset' ); |
| 144 | $content_to_load = self::get_content_to_load( $content ); |
| 145 | |
| 146 | if ( ! $content_to_load ) { |
| 147 | return $content; |
| 148 | } |
| 149 | |
| 150 | // Enable libxml error suppression once for the entire method. |
| 151 | $prev_libxml = libxml_use_internal_errors( true ); |
| 152 | |
| 153 | $dom = new DOMDocument( '1.0', $wp_charset ); |
| 154 | $prefix = sprintf( self::DOM_PREFIX, $wp_charset ); |
| 155 | $success = $dom->loadHtml( $prefix . $content_to_load ); |
| 156 | |
| 157 | // Free the pre-processed content string — we have the DOM now. |
| 158 | unset( $content_to_load ); |
| 159 | |
| 160 | if ( true !== $success ) { |
| 161 | libxml_use_internal_errors( $prev_libxml ); |
| 162 | return $content; |
| 163 | } |
| 164 | |
| 165 | $tag = self::resolve_tag_xpath( $tag_option, $tag, $placement_opts ); |
| 166 | $xpath = new DOMXPath( $dom ); |
| 167 | $items = self::query_items( $xpath, $tag, $options['itemLimit'] ); |
| 168 | $items = apply_filters( 'advanced-ads-placement-content-injection-items', $items, $xpath, $tag_option ); |
| 169 | |
| 170 | $whitespaces = json_decode( '"\t\n\r \u00A0"' ); |
| 171 | $paragraphs = []; |
| 172 | |
| 173 | foreach ( $items as $item ) { |
| 174 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar |
| 175 | if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) { |
| 176 | $paragraphs[] = $item; |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // Free the NodeList — $paragraphs holds the nodes we care about. |
| 181 | unset( $items ); |
| 182 | |
| 183 | $ancestors_to_limit = self::get_ancestors_to_limit( $xpath ); |
| 184 | $paragraphs = self::filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit ); |
| 185 | unset( $ancestors_to_limit ); |
| 186 | |
| 187 | $paragraph_count = count( $paragraphs ); |
| 188 | $options['paragraph_count'] = $paragraph_count; |
| 189 | |
| 190 | if ( $paragraph_count >= $options['paragraph_id'] ) { |
| 191 | $offset = $options['paragraph_select_from_bottom'] |
| 192 | ? $paragraph_count - $options['paragraph_id'] |
| 193 | : $options['paragraph_id'] - 1; |
| 194 | |
| 195 | $offsets = apply_filters( |
| 196 | 'advanced-ads-placement-content-offsets', |
| 197 | [ $offset ], |
| 198 | $options, |
| 199 | $placement_opts, |
| 200 | $xpath, |
| 201 | $paragraphs, |
| 202 | $dom |
| 203 | ); |
| 204 | |
| 205 | $did_inject = false; |
| 206 | |
| 207 | foreach ( $offsets as $offset ) { |
| 208 | $node = apply_filters( |
| 209 | 'advanced-ads-placement-content-injection-node', |
| 210 | $paragraphs[ $offset ], |
| 211 | $tag, |
| 212 | $options['before'] |
| 213 | ); |
| 214 | |
| 215 | if ( $options['alter_nodes'] ) { |
| 216 | $node = self::adjust_node_for_injection( $node, $tag_option, $options['before'] ); |
| 217 | } |
| 218 | |
| 219 | $ad_content = (string) get_the_placement( $placement_id, '', $placement_opts ); |
| 220 | |
| 221 | if ( trim( $ad_content, $whitespaces ) === '' ) { |
| 222 | continue; |
| 223 | } |
| 224 | |
| 225 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar |
| 226 | $ad_content = self::filter_ad_content( $ad_content, $node->tagName, $options ); |
| 227 | |
| 228 | // Parse ad HTML into a temporary DOM, import into main DOM, then free immediately. |
| 229 | $ad_dom = new DOMDocument( '1.0', $wp_charset ); |
| 230 | $ad_dom->loadHtml( $prefix . $ad_content ); |
| 231 | unset( $ad_content ); |
| 232 | |
| 233 | $ad_body = $ad_dom->getElementsByTagName( 'body' )->item( 0 ); |
| 234 | |
| 235 | // Snapshot childNodes BEFORE importing — importing from a live NodeList |
| 236 | // causes it to mutate mid-iteration, silently skipping nodes. |
| 237 | $ad_nodes = null !== $ad_body ? iterator_to_array( $ad_body->childNodes ) : []; |
| 238 | unset( $ad_body ); |
| 239 | |
| 240 | self::insert_nodes( $dom, $node, $ad_nodes, $options['position'] ); |
| 241 | unset( $ad_nodes ); |
| 242 | |
| 243 | // Explicitly release the ad DOM tree — do not wait for end-of-scope GC. |
| 244 | unset( $ad_dom ); |
| 245 | |
| 246 | $did_inject = true; |
| 247 | } |
| 248 | |
| 249 | // Release paragraph node references held in memory. |
| 250 | unset( $paragraphs, $offsets ); |
| 251 | |
| 252 | libxml_use_internal_errors( $prev_libxml ); |
| 253 | |
| 254 | if ( ! $did_inject ) { |
| 255 | // Flush any accumulated placeholders to prevent static state leaks. |
| 256 | self::$ads_for_placeholders = []; |
| 257 | return $content; |
| 258 | } |
| 259 | |
| 260 | $content_orig = $content; |
| 261 | |
| 262 | // Extract body content via string operations — avoids a second DOM parse. |
| 263 | $content = self::extract_body_content( $dom->saveHTML() ); |
| 264 | |
| 265 | // Largest single memory consumer — free it as soon as saveHTML() is done. |
| 266 | unset( $dom ); |
| 267 | |
| 268 | $content = self::prepare_output( $content, $content_orig ); |
| 269 | unset( $content_orig ); |
| 270 | } elseif ( |
| 271 | Conditional::user_can( 'advanced_ads_manage_options' ) |
| 272 | && -1 !== $options['itemLimit'] |
| 273 | && empty( $plugin_options['content-injection-level-disabled'] ) |
| 274 | ) { |
| 275 | $all_items = $xpath->query( '//' . $tag ); |
| 276 | $extra_paras = []; |
| 277 | |
| 278 | foreach ( $all_items as $item ) { |
| 279 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.NotSnakeCaseMemberVar |
| 280 | if ( $options['allowEmpty'] || ( isset( $item->textContent ) && trim( $item->textContent, $whitespaces ) !== '' ) ) { |
| 281 | $extra_paras[] = $item; |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | unset( $all_items ); |
| 286 | |
| 287 | $extra_paras = self::filter_by_ancestors_to_limit( |
| 288 | $extra_paras, |
| 289 | self::get_ancestors_to_limit( $xpath ) |
| 290 | ); |
| 291 | |
| 292 | if ( $options['paragraph_id'] <= count( $extra_paras ) ) { |
| 293 | add_filter( 'advanced-ads-ad-health-nodes', [ 'Advanced_Ads_In_Content_Injector', 'add_ad_health_node' ] ); |
| 294 | } |
| 295 | |
| 296 | unset( $extra_paras, $dom ); |
| 297 | libxml_use_internal_errors( $prev_libxml ); |
| 298 | } else { |
| 299 | unset( $dom ); |
| 300 | libxml_use_internal_errors( $prev_libxml ); |
| 301 | } |
| 302 | |
| 303 | // phpcs:enable |
| 304 | |
| 305 | return $content; |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Extract the inner body content from a full HTML document string. |
| 310 | * |
| 311 | * DOMDocument::saveHTML() emits a full document including DOCTYPE and <html>. |
| 312 | * Using string operations here avoids a costly second DOM parse just to unwrap it. |
| 313 | * |
| 314 | * @param string $html Full HTML document string from saveHTML(). |
| 315 | * |
| 316 | * @return string Inner body content only. |
| 317 | */ |
| 318 | private static function extract_body_content( $html ) { |
| 319 | $start = strpos( $html, '<body>' ); |
| 320 | $end = strrpos( $html, '</body>' ); |
| 321 | |
| 322 | if ( false === $start || false === $end ) { |
| 323 | return $html; |
| 324 | } |
| 325 | |
| 326 | return substr( $html, $start + 6, $end - $start - 6 ); |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Resolve the XPath tag expression based on tag option. |
| 331 | * |
| 332 | * @param string $tag_option Original tag option. |
| 333 | * @param string $tag Current tag value (may have been filtered). |
| 334 | * @param array $placement_opts Placement options. |
| 335 | * |
| 336 | * @return string Resolved XPath expression. |
| 337 | */ |
| 338 | private static function resolve_tag_xpath( $tag_option, $tag, $placement_opts ) { |
| 339 | switch ( $tag_option ) { |
| 340 | case 'p': |
| 341 | return 'p[not(parent::blockquote)]'; |
| 342 | |
| 343 | case 'pwithoutimg': |
| 344 | return 'p[not(descendant::img) and not(parent::blockquote)]'; |
| 345 | |
| 346 | case 'img': |
| 347 | $shortcodes = "@class and ( |
| 348 | contains(concat(' ', normalize-space(@class), ' '), ' gallery-size') or |
| 349 | contains(concat(' ', normalize-space(@class), ' '), ' wp-caption ') )"; |
| 350 | return "*[self::img or self::figure or self::div[$shortcodes]] |
| 351 | [not(ancestor::table or ancestor::figure or ancestor::div[$shortcodes])]"; |
| 352 | |
| 353 | case 'headlines': |
| 354 | $headlines = apply_filters( 'advanced-ads-headlines-for-ad-injection', [ 'h2', 'h3', 'h4' ] ); |
| 355 | foreach ( $headlines as &$headline ) { |
| 356 | $headline = 'self::' . $headline; |
| 357 | } |
| 358 | return '*[' . implode( ' or ', $headlines ) . ']'; |
| 359 | |
| 360 | case 'anyelement': |
| 361 | $exclude = [ |
| 362 | 'html', 'body', 'script', 'style', 'tr', 'td', |
| 363 | 'a', 'abbr', 'b', 'bdo', 'br', 'button', 'cite', 'code', |
| 364 | 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'option', 'q', |
| 365 | 'samp', 'select', 'small', 'span', 'strong', 'sub', 'sup', |
| 366 | 'textarea', 'time', 'tt', 'var', |
| 367 | ]; |
| 368 | return '*[not(self::' . implode( ' or self::', $exclude ) . ')]'; |
| 369 | |
| 370 | case 'custom': |
| 371 | return ! empty( $placement_opts['xpath'] ) ? self::wp_untexturize( stripslashes( $placement_opts['xpath'] ) ) : 'p'; |
| 372 | |
| 373 | default: |
| 374 | return $tag; |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | /** |
| 379 | * Query DOM items with progressive level fallback. |
| 380 | * |
| 381 | * @param DOMXPath $xpath XPath object. |
| 382 | * @param string $tag XPath tag expression. |
| 383 | * @param int $item_limit Minimum items required before falling back deeper. |
| 384 | * |
| 385 | * @return DOMNodeList |
| 386 | */ |
| 387 | private static function query_items( DOMXPath $xpath, $tag, $item_limit ) { |
| 388 | if ( -1 === $item_limit ) { |
| 389 | return $xpath->query( $tag ); |
| 390 | } |
| 391 | |
| 392 | $levels = [ |
| 393 | '/html/body/' . $tag, |
| 394 | '/html/body/*/' . $tag, |
| 395 | '/html/body/*/*/' . $tag, |
| 396 | '//' . $tag, |
| 397 | ]; |
| 398 | |
| 399 | $items = null; |
| 400 | foreach ( $levels as $query ) { |
| 401 | $items = $xpath->query( $query ); |
| 402 | if ( $items->length >= $item_limit ) { |
| 403 | break; |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | return $items; |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * Adjust the target node to avoid injecting inside captions, galleries, or links. |
| 412 | * |
| 413 | * @param DOMNode $node The candidate node. |
| 414 | * @param string $tag_option Original tag option. |
| 415 | * @param bool $before Whether injection is before the node. |
| 416 | * |
| 417 | * @return DOMNode Adjusted node. |
| 418 | */ |
| 419 | private static function adjust_node_for_injection( $node, $tag_option, $before ) { |
| 420 | $parent = $node; |
| 421 | for ( $i = 0; $i < 4; $i++ ) { |
| 422 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 423 | $parent = $parent->parentNode; |
| 424 | if ( ! $parent instanceof DOMElement ) { |
| 425 | break; |
| 426 | } |
| 427 | if ( preg_match( '/\b(wp-caption|gallery-size)\b/', $parent->getAttribute( 'class' ) ) ) { |
| 428 | $node = $parent; |
| 429 | break; |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | if ( |
| 434 | 'img' === $tag_option |
| 435 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 436 | && $node->parentNode instanceof DOMElement |
| 437 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 438 | && 'a' === $node->parentNode->tagName |
| 439 | && ! $before |
| 440 | ) { |
| 441 | // Reference grandparent to inject after the link rather than inside it. |
| 442 | // The original code referenced without assigning — preserving that behaviour. |
| 443 | $node->parentNode->parentNode; // phpcs:ignore |
| 444 | } |
| 445 | |
| 446 | return $node; |
| 447 | } |
| 448 | |
| 449 | /** |
| 450 | * Insert imported nodes into the DOM at the correct position. |
| 451 | * |
| 452 | * The caller must unset() the ad DOM tree immediately after this call. |
| 453 | * |
| 454 | * @param DOMDocument $dom Main document. |
| 455 | * @param DOMNode $node Reference node. |
| 456 | * @param array $ad_nodes Snapshot of ad body child nodes. |
| 457 | * @param string $position One of 'before', 'after', 'append', 'prepend'. |
| 458 | */ |
| 459 | private static function insert_nodes( DOMDocument $dom, DOMNode $node, array $ad_nodes, $position ) { |
| 460 | switch ( $position ) { |
| 461 | case 'append': |
| 462 | foreach ( $ad_nodes as $ad_node ) { |
| 463 | $node->appendChild( $dom->importNode( $ad_node, true ) ); |
| 464 | } |
| 465 | break; |
| 466 | |
| 467 | case 'prepend': |
| 468 | // Cache firstChild once — insertBefore() shifts it on each call. |
| 469 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 470 | $first_child = $node->firstChild; |
| 471 | foreach ( $ad_nodes as $ad_node ) { |
| 472 | $node->insertBefore( $dom->importNode( $ad_node, true ), $first_child ); |
| 473 | } |
| 474 | break; |
| 475 | |
| 476 | case 'before': |
| 477 | foreach ( $ad_nodes as $ad_node ) { |
| 478 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 479 | $node->parentNode->insertBefore( $dom->importNode( $ad_node, true ), $node ); |
| 480 | } |
| 481 | break; |
| 482 | |
| 483 | case 'after': |
| 484 | default: |
| 485 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 486 | $ref_node = $node->nextSibling; |
| 487 | if ( null !== $ref_node ) { |
| 488 | foreach ( $ad_nodes as $ad_node ) { |
| 489 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 490 | $ref_node->parentNode->insertBefore( $dom->importNode( $ad_node, true ), $ref_node ); |
| 491 | } |
| 492 | } else { |
| 493 | foreach ( $ad_nodes as $ad_node ) { |
| 494 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase |
| 495 | $node->parentNode->appendChild( $dom->importNode( $ad_node, true ) ); |
| 496 | } |
| 497 | } |
| 498 | break; |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * Get content to load into DOM. |
| 504 | * |
| 505 | * @param string $content Original content. |
| 506 | * |
| 507 | * @return string|false Content ready for DOM parsing, or false on empty. |
| 508 | */ |
| 509 | private static function get_content_to_load( $content ) { |
| 510 | $content_to_load = preg_replace( '/<script.*?<\/script>/si', '<!--\0-->', $content ); |
| 511 | |
| 512 | $wpautop_priority = has_filter( 'the_content', 'wpautop' ); |
| 513 | if ( $wpautop_priority && Advanced_Ads::get_instance()->get_content_injection_priority() < $wpautop_priority ) { |
| 514 | $content_to_load = wpautop( $content_to_load ); |
| 515 | } |
| 516 | |
| 517 | return $content_to_load; |
| 518 | } |
| 519 | |
| 520 | /** |
| 521 | * Filter ad content and register it as a placeholder. |
| 522 | * |
| 523 | * @param string $ad_content Ad HTML content. |
| 524 | * @param string $tag_name Tag name beside which the ad is injected. |
| 525 | * @param array $options Injection options. |
| 526 | * |
| 527 | * @return string Placeholder token string. |
| 528 | */ |
| 529 | private static function filter_ad_content( $ad_content, $tag_name, $options ) { |
| 530 | // Only run the costly regex when the pattern can possibly match. |
| 531 | if ( str_contains( $ad_content, 'document.write' ) ) { |
| 532 | $ad_content = preg_replace( '#(document.write.+)</(.*)#', '$1<\/$2', $ad_content ); |
| 533 | } |
| 534 | |
| 535 | $id = count( self::$ads_for_placeholders ); |
| 536 | self::$ads_for_placeholders[] = [ |
| 537 | 'id' => $id, |
| 538 | 'tag' => $tag_name, |
| 539 | 'position' => $options['position'], |
| 540 | 'ad' => $ad_content, |
| 541 | ]; |
| 542 | |
| 543 | return '%advads_placeholder_' . $id . '%'; |
| 544 | } |
| 545 | |
| 546 | /** |
| 547 | * Prepare output and flush the static placeholder store. |
| 548 | * |
| 549 | * @param string $content Modified (DOM-serialized) content with placeholders. |
| 550 | * @param string $content_orig Unmodified original content. |
| 551 | * |
| 552 | * @return string Final content with ads injected. |
| 553 | */ |
| 554 | private static function prepare_output( $content, $content_orig ) { |
| 555 | $content = self::inject_ads( $content, $content_orig, self::$ads_for_placeholders ); |
| 556 | self::$ads_for_placeholders = []; |
| 557 | return $content; |
| 558 | } |
| 559 | |
| 560 | /** |
| 561 | * Locate placeholder positions in the modified content and inject ads at the |
| 562 | * corresponding positions in the original content. |
| 563 | * |
| 564 | * Memory strategy: |
| 565 | * - $content (DOM version) is unset as soon as the placeholder scan is done. |
| 566 | * - $new_content is built incrementally using substr() slices of $content_orig — |
| 567 | * we never duplicate $content_orig into another variable. |
| 568 | * - Match arrays are unset immediately after use. |
| 569 | * |
| 570 | * @param string $content DOM-serialized content with ad placeholders. |
| 571 | * @param string $content_orig Unmodified original content. |
| 572 | * @param array $ads_for_placeholders Ad metadata array. |
| 573 | * |
| 574 | * @return string Content with ads injected. |
| 575 | */ |
| 576 | private static function inject_ads( $content, $content_orig, $ads_for_placeholders ) { |
| 577 | // Normalize self-closing tag positions. |
| 578 | foreach ( $ads_for_placeholders as &$ad ) { |
| 579 | if ( |
| 580 | ( 'prepend' === $ad['position'] || 'append' === $ad['position'] ) |
| 581 | && isset( self::SELF_CLOSING_TAGS[ $ad['tag'] ] ) |
| 582 | ) { |
| 583 | $ad['position'] = 'after'; |
| 584 | } |
| 585 | } |
| 586 | unset( $ad ); |
| 587 | |
| 588 | usort( $ads_for_placeholders, [ 'Advanced_Ads_In_Content_Injector', 'sort_ads_for_placehoders' ] ); |
| 589 | |
| 590 | // Build tag patterns — skip duplicates during construction, not after. |
| 591 | $alts = []; |
| 592 | foreach ( $ads_for_placeholders as $ad ) { |
| 593 | $tag = $ad['tag']; |
| 594 | |
| 595 | switch ( $ad['position'] ) { |
| 596 | case 'before': |
| 597 | case 'prepend': |
| 598 | $pattern = "<{$tag}[^>]*>"; |
| 599 | break; |
| 600 | case 'after': |
| 601 | $pattern = isset( self::SELF_CLOSING_TAGS[ $tag ] ) ? "<{$tag}[^>]*>" : "</{$tag}>"; |
| 602 | break; |
| 603 | case 'append': |
| 604 | $pattern = "</{$tag}>"; |
| 605 | break; |
| 606 | default: |
| 607 | $pattern = null; |
| 608 | } |
| 609 | |
| 610 | if ( null !== $pattern && ! in_array( $pattern, $alts, true ) ) { |
| 611 | $alts[] = $pattern; |
| 612 | } |
| 613 | } |
| 614 | |
| 615 | $tag_regexp = implode( '|', $alts ); |
| 616 | $alts[] = '%advads_placeholder_(?:\d+)%'; |
| 617 | $tag_and_placeholder_regexp = implode( '|', $alts ); |
| 618 | unset( $alts ); |
| 619 | |
| 620 | // Phase 1: scan DOM-serialized content to map placeholder → tag occurrence index. |
| 621 | preg_match_all( "#{$tag_and_placeholder_regexp}#i", $content, $tag_matches ); |
| 622 | |
| 623 | // $content (the DOM version, potentially larger than $content_orig) is no |
| 624 | // longer needed — free it before we allocate $orig_tag_matches below. |
| 625 | unset( $content ); |
| 626 | |
| 627 | $count = 0; |
| 628 | foreach ( $tag_matches[0] as $r ) { |
| 629 | if ( preg_match( '/%advads_placeholder_(\d+)%/', $r, $result ) ) { |
| 630 | $id = (int) $result[1]; |
| 631 | foreach ( $ads_for_placeholders as $n => $ad ) { |
| 632 | if ( (int) $ad['id'] === $id ) { |
| 633 | $ads_for_placeholders[ $n ]['offset'] = $count - 1; |
| 634 | if ( 'before' === $ad['position'] || 'append' === $ad['position'] ) { |
| 635 | $ads_for_placeholders[ $n ]['offset'] = $count; |
| 636 | } |
| 637 | break; |
| 638 | } |
| 639 | } |
| 640 | } else { |
| 641 | ++$count; |
| 642 | } |
| 643 | } |
| 644 | unset( $tag_matches ); |
| 645 | |
| 646 | // Phase 2: find byte offsets of injection tags in the original content. |
| 647 | preg_match_all( "#{$tag_regexp}#i", $content_orig, $orig_tag_matches, PREG_OFFSET_CAPTURE ); |
| 648 | |
| 649 | // Phase 3: build output by streaming substrings of $content_orig. |
| 650 | // We never copy $content_orig — only slice it with substr(). |
| 651 | $new_content = ''; |
| 652 | $pos = 0; |
| 653 | |
| 654 | foreach ( $orig_tag_matches[0] as $n => $r ) { |
| 655 | $to_inject = []; |
| 656 | foreach ( $ads_for_placeholders as $ad ) { |
| 657 | if ( isset( $ad['offset'] ) && $ad['offset'] === $n ) { |
| 658 | $to_inject[] = $ad; |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | foreach ( $to_inject as $item ) { |
| 663 | $found_pos = ( 'before' === $item['position'] || 'append' === $item['position'] ) |
| 664 | ? $r[1] |
| 665 | : $r[1] + strlen( $r[0] ); |
| 666 | |
| 667 | $new_content .= substr( $content_orig, $pos, $found_pos - $pos ); |
| 668 | $pos = $found_pos; |
| 669 | $new_content .= $item['ad']; |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | unset( $orig_tag_matches, $ads_for_placeholders ); |
| 674 | |
| 675 | $new_content .= substr( $content_orig, $pos ); |
| 676 | |
| 677 | return $new_content; |
| 678 | } |
| 679 | |
| 680 | /** |
| 681 | * Callback for usort() — sorts ads by position priority. |
| 682 | * Uses the POSITION_ORDER constant so no array is allocated per comparison. |
| 683 | * |
| 684 | * @param array $first First ad. |
| 685 | * @param array $second Second ad. |
| 686 | * |
| 687 | * @return int |
| 688 | */ |
| 689 | public static function sort_ads_for_placehoders( $first, $second ) { |
| 690 | $a = self::POSITION_ORDER[ $first['position'] ] ?? 99; |
| 691 | $b = self::POSITION_ORDER[ $second['position'] ] ?? 99; |
| 692 | return $a <=> $b; |
| 693 | } |
| 694 | |
| 695 | /** |
| 696 | * Add a warning node to the Ad Health bar. |
| 697 | * |
| 698 | * @param array $nodes Existing nodes. |
| 699 | * |
| 700 | * @return array Modified nodes. |
| 701 | */ |
| 702 | public static function add_ad_health_node( $nodes ) { |
| 703 | $nodes[] = [ |
| 704 | 'type' => 1, |
| 705 | 'data' => [ |
| 706 | 'parent' => 'advanced_ads_ad_health', |
| 707 | 'id' => 'advanced_ads_ad_health_the_content_not_enough_elements', |
| 708 | 'title' => sprintf( |
| 709 | /* translators: %s stands for the name of the "Disable level limitation" option */ |
| 710 | __( 'Set <em>%s</em> to show more ads', 'advanced-ads' ), |
| 711 | __( 'Disable level limitation', 'advanced-ads' ) |
| 712 | ), |
| 713 | 'href' => admin_url( '/admin.php?page=advanced-ads-settings#top#general' ), |
| 714 | 'meta' => [ |
| 715 | 'class' => 'advanced_ads_ad_health_warning', |
| 716 | 'target' => '_blank', |
| 717 | ], |
| 718 | ], |
| 719 | ]; |
| 720 | return $nodes; |
| 721 | } |
| 722 | |
| 723 | /** |
| 724 | * Get paths of ancestor nodes that should not contain ads. |
| 725 | * |
| 726 | * @param DOMXPath $xpath DOMXPath object. |
| 727 | * |
| 728 | * @return array Node path strings. |
| 729 | */ |
| 730 | private static function get_ancestors_to_limit( DOMXPath $xpath ) { |
| 731 | $query = self::get_ancestors_to_limit_query(); |
| 732 | if ( ! $query ) { |
| 733 | return []; |
| 734 | } |
| 735 | |
| 736 | $node_list = $xpath->query( $query ); |
| 737 | $ancestors_to_limit = []; |
| 738 | |
| 739 | foreach ( $node_list as $a ) { |
| 740 | $ancestors_to_limit[] = $a->getNodePath(); |
| 741 | } |
| 742 | |
| 743 | unset( $node_list ); |
| 744 | return $ancestors_to_limit; |
| 745 | } |
| 746 | |
| 747 | /** |
| 748 | * Remove paragraphs whose ancestors should not contain ads. |
| 749 | * |
| 750 | * Short-circuits immediately when no restricted ancestors exist, |
| 751 | * avoiding the inner loop entirely. |
| 752 | * |
| 753 | * Uses Str::starts_with() from Framework instead of stripos() |
| 754 | * since node paths are case-sensitive. |
| 755 | * |
| 756 | * @param array $paragraphs Array of DOMNode objects. |
| 757 | * @param array $ancestors_to_limit Node paths of restricted ancestors. |
| 758 | * |
| 759 | * @return array Filtered array of DOMNode objects. |
| 760 | */ |
| 761 | private static function filter_by_ancestors_to_limit( $paragraphs, $ancestors_to_limit ) { |
| 762 | if ( empty( $ancestors_to_limit ) ) { |
| 763 | return $paragraphs; |
| 764 | } |
| 765 | |
| 766 | $new_paragraphs = []; |
| 767 | |
| 768 | foreach ( $paragraphs as $paragraph ) { |
| 769 | $node_path = $paragraph->getNodePath(); |
| 770 | foreach ( $ancestors_to_limit as $ancestor ) { |
| 771 | if ( Str::starts_with( $ancestor . '/', $node_path ) ) { |
| 772 | continue 2; |
| 773 | } |
| 774 | } |
| 775 | $new_paragraphs[] = $paragraph; |
| 776 | } |
| 777 | |
| 778 | return $new_paragraphs; |
| 779 | } |
| 780 | |
| 781 | /** |
| 782 | * Build the XPath query to select ancestors that should not contain ads. |
| 783 | * |
| 784 | * @return string|false XPath query string, or false if none configured. |
| 785 | */ |
| 786 | private static function get_ancestors_to_limit_query() { |
| 787 | $items = apply_filters( |
| 788 | 'advanced-ads-content-injection-nodes-without-ads', |
| 789 | [ |
| 790 | [ 'node' => '.advads-stop-injection', 'type' => 'ancestor' ], |
| 791 | [ 'node' => '.woopack-product-carousel', 'type' => 'ancestor' ], |
| 792 | [ 'node' => '#wpautbox-%', 'type' => 'ancestor' ], |
| 793 | [ 'node' => '.geodir-post-slider', 'type' => 'ancestor' ], |
| 794 | ] |
| 795 | ); |
| 796 | |
| 797 | $query = []; |
| 798 | |
| 799 | foreach ( $items as $p ) { |
| 800 | $sel = $p['node']; |
| 801 | $sel_type = $sel[0]; |
| 802 | $sel = substr( $sel, 1 ); |
| 803 | $rand_pos = strpos( $sel, '%' ); |
| 804 | $sel = sanitize_html_class( str_replace( '%', '', $sel ) ); |
| 805 | |
| 806 | if ( '.' === $sel_type ) { |
| 807 | $query[] = false !== $rand_pos |
| 808 | ? "@class and contains(concat(' ', normalize-space(@class), ' '), ' $sel')" |
| 809 | : "@class and contains(concat(' ', normalize-space(@class), ' '), ' $sel ')"; |
| 810 | } elseif ( '#' === $sel_type ) { |
| 811 | $query[] = false !== $rand_pos |
| 812 | ? "@id and starts-with(@id, '$sel')" |
| 813 | : "@id and @id = '$sel'"; |
| 814 | } |
| 815 | } |
| 816 | |
| 817 | return $query ? '//*[' . implode( ' or ', $query ) . ']' : false; |
| 818 | } |
| 819 | |
| 820 | /** |
| 821 | * Un-texturize the text. |
| 822 | * |
| 823 | * @param string $text Text to un-texturize. |
| 824 | * |
| 825 | * @return string Un-texturized text. |
| 826 | */ |
| 827 | private static function wp_untexturize( $text ) { |
| 828 | return str_replace( |
| 829 | [ '“', '”', '‘', '’' ], |
| 830 | [ '"', '"', "'", "'" ], |
| 831 | $text |
| 832 | ); |
| 833 | } |
| 834 | } |
| 835 |