首页
/ Joplin HTML 转 Markdown 链接降级规则解析:以 anchor_same_title_and_url 测试用例为例

Joplin HTML 转 Markdown 链接降级规则解析:以 anchor_same_title_and_url 测试用例为例

2026-09-06 11:27:32作者:平淮齐Percy

本文围绕 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.htmlanchor_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. 第 1~3 条被折叠为纯文本 URL:当锚文本与 href 完全一致、且 URL 属于 Joplin 会自动链接化(linkify)的形式(https://http://file:// 等)时,url 这种冗余写法被简化为裸 URL。Markdown 渲染器本来就会自动把裸 URL 识别为链接,因此折叠后渲染效果不变,但文本更简洁——这正是用例文件名 “same title and url” 的含义:链接的“标题”(锚文本)与 URL 相同。
  2. 第 4 条保留完整链接语法并附加 title:虽然锚文本与 URL 相同,但 title 属性提供了 URL 之外的信息("with title"),此时不能折叠,必须保留 [https://example.com](https://example.com "with title"),否则标题信息会丢失。
  3. 第 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.comtest@example.com 不在其列,保留链接语法。
  • 内容转义豁免:在 L462-L464 附近,代码在“链接的 href 与其文本内容相同”时禁用对该链接内容的 Markdown 转义。这是折叠的前置保证——若锚文本中的 _> 等字符被转义(如 \>),则 href 的字符串比对就不会成立,折叠也就无从谈起。结合 HtmlToMd.tsdisableEscapeContent 选项的测试('> 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 实体 &quot;&#40;&#41;,并把连续换行折叠为单个换行,防止 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'] 提供锚点清单。
  • 转换前 scriptstyle 标签会被整体移除,避免污染 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 插件等全局风格,并通过 disableEscapeContentanchorNames 等选项影响链接内容的转义与锚点解析。
  • 回归保障来自 packages/app-cli/tests/HtmlToMd.ts 的夹具驱动测试,anchor 系列用例覆盖 title 折叠、转义、协议过滤、空白编码等全部边界,是理解 Joplin 剪藏与导入时链接保真策略的最佳入口。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388