• File: markdown.php
  • Full Path: /home/daemondevin/public_html/portableapps/lib/vendor/sodium_compat/src/Core/markdown.php
  • Date Modified: 10/23/2026 3:45 AM
  • File size: 17.94 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
defined('TS_ROOT') or exit;
// Small, safe Markdown renderer (CommonMark/GFM subset). No dependencies.
// Raw HTML is escaped unless $allowHtml is true.

final class TSMarkdown
{
    private array $stash = [];
    private array $ids = [];
    private array $refs = [];
    private bool $allowHtml;
    private string $alerts;

    // GitHub alert kinds: [!NOTE] and friends. Each maps to a Themestrap PluginAlert type and a title.
    private const ALERTS = [
        'NOTE' => ['info', 'Note'], 'TIP' => ['success', 'Tip'], 'IMPORTANT' => ['primary', 'Important'],
        'WARNING' => ['warning', 'Warning'], 'CAUTION' => ['danger', 'Caution'],
    ];

    // $alerts: 'plugin' writes markup for PluginAlert (the default), 'plain' writes a quote with a bold title (feeds)
    public function __construct(bool $allowHtml = false, string $alerts = 'plugin')
    {
        $this->allowHtml = $allowHtml;
        $this->alerts = $alerts;
    }

    public static function render(string $md, bool $allowHtml = false, string $alerts = 'plugin'): string
    {
        $self = new self($allowHtml, $alerts);
        $md = str_replace(["\r\n", "\r"], "\n", $md);
        $md = str_replace('<!--more-->', '', $md);
        return $self->blocks($self->collectRefs(explode("\n", $md)), false);
    }

    // Reference definitions, [id]: https://example.com "Title", can sit anywhere. They are removed from the text
    // and remembered. Lines inside fenced code are left alone.
    private function collectRefs(array $lines): array
    {
        $out = [];
        $fence = null;
        foreach ($lines as $line) {
            if (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $f)) {
                if ($fence === null) $fence = $f[1][0] . strlen($f[1]);
                elseif ($f[1][0] === $fence[0] && strlen($f[1]) >= (int) substr($fence, 1)) $fence = null;
            } elseif ($fence === null && preg_match('/^ {0,3}\[([^\]\^][^\]]*)\]:\s*(?:<([^>\s]*)>|(\S+))(?:\s+(?:"([^"]*)"|\'([^\']*)\'|\(([^)]*)\)))?\s*$/', $line, $m)) {
                $id = self::refKey($m[1]);
                if (!isset($this->refs[$id])) {
                    $url = $m[2] !== '' ? $m[2] : ($m[3] ?? '');
                    $title = ($m[4] ?? '') !== '' ? $m[4] : (($m[5] ?? '') !== '' ? $m[5] : ($m[6] ?? ''));
                    $this->refs[$id] = [$url, $title];
                }
                continue;
            }
            $out[] = $line;
        }
        return $out;
    }

    private static function refKey(string $id): string
    {
        return mb_strtolower(trim(preg_replace('/\s+/', ' ', $id)));
    }

    // Plain-text excerpt: text before <!--more-->, otherwise first paragraph.
    public static function excerpt(string $md, int $max = 200): string
    {
        $md = str_replace(["\r\n", "\r"], "\n", $md);
        $pos = strpos($md, '<!--more-->');
        $chunk = $pos !== false ? substr($md, 0, $pos) : '';
        if ($chunk === '') {
            foreach (preg_split('/\n\s*\n/', $md) as $para) {
                $p = trim($para);
                if ($p !== '' && !preg_match('/^(#{1,6}\s|```|~~~|>|!\[|\||[-*+]\s|\d+[.)]\s)|\n(=+|-+)\s*$/', $p)) { $chunk = $p; break; }
            }
        }
        $chunk = preg_replace('/^ {0,3}\[[^\]^][^\]]*\]:\s.*$/m', '', $chunk);
        $t = preg_replace('/!\[[^\]]*\](\([^)]*\)|\[[^\]]*\])/', '', $chunk);
        $t = preg_replace('/\[([^\]]+)\](\([^)]*\)|\[[^\]]*\])/', '$1', $t);
        $t = preg_replace('/[*_`~>#]+/', '', $t);
        $t = trim(preg_replace('/\s+/', ' ', $t));
        if (mb_strlen($t) > $max) {
            $t = rtrim(mb_substr($t, 0, $max), " .,;:!-") . '…';
        }
        return $t;
    }

    public static function wordCount(string $md): int
    {
        $t = preg_replace('/```.*?```/s', ' ', $md);
        $t = preg_replace('/^ {0,3}\[[^\]^][^\]]*\]:\s.*$/m', ' ', $t);
        $t = preg_replace('/[#>*_`~\[\]()!|-]+/', ' ', $t);
        return (int) preg_match_all('/[\p{L}\p{N}\']+/u', $t);
    }

    // Block level

    private function blocks(array $lines, bool $tight): string
    {
        $out = '';
        $n = count($lines);
        $i = 0;
        while ($i < $n) {
            $line = $lines[$i];
            if (trim($line) === '') { $i++; continue; }

            // fenced code
            if (preg_match('/^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)[^`]*$/', $line, $m)) {
                $fence = $m[1]; $lang = $m[2]; $code = [];
                $i++;
                while ($i < $n && !preg_match('/^ {0,3}' . preg_quote($fence[0], '/') . '{' . strlen($fence) . ',}\s*$/', $lines[$i])) {
                    $code[] = $lines[$i++];
                }
                $i++;
                $cls = $lang !== '' ? ' class="language-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
                $out .= '<pre><code' . $cls . '>' . htmlspecialchars(implode("\n", $code), ENT_NOQUOTES) . "</code></pre>\n";
                continue;
            }

            // ATX heading
            if (preg_match('/^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/', $line, $m)) {
                $out .= $this->heading(strlen($m[1]), $m[2]);
                $i++;
                continue;
            }

            // horizontal rule
            if (preg_match('/^ {0,3}([-*_])( *\1){2,} *$/', $line)) {
                $out .= "<hr>\n";
                $i++;
                continue;
            }

            // blockquote
            if (preg_match('/^ {0,3}>/', $line)) {
                $q = [];
                while ($i < $n && trim($lines[$i]) !== '') {
                    $q[] = preg_replace('/^ {0,3}> ?/', '', $lines[$i]);
                    $i++;
                }
                $alert = $this->alertKind($q);
                if ($alert !== null) {
                    [$type, $title] = self::ALERTS[$alert];
                    $inner = $this->blocks($q, false);
                    if ($this->alerts === 'plain') {
                        $out .= "<blockquote>\n<p><strong>" . $title . "</strong></p>\n" . $inner . "</blockquote>\n";
                    } else {
                        $opt = htmlspecialchars(json_encode(['showIcon' => true, 'title' => $title]), ENT_QUOTES);
                        $out .= '<div class="alert alert-' . ($type === 'primary' ? 'primary' : $type) . '" role="alert" data-plugin-alert data-plugin-options="' . $opt . "\">\n" . $inner . "</div>\n";
                    }
                    continue;
                }
                $out .= "<blockquote>\n" . $this->blocks($q, false) . "</blockquote>\n";
                continue;
            }

            // list
            if (preg_match('/^( {0,3})([-*+]|\d{1,9}[.)])\s+\S/', $line)) {
                [$html, $i] = $this->parseList($lines, $i);
                $out .= $html;
                continue;
            }

            // table
            if (strpos($line, '|') !== false && $i + 1 < $n
                && preg_match('/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/', $lines[$i + 1])
                && strpos($lines[$i + 1], '|') !== false) {
                $head = $this->cells($line);
                $aligns = array_map(function ($c) {
                    $c = trim($c);
                    $l = $c[0] === ':'; $r = substr($c, -1) === ':';
                    return $l && $r ? 'center' : ($r ? 'right' : ($l ? 'left' : ''));
                }, $this->cells($lines[$i + 1]));
                $i += 2;
                $t = "<table>\n<thead><tr>";
                foreach ($head as $k => $h) $t .= $this->cell('th', $h, $aligns[$k] ?? '');
                $t .= "</tr></thead>\n<tbody>\n";
                while ($i < $n && trim($lines[$i]) !== '' && strpos($lines[$i], '|') !== false) {
                    $t .= '<tr>';
                    $row = $this->cells($lines[$i]);
                    foreach ($head as $k => $_) $t .= $this->cell('td', $row[$k] ?? '', $aligns[$k] ?? '');
                    $t .= "</tr>\n";
                    $i++;
                }
                $out .= $t . "</tbody>\n</table>\n";
                continue;
            }

            // raw HTML block
            if ($this->allowHtml && preg_match('/^ {0,3}<(\/?[a-zA-Z][\w-]*|!--)/', $line)) {
                $h = [];
                while ($i < $n && trim($lines[$i]) !== '') $h[] = $lines[$i++];
                $out .= implode("\n", $h) . "\n";
                continue;
            }

            // paragraph, with setext heading support
            $p = [];
            while ($i < $n && trim($lines[$i]) !== '' && ($p === [] || !$this->startsBlock($lines[$i]))) {
                if ($p !== [] && preg_match('/^ {0,3}(=+|-+) *$/', $lines[$i])) {
                    $level = strpos($lines[$i], '=') !== false ? 1 : 2;
                    $out .= $this->heading($level, trim(implode(' ', $p)));
                    $p = [];
                    $i++;
                    continue 2;
                }
                $p[] = $lines[$i++];
            }
            if ($p) {
                $text = $this->inline(trim(implode("\n", $p)));
                $out .= $tight ? $text . "\n" : '<p>' . $text . "</p>\n";
            }
        }
        return $out;
    }

    // A quote whose first line is [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or [!CAUTION] (any case) is an alert.
    // The marker line is removed from $q. Returns the kind in capitals, or null.
    private function alertKind(array &$q): ?string
    {
        if (!$q || !preg_match('/^\s*\[!([A-Za-z]+)\]\s*$/', $q[0], $m)) return null;
        $k = strtoupper($m[1]);
        if (!isset(self::ALERTS[$k])) return null;
        array_shift($q);
        return $k;
    }

    private function startsBlock(string $l): bool
    {
        return (bool) preg_match('/^ {0,3}(#{1,6}\s|`{3,}|~{3,}|>|([-*_])( *\2){2,} *$|[-*+]\s+\S|1[.)]\s+\S)/', $l);
    }

    private function heading(int $level, string $text): string
    {
        $plain = trim(strip_tags($this->inline($text)));
        $id = trim(preg_replace('/[^\p{L}\p{N}]+/u', '-', mb_strtolower($plain)), '-');
        $id = $id === '' ? 'section' : $id;
        $base = $id; $k = 2;
        while (isset($this->ids[$id])) $id = $base . '-' . $k++;
        $this->ids[$id] = true;
        return "<h$level id=\"$id\">" . $this->inline($text) . "</h$level>\n";
    }

    private function parseList(array $lines, int $i): array
    {
        $n = count($lines);
        preg_match('/^( *)([-*+]|\d+[.)])\s+/', $lines[$i], $m);
        $base = strlen($m[1]);
        $ordered = ctype_digit($m[2][0]);
        $start = $ordered ? (int) $m[2] : 1;
        $marker = $ordered ? substr($m[2], -1) : $m[2];
        $items = []; $cur = null; $loose = false; $blank = false;

        while ($i < $n) {
            $l = $lines[$i];
            if (trim($l) === '') { $blank = true; if ($cur !== null) $cur[] = ''; $i++; continue; }
            $ind = strlen($l) - strlen(ltrim($l, ' '));
            if (preg_match('/^( *)([-*+]|\d+[.)])\s+(.*)$/', $l, $mm) && strlen($mm[1]) <= $base + 1) {
                $same = ctype_digit($mm[2][0]) === $ordered && ($ordered ? substr($mm[2], -1) : $mm[2]) === $marker;
                if (!$same || strlen($mm[1]) < $base - 1) break;
                if ($cur !== null) { $items[] = $cur; if ($blank) $loose = true; }
                $cur = [$mm[3]];
                $blank = false; $i++;
                continue;
            }
            if ($cur === null) break;
            if ($ind > $base || !$blank) {
                if ($blank) $loose = true;
                $cur[] = preg_replace('/^ {1,' . ($base + 4) . '}/', '', $l, 1);
                $blank = false; $i++;
                continue;
            }
            break;
        }
        if ($cur !== null) $items[] = $cur;

        $tag = $ordered ? 'ol' : 'ul';
        $attr = $ordered && $start !== 1 ? ' start="' . $start . '"' : '';
        $html = "<$tag$attr>\n";
        foreach ($items as $it) {
            while ($it && end($it) === '') array_pop($it);
            $task = '';
            if (preg_match('/^\[([ xX])\]\s+(.*)$/', $it[0], $t)) {
                $task = '<input type="checkbox" disabled' . ($t[1] !== ' ' ? ' checked' : '') . '> ';
                $it[0] = $t[2];
            }
            $inner = rtrim($this->blocks($it, !$loose));
            $html .= '<li' . ($task ? ' class="task"' : '') . '>' . $task . $inner . "</li>\n";
        }
        return [$html . "</$tag>\n", $i];
    }

    private function cells(string $row): array
    {
        $row = trim($row);
        $row = preg_replace('/^\||\|$/', '', $row);
        return array_map('trim', preg_split('/(?<!\\\\)\|/', $row));
    }

    private function cell(string $tag, string $text, string $align): string
    {
        return "<$tag" . ($align ? " style=\"text-align:$align\"" : '') . '>' . $this->inline($text) . "</$tag>";
    }

    // Inline level

    private function hold(string $html): string
    {
        $this->stash[] = $html;
        return "\x01" . (count($this->stash) - 1) . "\x02";
    }

    private function inline(string $t): string
    {
        // code spans
        $t = preg_replace_callback('/(`+)(.+?)(?<!`)\1(?!`)/s', fn($m) =>
            $this->hold('<code>' . htmlspecialchars(trim($m[2]), ENT_NOQUOTES) . '</code>'), $t);
        // backslash escapes
        $t = preg_replace_callback('/\\\\([\\\\`*_{}\[\]()#+\-.!|~<>])/', fn($m) =>
            $this->hold(htmlspecialchars($m[1], ENT_NOQUOTES)), $t);
        // raw inline html
        if ($this->allowHtml) {
            $t = preg_replace_callback('/<\/?[a-zA-Z][^<>]*>|<!--.*?-->/s', fn($m) => $this->hold($m[0]), $t);
        }
        // autolinks <https://...>
        $t = preg_replace_callback('/<(https?:\/\/[^\s<>]+|[^\s@<>]+@[^\s@<>]+\.[a-z]{2,})>/i', function ($m) {
            $u = $m[1];
            $href = strpos($u, '@') !== false && strpos($u, '://') === false ? 'mailto:' . $u : $u;
            return $this->hold('<a href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . htmlspecialchars($u, ENT_NOQUOTES) . '</a>');
        }, $t);
        // reference style images and links: ![alt][id], [text][id], [text][]
        $t = preg_replace_callback('/!\[([^\]]*)\]\[([^\]]*)\]/', function ($m) {
            $r = $this->refs[self::refKey($m[2] !== '' ? $m[2] : $m[1])] ?? null;
            $src = $r ? self::safeUrl($r[0], true) : '';
            if ($src === '') return $m[0];
            $title = $r[1] !== '' ? ' title="' . htmlspecialchars($r[1], ENT_QUOTES) . '"' : '';
            return $this->hold('<img src="' . htmlspecialchars($src, ENT_QUOTES) . '" alt="' . htmlspecialchars($m[1], ENT_QUOTES) . '"' . $title . ' loading="lazy">');
        }, $t);
        $t = preg_replace_callback('/\[((?:[^\[\]]|\[[^\]]*\])+)\]\[([^\]]*)\]/', fn($m) => $this->refLink($m[1], $m[2] !== '' ? $m[2] : $m[1], $m[0]), $t);
        // images
        $t = preg_replace_callback('/!\[([^\]]*)\]\(\s*((?:[^\s()]|\([^\s()]*\))+)(?:\s+"([^"]*)")?\s*\)/', function ($m) {
            $src = self::safeUrl($m[2], true);
            if ($src === '') return $this->hold(htmlspecialchars($m[0], ENT_NOQUOTES));
            $title = ($m[3] ?? '') !== '' ? ' title="' . htmlspecialchars($m[3], ENT_QUOTES) . '"' : '';
            return $this->hold('<img src="' . htmlspecialchars($src, ENT_QUOTES) . '" alt="' . htmlspecialchars($m[1], ENT_QUOTES) . '"' . $title . ' loading="lazy">');
        }, $t);
        // links
        $t = preg_replace_callback('/\[((?:[^\[\]]|\[[^\]]*\])+)\]\(\s*((?:[^\s()]|\([^\s()]*\))+)(?:\s+"([^"]*)")?\s*\)/', function ($m) {
            $href = self::safeUrl($m[2]);
            if ($href === '') return $m[1];
            $rel = preg_match('#^https?://#i', $href) ? ' rel="noopener"' : '';
            $title = ($m[3] ?? '') !== '' ? ' title="' . htmlspecialchars($m[3], ENT_QUOTES) . '"' : '';
            return $this->hold('<a href="' . htmlspecialchars($href, ENT_QUOTES) . '"' . $title . $rel . '>')
                . $this->inline($m[1]) . $this->hold('</a>');
        }, $t);
        // shortcut reference links: [id] when [id]: … is defined somewhere
        if ($this->refs) {
            $t = preg_replace_callback('/(?<![\\\]!])\[([^\[\]]+)\](?![\[(:])/', fn($m) => $this->refLink($m[1], $m[1], $m[0]), $t);
        }
        // bare urls
        $t = preg_replace_callback('/(?<![\w"=\x02])(https?:\/\/[^\s<>\x01]+[^\s<>.,;:!?)\x01])/', fn($m) =>
            $this->hold('<a href="' . htmlspecialchars($m[1], ENT_QUOTES) . '" rel="noopener">' . htmlspecialchars($m[1], ENT_NOQUOTES) . '</a>'), $t);

        $t = htmlspecialchars($t, ENT_NOQUOTES);
        $t = preg_replace('/\*\*\*(?=\S)(.+?)(?<=\S)\*\*\*/s', '<strong><em>$1</em></strong>', $t);
        $t = preg_replace('/\*\*(?=\S)(.+?)(?<=\S)\*\*|__(?=\S)(.+?)(?<=\S)__/s', '<strong>$1$2</strong>', $t);
        $t = preg_replace('/\*(?=[^\s*])(.+?)(?<=[^\s*])\*|(?<![\w])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![\w])/s', '<em>$1$2</em>', $t);
        $t = preg_replace('/~~(?=\S)(.+?)(?<=\S)~~/s', '<del>$1</del>', $t);
        $t = preg_replace('/(?: {2,}|\\\\)\n/', "<br>\n", $t);

        // restore stash; nested holds resolve over a few passes
        for ($k = 0; $k < 4 && strpos($t, "\x01") !== false; $k++) {
            $t = preg_replace_callback('/\x01(\d+)\x02/', fn($m) => $this->stash[(int) $m[1]], $t);
        }
        return $t;
    }

    private function refLink(string $text, string $id, string $raw): string
    {
        $r = $this->refs[self::refKey($id)] ?? null;
        $href = $r ? self::safeUrl($r[0]) : '';
        if ($href === '') return $raw;
        $rel = preg_match('#^https?://#i', $href) ? ' rel="noopener"' : '';
        $title = $r[1] !== '' ? ' title="' . htmlspecialchars($r[1], ENT_QUOTES) . '"' : '';
        return $this->hold('<a href="' . htmlspecialchars($href, ENT_QUOTES) . '"' . $title . $rel . '>') . $this->inline($text) . $this->hold('</a>');
    }

    public static function safeUrl(string $u, bool $image = false): string
    {
        $u = trim(html_entity_decode($u));
        if ($u === '' || preg_match('/[\x00-\x1f\x7f]/', $u)) return '';
        if (preg_match('/^[a-z][a-z0-9+.\-]*:/i', $u)) {
            $ok = $image ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel'];
            return in_array(strtolower(strstr($u, ':', true)), $ok, true) ? $u : '';
        }
        return $u;
    }
}