Joplin HTML 转 Markdown 链接降级规则解析:以 anchor_same_title_and_url 测试用例为例
本文围绕 Joplin 的 HTML→Markdown 转换引擎中的一个典型测试用例展开:当 <a> 标签的锚文本与其 href 完全相同(即“同名同 URL”)时,转换结果会被简化为纯文本 URL 还是保留 text 链接语法。通过解读该用例的 HTML 输入、期望的 Markdown 输出,并结合 @joplin/lib 与 @joplin/turndown 的源码实现,你可以掌握 Joplin 笔记导入与网页剪藏时链接降级(linkified URL collapse)的具体规则及其背后的实现逻辑。
测试用例的定位:html_to_md 夹具目录
Joplin 的 HTML 转 Markdown 功能由 packages/lib/HtmlToMd.ts 中的 HtmlToMd 类提供,其底层是基于 Joplin 自行维护的 Turndown 分支 packages/turndown。该功能的行为由一组“输入 HTML + 期望 Markdown”的成对夹具文件(fixture)来约束,全部位于 packages/app-cli/tests/html_to_md/ 目录下,覆盖锚点(anchor)、表格(table)、代码块(code)、图片(image)、任务列表(task_list)等几十类场景。
其中 anchor_same_title_and_url.html 与 anchor_same_title_and_url.md 这一对文件,专门验证“链接文本与 URL 相同”场景下的转换结果。测试驱动器 packages/app-cli/tests/HtmlToMd.ts 会遍历该目录下所有 .html 文件,逐一调用 htmlToMd.parse(),并将实际输出与同名 .md 文件逐行精确比对:
// packages/app-cli/tests/HtmlToMd.ts(节选)
const htmlToMd = new HtmlToMd();
const html = await readFile(htmlPath, 'utf8');
let expectedMd = await readFile(mdPath, 'utf8');
let actualMd = await htmlToMd.parse(`<div>${html}</div>`, htmlToMdOptions);
// 不一致时逐行打印 Got / Expected 差异并断言失败
从测试代码中可以看到一条被注释掉的调试语句 // if (htmlFilename !== 'anchor_same_title_and_url.html') continue;,说明该用例是开发者排查链接转换问题时的焦点用例之一。
输入:六条“锚文本与 URL 高度重合”的链接
用例的 HTML 输入是一个无序列表,包含六个 <a> 标签(文件位于 anchor_same_title_and_url.html):
<ul>
<li><a href="https://example.com"/>https://example.com</a></li>
<li><a href="http://example.com"/>http://example.com</a></li>
<li><a href="file:///mnt/c/test.txt"/>file:///mnt/c/test.txt</a></li>
<li><a href="https://example.com" title="with title"/>https://example.com</a></li>
<li><a href="example.com"/>example.com</a></li>
<li><a href="test@example.com"/>test@example.com</a></li>
</ul>
这六个链接分别覆盖了不同的边界情况:
| 序号 | href | 锚文本 | 特殊点 |
|---|---|---|---|
| 1 | https://example.com |
与 href 完全相同 | 标准 HTTPS 绝对 URL |
| 2 | http://example.com |
与 href 完全相同 | HTTP(非 TLS)协议 |
| 3 | file:///mnt/c/test.txt |
与 href 完全相同 | file:// 本地文件协议(WSL 风格路径 /mnt/c/) |
| 4 | https://example.com |
与 href 相同,但额外带 title="with title" |
标题与 URL 不同 |
| 5 | example.com |
与 href 相同 | 无协议前缀的相对 URL |
| 6 | test@example.com |
与 href 相同 | 形似邮箱的 URL |
期望输出:什么被“折叠”成纯文本,什么保留链接语法
与输入配对的期望 Markdown 输出(即 anchor_same_title_and_url.md 的全部内容)为:
- https://example.com
- http://example.com
- file:///mnt/c/test.txt
- [https://example.com](https://example.com "with title")
- example.com
- test@example.com
对照输入可以归纳出该用例锁定的三条转换规则:
- 第 1~3 条被折叠为纯文本 URL:当锚文本与
href完全一致、且 URL 属于 Joplin 会自动链接化(linkify)的形式(https://、http://、file://等)时,url这种冗余写法被简化为裸 URL。Markdown 渲染器本来就会自动把裸 URL 识别为链接,因此折叠后渲染效果不变,但文本更简洁——这正是用例文件名 “same title and url” 的含义:链接的“标题”(锚文本)与 URL 相同。 - 第 4 条保留完整链接语法并附加 title:虽然锚文本与 URL 相同,但
title属性提供了 URL 之外的信息("with title"),此时不能折叠,必须保留[https://example.com](https://example.com "with title"),否则标题信息会丢失。 - 第 5、6 条保留完整链接语法:
example.com(无协议前缀)和test@example.com这类相对 URL 不在自动链接化范围内,若折叠为裸文本将无法被渲染器识别为链接,因此保留text形式。
源码实现:link 规则的折叠与转义逻辑
上述行为由 Joplin Turndown 分支的 link 规则实现,位于 packages/turndown/src/commonmark-rules.js。核心 replacement 逻辑(约 L457-L495)可以概括为:
// packages/turndown/src/commonmark-rules.js(节选)
var href = filterLinkHref(node.getAttribute('href'))
if (!href) { /* 无 href 时回退为纯文本或忽略 */ }
var title = node.title && node.title !== href ? ' "' + filterTitleAttribute(node.title) + '"' : ''
let output = getNamedAnchorFromLink(node, options) + '' + filterLinkContent(content) + ''
// If the URL is automatically linkified by Joplin, and the title is
// the URL itself:
// <a href="https://example.com">https://example.com</a>
// then we can safely simplify it:
if (isLinkifiedUrl(href)) {
if (output === '' + href + '') return href;
}
从这段代码可以确认用例输出的产生机制:
- title 的附加条件:
node.title && node.title !== href才拼接"title"。第 4 条的title="with title"不等于 href,故拼入;其余条目无 title 属性,不附加——这与期望输出第 4 行完全吻合。 - 折叠判定:只有当构造出的链接字符串恰好等于
href(即锚文本、href 相同且无 title 后缀),并且isLinkifiedUrl(href)判定通过时,才返回裸href。从源码结构看,isLinkifiedUrl的判定范围覆盖了https://、http://、file://这类可被 Markdown 渲染器自动识别为链接的 URL,因此第 1~3 条命中折叠;而无协议的example.com与test@example.com不在其列,保留链接语法。 - 内容转义豁免:在 L462-L464 附近,代码在“链接的 href 与其文本内容相同”时禁用对该链接内容的 Markdown 转义。这是折叠的前置保证——若锚文本中的
_、>等字符被转义(如\>),则href的字符串比对就不会成立,折叠也就无从谈起。结合 HtmlToMd.ts 中disableEscapeContent选项的测试('> 1 _2_ 3.pdf'在两种模式下的不同输出),可以确认转义开关与链接规则是协同工作的。
链接与标题的净化函数
折叠之外,规则还依赖两个净化函数保证输出的 Markdown 合法性:
filterLinkHref(约 L410-L423):去除首尾空白;丢弃以javascript:开头的危险 href(注释明确说明“We don't want to keep js code in the markdown”);把空格、换行、制表符、圆括号分别编码为%20、%0A、%09、%28、%29。后者尤其关键——圆括号若未转义会破坏 Markdown 链接语法。filterTitleAttribute(约 L426-L433):把 title 中的双引号、圆括号分别替换为 HTML 实体"、(、),并把连续换行折叠为单个换行,防止 title 内容截断链接语法。
用例第 4 行输出中的 "with title" 正是经 filterTitleAttribute 处理后的结果。
上游封装:HtmlToMd 如何把配置传给 Turndown
HtmlToMd.parse()(见 packages/lib/HtmlToMd.ts)是上述 Turndown 规则之上的统一入口。它固定了若干转换风格并透传可选项:
// packages/lib/HtmlToMd.ts(节选)
const turndownOpts = {
headingStyle: 'atx',
anchorNames: options.anchorNames ? options.anchorNames.map(n => n.trim().toLowerCase()) : [],
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
strongDelimiter: '**',
// 行尾 <br/> 需要两个尾部空格才能在软换行下正确渲染
br: ' ',
...
};
const turndown = new TurndownService(turndownOpts);
turndown.use(turndownPluginGfm); // GFM 表格、删除线、任务列表
turndown.remove('script');
turndown.remove('style');
与链接行为相关的两点:
anchorNames选项供文档中的命名锚点(#anchor形式的内部跳转)使用,配合getNamedAnchorFromLink处理;anchor_local.html用例中即通过htmlToMdOptions.anchorNames = ['first', 'second', 'fourth']提供锚点清单。- 转换前
script与style标签会被整体移除,避免污染 Markdown 输出。
周边用例:锚点处理的完整图景
anchor_same_title_and_url 并非孤立存在,同一目录下的 anchor 系列用例从不同角度约束链接规则,可作为该行为的交叉验证:
| 用例文件 | 验证点 |
|---|---|
| anchor_local.html | 文档内部命名锚点(#first 等)的解析,依赖 anchorNames 选项 |
| anchor_multiline_title.html | 多行 title 属性的换行折叠(对应 filterTitleAttribute 的 /\n{2,}/g 处理) |
| anchor_with_brackets.html | 锚文本中含方括号时的转义,避免破坏 text 语法 |
| anchor_with_js.html | javascript: 伪协议 href 被 filterLinkHref 丢弃 |
| anchor_with_url_with_spaces.html | URL 中的空格被编码为 %20 |
| anchor_with_newlines.html | URL 中的换行/制表符被编码为 %0A/%09 |
| anchor_with_underscores.html | URL 下划线不被误当作 Markdown 强调符 |
这一组夹具共同构成了“链接净化 + 折叠”规则的回归测试网:任何对 commonmark-rules.js 中 link 规则的改动,都会通过这些用例暴露出行为偏差。
如何运行与验证
该测试属于 packages/app-cli 的 Jest 套件。在 packages/app-cli 目录下运行 Jest 并定位到 HtmlToMd 测试即可复现全文比对过程:
yarn jest HtmlToMd
若某个用例的输出与期望 .md 不一致,测试失败时会逐行打印 “Got” 与 “Expected” 两个版本(每行加引号显示,便于观察行尾空格差异),定位非常直观。需要说明的是:夹具比对是逐字精确匹配,因此新增或调整 Turndown 链接规则后,若行为变化符合预期,应同步更新对应的 .md 期望文件(在开发环境中进行,本仓库为只读示例)。
小结
anchor_same_title_and_url用例锁定了一条核心规则:锚文本等于 URL 且无额外 title 信息、URL 可被自动链接化时,url折叠为裸 URL;有 title、或 URL 不可链接化时保留完整链接语法。- 该规则实现于 packages/turndown/src/commonmark-rules.js 的 link 规则中,配合
filterLinkHref(编码危险字符、剔除javascript:)与filterTitleAttribute(转义引号括号)两个净化函数。 - 上游入口 packages/lib/HtmlToMd.ts 统一注入 atx 标题、fenced 代码块、GFM 插件等全局风格,并通过
disableEscapeContent、anchorNames等选项影响链接内容的转义与锚点解析。 - 回归保障来自 packages/app-cli/tests/HtmlToMd.ts 的夹具驱动测试,anchor 系列用例覆盖 title 折叠、转义、协议过滤、空白编码等全部边界,是理解 Joplin 剪藏与导入时链接保真策略的最佳入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00