` 只会被画成一行字, * 而不是被执行 —— 富文本编辑器最常见的 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])) . '' . $tag . '>';
$i++;
continue;
}
// ---- 分割线 ----------------------------------------------------
if (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $line)) {
$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]) // 🔴 v61:正文里出现表格也要**断开段落**,否则表格头会被吸进 // 上一段(GFM 允许表格紧跟正文,不强制空行)。 && !(strpos($lines[$i], '|') !== false && $i + 1 < $n && preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1]) && strpos($lines[$i + 1], '-') !== false)) { $buf[] = $lines[$i]; $i++; } $para = implode("\n", $buf); // 段内换行 →
' . $html . '
'; } $html = implode("\n", $out); return $inline ? $html : $html; } /** * 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。 * * 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新