Files
laoyang f513703491 v61 修补:Markdown 引擎回退 7.4 兼容写法(str_contains 线上会白屏)
部署后拿服务器 PHP 7.4 的 CLI 真跑了一次渲染,当场 Fatal:
`Call to undefined function App\str_contains()`。

- 全站基线是 **0 处 PHP8 函数**(项目一直兼容 7.4);我新写的表格代码
  引入了 6 处(str_contains×4、str_starts_with、str_ends_with)。
- `php -l` 只查语法——"函数不存在"是运行时错误,部署脚本的"双版本语法检查"
  拦不住这类回归。已改成 strpos/substr 写法,并在部署脚本加了一段
  **74 运行时探针**(真渲染一次表格+任务清单+转义),把这条底线钉死。
- 反向验证的注入锚点跟着源码改写同步更新(case 1/3),基线 30/0、
  锚点注入照样变红。
2026-10-18 11:35:13 +08:00

396 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 轻量 Markdown 渲染(v28 用户第 12 条)。
*
* 用户原话:「笔记功能,段落是需要支持富文本的,可以使用 markdown,但是需要使用
* 富文本的方式进行编辑,同时可以使用 markdown 源码观看编辑。就是说,
* 允许所见即所得编辑,也允许 markdown 编辑。」
*
* 为什么自己写而**不引入第三方库**(wyc 2026-10-07 已确认):
* · 本项目全站只有这一处要 Markdown,为一个功能引一个几百 KB~数 MB 的编辑器,
* 与"轻量级"直接冲突(Semi 那 4 MB 已经在按需加载了,不该再加一个);
* · 我们支持的语法是**有界的**(标题 / 粗斜体 / 行内代码 / 代码块 / 列表 /
* 引用 / 链接 / 分割线),不是完整 CommonMark —— 有界的东西自己写**更可靠**
* (不会在某次依赖升级后行为漂移)。
*
* 🔴 v61(用户 2026-10-18 第 2 条「笔记要支持成熟软件的那些块」):
* 按 Notion / flomo / Obsidian 的块清单逐项核对后,**只补 Markdown 原生就能表达的**:
* · GFM 表格(| 列 | 列 | + 分隔行,支持 :-: 对齐)
* · GFM 任务清单(- [ ] / - [x],勾选框渲染成**只读** checkbox)
* (行内代码 / 围栏代码 / 引用 / 分割线 / 多级列表 / 标题 / 删除线 / 链接 v28 起就有)
* 代码块语言标注仍然只作 class 记号(引高亮库 = 又一坨几百 KB,与"轻量"冲突,
* 用户当时已拍板;这里不偷偷改道)。
* "折叠块(toggle)"刻意不做:阅读页有逐段编辑、块列表可上移下移,折叠会把
* 内容藏进用户找不到地方改的界面里;flomo 也没有它。要做要整个交互重新设计。
*
* 🔴 安全:**不允许任何原始 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;
}
// ---- 表格(GFM:表头行 + `---` 分隔行 + 数据行)----------------
// 🔴 v61(用户 2026-10-18:笔记要支持成熟软件的块)。
// 识别条件**必须两行一起看**:本行像 `|a|b|` 且**下一行是分隔行**
// (只含 - : | 空格且至少一个 -)。只看本行会把"正文里恰好有竖线
// 的一行"当表格(比如「3 | 2 | 1」),假表格比没表格更糟。
// ⚠️ 一律用 strpos !== false 而**不用 str_contains**:线上 PHP 是 7.4
// (全站零处 PHP8 函数,本仓一直兼容 7.4),str_contains 在 7.4 直接 fatal。
if (strpos($line, '|') !== false && $i + 1 < $n
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
&& strpos($lines[$i + 1], '-') !== false) {
[$html, $i] = self::table($lines, $i, $n);
$out[] = $html;
continue;
}
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
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]) !== ''
&& !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);
// 段内换行 → <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>';
}
// 🔴 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);
}
$i++;
}
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
while ($stack) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
return [$html, $i];
}
/**
* 解析一段 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 {
// ⚠️ 7.4 兼容:不用 str_starts_with / str_ends_with(见上面的说明)
$sep = trim($sep);
$l = ($sep !== '' && $sep[0] === ':');
$r = ($sep !== '' && substr($sep, -1) === ':');
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 && strpos($lines[$i], '|') !== false && 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];
}
/**
* 行内语法:`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;
// ![alt](url) 图片(笔记里可能有,但我们**不内联渲染外部图片** ——
// 那是隐私与外链风险;只显示成一个链接,点了自己开)
$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;
// 🔴 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);
// 去掉行内标记
$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;
}
}