1 <?php 2 if (!defined('NEXUS')) exit('Forbidden'); 3 4 /** 5 * Nexus Forum — Markdown renderer (Discourse/Flarum-compatible) 6 * 7 * Input has already been sanitised by sanitise() which: 8 * - Strips dangerous tags (script, iframe, etc.) 9 * - htmlspecialchars() remaining content (< > & become entities) 10 * 11 * Supported syntax: 12 * ```lang … ``` fenced code block with syntax highlighting 13 * ``` … ``` fenced code block, no language 14 * `inline` inline code span 15 * > text blockquote (one or more lines) 16 * **bold** bold 17 * *italic* italic 18 * ~~strike~~ strikethrough 19 * # – ###### headings 20 * - item / * item bullet list 21 * 1. item ordered list 22 * [text](url) link 23 *  image 24 * @username mention 25 * --- horizontal rule 26 * https://… auto-embed videos / auto-link 27 */ 28 29 // ───────────────────────────────────────────────────────────────── 30 // Placeholder markers (STX/ETX can't appear in user content) 31 // ───────────────────────────────────────────────────────────────── 32 define('_MK_FP', "\x02FENCE"); 33 define('_MK_FS', "FNCE\x03"); 34 define('_MK_IP', "\x02INLIN"); 35 define('_MK_IS', "INLN\x03"); 36 define('_MK_BQ', "\x02BQUOT"); 37 define('_MK_BS', "BQUT\x03"); 38 39 function render_post(string $raw): string 40 { 41 // ── Normalise ──────────────────────────────────────────────── 42 $s = str_replace(["\r\n", "\r"], "\n", $raw); 43 44 $fences = []; 45 $quotes = []; 46 $inlines = []; 47 48 // ── Safe URL check ─────────────────────────────────────────── 49 $safeUrl = static fn(string $u): bool => 50 str_starts_with(strtolower(trim($u)), '/') || 51 (bool) preg_match('#^https?://#i', trim($u)); 52 53 // ── Copy button SVG ────────────────────────────────────────── 54 $copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" ' 55 . 'title="Copy" aria-label="Copy code">' 56 . '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' 57 . 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">' 58 . '<rect x="9" y="9" width="13" height="13" rx="2"/>' 59 . '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' 60 . '</svg><span>Copy</span></button>'; 61 62 // ════════════════════════════════════════════════════════════ 63 // PASS 1 — Extract code fences (line-by-line state machine) 64 // 65 // Runs BEFORE everything else so code content is never touched 66 // by any other processor. Handles blank lines, indentation, 67 // special characters without any regex backtracking. 68 // ════════════════════════════════════════════════════════════ 69 { 70 $in = false; 71 $lang = ''; 72 $type = ''; 73 $buf = []; 74 $newLines = []; 75 76 foreach (explode("\n", $s) as $line) { 77 if (!$in) { 78 // Triple-backtick opener: ```lang or ``` 79 if (preg_match('/^```([ \t]*\w*)[ \t]*$/', $line, $m)) { 80 $in = true; $type = 'triple'; $lang = trim($m[1]); $buf = []; 81 // Lone backtick on its own line 82 } elseif (preg_match('/^`[ \t]*$/', $line)) { 83 $in = true; $type = 'single'; $lang = ''; $buf = []; 84 } else { 85 $newLines[] = $line; 86 } 87 } else { 88 $close = ($type === 'triple' && preg_match('/^```[ \t]*$/', $line)) 89 || ($type === 'single' && preg_match('/^`[ \t]*$/', $line)); 90 if ($close) { 91 // Build code block HTML 92 // Content is already entity-encoded by sanitise() — don't double-encode 93 $code = implode("\n", $buf); 94 $l = strtolower(trim($lang)); 95 if ($l !== '') { 96 $lb = htmlspecialchars($l, ENT_QUOTES, 'UTF-8'); 97 $hdr = '<div class="cb-header">' 98 . '<span class="cb-lang">' . $lb . '</span>' . $copyBtn 99 . '</div>'; 100 $blk = '<pre class="code-block" data-lang="' . $lb . '">' 101 . '<code class="language-' . $lb . '">' . $code . '</code></pre>'; 102 } else { 103 $hdr = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>'; 104 $blk = '<pre class="code-block"><code>' . $code . '</code></pre>'; 105 } 106 $idx = count($fences); 107 $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>'; 108 $newLines[] = _MK_FP . $idx . _MK_FS; 109 $in = false; $buf = []; 110 } else { 111 $buf[] = $line; 112 } 113 } 114 } 115 // Unclosed fence — render what was collected 116 if ($in && $buf) { 117 $code = implode("\n", $buf); 118 $hdr = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>'; 119 $blk = '<pre class="code-block"><code>' . $code . '</code></pre>'; 120 $idx = count($fences); 121 $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>'; 122 $newLines[] = _MK_FP . $idx . _MK_FS; 123 } 124 $s = implode("\n", $newLines); 125 } 126 127 // ════════════════════════════════════════════════════════════ 128 // PASS 2 — Extract blockquotes (line-by-line, same approach) 129 // 130 // Consecutive "> " lines form one blockquote block. 131 // Supports nested content: bold, italic, inline code, links. 132 // Matches both raw > and entity-encoded > from sanitise(). 133 // ════════════════════════════════════════════════════════════ 134 { 135 $bqBuf = []; 136 $bqOut = []; 137 138 $flushBq = function () use (&$bqBuf, &$bqOut, &$quotes, $copyBtn): void { 139 if (!$bqBuf) return; 140 $inner = implode("\n", $bqBuf); 141 // Let inline markdown run inside blockquote 142 $inner = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $inner); 143 $inner = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $inner); 144 $inner = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $inner); 145 $inner = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $inner); 146 // Wrap each line in <p> if multiple lines, otherwise just the text 147 $bqLines = array_filter(explode("\n", $inner), fn($l) => trim($l) !== ''); 148 if (count($bqLines) > 1) { 149 $inner = implode('', array_map(fn($l) => '<p>' . trim($l) . '</p>', $bqLines)); 150 } else { 151 $inner = trim($inner); 152 } 153 $idx = count($quotes); 154 $quotes[] = '<blockquote class="post-quote">' . $inner . '</blockquote>'; 155 $bqOut[] = _MK_BQ . $idx . _MK_BS; 156 $bqBuf = []; 157 }; 158 159 foreach (explode("\n", $s) as $line) { 160 if (preg_match('/^(?:>|>) ?(.*)$/', $line, $m)) { 161 $bqBuf[] = $m[1]; // already entity-encoded 162 } else { 163 $flushBq(); 164 $bqOut[] = $line; 165 } 166 } 167 $flushBq(); 168 $s = implode("\n", $bqOut); 169 } 170 171 // ════════════════════════════════════════════════════════════ 172 // PASS 3 — Inline code spans 173 // ════════════════════════════════════════════════════════════ 174 $s = preg_replace_callback( 175 '/`([^`\n]+)`/', 176 static function (array $m) use (&$inlines): string { 177 $idx = count($inlines); 178 // Content already entity-encoded by sanitise() 179 $inlines[] = '<code class="inline-code">' . $m[1] . '</code>'; 180 return _MK_IP . $idx . _MK_IS; 181 }, 182 $s 183 ); 184 185 // ════════════════════════════════════════════════════════════ 186 // PASS 4 — Block-level markdown 187 // ════════════════════════════════════════════════════════════ 188 189 // Headings 190 $s = preg_replace('/^#{6} (.+)$/m', '<h6>$1</h6>', $s); 191 $s = preg_replace('/^#{5} (.+)$/m', '<h5>$1</h5>', $s); 192 $s = preg_replace('/^#{4} (.+)$/m', '<h4>$1</h4>', $s); 193 $s = preg_replace('/^#{3} (.+)$/m', '<h3>$1</h3>', $s); 194 $s = preg_replace('/^#{2} (.+)$/m', '<h2>$1</h2>', $s); 195 $s = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $s); 196 197 // Horizontal rules 198 $s = preg_replace('/^(-{3,}|\*{3,}|_{3,})$/m', '<hr>', $s); 199 200 // Lists — bullet 201 $s = preg_replace('/^[ \t]*[*\-+] (.+)$/m', '<li>$1</li>', $s); 202 $s = preg_replace('/((?:<li>.*<\/li>\n?)+)/', '<ul>$1</ul>', $s); 203 $s = preg_replace('/<\/ul>\s*<ul>/', '', $s); 204 $s = preg_replace_callback('/<ul>(.*?)<\/ul>/s', 205 static fn($m) => '<ul>' . str_replace("\n", '', $m[1]) . '</ul>', $s); 206 207 // Lists — ordered 208 $s = preg_replace('/^[ \t]*\d+\. (.+)$/m', '<oli>$1</oli>',$s); 209 $s = preg_replace('/((?:<oli>.*<\/oli>\n?)+)/', '<ol>$1</ol>', $s); 210 $s = preg_replace('/<\/ol>\s*<ol>/', '', $s); 211 $s = str_replace(['<oli>', '</oli>'], ['<li>', '</li>'], $s); 212 $s = preg_replace_callback('/<ol>(.*?)<\/ol>/s', 213 static fn($m) => '<ol>' . str_replace("\n", '', $m[1]) . '</ol>', $s); 214 215 // Tables 216 $s = preg_replace_callback('/\|(.+)\|\n\|[-| :]+\|\n((?:\|.+\|\n?)+)/', 217 static function (array $m): string { 218 $ths = implode('', array_map( 219 static fn($c) => '<th>' . trim($c) . '</th>', 220 array_filter(explode('|', $m[1]), static fn($c) => trim($c) !== '') 221 )); 222 $trs = implode('', array_map(static function (string $row): string { 223 $cells = array_filter(explode('|', $row), static fn($c) => trim($c) !== ''); 224 return '<tr>' . implode('', array_map(static fn($c) => '<td>' . trim($c) . '</td>', $cells)) . '</tr>'; 225 }, array_filter(explode("\n", trim($m[2]))))); 226 return '<table><thead><tr>' . $ths . '</tr></thead><tbody>' . $trs . '</tbody></table>'; 227 }, $s); 228 229 // ════════════════════════════════════════════════════════════ 230 // PASS 5 — Inline markdown 231 // ════════════════════════════════════════════════════════════ 232 233 $s = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $s); 234 $s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s); 235 $s = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $s); 236 $s = preg_replace('/___(.+?)___/s', '<strong><em>$1</em></strong>', $s); 237 $s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s); 238 $s = preg_replace('/_([^_\n]+)_/', '<em>$1</em>', $s); 239 $s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $s); 240 241 // Images 242 $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)/', 243 static function (array $m) use ($safeUrl): string { 244 $src = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); 245 $alt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'); 246 if (!$safeUrl($src)) return $m[0]; 247 return '<img src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8') 248 . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') 249 . '" loading="lazy" class="post-img" onclick="lightbox(this)">'; 250 }, $s); 251 252 // Links 253 $s = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/', 254 static function (array $m) use ($safeUrl): string { 255 $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); 256 $txt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'); 257 if (!$safeUrl($url)) return htmlspecialchars($txt, ENT_QUOTES, 'UTF-8'); 258 return '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') 259 . '" target="_blank" rel="noopener noreferrer nofollow">' 260 . htmlspecialchars($txt, ENT_QUOTES, 'UTF-8') . '</a>'; 261 }, $s); 262 263 // @mentions 264 $base = BASE; 265 $s = preg_replace_callback('/@([a-zA-Z0-9_\-]{3,30})/', 266 static function (array $m) use ($base): string { 267 $u = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8'); 268 return '<a href="' . $base . '/users/profile.php?u=' . $u 269 . '" class="mention-tag">@' . $u . '</a>'; 270 }, $s); 271 272 // ════════════════════════════════════════════════════════════ 273 // PASS 6 — URL auto-embed + auto-link 274 // 275 // EVERY bare URL (on its own line OR inline) is attempted as an embed. 276 // Entity-encoded URLs from sanitise() are decoded before matching. 277 // Multiple video links in one post all embed. 278 // ════════════════════════════════════════════════════════════ 279 280 // Decode entity-encoded URLs so patterns match (sanitise turns & → &) 281 // We match, decode, try embed or link, then re-encode for output. 282 $s = preg_replace_callback( 283 '#(^|[ \t]|<p>)(https?://[^\s<>"\'&]+(?:&[^\s<>"\'&]*)*)#m', 284 static function (array $m): string { 285 $pre = $m[1]; 286 $rawUrl = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); 287 $safeEnc = htmlspecialchars($rawUrl, ENT_QUOTES, 'UTF-8'); 288 289 // Try embed first 290 $embed = try_embed($rawUrl); 291 if ($embed !== null) { 292 // Wrap standalone embeds cleanly 293 return $pre . $embed; 294 } 295 296 // Otherwise link 297 return $pre . '<a href="' . $safeEnc . '" target="_blank" ' 298 . 'rel="noopener noreferrer nofollow">' . $safeEnc . '</a>'; 299 }, 300 $s 301 ); 302 303 // ════════════════════════════════════════════════════════════ 304 // PASS 7 — Paragraph wrapping 305 // ════════════════════════════════════════════════════════════ 306 $lines = explode("\n", $s); 307 $out = []; 308 $para = ''; 309 310 $fpQ = preg_quote(_MK_FP, '/'); 311 $fsQ = preg_quote(_MK_FS, '/'); 312 $bpQ = preg_quote(_MK_BQ, '/'); 313 $bsQ = preg_quote(_MK_BS, '/'); 314 315 $blockRe = '/^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|' 316 . $fpQ . '\d+' . $fsQ . '|' 317 . $bpQ . '\d+' . $bsQ . ')/'; 318 319 $flush = static function () use (&$para, &$out): void { 320 $t = trim($para); 321 if ($t !== '') $out[] = '<p>' . $t . '</p>'; 322 $para = ''; 323 }; 324 325 foreach ($lines as $line) { 326 $t = trim($line); 327 if ($t === '') { 328 $flush(); 329 } elseif (preg_match($blockRe, $t)) { 330 $flush(); 331 $out[] = $line; 332 } else { 333 $para .= ($para !== '' ? ' ' : '') . $line; 334 } 335 } 336 $flush(); 337 $s = implode("\n", $out); 338 339 // ════════════════════════════════════════════════════════════ 340 // PASS 8 — Restore all placeholders 341 // ════════════════════════════════════════════════════════════ 342 foreach ($fences as $i => $html) $s = str_replace(_MK_FP . $i . _MK_FS, $html, $s); 343 foreach ($quotes as $i => $html) $s = str_replace(_MK_BQ . $i . _MK_BS, $html, $s); 344 foreach ($inlines as $i => $html) $s = str_replace(_MK_IP . $i . _MK_IS, $html, $s); 345 346 return process_embeds($s); 347 }