xgit simple git

nexus

nexus

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

includes/markdown.php


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 *   # – ######        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// ─────────────────────────────────────────────────────────────────
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    // ── 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}