2026-10-08 02:20:19 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 轻量 Markdown 渲染(v28 用户第 12 条)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「笔记功能,段落是需要支持富文本的,可以使用 markdown,但是需要使用
|
|
|
|
|
|
* 富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。就是说,
|
|
|
|
|
|
* 允许所见即所得编辑,也允许 markdown 编辑。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么自己写而**不引入第三方库**(wyc 2026-10-07 已确认):
|
|
|
|
|
|
* · 本项目全站只有这一处要 Markdown,为一个功能引一个几百 KB~数 MB 的编辑器,
|
|
|
|
|
|
* 与"轻量级"直接冲突(Semi 那 4 MB 已经在按需加载了,不该再加一个);
|
|
|
|
|
|
* · 我们支持的语法是**有界的**(标题 / 粗斜体 / 行内代码 / 代码块 / 列表 /
|
|
|
|
|
|
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
|
|
|
|
|
|
* (不会在某次依赖升级后行为漂移)。
|
|
|
|
|
|
*
|
2026-10-18 11:22:34 +08:00
|
|
|
|
* 🔴 v61(用户 2026-10-18 第 2 条「笔记要支持成熟软件的那些块」):
|
|
|
|
|
|
* 按 Notion / flomo / Obsidian 的块清单逐项核对后,**只补 Markdown 原生就能表达的**:
|
|
|
|
|
|
* · GFM 表格(| 列 | 列 | + 分隔行,支持 :-: 对齐)
|
|
|
|
|
|
* · GFM 任务清单(- [ ] / - [x],勾选框渲染成**只读** checkbox)
|
|
|
|
|
|
* (行内代码 / 围栏代码 / 引用 / 分割线 / 多级列表 / 标题 / 删除线 / 链接 v28 起就有)
|
|
|
|
|
|
* 代码块语言标注仍然只作 class 记号(引高亮库 = 又一坨几百 KB,与"轻量"冲突,
|
|
|
|
|
|
* 用户当时已拍板;这里不偷偷改道)。
|
|
|
|
|
|
* "折叠块(toggle)"刻意不做:阅读页有逐段编辑、块列表可上移下移,折叠会把
|
|
|
|
|
|
* 内容藏进用户找不到地方改的界面里;flomo 也没有它。要做要整个交互重新设计。
|
|
|
|
|
|
*
|
2026-10-08 02:20:19 +08:00
|
|
|
|
* 🔴 安全:**不允许任何原始 HTML 通过**。所有输入先 `htmlspecialchars`,
|
|
|
|
|
|
* 再按我们自己的语法加标记。这样 `<script>` 只会被画成一行字,
|
|
|
|
|
|
* 而不是被执行 —— 富文本编辑器最常见的 XSS 来源就是"允许内嵌 HTML"。
|
|
|
|
|
|
* 链接只允许 http/https/mailto,`javascript:` 一律降级成纯文本。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Markdown
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把一段 Markdown 渲染成安全的 HTML 片段。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param bool $inline true = 只做行内语法(用于列表/表格单元格),不产生 <p> 包裹
|
|
|
|
|
|
*/
|
|
|
|
|
|
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[] = '<pre' . $cls . '><code>' . e(implode("\n", $buf)) . '</code></pre>';
|
|
|
|
|
|
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[] = '<hr class="md-hr">';
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 11:22:34 +08:00
|
|
|
|
// ---- 表格(GFM:表头行 + `---` 分隔行 + 数据行)----------------
|
|
|
|
|
|
// 🔴 v61(用户 2026-10-18:笔记要支持成熟软件的块)。
|
|
|
|
|
|
// 识别条件**必须两行一起看**:本行像 `|a|b|` 且**下一行是分隔行**
|
|
|
|
|
|
// (只含 - : | 空格且至少一个 -)。只看本行会把"正文里恰好有竖线
|
|
|
|
|
|
// 的一行"当表格(比如「3 | 2 | 1」),假表格比没表格更糟。
|
|
|
|
|
|
if (str_contains($line, '|') && $i + 1 < $n
|
|
|
|
|
|
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
|
|
|
|
|
|
&& str_contains($lines[$i + 1], '-')) {
|
|
|
|
|
|
[$html, $i] = self::table($lines, $i, $n);
|
|
|
|
|
|
$out[] = $html;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
|
|
|
|
|
|
if (preg_match('/^\s*>\s?(.*)$/', $line)) {
|
|
|
|
|
|
$buf = [];
|
|
|
|
|
|
while ($i < $n && preg_match('/^\s*>\s?(.*)$/', $lines[$i], $mm)) {
|
|
|
|
|
|
$buf[] = $mm[1];
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$out[] = '<blockquote class="md-quote">' . self::render(implode("\n", $buf)) . '</blockquote>';
|
|
|
|
|
|
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]) !== ''
|
2026-10-18 11:22:34 +08:00
|
|
|
|
&& !preg_match('/^\s*(#{1,6}\s|>|```|~~~|[-*+]\s|\d+[.)]\s)/', $lines[$i])
|
|
|
|
|
|
// 🔴 v61:正文里出现表格也要**断开段落**,否则表格头会被吸进
|
|
|
|
|
|
// 上一段(GFM 允许表格紧跟正文,不强制空行)。
|
|
|
|
|
|
&& !(str_contains($lines[$i], '|') && $i + 1 < $n
|
|
|
|
|
|
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
|
|
|
|
|
|
&& str_contains($lines[$i + 1], '-'))) {
|
2026-10-08 02:20:19 +08:00
|
|
|
|
$buf[] = $lines[$i];
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$para = implode("\n", $buf);
|
|
|
|
|
|
// 段内换行 → <br>(Markdown 的"两个空格结尾"太隐晦,这里一律当硬换行)
|
|
|
|
|
|
$html = self::inline($para);
|
|
|
|
|
|
$html = str_replace("\n", "<br>", $html);
|
|
|
|
|
|
$out[] = '<p class="md-p">' . $html . '</p>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$html = implode("\n", $out);
|
|
|
|
|
|
return $inline ? $html : $html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析一段列表(含**多级**:更深缩进的项嵌进上一项的里层)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 做法是经典的"按缩进栈":把每一项的缩进记下来,遇到更深的就开一层新 <ul>,
|
|
|
|
|
|
* 更浅就逐层关掉。这样"手工敲两个空格"也能自然形成层级,
|
|
|
|
|
|
* 不需要用户去记"必须用 4 个空格"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{0:string,1:int} [html, 新行号]
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function list(array $lines, int $i, int $n, bool $ordered): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$html = '';
|
|
|
|
|
|
$stack = []; // 每层的缩进宽度
|
|
|
|
|
|
$tag = $ordered ? 'ol' : 'ul';
|
|
|
|
|
|
|
|
|
|
|
|
while ($i < $n) {
|
|
|
|
|
|
$line = $lines[$i];
|
|
|
|
|
|
$pat = $ordered ? '/^(\s*)\d+[.)]\s+(.*)$/' : '/^(\s*)[-*+]\s+(.*)$/';
|
|
|
|
|
|
if (!preg_match($pat, $line, $m)) {
|
|
|
|
|
|
// 非列表行:若是空行且后面还有更深的列表项,继续;否则结束
|
|
|
|
|
|
if (trim($line) === '') {
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
$indent = strlen(str_replace("\t", ' ', $m[1]));
|
|
|
|
|
|
$content = $m[2];
|
|
|
|
|
|
|
|
|
|
|
|
if (!$stack) {
|
|
|
|
|
|
$html .= '<' . $tag . ' class="md-list">';
|
|
|
|
|
|
$stack[] = $indent;
|
|
|
|
|
|
} elseif ($indent > end($stack)) {
|
|
|
|
|
|
// 更深 → 开新的一层(塞进上一个 <li> 里,成为它的子列表)
|
|
|
|
|
|
$html .= '<' . $tag . ' class="md-list">';
|
|
|
|
|
|
$stack[] = $indent;
|
|
|
|
|
|
} elseif ($indent < end($stack)) {
|
|
|
|
|
|
// 更浅 → 逐层关掉
|
|
|
|
|
|
while ($stack && $indent < end($stack)) {
|
|
|
|
|
|
array_pop($stack);
|
|
|
|
|
|
$html .= '</li></' . $tag . '>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($stack) {
|
|
|
|
|
|
$html .= '</li>';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$html .= '</li>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 11:22:34 +08:00
|
|
|
|
// 🔴 v61:任务清单(GFM `- [ ]` / `- [x]`)。
|
|
|
|
|
|
// checkbox 渲染成**只读**(disabled)—— 笔记不是任务系统,
|
|
|
|
|
|
// "点一下勾上却没保存"是最糟糕的体验;要勾就去改源码里的 [x]。
|
|
|
|
|
|
$body = trim($content);
|
|
|
|
|
|
if (!$ordered && preg_match('/^\[( |x|X)\]\s+(.*)$/s', $body, $tm)) {
|
|
|
|
|
|
$on = ($tm[1] !== ' ');
|
|
|
|
|
|
$html .= '<li class="md-li md-task">'
|
|
|
|
|
|
. '<input type="checkbox" class="md-check" disabled'
|
|
|
|
|
|
. ($on ? ' checked' : '') . '>'
|
|
|
|
|
|
. '<span class="md-task-text">' . self::inline($tm[2]) . '</span>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$html .= '<li class="md-li">' . self::inline($body);
|
|
|
|
|
|
}
|
2026-10-08 02:20:19 +08:00
|
|
|
|
$i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
|
|
|
|
|
|
while ($stack) {
|
|
|
|
|
|
array_pop($stack);
|
|
|
|
|
|
$html .= '</li></' . $tag . '>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return [$html, $i];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 11:22:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 解析一段 GFM 表格:本行是表头、下一行是 `---` 分隔行(已在调用处确认)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v61。要点:
|
|
|
|
|
|
* · **先 htmlspecialchars**(走 `inline()`)再把内容塞进单元格 —— 单元格里的
|
|
|
|
|
|
* `<script>` 只会当字面文本,绝不会被当 HTML 执行(这是全站 Markdown 的底线)。
|
|
|
|
|
|
* · 对齐取自分隔行:`:---` 左、`---:` 右、`:---:` 居中,翻成 inline style。
|
|
|
|
|
|
* · 每行的列数**向表头看齐**(多出来的丢掉、少了补空)—— 否则某行漏一列,
|
|
|
|
|
|
* 整张表的格子会错位(用户看到的"表格烂了"多半就是这么来的)。
|
|
|
|
|
|
* · 分隔行之前那行表头单独渲染成 `<th>`;数据行渲染成 `<td>`。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{0:string,1:int} [html, 新行号]
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function table(array $lines, int $i, int $n): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$split = static function (string $row): array {
|
|
|
|
|
|
// 去掉首尾竖线,再按 | 切;每格去首尾空格(内容里的 ** 等交给 inline)
|
|
|
|
|
|
$row = trim($row);
|
|
|
|
|
|
$row = preg_replace('/^\|/', '', $row) ?? $row;
|
|
|
|
|
|
$row = preg_replace('/\|$/', '', $row) ?? $row;
|
|
|
|
|
|
return array_map('trim', explode('|', $row));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$header = $split($lines[$i]);
|
|
|
|
|
|
$aligns = $split($lines[$i + 1]);
|
|
|
|
|
|
$cols = count($header);
|
|
|
|
|
|
// 对齐数组补齐到列数,缺的按左对齐
|
|
|
|
|
|
for ($k = 0; $k < $cols; $k++) {
|
|
|
|
|
|
if (!isset($aligns[$k])) { $aligns[$k] = '---'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
$styleOf = static function (string $sep): string {
|
|
|
|
|
|
$l = str_starts_with(trim($sep), ':');
|
|
|
|
|
|
$r = str_ends_with(trim($sep), ':');
|
|
|
|
|
|
if ($l && $r) { return ' style="text-align:center"'; }
|
|
|
|
|
|
if ($r) { return ' style="text-align:right"'; }
|
|
|
|
|
|
if ($l) { return ' style="text-align:left"'; }
|
|
|
|
|
|
return '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$html = '<table class="md-table"><thead><tr>';
|
|
|
|
|
|
for ($c = 0; $c < $cols; $c++) {
|
|
|
|
|
|
$html .= '<th class="md-th"' . $styleOf($aligns[$c]) . '>'
|
|
|
|
|
|
. self::inline((string)$header[$c]) . '</th>';
|
|
|
|
|
|
}
|
|
|
|
|
|
$html .= '</tr></thead><tbody>';
|
|
|
|
|
|
|
|
|
|
|
|
$i += 2; // 跳过表头与分隔行
|
|
|
|
|
|
while ($i < $n && str_contains($lines[$i], '|') && trim($lines[$i]) !== '') {
|
|
|
|
|
|
$cells = $split($lines[$i]);
|
|
|
|
|
|
$html .= '<tr class="md-tr">';
|
|
|
|
|
|
for ($c = 0; $c < $cols; $c++) {
|
|
|
|
|
|
$val = (string)($cells[$c] ?? '');
|
|
|
|
|
|
$html .= '<td class="md-td"' . $styleOf($aligns[$c]) . '>'
|
|
|
|
|
|
. self::inline($val) . '</td>';
|
|
|
|
|
|
}
|
|
|
|
|
|
$html .= '</tr>';
|
|
|
|
|
|
$i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
$html .= '</tbody></table>';
|
|
|
|
|
|
return [$html, $i];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 行内语法:`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 '<code class="md-icode">' . $m[1] . '</code>';
|
|
|
|
|
|
}, $s) ?? $s;
|
|
|
|
|
|
|
|
|
|
|
|
//  图片(笔记里可能有,但我们**不内联渲染外部图片** ——
|
|
|
|
|
|
// 那是隐私与外链风险;只显示成一个链接,点了自己开)
|
|
|
|
|
|
$s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)\)/', static function ($m): string {
|
|
|
|
|
|
$u = self::safeUrl($m[2]);
|
|
|
|
|
|
return $u === '' ? $m[1] : '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
|
|
|
|
|
|
. ($m[1] !== '' ? $m[1] : $u) . '</a>';
|
|
|
|
|
|
}, $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 '<a class="md-link" href="' . $u . '" target="_blank" rel="noopener noreferrer">'
|
|
|
|
|
|
. $m[1] . '</a>';
|
|
|
|
|
|
}, $s) ?? $s;
|
|
|
|
|
|
|
|
|
|
|
|
// **粗体** / __粗体__
|
|
|
|
|
|
$s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s) ?? $s;
|
|
|
|
|
|
$s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s) ?? $s;
|
|
|
|
|
|
// *斜体* / _斜体_(** 已经先被吃掉,所以这里不会误伤)
|
|
|
|
|
|
$s = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $s) ?? $s;
|
|
|
|
|
|
// ~~删除线~~
|
|
|
|
|
|
$s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $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;
|
2026-10-18 11:22:34 +08:00
|
|
|
|
// 🔴 v61:表格与任务清单的记号也要去,且**顺序要紧**:
|
|
|
|
|
|
// ① 先整行丢掉表格的"分隔行"(只含 : - | 空格的那种)—— 摘要里出现
|
|
|
|
|
|
// `--- ---` 是纯粹的噪音;
|
|
|
|
|
|
// ② 再剥任务清单的方括号 —— **必须在剥 `- ` 之前**:否则先把项目符
|
|
|
|
|
|
// 吃掉、剩下 `[ ]` 就匹配不上任务模式了;
|
|
|
|
|
|
// ③ 最后才是普通列表符与表格竖线(竖线换空格,格与格之间还能读)。
|
|
|
|
|
|
$s = preg_replace('/^\s*\|?[\s:|-]+\|?\s*$/m', '', $s) ?? $s; // ①(含 - ,只吞分隔行)
|
|
|
|
|
|
$s = preg_replace('/^(\s*)(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s+/m', '$1', $s) ?? $s; // ②
|
|
|
|
|
|
$s = preg_replace('/^\s*(?:[-*+]|\d+[.)])\s+/m', '', $s) ?? $s; // ③
|
|
|
|
|
|
$s = preg_replace('/^\||\|$/m', '', $s) ?? $s;
|
|
|
|
|
|
$s = str_replace('|', ' ', $s);
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// 去掉行内标记
|
|
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|