// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否
// 增加网页前端的基本渲染支持」)。
//
// 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `
`(契约
// §4.16)、动作 ``(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条
// assistant 消息里有 6 个 ``)没有处理 ⇒ 用户直接看到字面的 `<b>`。
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
//
// 分九组:
// ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
// ② 白名单外(`", "") === "",
"② `").length === 0,
"② 元素树里除最外层 span 外没有任何节点"
);
// 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
for (const e of tree("
")) {
ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
}
}
// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
{
const broken = [
"x",
"日落 18:35 了",
"孤立闭标签",
"x y", // 开 b 未闭合(i 已闭合)⇒ 整段降级
"x", // 交错(i 未完)⇒ 整段降级
];
for (const src of broken) {
const html = markup(src);
ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
ok(!has(html, "") && !has(html, ""), `③ 降级后不产出元素:${src}`);
ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
}
ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
ok(seen("a`)不是标签");
ok(seen("x") === "x", "③ §4.16 护栏:`` 不是标签");
ok(seen("文本 `)不是标签");
}
// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
{
const interleaved = "abc
";
ok(seen(interleaved) === interleaved, "④ `abc` 逐字保留,不崩");
ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
// 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `` 对
// 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
const mixed = "日落 18:35,还有 未闭合";
ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
}
// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
{
const html = markup("z");
ok(has(html, "z"), "⑤ 只出粗体元素本身");
ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
const bEl = tree("z").find((e) => e.type === "b");
ok(Boolean(bEl), "⑤ 树里存在 b 元素");
ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
ok(seen("z") === "z", "⑤ 属性被丢弃,正文逐字保留");
const codeHtml = markup("c");
ok(has(codeHtml, "提示") === "提示", "⑤ 引号含空格的属性同样不生效");
}
// ── ⑥ 既有 `` / `` 行为无回归(真实气泡链路) ────────────────────────
{
const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
// 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是
// `…
`,注记被剥走后只剩空壳 `` ⇒ 与 `
` 碎片同一套
// 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒
// 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个 里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)");
// 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行
// (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。
ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)");
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
ok(has(html, "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 渲染为粗体");
ok(!has(html, "<p>") && !has(html, "
"), "⑥ `
` 一个都不进 DOM(段落机制不变)");
ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
// 纯 `
` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
const three = render({ id: "a2", role: "assistant", content: "
第一段
第二段
第三段
", createdAt: 2 } as ChatMessage);
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 三段 ⇒ 三个气泡");
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
// 🆕 **空 `
` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `
`
// 碎片同一套口径 —— 上一轮只丢了 `
` 碎片,空的 `
` 仍留一个空气泡,本轮改掉。
const emptyP = render({ id: "a5", role: "assistant", content: "
只有这段
", createdAt: 5 } as ChatMessage);
ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `` 块丢弃 ⇒ 只剩正文那一个气泡");
ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `` 块不留「只有头像」的空行");
const blankP = render({ id: "a6", role: "assistant", content: "甲
乙
", createdAt: 6 } as ChatMessage);
ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `
`(全空白)同样丢弃 ⇒ 两个气泡");
ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `
` 不留「只有头像」的空行");
const onlyEmptyP = render({ id: "a7", role: "assistant", content: "", createdAt: 7 } as ChatMessage);
ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "<p>"), "⑥ 正文只剩空 `` ⇒ 不画字面量 `
`,只留空回复外壳(与只剩 `
` 同一处置)");
// 🔴 `` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
const noteWithTag = render({ id: "a3", role: "assistant", content: "她 笑 了正文
", createdAt: 3 } as ChatMessage);
ok(has(noteWithTag, "*动作:她 <b>笑</b> 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
ok(has(noteWithTag, "正文"), "⑥ 注记后的正文照常渲染");
// 空正文 + streaming:仍是「生成中…」而不是空渲染。
const streaming = render({ id: "a4", role: "assistant", content: "", streaming: true, createdAt: 4 } as ChatMessage);
ok(has(streaming, "生成中…"), "⑥ 空正文 streaming 回落到「生成中…」");
// 用户侧同理(用户气泡也走 renderContent)。
const user = render({ id: "u1", role: "user", content: "帮我看看 这个 和 ", createdAt: 5 } as ChatMessage);
ok(has(user, "这个"), "⑥ 用户气泡同样渲染行内标签");
ok(!has(user, "";
const card = (mode: (typeof CARD_MODES)[number], content: string) =>
renderToStaticMarkup(
createElement(Fragment, null, modeBody(mode, { id: `card-${mode}`, role: "assistant", content, createdAt: 1 } as ChatMessage))
);
for (const mode of CARD_MODES) {
const html = card(mode, src);
ok(count(html, "
") === 1, `⑨ ${mode}:\`
\` ⇒ 一个真换行元素`);
ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`
\` 当字面量显示`);
ok(has(html, "18:35"), `⑨ ${mode}:行内 \`\` 出粗体`);
ok(has(html, "下划"), `⑨ ${mode}:行内 \`\` 出下划线`);
ok(!has(html, ""), `⑨ ${mode}:\`\` 仍在白名单外(不产出元素)`);
ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`\` 仍逐字字面`);
ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`