Files
FamilyBook/app/Markdown.php
T
laoyang 4c63e42091 v61:笔记补齐成熟软件的块(Markdown 引擎加表格与任务清单,命令表扩全)
用户反馈:「笔记应该增加更多的块:段落、清单、图片、代码……去查成熟笔记
软件都分哪些块,我们都应该支持。」

对照 Notion / flomo / 思源 / Logseq 的块清单逐项核对(块类型仍只有
段落/图片两种——v29 用户拍板"清单并入 Markdown"),补齐的是
**Markdown 引擎与编辑器到 GFM 关键子集**:

app/Markdown.php
- GFM 表格:表头+分隔行两行联合识别(正文里有竖线不会误判成表格);
  `:---`/`---:`/`:-:` 左右中对齐;列数向表头看齐(多丢少补,格子不错位);
  单元格走 inline()(先 htmlspecialchars,`<script>` 只会被画成字面文本);
  段落收集遇到"紧跟正文的表格"会断开(GFM 不强制空行)
- GFM 任务清单:`- [ ]`/`- [x]` 渲染成**只读**勾选框(笔记不是任务系统,
  能点却不保存是假交互;要勾就改源码里的 x)
- 摘要清洗:表格分隔行/竖线/任务方括号不进列表卡片

assets/js/note_editor.js
- 命令表补齐:H1/H2/H3、有序列表、待办清单、删除线、行内代码、表格模板、
  分割线(工具条与斜线菜单仍是同一份表)
- 🔴 修真 no-op:v29 的"逐行套"写着逐行实际原样返回,选中三行点列表只给
  第一行挂前缀;现在行首记号型命令逐行套(空行跳过)
- 表格模板光标落在第一行数据的第一格,插完可直接打字

templates/notes/form.php:工具条 8→14 键(高频);全部命令斜线 `/` 可调
assets/css/app.css:.md-table / .md-task 样式(跟主题变量,不写死色)

刻意不做:代码语法高亮(引库=几 MB,用户当年已拍板轻量)、折叠块
(与"逐段编辑"打架,flomo 也没有)、页内子页/数据库(家书没有那个形态)。

验证:_fb_v61_ui.py 真机 30/0(含 XSS 夹具、紧跟正文断段、实时预览同源、
逐段编辑接口、v28 老语法零回退、历史 list 块不丢);
_b61_falsify.py 8 处注入 8/8 全红在预期断言;回归 b29 38/0、v55 30/0、v60 28/0。
2026-10-18 11:22:34 +08:00

392 lines
18 KiB
PHP
Raw 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」),假表格比没表格更糟。
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;
}
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
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 允许表格紧跟正文,不强制空行)。
&& !(str_contains($lines[$i], '|') && $i + 1 < $n
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lines[$i + 1])
&& str_contains($lines[$i + 1], '-'))) {
$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 {
$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];
}
/**
* 行内语法:`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;
}
}