docs/06-decisions/ADR-0004-markdown-内嵌HTML前置转换策略.md
GitHub issue / PR / comment body 是一种「Markdown + 白名单 HTML」的混合格式。
GitHub GFM 规范(https://github.github.com/gfm/#raw-html)明确列出了允许的
HTML 标签白名单(<a> `` <details> <summary> <kbd> <sub> <sup>
<code> <b> <i> 等约 40 个),官方 Web/App 会把这些标签正常渲染成对应的
富文本元素。
项目一直使用 flutter_markdown_plus
(当前 1.0.6)作为渲染器,它默认已经启用 GFM 扩展(删除线 / 任务列表 / 表格 /
自动链接可直接工作),但明确不支持内联 HTML。README 原文:
"It supports the original format, but no inline HTML."
底层 package:markdown 解析器把 raw HTML 当作 文本节点 输出,
MarkdownBuilder 按纯文本原样吐到页面,结果就是用户看到:
<a href="/CarGuo/xxx/new/master?filename=..." class="Link--inTextBlock"
target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a>
一整段裸标签字面文本,完全丢失可读性与可点击链接。
真机截图 /tmp/gsy_smoke_11_final.png 是 Copilot 在 PR #938 提交的 review body
出现此问题的证据。
调研 pub 生态与自身项目边界,得到三条候选路径:
P1 前置文本预处理
package:html 把 HTML 段解析成 DOM,深度优先遍历翻译成 Markdown 等价语法GSYMarkdownWidget 管线markdown_html_transformer.dart),依赖只加一个官方
html: 0.15.5<details>
的折叠交互)会退化为可读的展开版本,但不影响信息传达P2 换渲染包(如 markdown_widget + html_support.dart 扩展)
MarkdownStyleSheet → MarkdownConfig 体系onTapLink 签名从 (text, href, title) 改为 (url)html_support.dart 扩展写自定义
SpanNode/InlineSyntax,工作量与 P1 相近但影响面大得多P3 服务端 rendered HTML + flutter_html
Accept: application/vnd.github.html+json 头,body 字段全改用 body_htmlflutter_html 渲染common/net + common/repositories + 所有 body 字段的模型 —— 属于 AGENTS.md
标记的高风险目录,代价极大采用 P1 前置文本预处理。
具体实现在 markdown_html_transformer.dart:
html: 0.15.5(Dart 官方维护、纯 Dart 零 native、非常轻)transformInlineHtmlToMarkdown(String input) 一入一出的纯函数<a href> → [text](href)<b> <strong> → **x**;<i> <em> → *x*;<s> <strike> <del> → ~~x~~<code> <kbd> <tt> <samp> <var> → `x`;<pre> → 三反引号代码块<sub> <sup> → 纯文本(GFM 未定义 ~x~ 上下标语法,与删除线 ~~x~~ 会冲突;退回纯文本保可读性)<h1>-<h6> → # ~ ######;<hr> → ---; → 两空格换行<blockquote> → > x(支持多级嵌套,单层加 > 前缀,总复杂度 O(N) 而非 O(D·N))<ul> <ol> <li> → Markdown 列表(支持嵌套,<ol start="5"> 支持起始序号)<details><summary>...</summary>body</details> → **summary** + 空行 + body(不做折叠交互)<table><thead><tbody><tr><th><td> → GFM 表格<dl><dt><dd> → **term**\n: definition<div> <span> <small> <q> 视为透传容器,只吐子节点<xxx> 字面泄漏GSYMarkdownWidget.build 里,把 markdownData 先过一遍
transformInlineHtmlToMarkdown 再喂给 _processMarkdownImages一次独立 reviewer subagent 复审发现 8 类核心问题,全部一次性修完并沉淀到单测。 这些"输出正确的 Markdown"细节,看似小、实则决定线上不出事:
<a href="a)fake">y</a> 直接翻译成 [y](a)fake) 会被
markdown 解析成 URL=a、后面 fake) 变正文,作者可借此把 URL 后缀伪装成正文
欺骗用户。修复:URL 含 [\s<>()] 时用 GFM 尖括号语法 <url> 包裹;
内部再把 <> 本身 URL 编码成 %3C %3E] [ \ 会破坏 [label](url) 语法。
按 CommonMark 反斜杠转义规则一次处理<pre> 内含 ``` 时,fence 至少加长到 4
反引号,避免被内容里的三反引号提前闭合<td> 内含 | 必须转义为 \|;含 \n 必须替换为空格,
因为 GFM 表格不允许多行 cell<div><div>...</div></div> 恶意深嵌套会栈爆,
_RenderCtx.recursion 计数超上限直接退回 textContent> 前缀,D 层嵌套时
复杂度 O(D·N)。重写为只加当前层前缀、内层递归各自负责,总复杂度回到 O(N)<[a-zA-Z/!] 快速判断是否含疑似 HTML 标签起始,
纯 markdown(如 1 < 2)直接原样返回,不走 parser优点:
<details> 加真正的折叠交互)而不破坏契约/tmp/gsy_smoke_13_final.png 证据齐全:
# 大标题 / ## 中标题 / <hr> / <ul> / <a> 蓝色链接 / 行内代码 全部渲染正确代价:
<details> 失去折叠 UI,直接展开显示(但保留了粗体 summary + 内容层次)<u> <ins> 用 *斜体* 近似,因为 Markdown 无原生下划线语义<sub> <sup> 退化为纯文本(H<sub>2</sub>O → H2O),失去下标视觉;
代价换来的是不与 GFM 删除线冲突package:html 是 Dart 官方(dart-lang/html),
优先选它比自己写正则、找社区小包稳得多。规则 2「参考开源项目」的正解html: 0.15.5)