true, 'header_offset' => 2, // # → h3, ## → h4 'use_wpautop' => false, 'strikethrough' => false, ]; case self::MODE_ADMIN: case self::MODE_SIMPLE: default: return [ 'convert_urls' => false, 'header_offset' => 0, // # → h1, ## → h2 'use_wpautop' => true, 'strikethrough' => true, ]; } } /** * Frontend conversion with placeholder protection and strict escaping * * Used for: Chatbot responses, search results, public-facing content * Security: Uses esc_html() during processing, placeholder protection * * @param string $text Markdown text * @param array $options Conversion options * * @return string HTML output */ private static function convert_frontend( $text, $options ) { // Step 1: Extract and protect elements with placeholders $placeholders = []; $index = 0; // Extract fenced code blocks ```language\ncode\n``` $text = preg_replace_callback( '/```(\w*)\n([\s\S]*?)```/', function ( $matches ) use ( &$placeholders, &$index ) { $placeholder = "%%WPF_CODEBLOCK_{$index}%%"; $language = ! empty( $matches[1] ) ? ' class="language-' . esc_attr( $matches[1] ) . '"' : ''; $code = esc_html( trim( $matches[2] ) ); $placeholders[ $placeholder ] = '
' . $code . '
'; $index++; return $placeholder; }, $text ); // Extract inline code `code` $text = preg_replace_callback( '/`([^`\n]+)`/', function ( $matches ) use ( &$placeholders, &$index ) { $placeholder = "%%WPF_INLINECODE_{$index}%%"; $placeholders[ $placeholder ] = '' . esc_html( $matches[1] ) . ''; $index++; return $placeholder; }, $text ); // Extract markdown links [text](url) $text = preg_replace_callback( '/\[([^\]]+)\]\(([^)]+)\)/', function ( $matches ) use ( &$placeholders, &$index ) { $placeholder = "%%WPF_LINK_{$index}%%"; $placeholders[ $placeholder ] = '' . esc_html( $matches[1] ) . ''; $index++; return $placeholder; }, $text ); // Extract plain URLs if enabled if ( ! empty( $options['convert_urls'] ) ) { $text = preg_replace_callback( '/(?\[\]"\']+)/', function ( $matches ) use ( &$placeholders, &$index ) { $url = rtrim( $matches[1], '.,;:!?' ); $placeholder = "%%WPF_PLAINURL_{$index}%%"; $placeholders[ $placeholder ] = '' . esc_html( $url ) . '
'; $index++; return $placeholder; }, $text ); } // Extract bold **text** or __text__ $text = preg_replace_callback( '/(\*\*|__)(.+?)\1/', function ( $matches ) use ( &$placeholders, &$index ) { $placeholder = "%%WPF_BOLD_{$index}%%"; $placeholders[ $placeholder ] = '' . esc_html( $matches[2] ) . ''; $index++; return $placeholder; }, $text ); // Extract italic *text* or _text_ (but not inside words, not matching HR like ***) // Uses [^\*_] to prevent matching horizontal rules or other markers $text = preg_replace_callback( '/(?' . esc_html( $matches[2] ) . ''; $index++; return $placeholder; }, $text ); // Step 2: Process block-level elements $text = self::process_blocks_frontend( $text, $options, $placeholders ); // Step 3: Restore all placeholders (in reverse order to handle nested placeholders) foreach ( array_reverse( $placeholders, true ) as $placeholder => $html ) { $text = str_replace( $placeholder, $html, $text ); } // Step 4: Convert remaining newlines to
$text = preg_replace( '/(?)\n(?!<)/', '
' . "\n", $text ); // Clean up extra line breaks $text = preg_replace( '/(]*>\s*)+/', '
', $text ); $text = preg_replace( '/]*>\s*(<\/?(ul|ol|li|pre|blockquote|h[1-6]|hr))/', '$1', $text ); $text = preg_replace( '/(<\/?(ul|ol|li|pre|blockquote|h[1-6]|hr)[^>]*>)\s*]*>/', '$1', $text ); return trim( $text ); } /** * Process block-level elements for frontend mode * * @param string $text Text to process * @param array $options Conversion options * @param array $placeholders Reference to placeholders array * * @return string Processed text */ private static function process_blocks_frontend( $text, $options, &$placeholders ) { $lines = explode( "\n", $text ); $result = []; $in_list = false; $list_type = ''; $in_blockquote = false; $header_offset = $options['header_offset'] ?? 2; foreach ( $lines as $line ) { $trimmed = trim( $line ); // Skip if line is a placeholder (code block) if ( preg_match( '/^%%WPF_CODEBLOCK_\d+%%$/', $trimmed ) ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; $in_list = false; } if ( $in_blockquote ) { $result[] = ''; $in_blockquote = false; } $result[] = $trimmed; continue; } // Horizontal rule if ( preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $trimmed ) ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; $in_list = false; } if ( $in_blockquote ) { $result[] = ''; $in_blockquote = false; } $result[] = '
'; continue; } // Headers (# ## ### up to ######) if ( preg_match( '/^(#{1,6})\s+(.+)$/', $trimmed, $matches ) ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; $in_list = false; } if ( $in_blockquote ) { $result[] = ''; $in_blockquote = false; } $level = min( strlen( $matches[1] ) + $header_offset, 6 ); $result[] = '' . esc_html( $matches[2] ) . ''; continue; } // Blockquote if ( preg_match( '/^>\s*(.*)$/', $trimmed, $matches ) ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; $in_list = false; } if ( ! $in_blockquote ) { $result[] = '
'; $in_blockquote = true; } $result[] = esc_html( $matches[1] ); continue; } elseif ( $in_blockquote && ! empty( $trimmed ) ) { $result[] = '
'; $in_blockquote = false; } // Unordered list (- or *) if ( preg_match( '/^[-*]\s+(.+)$/', $trimmed, $matches ) ) { if ( $in_blockquote ) { $result[] = ''; $in_blockquote = false; } if ( ! $in_list || $list_type !== 'ul' ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; } $result[] = '
    '; $in_list = true; $list_type = 'ul'; } $result[] = '
  • ' . esc_html( $matches[1] ) . '
  • '; continue; } // Ordered list (1. or 1) format) if ( preg_match( '/^\d+[.)]\s+(.+)$/', $trimmed, $matches ) ) { if ( $in_blockquote ) { $result[] = ''; $in_blockquote = false; } if ( ! $in_list || $list_type !== 'ol' ) { if ( $in_list ) { $result[] = $list_type === 'ul' ? '
' : ''; } $result[] = '
    '; $in_list = true; $list_type = 'ol'; } $result[] = '
  1. ' . esc_html( $matches[1] ) . '
  2. '; continue; } // End list if we hit a non-list line if ( $in_list && ! empty( $trimmed ) ) { $result[] = $list_type === 'ul' ? '' : '
'; $in_list = false; } // Regular line - escape and add if ( ! empty( $trimmed ) ) { $result[] = esc_html( $line ); } elseif ( ! $in_list && ! $in_blockquote ) { $result[] = ''; } } // Close any open tags if ( $in_list ) { $result[] = $list_type === 'ul' ? '' : ''; } if ( $in_blockquote ) { $result[] = ''; } return implode( "\n", $result ); } /** * Admin conversion with simple regex and wp_kses_post * * Used for: Log viewer, admin panels, trusted content * Security: Uses wp_kses_post() at the end for sanitization * * @param string $text Markdown text * @param array $options Conversion options * * @return string HTML output */ private static function convert_admin( $text, $options ) { $header_offset = $options['header_offset'] ?? 0; // Code blocks FIRST: ```code``` (must be before inline code) $text = preg_replace( '/```(\w*)\n?([\s\S]*?)```/', '
$2
', $text ); // Inline code: `code` (after code blocks) $text = preg_replace( '/`([^`]+)`/', '$1', $text ); // Headers (h1-h6) with optional offset if ( $header_offset === 0 ) { $text = preg_replace( '/^######\s+(.+)$/m', '
$1
', $text ); $text = preg_replace( '/^#####\s+(.+)$/m', '
$1
', $text ); $text = preg_replace( '/^####\s+(.+)$/m', '

$1

', $text ); $text = preg_replace( '/^###\s+(.+)$/m', '

$1

', $text ); $text = preg_replace( '/^##\s+(.+)$/m', '

$1

', $text ); $text = preg_replace( '/^#\s+(.+)$/m', '

$1

', $text ); } else { // Apply header offset (e.g., # → h3 when offset is 2) for ( $i = 6; $i >= 1; $i-- ) { $hashes = str_repeat( '#', $i ); $new_level = min( $i + $header_offset, 6 ); $text = preg_replace( '/^' . $hashes . '\s+(.+)$/m', '$1', $text ); } } // Bold: **text** or __text__ $text = preg_replace( '/\*\*(.+?)\*\*/', '$1', $text ); $text = preg_replace( '/__(.+?)__/', '$1', $text ); // Italic: *text* or _text_ (but not inside words) $text = preg_replace( '/(?$1', $text ); $text = preg_replace( '/(?$1', $text ); // Strikethrough: ~~text~~ (admin mode only) if ( ! empty( $options['strikethrough'] ) ) { $text = preg_replace( '/~~(.+?)~~/', '$1', $text ); } // Links: [text](url) $text = preg_replace( '/\[([^\]]+)\]\(([^\)]+)\)/', '$1', $text ); // Ordered lists: 1. or 1) item (process BEFORE unordered to wrap correctly) $text = preg_replace( '/^\d+[.)]\s+(.+)$/m', '
  • $1
  • ', $text ); $text = preg_replace( '/(
  • .*<\/li>\n?)+/', '
      $0
    ', $text ); // Unordered lists: - item or * item $text = preg_replace( '/^[\-\*]\s+(.+)$/m', '
  • $1
  • ', $text ); $text = preg_replace( '/(
  • .*<\/li>\n?)+/', '
      $0
    ', $text ); // Blockquotes: > text $text = preg_replace( '/^>\s+(.+)$/m', '
    $1
    ', $text ); // Horizontal rules: --- or *** $text = preg_replace( '/^[\-\*]{3,}$/m', '
    ', $text ); // Use wpautop for paragraph handling if enabled if ( ! empty( $options['use_wpautop'] ) ) { $text = wpautop( $text ); } // Sanitize with wp_kses_post for admin context return wp_kses_post( $text ); } /** * Convert citation markers to clickable links * * Handles various citation formats from AI responses: * - [[#123]] - wpForo post reference * - [[#123:Title]] - wpForo with title (title ignored) * - [[#wp_123]] - WordPress post reference * - [[#wp_123:Title]] - WordPress with title * - [[123]] - Missing hash (common LLM mistake) * - [#123] - Missing outer brackets * * @param string $text Text containing citation markers * @param array $options Optional settings: * - 'format': 'superscript' (default) or 'inline' * - 'class': CSS class for links (default: 'wpf-ai-chat-reference') * * @return string Text with citations converted to HTML links */ public static function convert_citations( $text, $options = [] ) { if ( empty( $text ) ) { return ''; } $defaults = [ 'format' => 'superscript', 'class' => 'wpf-ai-chat-reference', ]; $options = wp_parse_args( $options, $defaults ); // Step 1: Normalize malformed citations // Convert [[123]] to [[#123]] $text = preg_replace( '/\[\[(\d+)\]\]/', '[[#$1]]', $text ); // Convert [#123] to [[#123]] $text = preg_replace( '/(?post->get_url( $postid ); if ( ! $url ) { return $matches[0]; // Return original if URL not found } if ( $options['format'] === 'superscript' ) { return '[' . $postid . ']'; } return '#' . $postid . ''; }, $text ); // Step 4: Convert WordPress citations [[#wp_postid]] or [[#wp_postid:title]] $text = preg_replace_callback( '/\[\[#wp_(\d+)(?::([^\]]+))?\]\]/', function ( $matches ) use ( $options ) { $postid = intval( $matches[1] ); $title = ! empty( $matches[2] ) ? $matches[2] : ''; $url = get_permalink( $postid ); if ( ! $url ) { return $matches[0]; // Return original if URL not found } // Get title from post if not provided if ( empty( $title ) ) { $post = get_post( $postid ); $title = $post ? $post->post_title : "Post #{$postid}"; } if ( $options['format'] === 'superscript' ) { return '[' . $postid . ']'; } return '' . esc_html( $title ) . ''; }, $text ); // Step 5: Fallback for WordPress citations missing # - [[wp_postid]] or [[wp_postid:title]] // AI sometimes forgets the hash, so handle this common mistake $text = preg_replace_callback( '/\[\[wp_(\d+)(?::([^\]]+))?\]\]/', function ( $matches ) use ( $options ) { $postid = intval( $matches[1] ); $title = ! empty( $matches[2] ) ? $matches[2] : ''; $url = get_permalink( $postid ); if ( ! $url ) { return $matches[0]; // Return original if URL not found } // Get title from post if not provided if ( empty( $title ) ) { $post = get_post( $postid ); $title = $post ? $post->post_title : "Post #{$postid}"; } if ( $options['format'] === 'superscript' ) { return '[' . $postid . ']'; } return '' . esc_html( $title ) . ''; }, $text ); return $text; } /** * Replace [NO_FORUM_CONTENT] placeholder with custom message * * @param string $text Text containing placeholder * @param string $custom_message Custom message to use (or empty for default) * @param array $replacements Key-value pairs for placeholder replacement in message * * @return string Text with placeholder replaced */ public static function replace_no_content_placeholder( $text, $custom_message = '', $replacements = [] ) { if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) { return $text; } // Default message if ( empty( $custom_message ) ) { $custom_message = __( "I couldn't find specific forum content related to your question. Would you like to start a new topic to discuss this?", 'wpforo' ); } // Apply replacements (e.g., {add_topic_url}) foreach ( $replacements as $key => $value ) { $custom_message = str_replace( '{' . $key . '}', $value, $custom_message ); } // Sanitize: allow only safe HTML tags $allowed_tags = '

      1. '; $custom_message = strip_tags( $custom_message, $allowed_tags ); return str_replace( '[NO_FORUM_CONTENT]', $custom_message, $text ); } /** * Full AI response formatting pipeline * * Combines all formatting steps in the correct order: * 1. Replace [NO_FORUM_CONTENT] placeholder * 2. Convert markdown to HTML * 3. Convert citation markers to links * * @param string $text Raw AI response text * @param string $mode Conversion mode (default: MODE_FRONTEND) * @param array $options Options for to_html() * @param array $citation_options Options for convert_citations() * @param string $no_content_msg Custom message for [NO_FORUM_CONTENT] * @param array $no_content_vars Replacements for no-content message * * @return string Fully formatted HTML */ public static function format_ai_response( $text, $mode = self::MODE_FRONTEND, $options = [], $citation_options = [], $no_content_msg = '', $no_content_vars = [] ) { if ( empty( $text ) ) { return ''; } // Step 1: Replace [NO_FORUM_CONTENT] placeholder $text = self::replace_no_content_placeholder( $text, $no_content_msg, $no_content_vars ); // Step 2: Convert markdown to HTML $text = self::to_html( $text, $mode, $options ); // Step 3: Convert citation markers to links $text = self::convert_citations( $text, $citation_options ); return $text; } }