Files
FamilyBook/app/Markdown.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

280 lines
12 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 —— 有界的东西自己写**更可靠**
* (不会在某次依赖升级后行为漂移)。
*
* 🔴 安全:**不允许任何原始 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;
}
// ---- 引用 > (支持多层,用前缀数决定层级)----------------------
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])) {
$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>';
}
$html .= '<li class="md-li">' . self::inline(trim($content));
$i++;
}
// 收尾:把还开着的层全部关掉(每层一个 </li> + </ul>)
while ($stack) {
array_pop($stack);
$html .= '</li></' . $tag . '>';
}
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;
$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;
}
}