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[] = '- ' . esc_html( $matches[1] ) . '
';
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?)+/', '', $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 = '
![]()
- ';
$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;
}
}