技术写作工具最容易被低估的部分,不是文本框,也不是把 Markdown 变成 HTML,而是确定每一步能信任什么。只要预览区允许原始 HTML、代码高亮器会改写 DOM,或者导入的文件来自外部,渲染链路就已经跨过了普通文本展示的边界。
CodePaper 是 EchoBlog 内的本地优先写作原型。它没有账户、云同步和执行代码的沙箱,目标只是把编辑、预览、目录、草稿恢复和 Markdown 导出串成一条足够可靠的最小工作流。本文用这篇真实长文回头验证实现,并记录安全边界与仍然存在的发布摩擦。
解析器不负责替你消毒
Marked 官方文档明确说明,它是 Markdown 编译器,不会消毒输出 HTML。下面这段输入对解析器来说是合法内容,但直接交给 v-html 就会把风险带到 DOM sink:
<img src="x" onerror="alert('xss')">
<a href="javascript:alert('xss')">危险链接</a>
因此,解析和安全处理必须是两个独立阶段:
const parsedHtml = marked.parse(markdown)
const sanitizedHtml = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
这里使用 DOMPurify,而不是自己维护标签正则。OWASP 的 XSS Prevention Cheat Sheet也把 DOMPurify 作为 HTML 消毒方案推荐,并强调框架的默认转义不能覆盖 innerHTML、v-html 一类显式逃生口。
CodePaper 只启用 HTML profile,并额外禁止脚本、样式、嵌入式内容、表单控件、内联样式和自定义 data-* 属性。这个允许范围不是通用富文本策略,而是根据当前 Markdown 预览需求收窄后的项目策略。
为什么高亮之后还要再消毒
只在 Marked 之后消毒一次仍然不够。代码高亮器需要把纯文本拆成 token,再插入带 class 的 span。DOMPurify 和 OWASP 都提醒:消毒后的内容如果又被第三方库修改,原来的安全保证可能失效。
CodePaper 因此采用双重消毒:
Markdown
-> Marked 解析
-> 第一次 DOMPurify
-> 读取 code.textContent
-> 语言白名单归一
-> 生成高亮 token
-> 第二次 DOMPurify
-> 添加受控标题 ID 与展示属性
-> v-html
高亮器拿到的是 code.textContent,而不是代码块内部的 HTML。语言名称也不会直接变成任意 class:typescript、ts 等别名先映射到受支持语言,未知值降级为 plain。这样既限制了高亮器输入,也避免把用户提供的 fence 信息原样扩散到 DOM。
最终一次消毒完成后,代码只补充程序自身生成的标题 ID、代码语言标签、外链安全属性与图片懒加载属性,不再拼接用户 HTML。这个顺序比“先消毒,之后随意增强 DOM”更容易审查。
在低带宽站点里选择轻量高亮
Shiki 的 TextMate grammar 和主题能力适合追求编辑器级一致性的场景,但 CodePaper 当前只是嵌入博客的浏览器原型。为了不让一个实验工具显著扩大页面 JavaScript,项目选择了 @speed-highlight/core。
Speed Highlight 的项目说明把核心体积描述为约 2 KB,每种语言约增加 1 KB,并提供 TypeScript、Markdown、HTML、CSS、JSON、Bash 等常用语言支持。CodePaper 只把白名单归一后的语言交给高亮器,并把未知语言保留为可读纯文本。
这是一项明确的产品取舍:
| 目标 | 当前选择 |
|---|---|
| 快速打开 | 按需加载 CodePaper 客户端组件 |
| 代码可读 | 常用语言轻量词法高亮 |
| 不破坏正文 | 未知语言显示纯文本 |
| 控制资源 | 构建预算检查页面脚本体积 |
| 编辑器级语义 | 不属于当前原型范围 |
如果未来需要 TextMate 级语义、更多主题或服务端一致高亮,应该重新评估 Shiki,而不是继续给轻量高亮器叠加复杂规则。
目录必须来自最终可见结构
长文预览除了代码块,还需要让作者快速检查文章层级。CodePaper 从最终 DOM 中读取二、三级标题,生成目录并分配稳定锚点。
重复标题需要确定性去重:
function createHeadingId(text: string, occurrences: Map<string, number>) {
const base = slugify(text) || 'section'
const count = (occurrences.get(base) ?? 0) + 1
occurrences.set(base, count)
return count === 1 ? base : `${base}-${count}`
}
目录使用语义化 nav,点击时定位到标题;滚动时再根据标题位置更新当前章节。锚点只来自文本和受控计数器,不接受 Markdown 中的任意 ID。这样目录、链接和预览区引用的是同一份最终结构。
本地优先不等于永不丢失
CodePaper 把草稿写进 localStorage,500ms 防抖自动保存,并支持 Ctrl+S 或 Command+S 立即保存。MDN 的 localStorage 文档说明,数据按 origin 隔离并可以跨浏览器会话保留;同时,浏览器策略可能拒绝持久化并抛出 SecurityError。
因此本地保存必须是可失败的能力,而不能是唯一出口:
- 写入失败时显示明确状态。
- 导入文件限制为 256 KB,正文还需通过长度校验。
- 用户随时可以复制正文或导出 Markdown。
- 不承诺跨设备恢复、版本历史或云端备份。
这也解释了为什么当前导出仍然重要:它不是装饰性的“下载”按钮,而是浏览器存储不可用时的逃生路径。
真实文章暴露的发布缺口
这篇文章实际覆盖了 CodePaper 需要处理的主要结构:多个二级标题、重复的技术术语、表格、列表、外链、html、ts 与纯文本代码块。导入后,高亮、语言标签、目录和手机预览都能正常工作。
这次真实使用当时暴露出一个比片段库更靠前的问题:CodePaper 导出的是便携 Markdown,只包含标题、标签和正文;EchoBlog 正式文章还要求日期、作者、分类、封面、阅读时间、索引状态与 SEO 字段。直接把导出文件放进 content/articles/ 不能通过发布门禁。
这个结果把后续优先级调整为可审查的“导出为 EchoBlog 草稿”流程:
- 显式选择已登记的分类和标签。
- 默认输出
draft: true与indexable: false。 - 生成符合项目 schema 的完整 Frontmatter。
- 导出后仍由
pnpm validate:content和链接检查负责最终发布判断。
这条交接流程现已实现,并用《从浏览器草稿到 Git 发布:CodePaper 的安全内容交接》完成自举验证:安全草稿不会进入生产构建,只有经过人工复核并显式解除草稿与不可索引状态后才成为正式内容。它缩短了真实写作到仓库内容之间的距离,同时保留 Git 审查和现有发布门禁,不需要把 CodePaper 变成 CMS。
当前边界
CodePaper 的预览经过消毒,但它不是不可信代码执行环境。代码块永远作为文本展示,不会运行 JavaScript、HTML 或用户脚本;导出的 Markdown 也必须再次经过目标发布系统的校验。
当前实现已经验证了本地长文编辑、安全预览、轻量高亮、目录和博客草稿交接闭环。下一阶段继续观察重复代码是否真的成为主要阻塞;只有出现明确需求时,才增加片段库、全文搜索、历史版本或独立部署。
