From 19c79f1992a5a7edf41f4a8e29f10981b97aff7c Mon Sep 17 00:00:00 2001 From: svfcode Date: Sat, 11 Jul 2026 13:46:56 +0300 Subject: [PATCH 1/8] Upd. ContactEncoder. Improve skip shortcode work. --- .../ContactsEncoder/ContactsEncoder.php | 4 +- .../Shortcodes/ExcludedEncodeContentSC.php | 849 +++++++++++++++++- .../Shortcodes/ShortCodesService.php | 45 +- .../ExcludedEncodeContentSCTest.php | 96 +- 4 files changed, 961 insertions(+), 33 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php index f3591133c..801a392ff 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php @@ -99,11 +99,11 @@ public function runEncoding($content = '') } if ( $hook === 'the_content' ) { // Priority 9 runs after do_blocks (9) when registered from init — placeholders keep

wrappers. - $this->shortcodes->addActionsBeforeModify($hook, 9); + $this->shortcodes->addActionsBeforeModifyEncodeOnly($hook, 9); $this->shortcodes->addActionsAfterModifyEncodeOnly($hook, 999); continue; } - $this->shortcodes->addActionsBeforeModify($hook, 9); + $this->shortcodes->addActionsBeforeModifyEncodeOnly($hook, 9); $this->shortcodes->addActionsAfterModifyEncodeOnly($hook, 999); } } else { diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 6d431d45e..8e12fab8b 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -6,6 +6,31 @@ class ExcludedEncodeContentSC extends EmailEncoderShortCode { protected $public_name = 'apbct_skip_encoding'; + /** + * @var array Placeholder => original shortcode inner content. + */ + public $shortcode_replacements = array(); + + /** + * @var string Wrapper used to mark content excluded from encoding. + */ + public $exclusion_wrapper = '%%APBCT_SHORT_CODE_SKIP_EE_0%%'; + + /** + * @var int Counter for shortcode placeholders. + */ + private $shortcode_counter = 0; + + /** + * @var string[] Active render_block stack. + */ + private $render_block_stack = array(); + + /** + * @var array Raw post titles captured before encoder mutates post objects. + */ + private static $raw_titles_cache = array(); + /** * Callback for the shortcode * @@ -21,6 +46,12 @@ public function callback($_atts, $content, $_tag) return $content; } + global $apbct; + + if ( ! $apbct->settings['data__email_decoder_buffer'] ) { + return $this->createPlaceholder($content); + } + // Pattern to get data-original-string attribute $pattern = '/data-original-string=(["\'])(.*?)\1/'; preg_match($pattern, $content, $matches); @@ -36,6 +67,30 @@ public function callback($_atts, $content, $_tag) return wp_kses_post($content); } + /** + * Replace skip-encoding shortcodes with placeholders before ContactsEncoder::modifyContent(). + * + * @param string $content + * + * @return string + */ + public function changeContentBeforeEncoderModify($content) + { + if ($this->isShortcodeInsideHtmlAttribute($content)) { + return $content; + } + + $pattern = '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s'; + + return preg_replace_callback($pattern, function ($matches) { + if (isset($matches[1])) { + return $this->createPlaceholder($matches[1]); + } + + return isset($matches[0]) ? $matches[0] : ''; + }, $content); + } + /** * This method runs at the end of Contacts Encoder and tries to process unprocessed shortcodes * The unprocessed shortcodes may be only in `the_title` hook @@ -48,8 +103,12 @@ public function changeContentAfterEncoderModify($content) { global $apbct; - if ( ! $apbct->settings['data__email_decoder_buffer'] && $this->getCurrentAction() !== 'the_title' ) { - return $content; + if ( ! $apbct->settings['data__email_decoder_buffer'] ) { + if ( ! $this->shouldDeferPlaceholderRestore() ) { + $content = $this->restorePlaceholders($content); + } + + return $this->processSkipEncodingShortcodes($content); } if ( $apbct->settings['data__email_decoder_buffer'] ) { @@ -61,33 +120,799 @@ public function changeContentAfterEncoderModify($content) } } - // Skip processing if shortcode is inside an HTML tag to prevent attribute injection - if ($this->isShortcodeInsideHtmlTag($content)) { + // Skip processing if shortcode is inside an HTML attribute to prevent attribute injection + if ($this->isShortcodeInsideHtmlAttribute($content)) { if ( $apbct->settings['data__email_decoder_buffer'] ) { $apbct->buffer = $content; } return $content; } + $content = $this->restorePlaceholders($content); + + $result = $this->processSkipEncodingShortcodes($content); + + if ( $apbct->settings['data__email_decoder_buffer'] ) { + $apbct->buffer = $result; + } + + return $result; + } + + /** + * @param string $content + * + * @return string + */ + protected function processSkipEncodingShortcodes($content) + { + if ($this->isShortcodeInsideHtmlAttribute($content)) { + return $content; + } + $pattern = '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s'; - $result = preg_replace_callback($pattern, function ($matches) { - // $matches[0] - all full match + + return preg_replace_callback($pattern, function ($matches) { if ( isset($matches[1]) ) { - // $matches[1] - only between tags group - $modifiedContent = $this->callback([], $matches[1], ''); - return $modifiedContent; // Return modified (decoded) content without tags + return $this->callback([], $matches[1], ''); } /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ - return $matches[0]; // By default, return not modified match + return $matches[0]; }, $content); + } - if ( $apbct->settings['data__email_decoder_buffer'] ) { - $apbct->buffer = $result; + /** + * @param string $content + * + * @return string + */ + protected function restorePlaceholders($content) + { + $content = $content === null ? '' : $content; + + foreach ($this->shortcode_replacements as $placeholder => $original) { + $content = str_replace($placeholder, esc_html($original), $content); + } + + $this->resetShortcodeReplacements(); + + return $content; + } + + /** + * Track currently rendered blocks to restore placeholders after the last encoding pass. + * + * @param mixed $pre_render + * @param array $block + * + * @return mixed + * @psalm-suppress PossiblyUnusedMethod + */ + public function pushRenderBlockStack($pre_render, $block) + { + $this->render_block_stack[] = isset($block['blockName']) ? (string)$block['blockName'] : ''; + + return $pre_render; + } + + /** + * Restore placeholders once the outermost render_block finished ContactsEncoder::modifyContent(). + * + * @param string $content + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function finalizePlaceholderRestoreAfterRenderBlock($content) + { + array_pop($this->render_block_stack); + + if ( ! empty($this->render_block_stack) ) { + return $content; + } + + if ( function_exists('doing_filter') && doing_filter('the_content') ) { + return $content; + } + + if ( strpos($content, 'APBCT_SHORT_CODE_SKIP') !== false ) { + $content = $this->restorePlaceholders($content); + } + + return $content; + } + + /** + * Keep placeholders until the outermost render_block finishes encoding. + * + * @return bool + */ + protected function shouldDeferPlaceholderRestore() + { + if ( $this->getCurrentAction() === 'render_block' ) { + return true; + } + + return ! empty($this->render_block_stack); + } + + /** + * @param string $content + * + * @return string + */ + protected function createPlaceholder($content) + { + $placeholder = preg_replace('/EE\_\d+/', 'EE_' . (string)$this->shortcode_counter++, $this->exclusion_wrapper); + if (is_null($placeholder)) { + $placeholder = $this->exclusion_wrapper; + } + $this->shortcode_replacements[$placeholder] = $content; + + return $placeholder; + } + + /** + * @return void + */ + public function resetShortcodeReplacements() + { + $this->shortcode_replacements = array(); + $this->shortcode_counter = 0; + } + + /** + * @param string $content + * @param array $block + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function restorePageListLinkTitles($content, $block) + { + $block_name = isset($block['blockName']) ? $block['blockName'] : ''; + if ( ! in_array($block_name, array('core/page-list', 'core/navigation'), true) ) { + return $content; + } + + return $this->restorePageListLinkTitlesInHtml($content); + } + + /** + * @param string $content + * @param array $_parsed_block + * @param object|null $block + * + * @return string + */ + public function restoreEncodedBlockTitlesFilter($content, $_parsed_block, $block = null) + { + if ( $this->isBufferMode() ) { + return $content; + } + + $content = $this->restorePageListLinkTitlesInHtml($content); + + return $this->restorePostTitleInHtml($content, $_parsed_block, $block); + } + + /** + * @param string $content + * @param array $_parsed_block + * + * @return string + * @deprecated Use restoreEncodedBlockTitlesFilter(). + * @psalm-suppress PossiblyUnusedMethod + */ + public function restorePageListLinkTitlesFilter($content, $_parsed_block) + { + return $this->restoreEncodedBlockTitlesFilter($content, $_parsed_block); + } + + /** + * @param string $content + * + * @return string + */ + protected function restorePageListLinkTitlesInHtml($content) + { + return preg_replace_callback( + '/(]*href=["\']([^"\']+)["\'][^>]*>)(.*?)(<\/a>)/is', + function ($matches) { + if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3], $matches[4]) ) { + return isset($matches[0]) ? $matches[0] : ''; + } + + $post_id = url_to_postid($matches[2]); + if ( ! $post_id ) { + return $matches[0]; + } + + $title = $this->getRawPostTitle($post_id); + if ( ! is_string($title) || $title === '' ) { + return $matches[0]; + } + + if ( + strpos($title, '[apbct_skip_encoding]') === false + && strpos($matches[3], 'apbct-email-encoder') === false + && strpos($matches[3], '[apbct_skip_encoding]') === false + ) { + return $matches[0]; + } + + return $matches[1] . esc_html($this->processTitleString($title)) . $matches[4]; + }, + $content + ); + } + + /** + * Restore post-title block output after nested render_block encoding passes. + * + * @param string $content + * @param array $_parsed_block + * @param object|null $block + * + * @return string + */ + protected function restorePostTitleInHtml($content, $_parsed_block = array(), $block = null) + { + if ( strpos($content, 'wp-block-post-title') === false ) { + return $content; + } + + $post_id = $this->resolvePostIdFromBlockContext($_parsed_block, $block); + if ( ! $post_id ) { + return $content; + } + + $title = $this->getRawPostTitle($post_id); + if ( $title === '' || strpos($title, '[apbct_skip_encoding]') === false ) { + return $content; + } + + $processed = $this->processTitleString($title, false); + + return preg_replace_callback( + '/(]*\bwp-block-post-title\b[^>]*>)(.*?)(<\/h[1-6]>)/is', + function ($matches) use ($processed) { + if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3]) ) { + return isset($matches[0]) ? $matches[0] : ''; + } + + if ( + strpos($matches[2], '[apbct_skip_encoding]') === false + && strpos($matches[2], 'apbct-email-encoder') === false + && strpos($matches[2], '%%APBCT_SHORT_CODE_SKIP') === false + ) { + return $matches[0]; + } + + return $matches[1] . $processed . $matches[3]; + }, + $content + ); + } + + /** + * @param array $_parsed_block + * @param object|null $block + * + * @return int + */ + protected function resolvePostIdFromBlockContext($_parsed_block, $block) + { + if ( is_object($block) && isset($block->context['postId']) ) { + return (int)$block->context['postId']; + } + + if ( is_singular() ) { + return get_queried_object_id(); + } + + return 0; + } + + /** + * Check whether shortcode is inside an HTML attribute rather than element text. + * + * @param string $content + * + * @return bool + */ + protected function isShortcodeInsideHtmlAttribute($content) + { + preg_match_all( + '/\[\/?apbct_skip_encoding(?:\s[^\]]*)?\]/', + $content, + $matches, + PREG_OFFSET_CAPTURE + ); + + if ( ! isset($matches[0]) ) { + return false; + } + + foreach ($matches[0] as $match) { + $offset = $match[1] ?? null; + + if ( $offset === null ) { + continue; + } + + if ( $this->isOffsetInsideHtmlTag($content, $offset) ) { + return true; + } + } + + return false; + } + + protected function removeDuplicateContactsOutsideShortcodes($title) + { + if ( strpos($title, '[apbct_skip_encoding]') === false ) { + return $title; + } + + preg_match_all('/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s', $title, $matches); + if ( empty($matches[0]) || ! isset($matches[1]) ) { + return $title; + } + + $outside = preg_replace('/\[apbct_skip_encoding\].*?\[\/apbct_skip_encoding\]/s', ' ', $title); + $outside = trim(preg_replace('/\s+/', ' ', $outside)); + + foreach ( $matches[1] as $inner ) { + $inner = trim($inner); + if ( $inner === '' ) { + continue; + } + + $quoted = preg_quote($inner, '/'); + $outside = preg_replace('/' . $quoted . '/', '', $outside, 1); + $outside = trim(preg_replace('/\s+/', ' ', $outside)); + } + + $result = $outside; + foreach ( $matches[0] as $shortcode ) { + $result .= ($result !== '' ? ' ' : '') . $shortcode; + } + + return trim(preg_replace('/\s+/', ' ', $result)); + } + + /** + * Cache titles with skip-encoding shortcodes before frontend encoding mutates post objects. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function primeRawTitleCache() + { + if ( is_admin() ) { + return; + } + + global $wpdb; + + $rows = $wpdb->get_results( + "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish'", + OBJECT_K + ); + + if ( ! is_array($rows) ) { + return; + } + + foreach ( $rows as $post_id => $row ) { + if ( + isset($row->post_title) + && is_string($row->post_title) + && strpos($row->post_title, '[apbct_skip_encoding]') !== false + && strpos($row->post_title, 'apbct-email-encoder') === false + ) { + self::$raw_titles_cache[(int)$post_id] = $row->post_title; + } + } + } + + /** + * Read post title from DB bypassing in-memory mutations during encoding. + * + * @param int $post_id + * + * @return string + */ + protected function getRawPostTitle($post_id) + { + $post_id = (int) $post_id; + + if ( ! $post_id ) { + return ''; + } + + if ( + isset(self::$raw_titles_cache[$post_id]) + && strpos(self::$raw_titles_cache[$post_id], 'apbct-email-encoder') === false + ) { + return self::$raw_titles_cache[$post_id]; + } + + global $wpdb; + + clean_post_cache($post_id); + wp_cache_delete($post_id, 'posts'); + wp_cache_delete('last_changed', 'posts'); + + $title = $wpdb->get_var( + $wpdb->prepare( + "SELECT post_title FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", + $post_id + ) + ); + + if ( ! is_string($title) ) { + return ''; + } + + if ( strpos($title, 'apbct-email-encoder') === false ) { + self::$raw_titles_cache[$post_id] = $title; + } + + return $title; + } + + /** + * @return bool + */ + protected function isBufferMode() + { + global $apbct; + + return ! empty($apbct->settings['data__email_decoder_buffer']); + } + + /** + * Keep shortcode tags in title during render; buffer mode encodes on shutdown. + * + * @param string $title + * + * @return string + */ + protected function prepareTitleForBufferMode($title) + { + if ( strpos($title, '[apbct_skip_encoding]') === false ) { + return $title; + } + + return $this->removeDuplicateContactsOutsideShortcodes($title); + } + + /** + * Rebuild encoded titles in the full output buffer after buffer-mode encoding. + * + * @param string $buffer + * + * @return string + */ + public function restoreEncodedTitlesInBuffer($buffer) + { + if ( ! is_string($buffer) || $buffer === '' ) { + return $buffer; + } + + $buffer = $this->restoreDocumentTitleInHtml($buffer); + $buffer = $this->restorePageListLinkTitlesInHtml($buffer); + + return $this->restorePostTitleInHtml($buffer); + } + + /** + * Finalize output buffer after ContactsEncoder::modifyContent() in buffer mode. + * + * @param string $buffer + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function finalizeBufferAfterEncoding($buffer) + { + if ( ! is_string($buffer) || $buffer === '' ) { + return $buffer; + } + + if ( ! $this->isShortcodeInsideHtmlAttribute($buffer) ) { + $buffer = $this->restorePlaceholders($buffer); + $buffer = $this->processSkipEncodingShortcodes($buffer); + } + + return $this->restoreEncodedTitlesInBuffer($buffer); + } + + /** + * Restore document after buffer-mode encoding. + * + * @param string $content + * + * @return string + */ + protected function restoreDocumentTitleInHtml($content) + { + if ( strpos($content, '<title') === false ) { + return $content; + } + + $post_id = is_singular() ? get_queried_object_id() : 0; + if ( ! $post_id ) { + return $content; + } + + $raw_title = $this->getRawPostTitle($post_id); + if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) { + return $content; + } + + $processed = esc_html($this->processTitleString($raw_title)); + + return preg_replace_callback( + '/(<title[^>]*>)(.*?)(<\/title>)/is', + function ($matches) use ($processed) { + if ( ! isset($matches[0], $matches[1], $matches[2], $matches[3]) ) { + return isset($matches[0]) ? $matches[0] : ''; + } + + if ( + strpos($matches[2], '[apbct_skip_encoding]') === false + && strpos($matches[2], 'apbct-email-encoder') === false + && strpos($matches[2], '%%APBCT_SHORT_CODE_SKIP') === false + ) { + return $matches[0]; + } + + $suffix = ''; + if ( preg_match('/^(.+?)(\s(?:–|–|–|-)\s.+)$/u', $matches[2], $parts) && isset($parts[2]) ) { + $suffix = $parts[2]; + } + + return $matches[1] . $processed . $suffix . $matches[3]; + }, + $content, + 1 + ); + } + + /** + * Run callback without mutating shared placeholder state used by content hooks. + * + * @param callable $callback + * + * @return mixed + */ + protected function withIsolatedShortcodeState($callback) + { + $saved_replacements = $this->shortcode_replacements; + $saved_counter = $this->shortcode_counter; + + $this->resetShortcodeReplacements(); + + try { + return $callback(); + } finally { + $this->shortcode_replacements = $saved_replacements; + $this->shortcode_counter = $saved_counter; + } + } + + /** + * Process title strings where WordPress does not run the the_title filter. + * + * @param string $title + * @param bool $strip_html + * + * @return string + */ + public function processTitleString($title, $strip_html = true) + { + if ( $title === '' || $title === null ) { + return (string)$title; + } + + $result = $this->withIsolatedShortcodeState(function () use ($title) { + $title = $this->removeDuplicateContactsOutsideShortcodes($title); + $processed = $this->changeContentBeforeEncoderModify($title); + $processed = apbctGetContactsEncoder()->modifyContent($processed); + $processed = $this->restorePlaceholders($processed); + + return $this->processSkipEncodingShortcodes($processed); + }); + + if ( $strip_html ) { + $result = wp_strip_all_tags($result); + $result = trim(preg_replace('/\s+/', ' ', $result)); } return $result; } + /** + * Remove skip-encoding shortcodes before slug generation. + * + * @param string $title + * + * @return string + */ + public static function stripShortcodesForSlug($title) + { + $title = preg_replace('/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s', '$1', $title); + $title = preg_replace('/\[\/?apbct_skip_encoding\]/', '', $title); + + return trim(preg_replace('/\s+/', ' ', $title)); + } + + /** + * @param string $title + * @param int $post_id + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function filterTheTitle($title, $post_id) + { + if ( is_admin() && ! wp_doing_ajax() ) { + return $title; + } + + $raw_title = $this->getRawPostTitle($post_id); + if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) { + return $title; + } + + if ( $this->isBufferMode() ) { + return $this->prepareTitleForBufferMode($raw_title); + } + + return $this->processTitleString($raw_title, false); + } + + /** + * @param string $title + * @param \WP_Post $_post + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function filterSinglePostTitle($title, $_post) + { + $post_id = is_object($_post) && isset($_post->ID) ? (int)$_post->ID : 0; + $raw_title = $post_id ? $this->getRawPostTitle($post_id) : $title; + + if ( strpos($raw_title, '[apbct_skip_encoding]') === false ) { + return $title; + } + + if ( $this->isBufferMode() ) { + return $this->prepareTitleForBufferMode($raw_title); + } + + return $this->processTitleString($raw_title); + } + + /** + * @param array $parts + * + * @return array + * @psalm-suppress PossiblyUnusedMethod + */ + public function filterDocumentTitleParts($parts) + { + if ( empty($parts['title']) ) { + return $parts; + } + + $post_id = is_singular() ? get_queried_object_id() : 0; + $title = $parts['title']; + + if ( $post_id ) { + $raw_title = $this->getRawPostTitle($post_id); + if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) { + $title = $raw_title; + } + } + + if ( strpos($title, '[apbct_skip_encoding]') === false ) { + return $parts; + } + + if ( $this->isBufferMode() ) { + $parts['title'] = $this->prepareTitleForBufferMode($title); + + return $parts; + } + + $parts['title'] = $this->processTitleString($title); + + return $parts; + } + + /** + * @param string $title + * @param \WP_Post $menu_item + * + * @return string + * @psalm-suppress PossiblyUnusedMethod + */ + public function filterNavMenuItemTitle($title, $menu_item) + { + $post_id = 0; + if ( is_object($menu_item) && isset($menu_item->object, $menu_item->object_id) && $menu_item->object === 'page' ) { + $post_id = (int)$menu_item->object_id; + } + + if ( $post_id ) { + $raw_title = $this->getRawPostTitle($post_id); + if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) { + if ( $this->isBufferMode() ) { + return $this->prepareTitleForBufferMode($raw_title); + } + + return $this->processTitleString($raw_title); + } + } + + if ( strpos($title, '[apbct_skip_encoding]') === false ) { + return $title; + } + + if ( $this->isBufferMode() ) { + return $this->prepareTitleForBufferMode($title); + } + + return $this->processTitleString($title); + } + + /** + * @param array $data + * @param array $postarr + * + * @return array + * @psalm-suppress PossiblyUnusedMethod + */ + public function filterPostDataForSlug($data, $postarr) + { + $post_id = isset($postarr['ID']) ? (int)$postarr['ID'] : 0; + + if ( + ! empty($data['post_title']) + && strpos($data['post_title'], 'apbct-email-encoder') !== false + && $post_id + && isset(self::$raw_titles_cache[$post_id]) + ) { + $data['post_title'] = self::$raw_titles_cache[$post_id]; + } + + if ( empty($data['post_title']) || strpos($data['post_title'], '[apbct_skip_encoding]') === false ) { + return $data; + } + + $status = isset($data['post_status']) ? $data['post_status'] : ''; + if ( in_array($status, array('draft', 'pending', 'auto-draft'), true) ) { + return $data; + } + + $post_name = isset($data['post_name']) ? $data['post_name'] : ''; + $explicit_slug = isset($postarr['post_name']) && $postarr['post_name'] !== ''; + $clean_slug = sanitize_title(self::stripShortcodesForSlug($data['post_title'])); + + if ( $post_name === '' && ! $explicit_slug ) { + $data['post_name'] = $clean_slug; + } elseif ( strpos($post_name, 'apbct_skip_encoding') !== false ) { + $data['post_name'] = $clean_slug; + } + + return $data; + } + protected function getCurrentAction() { return function_exists('current_action') ? current_action() : null; diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php index ebacb45a8..5ba480277 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php @@ -24,15 +24,43 @@ public function registerAll() if (!$this->shortcodes_registered) { $this->encode->register(); + $this->registerTitleFilters(); if ( ! $apbct->settings['data__email_decoder_buffer'] ) { // If buffer is active, do not run WordPress shortcode replacement - // encoder processes apbct_skip_encoding in modifyBufferAfter(). $this->shortcode_to_exclude->register(); + $this->registerRenderBlockNestingHooks(); } $this->shortcodes_registered = true; } } + /** + * @return void + */ + private function registerRenderBlockNestingHooks() + { + add_filter('pre_render_block', array($this->shortcode_to_exclude, 'pushRenderBlockStack'), 10, 2); + add_filter('render_block', array($this->shortcode_to_exclude, 'finalizePlaceholderRestoreAfterRenderBlock'), 11); + } + + /** + * @return void + */ + public function registerTitleFilters() + { + add_filter('the_title', array($this->shortcode_to_exclude, 'filterTheTitle'), 1000, 2); + add_filter('single_post_title', array($this->shortcode_to_exclude, 'filterSinglePostTitle'), 20, 2); + add_filter('document_title_parts', array($this->shortcode_to_exclude, 'filterDocumentTitleParts'), 20); + add_filter('nav_menu_item_title', array($this->shortcode_to_exclude, 'filterNavMenuItemTitle'), 20, 2); + add_filter('wp_insert_post_data', array($this->shortcode_to_exclude, 'filterPostDataForSlug'), 10, 2); + add_action('init', array($this->shortcode_to_exclude, 'primeRawTitleCache'), 1); + add_filter('render_block', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3); + add_filter('render_block_core/page-list', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3); + add_filter('render_block_core/navigation', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3); + add_filter('render_block_core/post-title', array($this->shortcode_to_exclude, 'restoreEncodedBlockTitlesFilter'), 1000, 3); + } + public function __construct(Params $params) { $this->encode = new EncodeContentSC($params); @@ -40,6 +68,12 @@ public function __construct(Params $params) } public function addActionsBeforeModify($hook, $priority = 1) + { + add_filter($hook, array($this->encode, 'changeContentBeforeEncoderModify'), $priority); + add_filter($hook, array($this->shortcode_to_exclude, 'changeContentBeforeEncoderModify'), $priority); + } + + public function addActionsBeforeModifyEncodeOnly($hook, $priority = 1) { add_filter($hook, array($this->encode, 'changeContentBeforeEncoderModify'), $priority); } @@ -65,6 +99,8 @@ public function addActionsAfterModifyEncodeOnly($hook, $priority = 999) public function modifyBufferBefore($buffer) { $this->encode->resetShortcodeReplacements(); + $this->shortcode_to_exclude->resetShortcodeReplacements(); + $buffer = $this->shortcode_to_exclude->changeContentBeforeEncoderModify($buffer); return $this->encode->changeContentBeforeEncoderModify($buffer); } @@ -86,12 +122,15 @@ public function modifyBufferAfter($buffer) $apbct->buffer = $buffer; } - $buffer = $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer); - if ( $apbct->settings['data__email_decoder_buffer'] ) { - return $apbct->buffer; + $buffer = $this->shortcode_to_exclude->finalizeBufferAfterEncoding($buffer); + $apbct->buffer = $buffer; + + return $buffer; } + $buffer = $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer); + return $buffer; } } diff --git a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php index 38479a03e..6093d4a00 100644 --- a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php +++ b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php @@ -47,11 +47,14 @@ public function testCallbackWithEmptyContent(): void } /** - * Test callback with valid content containing data-original-string + * Test callback with valid content containing data-original-string (buffer mode). */ public function testCallbackWithValidContent(): void { - // Prepare test data + global $apbct; + + $apbct->settings['data__email_decoder_buffer'] = true; + $originalString = 'test@example.com'; $encodedString = $this->contacts_encoder->modifyContent($originalString); @@ -60,11 +63,32 @@ public function testCallbackWithValidContent(): void $this->assertEquals($originalString, $result); } + /** + * Test callback stores placeholder when buffer mode is off. + */ + public function testCallbackWithValidContentBufferOffReturnsPlaceholder(): void + { + global $apbct; + + $apbct->settings['data__email_decoder_buffer'] = false; + + $originalString = 'test@example.com'; + $encodedString = $this->contacts_encoder->modifyContent($originalString); + + $result = $this->exclude_content_sc->callback([], $encodedString, ''); + + $this->assertStringContainsString('APBCT_SHORT_CODE_SKIP', $result); + } + /** * Test callback with content without data-original-string */ public function testCallbackWithContentWithoutDataAttribute(): void { + global $apbct; + + $apbct->settings['data__email_decoder_buffer'] = true; + $content = '<span>Some text without data attribute</span>'; $result = $this->exclude_content_sc->callback([], $content, ''); @@ -118,32 +142,64 @@ public function testChangeContentAfterEncoderModifyWithBufferOnUsesBufferDuringS } /** - * Test changeContentAfterEncoderModify when buffer is off but in the_title hook + * Test changeContentAfterEncoderModify when buffer is off and not in the_title */ - public function testChangeContentAfterEncoderModifyInTheTitleHook(): void + public function testChangeContentAfterEncoderModifyWithBufferOffOnTheContent(): void { global $apbct; - $content = 'title with [apbct_skip_encoding]test@example.com[/apbct_skip_encoding]'; + $content = '<p>[apbct_skip_encoding]asfg@srth.rew[/apbct_skip_encoding]</p>'; $apbct->settings['data__email_decoder_buffer'] = false; $apbct->saveSettings(); - $this->contacts_encoder->dropInstance(); // Need to rebuild the object after the settings changed + $this->contacts_encoder->dropInstance(); $this->contacts_encoder = apbctGetContactsEncoder(); - // Create a partial mock that overrides getCurrentAction - $shortcodeMock = $this->getMockBuilder(ExcludedEncodeContentSC::class) - ->setMethods(['getCurrentAction']) - ->getMock(); + $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content); + $encoded_content = $this->contacts_encoder->modifyContent($protected_content); + $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content); - // Mock getCurrentAction to return 'the_title' - $shortcodeMock->expects($this->once()) - ->method('getCurrentAction') - ->willReturn('the_title'); + $this->assertEquals('<p>asfg@srth.rew</p>', $result); + } - $encoded_content = $this->contacts_encoder->modifyContent($content); + /** + * Test changeContentAfterEncoderModify when buffer is off and not in the_title + */ + public function testChangeContentAfterEncoderModifyWithBufferOffSkipsPhoneAndEmail(): void + { + global $apbct; + + $content = '[apbct_skip_encoding]asfg@srth.rew[/apbct_skip_encoding] [apbct_skip_encoding]+79991112233[/apbct_skip_encoding]'; + + $apbct->settings['data__email_decoder_buffer'] = false; + $apbct->saveSettings(); + $this->contacts_encoder->dropInstance(); + $this->contacts_encoder = apbctGetContactsEncoder(); - $result = $shortcodeMock->changeContentAfterEncoderModify($encoded_content); + $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content); + $encoded_content = $this->contacts_encoder->modifyContent($protected_content); + $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content); + + $this->assertEquals('asfg@srth.rew +79991112233', $result); + } + + /** + * Test changeContentAfterEncoderModify when buffer is off for title-like content + */ + public function testChangeContentAfterEncoderModifyInTheTitleHook(): void + { + global $apbct; + + $content = 'title with [apbct_skip_encoding]test@example.com[/apbct_skip_encoding]'; + + $apbct->settings['data__email_decoder_buffer'] = false; + $apbct->saveSettings(); + $this->contacts_encoder->dropInstance(); + $this->contacts_encoder = apbctGetContactsEncoder(); + + $protected_content = $this->exclude_content_sc->changeContentBeforeEncoderModify($content); + $encoded_content = $this->contacts_encoder->modifyContent($protected_content); + $result = $this->exclude_content_sc->changeContentAfterEncoderModify($encoded_content); $this->assertEquals('title with test@example.com', $result); } @@ -224,6 +280,10 @@ public function testChangeContentAfterEncoderModifyWhenCallbackReturnsUnmodified */ public function testCallbackSanitizesDirectScriptInjection(): void { + global $apbct; + + $apbct->settings['data__email_decoder_buffer'] = true; + $content = '<script>alert(document.domain)</script>'; $result = $this->exclude_content_sc->callback([], $content, ''); $this->assertStringNotContainsString('<script>', $result); @@ -234,6 +294,10 @@ public function testCallbackSanitizesDirectScriptInjection(): void */ public function testCallbackSanitizesScriptInSpanFallback(): void { + global $apbct; + + $apbct->settings['data__email_decoder_buffer'] = true; + $content = '<span><script>alert(1)</script></span>'; $result = $this->exclude_content_sc->callback([], $content, ''); $this->assertStringNotContainsString('<script>', $result); From ab46035a1a05a4ebc95980913ec713207e154d36 Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Sat, 11 Jul 2026 14:35:00 +0300 Subject: [PATCH 2/8] fix cp --- .../Shortcodes/ExcludedEncodeContentSC.php | 9 ++------- lib/Cleantalk/ApbctWP/RemoteCalls.php | 2 +- readme.txt | 1 - 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 8e12fab8b..4710135cd 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -171,7 +171,7 @@ protected function restorePlaceholders($content) $content = $content === null ? '' : $content; foreach ($this->shortcode_replacements as $placeholder => $original) { - $content = str_replace($placeholder, esc_html($original), $content); + $content = str_replace($placeholder, wp_kses_post($original), $content); } $this->resetShortcodeReplacements(); @@ -497,7 +497,7 @@ public function primeRawTitleCache() global $wpdb; $rows = $wpdb->get_results( - "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish'", + "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_title LIKE '%[apbct_skip_encoding]%'", OBJECT_K ); @@ -509,7 +509,6 @@ public function primeRawTitleCache() if ( isset($row->post_title) && is_string($row->post_title) - && strpos($row->post_title, '[apbct_skip_encoding]') !== false && strpos($row->post_title, 'apbct-email-encoder') === false ) { self::$raw_titles_cache[(int)$post_id] = $row->post_title; @@ -541,10 +540,6 @@ protected function getRawPostTitle($post_id) global $wpdb; - clean_post_cache($post_id); - wp_cache_delete($post_id, 'posts'); - wp_cache_delete('last_changed', 'posts'); - $title = $wpdb->get_var( $wpdb->prepare( "SELECT post_title FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php index bead9ee7b..852e53241 100644 --- a/lib/Cleantalk/ApbctWP/RemoteCalls.php +++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php @@ -710,7 +710,7 @@ private static function rateLimitCheck() $action = strtolower(Request::getString('spbc_remote_call_action')); - // Self-RC for SFW queue — not abuse, skip rate limit + // Self-RC for SFW queue — raise rate limit (not skip) if (self::isSelfRemoteCall() && $action === 'sfw_update__worker') { $limit = 100; } diff --git a/readme.txt b/readme.txt index 0ebf21f69..1d8cc36bf 100644 --- a/readme.txt +++ b/readme.txt @@ -401,7 +401,6 @@ CleanTalk stops up to 99.998% of spam bots, so you can disable other anti-spam p New. Settings. Getting apikey wizard. New. RateLimit. Enabled the RateLimit shared library New. Footer. New link on Website Feedback Plugin -New. RateLimit. Enabled the RateLimit shared library Upd. Contacts Encoder. Flow improvements. Upd. ContactEncoder. Improve shortcode flow. Mod. BannerReview. Changing the design of the review banner From 5a81697dbd53a8c87547cf2c0388cf70c97a8759 Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Sat, 11 Jul 2026 15:32:26 +0300 Subject: [PATCH 3/8] fix cp --- lib/Cleantalk/ApbctWP/RemoteCalls.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php index 852e53241..a4ef843ef 100644 --- a/lib/Cleantalk/ApbctWP/RemoteCalls.php +++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php @@ -691,11 +691,17 @@ private static function isSelfRemoteCall(): bool $remote = Helper::ipGet('remote_addr', true); $server = Server::getString('SERVER_ADDR'); - if ($remote !== '' && $server !== '' && $remote === $server) { + if ( $remote === '' || $server === '' ) { + return false; + } + + if ( $remote === $server ) { return true; } - return in_array($remote, array('127.0.0.1', '::1'), true); + $loopback = array('127.0.0.1', '::1'); + + return in_array($remote, $loopback, true) && in_array($server, $loopback, true); } /** From 901ba344ba69c36a2352239dfb5f304ce904eb3c Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Sun, 12 Jul 2026 09:47:15 +0300 Subject: [PATCH 4/8] upd exclusion --- .../Shortcodes/ExcludedEncodeContentSC.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 4710135cd..72540a8ba 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -316,6 +316,19 @@ public function restorePageListLinkTitlesFilter($content, $_parsed_block) */ protected function restorePageListLinkTitlesInHtml($content) { + if ( strpos($content, '<a') === false ) { + return $content; + } + + if ( + empty(self::$raw_titles_cache) + && strpos($content, 'apbct-email-encoder') === false + && strpos($content, '[apbct_skip_encoding]') === false + && strpos($content, '%%APBCT_SHORT_CODE_SKIP') === false + ) { + return $content; + } + return preg_replace_callback( '/(<a\b[^>]*href=["\']([^"\']+)["\'][^>]*>)(.*?)(<\/a>)/is', function ($matches) { From e81419ec3007a18533a7b7698a4325d326697a89 Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Sun, 12 Jul 2026 11:41:53 +0300 Subject: [PATCH 5/8] upd work with buffer --- .../ContactsEncoder/Shortcodes/ShortCodesService.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php index 5ba480277..7dbdd82c5 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ShortCodesService.php @@ -118,10 +118,6 @@ public function modifyBufferAfter($buffer) $buffer = $this->encode->changeContentAfterEncoderModify($buffer); - if ( $apbct->settings['data__email_decoder_buffer'] ) { - $apbct->buffer = $buffer; - } - if ( $apbct->settings['data__email_decoder_buffer'] ) { $buffer = $this->shortcode_to_exclude->finalizeBufferAfterEncoding($buffer); $apbct->buffer = $buffer; @@ -129,8 +125,6 @@ public function modifyBufferAfter($buffer) return $buffer; } - $buffer = $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer); - - return $buffer; + return $this->shortcode_to_exclude->changeContentAfterEncoderModify($buffer); } } From 66ffb05e3788799bb42e24683fec0e69a23d1c12 Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Sun, 12 Jul 2026 12:06:14 +0300 Subject: [PATCH 6/8] upd flow to skip reqs --- .../Shortcodes/ExcludedEncodeContentSC.php | 91 ++++++++++++++----- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 72540a8ba..56744bd56 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -321,8 +321,7 @@ protected function restorePageListLinkTitlesInHtml($content) } if ( - empty(self::$raw_titles_cache) - && strpos($content, 'apbct-email-encoder') === false + strpos($content, 'apbct-email-encoder') === false && strpos($content, '[apbct_skip_encoding]') === false && strpos($content, '%%APBCT_SHORT_CODE_SKIP') === false ) { @@ -336,21 +335,24 @@ function ($matches) { return isset($matches[0]) ? $matches[0] : ''; } + if ( + strpos($matches[3], 'apbct-email-encoder') === false + && strpos($matches[3], '[apbct_skip_encoding]') === false + ) { + return $matches[0]; + } + $post_id = url_to_postid($matches[2]); if ( ! $post_id ) { return $matches[0]; } - $title = $this->getRawPostTitle($post_id); - if ( ! is_string($title) || $title === '' ) { - return $matches[0]; + $title = $this->getPrimedRawPostTitle($post_id); + if ( $title === '' ) { + $title = $this->getRawPostTitle($post_id); } - if ( - strpos($title, '[apbct_skip_encoding]') === false - && strpos($matches[3], 'apbct-email-encoder') === false - && strpos($matches[3], '[apbct_skip_encoding]') === false - ) { + if ( ! is_string($title) || $title === '' || strpos($title, '[apbct_skip_encoding]') === false ) { return $matches[0]; } @@ -380,7 +382,19 @@ protected function restorePostTitleInHtml($content, $_parsed_block = array(), $b return $content; } - $title = $this->getRawPostTitle($post_id); + if ( + strpos($content, '[apbct_skip_encoding]') === false + && strpos($content, 'apbct-email-encoder') === false + && $this->getPrimedRawPostTitle($post_id) === '' + ) { + return $content; + } + + $title = $this->getPrimedRawPostTitle($post_id); + if ( $title === '' ) { + $title = $this->getRawPostTitle($post_id); + } + if ( $title === '' || strpos($title, '[apbct_skip_encoding]') === false ) { return $content; } @@ -529,6 +543,28 @@ public function primeRawTitleCache() } } + /** + * Return raw title from init-time prime cache when this post is known to use skip-encoding. + * + * @param int $post_id + * + * @return string + */ + protected function getPrimedRawPostTitle($post_id) + { + $post_id = (int) $post_id; + + if ( + $post_id + && isset(self::$raw_titles_cache[$post_id]) + && strpos(self::$raw_titles_cache[$post_id], 'apbct-email-encoder') === false + ) { + return self::$raw_titles_cache[$post_id]; + } + + return ''; + } + /** * Read post title from DB bypassing in-memory mutations during encoding. * @@ -544,11 +580,9 @@ protected function getRawPostTitle($post_id) return ''; } - if ( - isset(self::$raw_titles_cache[$post_id]) - && strpos(self::$raw_titles_cache[$post_id], 'apbct-email-encoder') === false - ) { - return self::$raw_titles_cache[$post_id]; + $primed_title = $this->getPrimedRawPostTitle($post_id); + if ( $primed_title !== '' ) { + return $primed_title; } global $wpdb; @@ -656,7 +690,20 @@ protected function restoreDocumentTitleInHtml($content) return $content; } - $raw_title = $this->getRawPostTitle($post_id); + if ( + strpos($content, '[apbct_skip_encoding]') === false + && strpos($content, 'apbct-email-encoder') === false + && strpos($content, '%%APBCT_SHORT_CODE_SKIP') === false + && $this->getPrimedRawPostTitle($post_id) === '' + ) { + return $content; + } + + $raw_title = $this->getPrimedRawPostTitle($post_id); + if ( $raw_title === '' ) { + $raw_title = $this->getRawPostTitle($post_id); + } + if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) { return $content; } @@ -771,7 +818,7 @@ public function filterTheTitle($title, $post_id) return $title; } - $raw_title = $this->getRawPostTitle($post_id); + $raw_title = $this->getPrimedRawPostTitle($post_id); if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) { return $title; } @@ -793,9 +840,9 @@ public function filterTheTitle($title, $post_id) public function filterSinglePostTitle($title, $_post) { $post_id = is_object($_post) && isset($_post->ID) ? (int)$_post->ID : 0; - $raw_title = $post_id ? $this->getRawPostTitle($post_id) : $title; + $raw_title = $post_id ? $this->getPrimedRawPostTitle($post_id) : $title; - if ( strpos($raw_title, '[apbct_skip_encoding]') === false ) { + if ( $raw_title === '' || strpos($raw_title, '[apbct_skip_encoding]') === false ) { return $title; } @@ -822,7 +869,7 @@ public function filterDocumentTitleParts($parts) $title = $parts['title']; if ( $post_id ) { - $raw_title = $this->getRawPostTitle($post_id); + $raw_title = $this->getPrimedRawPostTitle($post_id); if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) { $title = $raw_title; } @@ -858,7 +905,7 @@ public function filterNavMenuItemTitle($title, $menu_item) } if ( $post_id ) { - $raw_title = $this->getRawPostTitle($post_id); + $raw_title = $this->getPrimedRawPostTitle($post_id); if ( $raw_title !== '' && strpos($raw_title, '[apbct_skip_encoding]') !== false ) { if ( $this->isBufferMode() ) { return $this->prepareTitleForBufferMode($raw_title); From 0b7c90da6db8b941dd57b4cca486f7fa34f1f96b Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Thu, 16 Jul 2026 16:30:59 +0300 Subject: [PATCH 7/8] upd flow --- .../Shortcodes/ExcludedEncodeContentSC.php | 129 +++++++++++++++--- 1 file changed, 113 insertions(+), 16 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 56744bd56..cc2dce6b7 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -108,7 +108,11 @@ public function changeContentAfterEncoderModify($content) $content = $this->restorePlaceholders($content); } - return $this->processSkipEncodingShortcodes($content); + $content = $this->processSkipEncodingShortcodes($content); + + // widget_block_content (and similar hooks) re-run modifyContent() on already + // rendered page-list HTML and encode skip titles again — restore after that. + return $this->restorePageListLinkTitlesInHtml($content); } if ( $apbct->settings['data__email_decoder_buffer'] ) { @@ -342,7 +346,7 @@ function ($matches) { return $matches[0]; } - $post_id = url_to_postid($matches[2]); + $post_id = $this->resolvePostIdFromHref($matches[2]); if ( ! $post_id ) { return $matches[0]; } @@ -362,6 +366,58 @@ function ($matches) { ); } + /** + * Resolve post ID from an href used in page-list / navigation links. + * + * @param string $href + * + * @return int + */ + protected function resolvePostIdFromHref($href) + { + if ( ! is_string($href) || $href === '' ) { + return 0; + } + + $post_id = url_to_postid($href); + if ( $post_id ) { + return (int)$post_id; + } + + $path = wp_parse_url($href, PHP_URL_PATH); + if ( ! is_string($path) || $path === '' ) { + return 0; + } + + $path = trim($path, '/'); + if ( $path === '' ) { + return 0; + } + + $page = get_page_by_path($path); + if ( $page instanceof \WP_Post ) { + return (int)$page->ID; + } + + foreach ( self::$raw_titles_cache as $cached_id => $_title ) { + $permalink = get_permalink((int)$cached_id); + if ( ! is_string($permalink) || $permalink === '' ) { + continue; + } + + if ( untrailingslashit($permalink) === untrailingslashit($href) ) { + return (int)$cached_id; + } + + $cached_path = wp_parse_url($permalink, PHP_URL_PATH); + if ( is_string($cached_path) && trim($cached_path, '/') === $path ) { + return (int)$cached_id; + } + } + + return 0; + } + /** * Restore post-title block output after nested render_block encoding passes. * @@ -476,39 +532,80 @@ protected function isShortcodeInsideHtmlAttribute($content) return false; } + /** + * Remove contacts that appear both inside skip-encoding shortcodes and as plain text outside them. + * Preserves original text/shortcode order (does not move shortcodes to the end). + * + * @param string $title + * + * @return string + */ protected function removeDuplicateContactsOutsideShortcodes($title) { if ( strpos($title, '[apbct_skip_encoding]') === false ) { return $title; } - preg_match_all('/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s', $title, $matches); + preg_match_all( + '/\[apbct_skip_encoding\](.*?)\[\/apbct_skip_encoding\]/s', + $title, + $matches, + PREG_OFFSET_CAPTURE + ); + if ( empty($matches[0]) || ! isset($matches[1]) ) { return $title; } - $outside = preg_replace('/\[apbct_skip_encoding\].*?\[\/apbct_skip_encoding\]/s', ' ', $title); - $outside = trim(preg_replace('/\s+/', ' ', $outside)); - - foreach ( $matches[1] as $inner ) { - $inner = trim($inner); - if ( $inner === '' ) { - continue; + $protected_contacts = array(); + foreach ( $matches[1] as $inner_match ) { + $inner = trim($inner_match[0]); + if ( $inner !== '' ) { + $protected_contacts[$inner] = true; } + } - $quoted = preg_quote($inner, '/'); - $outside = preg_replace('/' . $quoted . '/', '', $outside, 1); - $outside = trim(preg_replace('/\s+/', ' ', $outside)); + if ( empty($protected_contacts) ) { + return $title; } - $result = $outside; - foreach ( $matches[0] as $shortcode ) { - $result .= ($result !== '' ? ' ' : '') . $shortcode; + $result = ''; + $offset = 0; + + foreach ( $matches[0] as $shortcode_match ) { + $shortcode = $shortcode_match[0]; + $position = $shortcode_match[1]; + $text_before = substr($title, $offset, $position - $offset); + $result .= $this->stripProtectedContactsFromPlainText($text_before, array_keys($protected_contacts)); + $result .= $shortcode; + $offset = $position + strlen($shortcode); } + $result .= $this->stripProtectedContactsFromPlainText(substr($title, $offset), array_keys($protected_contacts)); + return trim(preg_replace('/\s+/', ' ', $result)); } + /** + * @param string $text + * @param string[] $contacts + * + * @return string + */ + protected function stripProtectedContactsFromPlainText($text, array $contacts) + { + if ( $text === '' || $text === false ) { + return ''; + } + + foreach ( $contacts as $contact ) { + $quoted = preg_quote($contact, '/'); + $text = preg_replace('/' . $quoted . '/', '', $text); + } + + return $text; + } + /** * Cache titles with skip-encoding shortcodes before frontend encoding mutates post objects. * From f450d28445b58d17c468a2ac6d7b76309b6cc0f4 Mon Sep 17 00:00:00 2001 From: svfcode <svfcode@mail.ru> Date: Thu, 16 Jul 2026 17:08:05 +0300 Subject: [PATCH 8/8] fix cp --- .../Shortcodes/ExcludedEncodeContentSC.php | 8 +++++++- .../Shortcodes/ExcludedEncodeContentSCTest.php | 13 +++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index cc2dce6b7..7eed749ac 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -194,6 +194,10 @@ protected function restorePlaceholders($content) */ public function pushRenderBlockStack($pre_render, $block) { + if ( $pre_render !== null ) { + return $pre_render; + } + $this->render_block_stack[] = isset($block['blockName']) ? (string)$block['blockName'] : ''; return $pre_render; @@ -215,7 +219,9 @@ public function finalizePlaceholderRestoreAfterRenderBlock($content) return $content; } - if ( function_exists('doing_filter') && doing_filter('the_content') ) { + if ( function_exists('doing_filter') + && ( doing_filter('the_content') || doing_filter('widget_block_content') ) + ) { return $content; } diff --git a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php index 6093d4a00..28e205003 100644 --- a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php +++ b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php @@ -70,14 +70,19 @@ public function testCallbackWithValidContentBufferOffReturnsPlaceholder(): void { global $apbct; + $previous_buffer = $apbct->settings['data__email_decoder_buffer']; $apbct->settings['data__email_decoder_buffer'] = false; - $originalString = 'test@example.com'; - $encodedString = $this->contacts_encoder->modifyContent($originalString); + try { + $originalString = 'test@example.com'; + $encodedString = $this->contacts_encoder->modifyContent($originalString); - $result = $this->exclude_content_sc->callback([], $encodedString, ''); + $result = $this->exclude_content_sc->callback([], $encodedString, ''); - $this->assertStringContainsString('APBCT_SHORT_CODE_SKIP', $result); + $this->assertStringContainsString('APBCT_SHORT_CODE_SKIP', $result); + } finally { + $apbct->settings['data__email_decoder_buffer'] = $previous_buffer; + } } /**