` 只会被画成一行字, * 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。 * 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。 */ declare(strict_types=1); namespace App; final class Markdown { /** * 把一段 Markdown 渲染成安全的 HTML 片段。 * * @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生

包裹 */ public static function render(string $src, bool $inline = false): string { // 统一换行,去掉 BOM(从别处粘贴来的文本常带) $src = str_replace(["\r\n", "\r"], "\n", $src); $src = preg_replace('/^\xEF\xBB\xBF/', '', $src) ?? $src; $lines = explode("\n", $src); $out = []; $i = 0; $n = count($lines); while ($i < $n) { $line = $lines[$i]; // ---- 代码块 ``` 或 ~~~ ---------------------------------------- if (preg_match('/^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/', $line, $m)) { $fence = $m[1][0]; $lang = (string)$m[2]; $buf = []; $i++; while ($i < $n && !preg_match('/^\s*' . preg_quote(str_repeat($fence, 3), '/') . '+/', $lines[$i])) { $buf[] = $lines[$i]; $i++; } $i++; // 跳过收尾的围栏 $cls = $lang !== '' ? ' class="md-code lang-' . e($lang) . '"' : ' class="md-code"'; $out[] = '' . e(implode("\n", $buf)) . ''; continue; } // ---- 标题 # ~ ###### ------------------------------------------ if (preg_match('/^(#{1,6})\s+(.*)$/', $line, $m)) { $lv = strlen($m[1]); // ⚠️ 从 h4 起(h1/h2 留给页面本身),免得笔记里的标题盖过页面标题 $tag = 'h' . min(6, $lv + 2); $out[] = '<' . $tag . ' class="md-h">' . self::inline(trim($m[2])) . ''; $i++; continue; } // ---- 分割线 ---------------------------------------------------- if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) { $out[] = '


'; $i++; continue; } // ---- 引用 > (支持多层,用前缀数决定层级)---------------------- if (preg_match('/^\s*>\s?(.*)$/', $line)) { $buf = []; while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) { $buf[] = $mm[1]; $i++; } $out[] = '
' . self::render(implode("\n", $buf)) . '
'; continue; } // ---- 无序列表(支持**多级**:按前导空格数决定层级)-------------- if (preg_match('/^(\s*)[-*+]\s+(.*)$/', $line, $m)) { [$html, $i] = self::list($lines, $i, $n, false); $out[] = $html; continue; } // ---- 有序列表 -------------------------------------------------- if (preg_match('/^(\s*)\d+[.)]\s+(.*)$/', $line, $m)) { [$html, $i] = self::list($lines, $i, $n, true); $out[] = $html; continue; } // ---- 空行 ------------------------------------------------------ if (trim($line) === '') { $i++; continue; } // ---- 普通段落:连续非空行合成一段(软换行)----------------------- $buf = [$line]; $i++; while ($i < $n && trim($lines[$i]) !== '' && !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])) { $buf[] = $lines[$i]; $i++; } $para = implode("\n", $buf); // 段内换行 →
(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行) $html = self::inline($para); $html = str_replace("\n", "
", $html); $out[] = '

' . $html . '

'; } $html = implode("\n", $out); return $inline ? $html : $html; } /** * 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。 * * 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 ) while ($stack) { array_pop($stack); $html .= ''; } return [$html, $i]; } /** * 行内语法:`code` → 粗体 → 斜体 → 链接 → 删除线。 * * ⚠️ 顺序要紧:**先处理行内代码**,再处理粗斜体 —— * 否则 `` `a*b*c` `` 里的星号会被当斜体吃掉(代码里的字符不该被解释)。 * ⚠️ 先 escape 再加标记:所有 `<` `>` `&` 在进来时就已经是实体了, * 我们只往里加**自己的**标签,不可能被注入。 */ public static function inline(string $s): string { $s = e($s); // `code`(先做,且内部不再解释其它语法) $s = preg_replace_callback('/`([^`\n]+)`/', static function ($m): string { return '' . $m[1] . ''; }, $s) ?? $s; // ![alt](url) 图片(笔记里可能有,但我们**不内联渲染外部图片** —— // 那是隐私与外链风险;只显示成一个链接,点了自己开) $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string { $u = self::safeUrl($m[2]); return $u === '' ? $m[1] : '' . ($m[1] !== '' ? $m[1] : $u) . ''; }, $s) ?? $s; // [文字](url) // ⚠️ URL 里遇到右括号就截断 —— 所以 `javascript:alert(1)` 这种带括号的 // 会被切掉后半截(只剩 `javascript:alert(1`)→ 协议判定仍然不过 → 降级成纯文本。 // 剩下的那截尾巴(`)`)会留在正文里,看着像个小瑕疵。 // ⇒ 用回调把"不合法链接"的**整段原文**还原回去,而不是只留前半截。 $s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]*)\)/', static function ($m): string { $u = self::safeUrl($m[2]); if ($u === '') { return $m[0]; // 不合法 → 原样保留(用户看到自己写的东西,而不是被切一半) } return '' . $m[1] . ''; }, $s) ?? $s; // **粗体** / __粗体__ $s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s; $s = preg_replace('/__(.+?)__/s', '$1', $s) ?? $s; // *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤) $s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s; // ~~删除线~~ $s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s; return $s; } /** 只放行 http / https / mailto;其它(含 javascript:)返回空串 */ private static function safeUrl(string $u): string { $u = trim($u); if ($u === '') { return ''; } // 已 escape 过,还原常见实体再做协议判定 $raw = html_entity_decode($u, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (!preg_match('#^(https?:|mailto:)#i', $raw)) { return ''; } return $u; } /** * 纯文本摘要(列表页卡片用):把 Markdown 记号去掉,只留可读的字。 * 比"渲染成 HTML 再 strip_tags"更省,也不会把 `**` 这种记号露给用户。 */ public static function excerpt(string $src, int $len = 120): string { $s = str_replace(["\r\n", "\r"], "\n", $src); // 去掉代码围栏标记、标题号、引用号、列表号 $s = preg_replace('/^\s*(```+|~~~+).*$/m', '', $s) ?? $s; $s = preg_replace('/^\s*#{1,6}\s*/m', '', $s) ?? $s; $s = preg_replace('/^\s*>\s?/m', '', $s) ?? $s; $s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s; // 去掉行内标记 $s = preg_replace('/\*\*(.+?)\*\*/s', '$1', $s) ?? $s; $s = preg_replace('/\*(.+?)\*/s', '$1', $s) ?? $s; $s = preg_replace('/~~(.+?)~~/s', '$1', $s) ?? $s; $s = preg_replace('/`([^`\n]+)`/', '$1', $s) ?? $s; $s = preg_replace('/!?\[([^\]]*)\]\([^)\s]+\)/', '$1', $s) ?? $s; $s = preg_replace('/\s+/u', ' ', $s) ?? $s; $s = trim($s); if (mb_strlen($s, 'UTF-8') > $len) { $s = mb_substr($s, 0, $len, 'UTF-8') . '…'; } return $s; } }