1<?php
2if (!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 *
20 * - item / * item bullet list
21 * 1. item ordered list
22 * [text](url) link
23 *  image
24 * @username mention
25 * --- horizontal rule
26 * https:
27 */
28
29
30
31
32define('_MK_FP', "\x02FENCE");
33define('_MK_FS', "FNCE\x03");
34define('_MK_IP', "\x02INLIN");
35define('_MK_IS', "INLN\x03");
36define('_MK_BQ', "\x02BQUOT");
37define('_MK_BS', "BQUT\x03");
38
39function render_post(string $raw): string
40{
41
42 $s = str_replace(["\r\n", "\r"], "\n", $raw);
43
44 $fences = [];
45 $quotes = [];
46 $inlines = [];
47
48
49 $safeUrl = static fn(string $u): bool =>
50 str_starts_with(strtolower(trim($u)), '/') ||
51 (bool) preg_match('#^https?://#i', trim($u));
52
53
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
64
65
66
67
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
79 if (preg_match('/^```([ \t]*\w*)[ \t]*$/', $line, $m)) {
80 $in = true; $type = 'triple'; $lang = trim($m[1]); $buf = [];
81
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
92
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
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
129
130
131
132
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
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
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];
162 } else {
163 $flushBq();
164 $bqOut[] = $line;
165 }
166 }
167 $flushBq();
168 $s = implode("\n", $bqOut);
169 }
170
171
172
173
174 $s = preg_replace_callback(
175 '/`([^`\n]+)`/',
176 static function (array $m) use (&$inlines): string {
177 $idx = count($inlines);
178
179 $inlines[] = '<code class="inline-code">' . $m[1] . '</code>';
180 return _MK_IP . $idx . _MK_IS;
181 },
182 $s
183 );
184
185
186
187
188
189
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
198 $s = preg_replace('/^(-{3,}|\*{3,}|_{3,})$/m', '<hr>', $s);
199
200
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
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
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
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
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
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
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
274
275
276
277
278
279
280
281
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
290 $embed = try_embed($rawUrl);
291 if ($embed !== null) {
292
293 return $pre . $embed;
294 }
295
296
297 return $pre . '<a href="' . $safeEnc . '" target="_blank" '
298 . 'rel="noopener noreferrer nofollow">' . $safeEnc . '</a>';
299 },
300 $s
301 );
302
303
304
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
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}