xgit simple git

nexus

nexus

clone git clone https://kb.hax.al/nexus

includes/markdown.php

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  *   ![alt](url)       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 &gt; 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('/^(?:&gt;|>) ?(.*)$/', $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 & → &amp;)
281     // We match, decode, try embed or link, then re-encode for output.
282     $s = preg_replace_callback(
283         '#(^|[ \t]|<p>)(https?://[^\s<>"\'&]+(?:&amp;[^\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 }