PluginProbe
wpForo Forum / 3.0.9
wpForo Forum v3.0.9
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / AIMarkdown.php

AIMarkdown.php in wpForo Forum 3.0.9, at classes/AIMarkdown.php

604 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * AIMarkdown - Unified Markdown to HTML converter for AI features
11 *
12 * Provides consistent markdown conversion across all AI features with
13 * mode-based behavior for different security contexts.
14 *
15 * Modes:
16 * - MODE_FRONTEND: Strict escaping with esc_html(), placeholder protection (chatbot, search)
17 * - MODE_ADMIN: Permissive with wp_kses_post(), includes strikethrough (log viewer)
18 * - MODE_SIMPLE: Basic conversion without escaping (for pre-escaped content)
19 *
20 * @since 3.0.0
21 */
22 class AIMarkdown {
23
24 /**
25 * Mode constants
26 */
27 const MODE_FRONTEND = 'frontend';
28 const MODE_ADMIN = 'admin';
29 const MODE_SIMPLE = 'simple';
30
31 /**
32 * Convert markdown to HTML
33 *
34 * @param string $text Markdown text to convert
35 * @param string $mode Conversion mode (MODE_FRONTEND, MODE_ADMIN, MODE_SIMPLE)
36 * @param array $options Optional settings:
37 * - 'convert_urls': bool - Convert plain URLs to links (default: true for frontend)
38 * - 'header_offset': int - Offset for header levels (default: 2 for frontend, 0 for admin)
39 * - 'use_wpautop': bool - Use wpautop for paragraphs (default: true for admin)
40 *
41 * @return string HTML output
42 */
43 public static function to_html( $text, $mode = self::MODE_FRONTEND, $options = [] ) {
44 if ( empty( $text ) ) {
45 return '';
46 }
47
48 // Normalize line endings
49 $text = str_replace( [ "\r\n", "\r" ], "\n", $text );
50
51 // Set defaults based on mode
52 $defaults = self::get_mode_defaults( $mode );
53 $options = wp_parse_args( $options, $defaults );
54
55 // Route to appropriate converter
56 if ( $mode === self::MODE_FRONTEND ) {
57 return self::convert_frontend( $text, $options );
58 }
59
60 return self::convert_admin( $text, $options );
61 }
62
63 /**
64 * Get default options for each mode
65 *
66 * @param string $mode Conversion mode
67 *
68 * @return array Default options
69 */
70 private static function get_mode_defaults( $mode ) {
71 switch ( $mode ) {
72 case self::MODE_FRONTEND:
73 return [
74 'convert_urls' => true,
75 'header_offset' => 2, // # → h3, ## → h4
76 'use_wpautop' => false,
77 'strikethrough' => false,
78 ];
79
80 case self::MODE_ADMIN:
81 case self::MODE_SIMPLE:
82 default:
83 return [
84 'convert_urls' => false,
85 'header_offset' => 0, // # → h1, ## → h2
86 'use_wpautop' => true,
87 'strikethrough' => true,
88 ];
89 }
90 }
91
92 /**
93 * Frontend conversion with placeholder protection and strict escaping
94 *
95 * Used for: Chatbot responses, search results, public-facing content
96 * Security: Uses esc_html() during processing, placeholder protection
97 *
98 * @param string $text Markdown text
99 * @param array $options Conversion options
100 *
101 * @return string HTML output
102 */
103 private static function convert_frontend( $text, $options ) {
104 // Step 1: Extract and protect elements with placeholders
105 $placeholders = [];
106 $index = 0;
107
108 // Extract fenced code blocks ```language\ncode\n```
109 $text = preg_replace_callback(
110 '/```(\w*)\n([\s\S]*?)```/',
111 function ( $matches ) use ( &$placeholders, &$index ) {
112 $placeholder = "%%WPF_CODEBLOCK_{$index}%%";
113 $language = ! empty( $matches[1] ) ? ' class="language-' . esc_attr( $matches[1] ) . '"' : '';
114 $code = esc_html( trim( $matches[2] ) );
115 $placeholders[ $placeholder ] = '<pre><code' . $language . '>' . $code . '</code></pre>';
116 $index++;
117 return $placeholder;
118 },
119 $text
120 );
121
122 // Extract inline code `code`
123 $text = preg_replace_callback(
124 '/`([^`\n]+)`/',
125 function ( $matches ) use ( &$placeholders, &$index ) {
126 $placeholder = "%%WPF_INLINECODE_{$index}%%";
127 $placeholders[ $placeholder ] = '<code>' . esc_html( $matches[1] ) . '</code>';
128 $index++;
129 return $placeholder;
130 },
131 $text
132 );
133
134 // Extract markdown links [text](url)
135 $text = preg_replace_callback(
136 '/\[([^\]]+)\]\(([^)]+)\)/',
137 function ( $matches ) use ( &$placeholders, &$index ) {
138 $placeholder = "%%WPF_LINK_{$index}%%";
139 $placeholders[ $placeholder ] = '<a href="' . esc_url( $matches[2] ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $matches[1] ) . '</a>';
140 $index++;
141 return $placeholder;
142 },
143 $text
144 );
145
146 // Extract plain URLs if enabled
147 if ( ! empty( $options['convert_urls'] ) ) {
148 $text = preg_replace_callback(
149 '/(?<!["\'])(https?:\/\/[^\s<>\[\]"\']+)/',
150 function ( $matches ) use ( &$placeholders, &$index ) {
151 $url = rtrim( $matches[1], '.,;:!?' );
152 $placeholder = "%%WPF_PLAINURL_{$index}%%";
153 $placeholders[ $placeholder ] = '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $url ) . '</a><br class="wpf-ai-br">';
154 $index++;
155 return $placeholder;
156 },
157 $text
158 );
159 }
160
161 // Extract bold **text** or __text__
162 $text = preg_replace_callback(
163 '/(\*\*|__)(.+?)\1/',
164 function ( $matches ) use ( &$placeholders, &$index ) {
165 $placeholder = "%%WPF_BOLD_{$index}%%";
166 $placeholders[ $placeholder ] = '<strong>' . esc_html( $matches[2] ) . '</strong>';
167 $index++;
168 return $placeholder;
169 },
170 $text
171 );
172
173 // Extract italic *text* or _text_ (but not inside words, not matching HR like ***)
174 // Uses [^\*_] to prevent matching horizontal rules or other markers
175 $text = preg_replace_callback(
176 '/(?<![a-zA-Z0-9\*_])(\*|_)([^\*_\n]+?)\1(?![a-zA-Z0-9\*_])/',
177 function ( $matches ) use ( &$placeholders, &$index ) {
178 $placeholder = "%%WPF_ITALIC_{$index}%%";
179 $placeholders[ $placeholder ] = '<em>' . esc_html( $matches[2] ) . '</em>';
180 $index++;
181 return $placeholder;
182 },
183 $text
184 );
185
186 // Step 2: Process block-level elements
187 $text = self::process_blocks_frontend( $text, $options, $placeholders );
188
189 // Step 3: Restore all placeholders (in reverse order to handle nested placeholders)
190 foreach ( array_reverse( $placeholders, true ) as $placeholder => $html ) {
191 $text = str_replace( $placeholder, $html, $text );
192 }
193
194 // Step 4: Convert remaining newlines to <br>
195 $text = preg_replace( '/(?<!>)\n(?!<)/', '<br class="wpf-ai-br">' . "\n", $text );
196
197 // Clean up extra line breaks
198 $text = preg_replace( '/(<br[^>]*>\s*)+/', '<br class="wpf-ai-br">', $text );
199 $text = preg_replace( '/<br[^>]*>\s*(<\/?(ul|ol|li|pre|blockquote|h[1-6]|hr))/', '$1', $text );
200 $text = preg_replace( '/(<\/?(ul|ol|li|pre|blockquote|h[1-6]|hr)[^>]*>)\s*<br[^>]*>/', '$1', $text );
201
202 return trim( $text );
203 }
204
205 /**
206 * Process block-level elements for frontend mode
207 *
208 * @param string $text Text to process
209 * @param array $options Conversion options
210 * @param array $placeholders Reference to placeholders array
211 *
212 * @return string Processed text
213 */
214 private static function process_blocks_frontend( $text, $options, &$placeholders ) {
215 $lines = explode( "\n", $text );
216 $result = [];
217 $in_list = false;
218 $list_type = '';
219 $in_blockquote = false;
220 $header_offset = $options['header_offset'] ?? 2;
221
222 foreach ( $lines as $line ) {
223 $trimmed = trim( $line );
224
225 // Skip if line is a placeholder (code block)
226 if ( preg_match( '/^%%WPF_CODEBLOCK_\d+%%$/', $trimmed ) ) {
227 if ( $in_list ) {
228 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
229 $in_list = false;
230 }
231 if ( $in_blockquote ) {
232 $result[] = '</blockquote>';
233 $in_blockquote = false;
234 }
235 $result[] = $trimmed;
236 continue;
237 }
238
239 // Horizontal rule
240 if ( preg_match( '/^(-{3,}|\*{3,}|_{3,})$/', $trimmed ) ) {
241 if ( $in_list ) {
242 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
243 $in_list = false;
244 }
245 if ( $in_blockquote ) {
246 $result[] = '</blockquote>';
247 $in_blockquote = false;
248 }
249 $result[] = '<hr>';
250 continue;
251 }
252
253 // Headers (# ## ### up to ######)
254 if ( preg_match( '/^(#{1,6})\s+(.+)$/', $trimmed, $matches ) ) {
255 if ( $in_list ) {
256 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
257 $in_list = false;
258 }
259 if ( $in_blockquote ) {
260 $result[] = '</blockquote>';
261 $in_blockquote = false;
262 }
263 $level = min( strlen( $matches[1] ) + $header_offset, 6 );
264 $result[] = '<h' . $level . '>' . esc_html( $matches[2] ) . '</h' . $level . '>';
265 continue;
266 }
267
268 // Blockquote
269 if ( preg_match( '/^>\s*(.*)$/', $trimmed, $matches ) ) {
270 if ( $in_list ) {
271 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
272 $in_list = false;
273 }
274 if ( ! $in_blockquote ) {
275 $result[] = '<blockquote>';
276 $in_blockquote = true;
277 }
278 $result[] = esc_html( $matches[1] );
279 continue;
280 } elseif ( $in_blockquote && ! empty( $trimmed ) ) {
281 $result[] = '</blockquote>';
282 $in_blockquote = false;
283 }
284
285 // Unordered list (- or *)
286 if ( preg_match( '/^[-*]\s+(.+)$/', $trimmed, $matches ) ) {
287 if ( $in_blockquote ) {
288 $result[] = '</blockquote>';
289 $in_blockquote = false;
290 }
291 if ( ! $in_list || $list_type !== 'ul' ) {
292 if ( $in_list ) {
293 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
294 }
295 $result[] = '<ul>';
296 $in_list = true;
297 $list_type = 'ul';
298 }
299 $result[] = '<li>' . esc_html( $matches[1] ) . '</li>';
300 continue;
301 }
302
303 // Ordered list (1. or 1) format)
304 if ( preg_match( '/^\d+[.)]\s+(.+)$/', $trimmed, $matches ) ) {
305 if ( $in_blockquote ) {
306 $result[] = '</blockquote>';
307 $in_blockquote = false;
308 }
309 if ( ! $in_list || $list_type !== 'ol' ) {
310 if ( $in_list ) {
311 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
312 }
313 $result[] = '<ol>';
314 $in_list = true;
315 $list_type = 'ol';
316 }
317 $result[] = '<li>' . esc_html( $matches[1] ) . '</li>';
318 continue;
319 }
320
321 // End list if we hit a non-list line
322 if ( $in_list && ! empty( $trimmed ) ) {
323 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
324 $in_list = false;
325 }
326
327 // Regular line - escape and add
328 if ( ! empty( $trimmed ) ) {
329 $result[] = esc_html( $line );
330 } elseif ( ! $in_list && ! $in_blockquote ) {
331 $result[] = '';
332 }
333 }
334
335 // Close any open tags
336 if ( $in_list ) {
337 $result[] = $list_type === 'ul' ? '</ul>' : '</ol>';
338 }
339 if ( $in_blockquote ) {
340 $result[] = '</blockquote>';
341 }
342
343 return implode( "\n", $result );
344 }
345
346 /**
347 * Admin conversion with simple regex and wp_kses_post
348 *
349 * Used for: Log viewer, admin panels, trusted content
350 * Security: Uses wp_kses_post() at the end for sanitization
351 *
352 * @param string $text Markdown text
353 * @param array $options Conversion options
354 *
355 * @return string HTML output
356 */
357 private static function convert_admin( $text, $options ) {
358 $header_offset = $options['header_offset'] ?? 0;
359
360 // Code blocks FIRST: ```code``` (must be before inline code)
361 $text = preg_replace( '/```(\w*)\n?([\s\S]*?)```/', '<pre><code>$2</code></pre>', $text );
362
363 // Inline code: `code` (after code blocks)
364 $text = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $text );
365
366 // Headers (h1-h6) with optional offset
367 if ( $header_offset === 0 ) {
368 $text = preg_replace( '/^######\s+(.+)$/m', '<h6>$1</h6>', $text );
369 $text = preg_replace( '/^#####\s+(.+)$/m', '<h5>$1</h5>', $text );
370 $text = preg_replace( '/^####\s+(.+)$/m', '<h4>$1</h4>', $text );
371 $text = preg_replace( '/^###\s+(.+)$/m', '<h3>$1</h3>', $text );
372 $text = preg_replace( '/^##\s+(.+)$/m', '<h2>$1</h2>', $text );
373 $text = preg_replace( '/^#\s+(.+)$/m', '<h1>$1</h1>', $text );
374 } else {
375 // Apply header offset (e.g., # → h3 when offset is 2)
376 for ( $i = 6; $i >= 1; $i-- ) {
377 $hashes = str_repeat( '#', $i );
378 $new_level = min( $i + $header_offset, 6 );
379 $text = preg_replace( '/^' . $hashes . '\s+(.+)$/m', '<h' . $new_level . '>$1</h' . $new_level . '>', $text );
380 }
381 }
382
383 // Bold: **text** or __text__
384 $text = preg_replace( '/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text );
385 $text = preg_replace( '/__(.+?)__/', '<strong>$1</strong>', $text );
386
387 // Italic: *text* or _text_ (but not inside words)
388 $text = preg_replace( '/(?<!\w)\*([^\*]+)\*(?!\w)/', '<em>$1</em>', $text );
389 $text = preg_replace( '/(?<!\w)_([^_]+)_(?!\w)/', '<em>$1</em>', $text );
390
391 // Strikethrough: ~~text~~ (admin mode only)
392 if ( ! empty( $options['strikethrough'] ) ) {
393 $text = preg_replace( '/~~(.+?)~~/', '<del>$1</del>', $text );
394 }
395
396 // Links: [text](url)
397 $text = preg_replace( '/\[([^\]]+)\]\(([^\)]+)\)/', '<a href="$2" target="_blank">$1</a>', $text );
398
399 // Ordered lists: 1. or 1) item (process BEFORE unordered to wrap correctly)
400 $text = preg_replace( '/^\d+[.)]\s+(.+)$/m', '<li>$1</li>', $text );
401 $text = preg_replace( '/(<li>.*<\/li>\n?)+/', '<ol>$0</ol>', $text );
402
403 // Unordered lists: - item or * item
404 $text = preg_replace( '/^[\-\*]\s+(.+)$/m', '<li>$1</li>', $text );
405 $text = preg_replace( '/(<li>.*<\/li>\n?)+/', '<ul>$0</ul>', $text );
406
407 // Blockquotes: > text
408 $text = preg_replace( '/^>\s+(.+)$/m', '<blockquote>$1</blockquote>', $text );
409
410 // Horizontal rules: --- or ***
411 $text = preg_replace( '/^[\-\*]{3,}$/m', '<hr>', $text );
412
413 // Use wpautop for paragraph handling if enabled
414 if ( ! empty( $options['use_wpautop'] ) ) {
415 $text = wpautop( $text );
416 }
417
418 // Sanitize with wp_kses_post for admin context
419 return wp_kses_post( $text );
420 }
421
422 /**
423 * Convert citation markers to clickable links
424 *
425 * Handles various citation formats from AI responses:
426 * - [[#123]] - wpForo post reference
427 * - [[#123:Title]] - wpForo with title (title ignored)
428 * - [[#wp_123]] - WordPress post reference
429 * - [[#wp_123:Title]] - WordPress with title
430 * - [[123]] - Missing hash (common LLM mistake)
431 * - [#123] - Missing outer brackets
432 *
433 * @param string $text Text containing citation markers
434 * @param array $options Optional settings:
435 * - 'format': 'superscript' (default) or 'inline'
436 * - 'class': CSS class for links (default: 'wpf-ai-chat-reference')
437 *
438 * @return string Text with citations converted to HTML links
439 */
440 public static function convert_citations( $text, $options = [] ) {
441 if ( empty( $text ) ) {
442 return '';
443 }
444
445 $defaults = [
446 'format' => 'superscript',
447 'class' => 'wpf-ai-chat-reference',
448 ];
449 $options = wp_parse_args( $options, $defaults );
450
451 // Step 1: Normalize malformed citations
452 // Convert [[123]] to [[#123]]
453 $text = preg_replace( '/\[\[(\d+)\]\]/', '[[#$1]]', $text );
454 // Convert [#123] to [[#123]]
455 $text = preg_replace( '/(?<!\[)\[#(\d+)\](?!\])/', '[[#$1]]', $text );
456
457 // Step 2: Split grouped citations like [[#1360],[#1506]]
458 $text = preg_replace( '/\[\[#(\d+)\],\s*\[#(\d+)\]\]/', '[[#$1]][[#$2]]', $text );
459 $text = preg_replace( '/\[\[#(\d+)\],\s*\[#(\d+)\],\s*\[#(\d+)\]\]/', '[[#$1]][[#$2]][[#$3]]', $text );
460
461 // Step 3: Convert wpForo citations [[#postid]] or [[#postid:title]]
462 $text = preg_replace_callback(
463 '/\[\[#(\d+)(?::[^\]]+)?\]\]/',
464 function ( $matches ) use ( $options ) {
465 $postid = intval( $matches[1] );
466 $url = \WPF()->post->get_url( $postid );
467
468 if ( ! $url ) {
469 return $matches[0]; // Return original if URL not found
470 }
471
472 if ( $options['format'] === 'superscript' ) {
473 return '<sup class="' . esc_attr( $options['class'] ) . '"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[' . $postid . ']</a></sup>';
474 }
475
476 return '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener" class="' . esc_attr( $options['class'] ) . '">#' . $postid . '</a>';
477 },
478 $text
479 );
480
481 // Step 4: Convert WordPress citations [[#wp_postid]] or [[#wp_postid:title]]
482 $text = preg_replace_callback(
483 '/\[\[#wp_(\d+)(?::([^\]]+))?\]\]/',
484 function ( $matches ) use ( $options ) {
485 $postid = intval( $matches[1] );
486 $title = ! empty( $matches[2] ) ? $matches[2] : '';
487 $url = get_permalink( $postid );
488
489 if ( ! $url ) {
490 return $matches[0]; // Return original if URL not found
491 }
492
493 // Get title from post if not provided
494 if ( empty( $title ) ) {
495 $post = get_post( $postid );
496 $title = $post ? $post->post_title : "Post #{$postid}";
497 }
498
499 if ( $options['format'] === 'superscript' ) {
500 return '<sup class="' . esc_attr( $options['class'] ) . '"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:2px"><path d="M4 4h16v16H4z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg>' . $postid . ']</a></sup>';
501 }
502
503 return '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener" class="' . esc_attr( $options['class'] ) . '">' . esc_html( $title ) . '</a>';
504 },
505 $text
506 );
507
508 // Step 5: Fallback for WordPress citations missing # - [[wp_postid]] or [[wp_postid:title]]
509 // AI sometimes forgets the hash, so handle this common mistake
510 $text = preg_replace_callback(
511 '/\[\[wp_(\d+)(?::([^\]]+))?\]\]/',
512 function ( $matches ) use ( $options ) {
513 $postid = intval( $matches[1] );
514 $title = ! empty( $matches[2] ) ? $matches[2] : '';
515 $url = get_permalink( $postid );
516
517 if ( ! $url ) {
518 return $matches[0]; // Return original if URL not found
519 }
520
521 // Get title from post if not provided
522 if ( empty( $title ) ) {
523 $post = get_post( $postid );
524 $title = $post ? $post->post_title : "Post #{$postid}";
525 }
526
527 if ( $options['format'] === 'superscript' ) {
528 return '<sup class="' . esc_attr( $options['class'] ) . '"><a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">[<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:2px"><path d="M4 4h16v16H4z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg>' . $postid . ']</a></sup>';
529 }
530
531 return '<a href="' . esc_url( $url ) . '" target="_blank" rel="noopener" class="' . esc_attr( $options['class'] ) . '">' . esc_html( $title ) . '</a>';
532 },
533 $text
534 );
535
536 return $text;
537 }
538
539 /**
540 * Replace [NO_FORUM_CONTENT] placeholder with custom message
541 *
542 * @param string $text Text containing placeholder
543 * @param string $custom_message Custom message to use (or empty for default)
544 * @param array $replacements Key-value pairs for placeholder replacement in message
545 *
546 * @return string Text with placeholder replaced
547 */
548 public static function replace_no_content_placeholder( $text, $custom_message = '', $replacements = [] ) {
549 if ( strpos( $text, '[NO_FORUM_CONTENT]' ) === false ) {
550 return $text;
551 }
552
553 // Default message
554 if ( empty( $custom_message ) ) {
555 $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' );
556 }
557
558 // Apply replacements (e.g., {add_topic_url})
559 foreach ( $replacements as $key => $value ) {
560 $custom_message = str_replace( '{' . $key . '}', $value, $custom_message );
561 }
562
563 // Sanitize: allow only safe HTML tags
564 $allowed_tags = '<a><br><p><img><strong><em><ul><ol><li>';
565 $custom_message = strip_tags( $custom_message, $allowed_tags );
566
567 return str_replace( '[NO_FORUM_CONTENT]', $custom_message, $text );
568 }
569
570 /**
571 * Full AI response formatting pipeline
572 *
573 * Combines all formatting steps in the correct order:
574 * 1. Replace [NO_FORUM_CONTENT] placeholder
575 * 2. Convert markdown to HTML
576 * 3. Convert citation markers to links
577 *
578 * @param string $text Raw AI response text
579 * @param string $mode Conversion mode (default: MODE_FRONTEND)
580 * @param array $options Options for to_html()
581 * @param array $citation_options Options for convert_citations()
582 * @param string $no_content_msg Custom message for [NO_FORUM_CONTENT]
583 * @param array $no_content_vars Replacements for no-content message
584 *
585 * @return string Fully formatted HTML
586 */
587 public static function format_ai_response( $text, $mode = self::MODE_FRONTEND, $options = [], $citation_options = [], $no_content_msg = '', $no_content_vars = [] ) {
588 if ( empty( $text ) ) {
589 return '';
590 }
591
592 // Step 1: Replace [NO_FORUM_CONTENT] placeholder
593 $text = self::replace_no_content_placeholder( $text, $no_content_msg, $no_content_vars );
594
595 // Step 2: Convert markdown to HTML
596 $text = self::to_html( $text, $mode, $options );
597
598 // Step 3: Convert citation markers to links
599 $text = self::convert_citations( $text, $citation_options );
600
601 return $text;
602 }
603 }
604