鸿蒙 PC Markdown 编辑器 GFM 渲染:表格、删除线、自动链接与任务列表
Markdown 预览并不是把井号替换成标题标签。桌面用户从 GitHub、GitCode 和团队文档仓库带来的文件,通常包含表格、删除线、裸 URL 和任务列表;如果编辑器只支持最小语法,源码可以打开,预览却会丢失信息。反过来,如果为了兼容而允许任意 HTML,嵌入式 ArkWeb 又会扩大脚本和导航风险。
本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,分析如何用 markdown-it 建立 GFM 常用语法基线,用固定版本任务列表插件补齐复选框,再通过 DOMPurify 和受限链接行为保持本地预览安全。完整代码位于 https://gitcode.com/VON-/codex_md_oh,本文对应提交3a9146e。
先明确 GFM 范围
“支持 GFM”容易成为模糊宣传。GitHub Flavored Markdown 包含一组规范扩展,具体解析库又可能默认打开部分能力。OhMarkdown 当前把验收范围写成四个可测试点:
- 管道表格生成
table、thead、tbody和单元格结构。 ~~内容~~生成删除线。- 裸
https://URL 自动成为链接。 - [ ]与- [x]生成只读任务复选框并保留勾选状态。
这四项覆盖日常项目说明、技术方案和任务记录,但不等于支持所有 GitHub 页面特性。脚注、警告块、数学公式、Mermaid、仓库相对链接解析和语法高亮都需要独立设计。把范围拆成 DOM 结果,比一句“兼容 GFM”更容易持续回归。
markdown-it 的基础配置
渲染器在 Web 内核启动时创建一次:
constmarkdownRenderer=newMarkdownIt({html:false,linkify:true,typographer:false,breaks:false});markdownRenderer.use(taskLists,{enabled:false,label:true,labelAfter:true});html: false表示源码中的原生 HTML 不参与渲染。用户输入<script>、<iframe>或<div style=...>时,不会直接成为活动 DOM。这是第一道安全边界,也让 Markdown 文件在不同平台上的表现更可预测。需要支持安全 HTML 子集时,应单独定义标签和属性白名单,而不是把开关改为 true 后完全依赖浏览器。
linkify: true打开裸链接识别,因此正文里的https://example.com不必写成[链接](...)。typographer: false避免渲染器自动替换引号、破折号和符号,技术文档中的字符应尽量忠于源码。breaks: false保持标准段落换行语义,单个源码换行不会无条件生成<br>。
markdown-it 默认已经支持表格和删除线规则,因此不需要再装两个插件。任务列表不是核心规则,由markdown-it-task-lists增加。依赖在package.json中固定为2.1.1:
{"dependencies":{"markdown-it":"^14.3.0","markdown-it-task-lists":"2.1.1"}}任务列表插件固定精确版本,是为了降低构建结果随补丁发布变化的风险。解析内核自身目前允许兼容范围升级,锁文件仍固定实际安装版本。涉及输出 DOM 的依赖升级必须重新跑安全与结构测试,不能只看 TypeScript 编译。
为什么任务复选框默认只读
插件配置enabled: false会生成禁用复选框。预览区的职责是阅读渲染结果,不直接修改源码。若允许用户点击预览中的任务项,应用必须把 DOM 节点反向映射到源码偏移,修改[ ]为[x],处理重复条目、嵌套列表和编辑期间偏移变化,还要把变更放进 CodeMirror 撤销历史。
在没有完整双向映射前,让复选框可点击会制造假交互:界面看似勾选,源码和保存文件却没变化。只读复选框诚实表达当前能力,同时保留勾选视觉状态。
label: true和labelAfter: true让插件生成可关联标签结构,文字位于复选框之后。即使复选框禁用,语义结构仍有利于可访问性和样式。未来开放交互时,也不必重新修改输出形态。
表格需要结构与样式共同完成
解析器把如下源码转换为表格:
| 名称 | 状态 | | --- | --- | | 鸿蒙 PC | 完成 |仅生成 HTML 还不够。浏览器默认表格没有清晰边框,长内容也可能撑破预览列。当前样式建立紧凑的桌面阅读结构:
#preview table{border-collapse:collapse;}#preview th, #preview td{padding:7px 12px;border:1px solid #dce1e4;}深色主题覆盖边框:
:root[data-theme='dark'] #preview th, :root[data-theme='dark'] #preview td{border-color:#465057;}后续还需补充宽表格的横向滚动策略。当前table没有外层滚动容器,超长单元格可能挤压布局。可靠方案通常是在渲染后给表格包裹容器,或设置display: block; overflow-x: auto,但这会影响表格布局算法。应在真实宽表、中文长词、代码字段和窄窗口上验证后再选。
表格对齐标记、空单元格、转义管道和行内代码里的管道也是需要版本化语料覆盖的边界。只测三列表格能证明规则启动,不能证明复杂文档完全正确。
删除线与自动链接的语义
删除线输入~~旧内容~~,预期生成<s>旧内容</s>。这项功能实现简单,仍需要测试,因为解析器选项或版本升级可能关闭相关规则。删除线在变更记录、废弃方案和任务说明中很常见,若渲染成两个波浪号会明显降低文档可读性。
裸 URL 由linkify转换。预览渲染后,应用会统一处理所有链接:
preview.querySelectorAll<HTMLAnchorElement>('a').forEach((link)=>{link.target='_blank';link.rel='noopener noreferrer';link.addEventListener('click',(event)=>event.preventDefault());});设置_blank和noopener noreferrer是标准的外部链接隔离,但当前还会阻止默认点击,因此预览不会直接从本地编辑器导航到外部页面。代码保留链接视觉与 DOM 语义,用户能够识别 URL,后续可由原生层接管点击并经过协议白名单确认后调用系统浏览器。
如果只依赖target='_blank',ArkWeb 可能创建新窗口或离开应用上下文;如果完全删除href,导出的 HTML 又失去链接。当前渲染链把“生成安全链接结构”和“应用内是否允许导航”分开处理。
所有扩展输出仍要经过净化
markdown-it 配置html: false已经阻止源码原生 HTML,但插件和链接规则仍会产生 HTML。应用将渲染结果统一交给 DOMPurify:
functionsanitizeMarkdown(content:string):string{constunsafeHtml=markdownRenderer.render(content);returnDOMPurify.sanitize(unsafeHtml,{USE_PROFILES:{html:true},FORBID_TAGS:['style','iframe','object','embed','form'],FORBID_ATTR:['style']});}安全净化必须位于所有渲染规则之后。如果先净化 Markdown 源码,再让插件生成 HTML,插件输出绕过了最终白名单。当前流程固定为 Markdown 到 HTML、HTML 净化、写入 DOM。
禁止内联 style 能防止文档覆盖编辑器 UI、制造不可见链接或使用 CSS 读取行为。禁止 iframe、object、embed 和 form 缩小嵌入内容与提交能力。DOMPurify 还会处理危险协议属性;导出测试明确断言不存在href="javascript:..."。
任务列表插件需要的input和label在 HTML profile 中保留,但复选框禁用。每次新增 Markdown 插件都要检查它输出哪些标签和属性,确认净化后功能仍在、安全边界没有被放宽。插件兼容不是“页面看起来有内容”,还要验证净化前后 DOM。
预览更新采用脏标记
编辑器有源码、分栏和预览三种模式。源码模式下不需要每次按键都渲染隐藏预览,只把previewDirty设为 true。进入分栏或预览时再生成:
functionsetMode(mode:ViewMode):void{if(largeDocumentMode&&mode!=='source'){return;}currentMode=mode;workspace.dataset.mode=currentMode;if(mode!=='source'&&previewDirty){renderPreview(editor.state.sliceDoc());}if(mode!=='preview'){window.requestAnimationFrame(()=>editor.focus());}}分栏和预览可见时,编辑变更会刷新渲染;纯源码时延迟。这个策略减少后台 DOM 构建,特别适合用户长时间专注源码输入。五兆字符以上文档直接进入大文档保护,只允许源码模式,避免 markdown-it 和 DOMPurify 对超大文本建立庞大 DOM。
renderPreview每次替换整个innerHTML:
functionrenderPreview(content:string):void{preview.innerHTML=sanitizeMarkdown(content);// 重新约束链接previewDirty=false;}全量渲染实现简单,输出确定,但长文档频繁输入可能产生性能压力和滚动位置变化。后续可以做节流、按块 diff 或增量 token 渲染,不过任何优化都必须保留净化边界,不能把未经净化的局部片段直接插入 DOM。
GFM 样式也要适配深色与窄窗口
任务列表去掉普通列表圆点,复选框使用固定尺寸和强调色:
#preview .contains-task-list{padding-left:0;list-style:none;}#preview .task-list-item{list-style:none;}#preview .task-list-item-checkbox{width:15px;height:15px;margin:0 8px 0 0;vertical-align:-2px;accent-color:#087a63;}固定尺寸防止浏览器默认控件在不同系统缩放下挤压行高,vertical-align让复选框与文本基线协调。真正的鸿蒙 PC 适配还要测试系统字体放大和高 DPI,固定十五像素是否足够可点并不重要,因为当前禁用,但视觉可辨识度仍重要。
分栏在宽窗口使用两列,窄于 760 像素后变成上下两行。GFM 表格和代码块需要在两种布局中都不把容器撑破。预览图片使用max-width: 100%,代码块使用overflow: auto。表格的窄窗口策略仍是后续重点。
鸿蒙 PC 模拟器中的预览链路
下图来自 MateBook Pro 2in1 模拟器。源码编辑区与经过 markdown-it、DOMPurify 处理的预览同屏,标题层级、边框、排版和同步开关均在真实 ArkWeb 容器中运行。
这张应用截图证明的是完整预览链已经进入鸿蒙 PC 工作台,而 GFM 四项由自动化 DOM 断言覆盖。正式发布前还应在模拟器准备包含表格、删除线、裸链接和任务列表的专用文档,补充一张四项同屏截图;当前文章不把普通标题截图伪装成 GFM 四项视觉证据。
自动化测试直接检查 DOM
Playwright 用例一次构造四类语法:
constsource='| 名称 | 状态 |\n'+'| --- | --- |\n'+'| 鸿蒙 PC | 完成 |\n\n'+'~~旧内容~~\n\n'+'https://example.com\n\n'+'- [ ] 待办\n'+'- [x] 完成';host.OhMarkdownEditor.setDocument(content);host.OhMarkdownEditor.setMode('preview');断言不是截图比对,而是结构与属性:
awaitexpect(page.locator('#preview table')).toHaveCount(1);awaitexpect(page.locator('#preview s')).toHaveText('旧内容');awaitexpect(page.locator('#preview a')).toHaveAttribute('href','https://example.com');awaitexpect(page.locator('#preview .task-list-item-checkbox')).toHaveCount(2);awaitexpect(page.locator('#preview .task-list-item-checkbox').first()).toBeDisabled();awaitexpect(page.locator('#preview .task-list-item-checkbox').nth(1)).toBeChecked();表格存在证明规则启用,删除线文本证明 token 正确,链接 href 证明 linkify 结果,两个 checkbox 的 disabled 与 checked 同时验证只读语义和勾选状态。比起只断言预览包含文字,这些条件更接近功能契约。
安全测试另外输入<script>和javascript:链接,确认脚本没有进入 DOM、全局变量没有执行、导出 HTML 不含危险协议。功能测试与安全测试必须同时存在,因为新增插件可能让一种 GFM 语法正确,却改变净化输出。
版本化语料的作用
项目在test-fixtures/markdown/gfm-baseline.md保存稳定语料。测试代码内的最小字符串适合快速断言,版本化文件适合人工预览、模拟器截图和未来差异比较。两者职责不同。
语料应逐步增加:对齐表格、空任务、嵌套任务、删除线跨行边界、带括号 URL、中文域名、转义波浪号、代码块中的任务标记、表格单元格内链接。每次修复解析差异时先加入最小复现,再升级依赖,避免“新版本看起来更强”却破坏旧文档。
GFM 与 CommonMark 基线要分开。普通段落、标题、列表、引用和代码围栏属于基础语义;表格和任务列表属于扩展。测试失败时能快速判断是核心解析退化还是插件变化。
导出与预览必须共享渲染语义
HTML 导出调用同一个sanitizeMarkdown:
functionexportHtml(title:string):string{constsafeTitle=escapeHtmlText(title.trim()||'OhMarkdown document');constbody=sanitizeMarkdown(editor.state.sliceDoc());return`<!doctype html>...${body}...</html>`;}因此表格、删除线、自动链接和任务列表在应用预览与导出 HTML 中使用同一解析器和净化策略。若导出另建一套 Markdown 库,用户会遇到“应用里正确、导出后不同”的问题。打印 PDF也先准备同一预览 DOM,再交给系统打印适配器。
共享语义不代表样式完全相同。导出 HTML内嵌独立 CSS,不依赖应用资源,表格有边框,但任务列表样式目前需要检查是否完整进入导出样式。任何新增 GFM 展示规则都要同步考虑应用 CSS、导出 CSS 和打印媒体。
当前边界
OhMarkdown 当前没有语法高亮代码块,没有 Mermaid、数学公式或脚注插件,没有允许预览直接勾选任务,也不会自动打开外部链接。相对图片和链接的工作区基准 URI 仍需进一步完善。大文档模式禁用预览和导出,避免不受控内存占用。
这些边界应当公开,而不是通过不断安装插件掩盖。每个插件都会增加包体、供应链、DOM 输出和安全审查成本。产品要优于现有编辑器,不是插件数量最多,而是在承诺语法上渲染一致、离线可用、导出一致、升级可回归。
结语
GFM 渲染是一条完整管线:markdown-it 提供基础表格、删除线和链接识别,固定版本插件生成只读任务列表,DOMPurify 对所有输出做最终净化,CSS 在亮暗主题和分栏布局中建立可读样式,Playwright 用结构断言锁定行为,导出与打印复用相同语义。
当这条管线的范围、风险和测试都明确后,用户打开来自 GitCode 的 Markdown 文件,才能相信表格不会散、任务状态不会丢、链接不会劫持应用、导出不会换一种语法。对鸿蒙 PC 编辑器而言,这比单纯展示一块 HTML 预览更接近真正的文档兼容能力。