Quartz 路径系统深度解析:从文件路径到 URL 的四类名义类型与转换链路
【免费下载链接】quartz🌱 a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites项目地址: https://gitcode.com/GitHub_Trending/qua/quartz
Quartz 是一个将 Markdown 内容转换为完整静态网站的生成器,其内部对"路径"的处理贯穿构建全流程:磁盘上的文件路径、内容 slug、浏览器中的相对 URL 都各自拥有独立的类型系统。本篇指南以 docs/advanced/paths.md 为核心骨架,结合 quartz/util/path.ts、quartz/processors/parse.ts 与 quartz/util/path.test.ts 中的源码与测试用例,系统讲解 Quartz 的四类路径名义类型、品牌类型(branded type)实现原理、各转换函数的行为契约,以及链接解析策略在 CLI 与配置层面的落地方式,帮助读者在开发 Quartz 插件、排查链接问题时准确区分并正确使用每一种路径类型。
为什么路径在静态站点生成器中如此复杂
对于一个静态站点生成器,路径可以来自完全不同的场景:
- 磁盘上某篇内容的完整文件路径(如
content/posts/hello.md); - 一篇内容对应的slug(如
posts/hello); - 页面之间互相引用的Markdown 链接;
- 浏览器地址栏中的相对 URL(如
../../posts/hello)。
它们语义各异,却又都长成字符串的样子。如果把这一切都简单声明为string,开发时极容易把一种路径误当成另一种路径使用——例如把服务端文件路径直接塞给浏览器侧的逻辑,而 TypeScript 却无法在编译期发现错误。
Quartz 的解决方案是引入一套**名义类型(nominal types)**系统,为每种路径赋予独立类型,并在构建管线的关键入口处强制类型守卫,让"路径混淆"这类错误在编译期或构建早期就被暴露出来。
用品牌类型模拟名义类型
TypeScript 的类型别名(type)是结构化类型,没有名义类型(nominal type)机制——两个结构完全相同的类型别名在类型检查时被认为是兼容的。这意味着即使为"服务端 slug"和"客户端 slug"分别定义了类型别名,仍然可以互相赋值而不会被编译器拦截。
为了模拟名义类型,Quartz 采用**品牌类型(branded type)**技巧:在字符串类型上交叉一个带有唯一"品牌"标记的字段。
// 错误做法:完全等价于 string,无法区分 type FullSlug = string // 正确做法:附加品牌标记,形成名义上的独立类型 type FullSlug = string & { __brand: "full" } // 这样,下面这行代码将无法通过类型检查 const slug: FullSlug = "some random string"其原理在于:{ __brand: "full" }字段只存在于类型层面,运行时字符串本身并不携带该字段,因此任何普通字符串字面量都无法满足FullSlug的结构约束,赋值即报错。这就让"把客户端 slug 误当服务端 slug"这类错误在类型系统内部被拦截。
不过品牌类型并非万能。文档特别强调:它只能防止类型系统内部的混用(例如把服务端 slug 错当成客户端 slug),却无法防止开发者在使用强制类型断言(as)把普通字符串强行转换成某种 slug 类型。因此在所有"字符串进入路径系统"的入口点(entrypoint),仍然需要开发者保持谨慎,主动调用类型守卫或转换函数。在 Quartz 源码中,这些入口点(即下图中的六边形节点)就是getFullSlug()、slugifyFilePath()、transformLink()等函数所在的位置。
路径类型全景图
文档给出了一张 mermaid 图,完整描绘了所有路径来源、四类名义路径类型,以及 quartz/util/path.ts 中负责在它们之间转换的函数:
从图中可以看出:FullSlug是整个路径系统的枢纽(图中加粗表示)。浏览器一侧通过getFullSlug()从window.location得到当前页面的FullSlug;Markdown 文件一侧则通过slugifyFilePath()把磁盘文件路径转化为FullSlug。之后FullSlug可以再经simplifySlug()降级为SimpleSlug,再经pathToRoot()或resolveRelative()得到最终的RelativeURL。
这一设计在源码中的实现体现为:path.ts从@quartz-community/utils重新导出全部路径工具(getFullSlug、slugifyFilePath、simplifySlug、pathToRoot、resolveRelative、transformLink等)及全部类型(FilePath、FullSlug、SimpleSlug、RelativeURL),同时额外导出了normalizeRelativeURLs用于在客户端把文档中相对形式的href/src按当前地址重基(见 quartz/util/path.ts)。
四类核心路径类型
文档对四类主要路径类型给出了精确定义,这是理解整个路径系统的关键:
| 类型 | 核心约束 | 典型示例 |
|---|---|---|
FilePath | 磁盘上真实存在的文件路径,不能是相对路径,必须带有文件扩展名 | content/posts/hello.md |
FullSlug | 不能是相对路径,不允许前导/尾随斜杠;最后一段可以是index。它是"最通用"的 slug 解释,尽可能优先使用 | posts/hello、posts/index |
SimpleSlug | 不能是相对路径;末尾不应是/index,不应带文件扩展名;可以以尾随斜杠表示文件夹路径 | posts/、posts/hello |
RelativeURL | 必须以.或..开头以表示相对 URL;末尾不应是/index,不应带文件扩展名,但可以包含尾随斜杠 | ./posts/hello、../../ |
这些约束并非纸面约定,而是被硬编码进了类型守卫函数中,并由 quartz/util/path.test.ts 中的typeguards测试组逐条验证。例如:
isSimpleSlug接受""、"abc"、"abc/"、"notindex/def",拒绝"//"、"index"、"/abc"、"abc/index"、"abc#anchor"、"abc?query=1"、"index.md"(见 quartz/util/path.test.ts);isRelativeURL接受"."、".."、"./abc/def"、"./abc/def#an-anchor"、"./abc/def.pdf",拒绝"abc"、"/abc/def"、"./abc/def.html"、"./abc/def.md"(见 quartz/util/path.test.ts);isFullSlug接受"index"、"abc/def"、"html.energy"、"test.pdf",拒绝相对路径、带锚点/查询串的字符串以及含空格的"note with spaces"(见 quartz/util/path.test.ts);isFilePath接受"content/index.md"、"content/test.png",拒绝"../test.pdf"、无扩展名的"content/test"(见 quartz/util/path.test.ts)。
这些测试清晰地展示了每个类型的"合法值域",是理解路径语义的最佳参考。
核心转换函数的行为契约
path.test.ts的transforms测试组给出了每个转换函数最精确的输入输出契约,下面逐一展开。
simplifySlug:FullSlug → SimpleSlug
把FullSlug简化为SimpleSlug的核心逻辑是处理index段:
| 输入(FullSlug) | 输出(SimpleSlug) |
|---|---|
index | / |
abc | abc |
abc/index | abc/ |
abc/def | abc/def |
可见abc/index被规范化为带尾随斜杠的文件夹路径abc/,而顶级index则被规范化为/(见 quartz/util/path.test.ts)。
slugifyFilePath:FilePath → FullSlug
这是"磁盘文件 → 内容 slug"的关键入口,负责去掉扩展名、规范化特殊字符,并实现 Obsidian 风格的文件夹笔记(Folder Note)约定:
| 输入(FilePath) | 输出(FullSlug) |
|---|---|
content/index.md | content/index |
content/index.html | content/index |
content/_index.md | content/index |
/content/index.md | content/index |
content/cool.png | content/cool.png |
note with spaces.md | note-with-spaces |
notes.with.dots.md | notes.with.dots |
test/special chars?.md | test/special-chars |
test/special chars #3.md | test/special-chars-3 |
cool/what about r&d?.md | cool/what-about-r-and-d |
值得注意的细节包括:_index.md会被重写为index;带空格和?、#、&等特殊字符的文件名会被 slug 化(?被删除、&变成-and-);而多段文件名中的点号(如notes.with.dots.md)会被保留,不会与扩展名混淆。
该函数还实现了Obsidian 文件夹笔记约定:folder/folder.md形式的文件(末两段同名)会被视为该文件夹的落地页,重写为folder/index,例如:
characters/characters.md→characters/indexfiction/books/books.md→fiction/books/indexa/a/a.md→a/a/index
同时有一系列边界规则:顶级单段characters.md不重写;末两段不同名(characters/alice.md)不重写;更深层同名的characters/sub/characters.md也不重写;而文件夹本身名叫index的情况(index/index.md、docs/index/index.md)保持原样(见 quartz/util/path.test.ts)。
测试还验证了一个端到端一致性:无论用户采用characters/index.md还是 Obsidian 的characters/characters.md约定,simplifySlug(slugifyFilePath(...))最终都会得到相同的用户可见 URLcharacters/(见 quartz/util/path.test.ts)。
在构建流程中,slugifyFilePath被用于把每个 Markdown 文件的相对路径转换为 slug 并写入file.data.slug(见 quartz/processors/parse.ts),同时用于生成全站 slug 列表ctx.allSlugs(见 quartz/build.ts)和静态资源(如图片、PDF)的 slug 化(见 quartz/plugins/emitters/assets.ts)。
pathToRoot:SimpleSlug → RelativeURL
根据当前页面 slug 的深度计算回到站点根目录所需的相对路径:
| 输入(FullSlug) | 输出(RelativeURL) |
|---|---|
index | . |
abc | . |
abc/def | .. |
abc/def/ghi | ../.. |
abc/def/index | ../.. |
规则很直观:slug 有几层目录,就向上回溯几级;index段不增加层级深度(见 quartz/util/path.test.ts)。该函数主要用于定位全局资源(如站点级样式、脚本、图标)在每页输出 HTML 中的相对前缀。
resolveRelative:FullSlug → RelativeURL
计算"从当前页面到目标页面"的相对 URL,是pathToRoot的通用化版本:
- 从顶级页
index出发:index → ./,index → abc得./abc,index → abc/def得./abc/def; - 从嵌套页
abc/def出发:→index得../,→abc得../abc,→abc/def得../abc/def,→ghi/jkl得../ghi/jkl; - 含
index路径时:abc/index → index得../,abc/def/index → index得../../,index → abc/index得./abc/(见 quartz/util/path.test.ts)。
joinSegments:安全拼接路径段
joinSegments负责按/拼接路径段,同时保留首尾斜杠与协议前缀:
joinSegments("a", "b") // "a/b" joinSegments("a/", "b/") // "a/b/" joinSegments("/a", "b") // "/a/b" joinSegments("/a/", "b", "/") // "/a/b/" joinSegments("https://example.com", "a") // "https://example.com/a"它支持协议说明符(https://example.com后接段不会丢失//),见 quartz/util/path.test.ts。
链接转换:transformLink 与三种解析策略
Markdown 文件中的链接(Links)经transformLink()转换为相对 URL 是图中另一条关键链路。从源码看,transformLink与transformInternalLink是内部链接转换的核心实现(均从@quartz-community/utils重新导出,见 quartz/util/path.ts),而transformInternalLink负责把"笔记间链接"规范化成相对 URL,测试覆盖了锚点、查询串、index折叠、URL 编码空格与文件夹笔记约定等大量场景:
| 输入 | 输出(RelativeURL) |
|---|---|
./index | ./ |
./index#abc | ./#abc |
./index.md | ./ |
content/test.md | ./content/test |
../content/test.md | ../content/test |
/tags/ | ./tags/ |
content/with spaces | ./content/with-spaces |
content/with spaces#and Anchor! | ./content/with-spaces#and-anchor |
characters/characters | ./characters/ |
My%20Folder/My%20Note | ./my-folder/my-note |
(完整用例见 quartz/util/path.test.ts。)注意这里同样应用了文件夹笔记约定:characters/characters会被折叠为./characters/,My Folder/My Folder#heading会变成./my-folder/#heading,锚点与查询串会被 slug 化并保留。
transformLink的"link strategies"测试组则揭示了三种链接解析策略(absolute/shortest/relative)的完整行为矩阵(见 quartz/util/path.test.ts)。以页面a/b/c为例:
- absolute 策略:按 slug 的绝对路径计算相对引用,
a/b/d→../../a/b/d,a/b/index→../../a/b/,index→../../; - shortest 策略:先在
allSlugs中查找能唯一定位目标的最短 slug(如d解析为../../a/b/d、h解析为../../e/g/h),找不到则退化为绝对路径; - relative 策略:只处理当前目录内的链接,
d→./d,index→./,对../../../形式的外部引用保持原样。
从源码结构看,TransformOptions(包含strategy与allSlugs字段,见 quartz/util/path.ts)正是transformLink的配置输入,allSlugs为shortest策略提供全站 slug 索引。
三种策略在 CLI 与配置中的落地
链接解析策略不仅是纯函数参数,还贯通了 Quartz 的初始化 CLI 与 YAML 配置两个层面。
在 quartz/cli/args.js 中,quartz create提供了-l, --links参数,取值限定为absolute/shortest/relative,描述为"strategy to resolve links"。
交互流程中(见 quartz/cli/handlers.js):使用obsidian或ttrpg模板时策略自动预设为shortest(以匹配 Obsidian 的链接格式);否则 CLI 会弹出选择提示,默认推荐shortest。随后该策略被写入crawl-links插件的options.markdownLinkResolution字段,并同时更新配置中的baseUrl(见 quartz/cli/handlers.js)。
四个内置模板(default.yaml、obsidian.yaml、blog.yaml、ttrpg.yaml)均默认写入markdownLinkResolution: shortest。因此对于绝大多数用户,shortest就是开箱即用的默认链接解析策略;如需改为absolute或relative,可在quartz.config.yaml中修改crawl-links插件的markdownLinkResolution选项。
浏览器端入口:getFullSlug 与 normalizeRelativeURLs
回到图中的浏览器侧:getFullSlug()从window.location提取当前页面的FullSlug,是"URL → 路径系统"的客户端入口。它在 SPA 客户端脚本中被用于导航通知(见 quartz/components/scripts/spa.inline.ts),与normalizeRelativeURLs配合,在无刷新跳转后将文档中./、../形式的href与src按新页面地址重基(见 quartz/util/path.ts)。这也解释了为什么文档强调FullSlug是"最通用的解释"——它同时是服务端构建与浏览器端 SPA 两条链路共享的中间表示。
实践要点与排查建议
综合文档、源码与测试,可以沉淀出以下实践建议:
- 优先使用
FullSlug:它是路径系统的枢纽与"最通用"解释,插件或组件中表达"一篇内容"时应优先采用,避免在SimpleSlug/RelativeURL之间反复横跳。 - 在入口点做显式转换:品牌类型只能防类型系统内部混用,任何
string → 路径类型的转换都应通过slugifyFilePath、getFullSlug、transformLink等入口函数完成,而不是直接as断言。 - 记住三类约束的差异:
FilePath必须带扩展名;FullSlug允许末段为index;SimpleSlug与RelativeURL用尾随斜杠表达文件夹语义,且RelativeURL必须以./..开头。 - 链接失效时先检查策略:
absolute/shortest/relative的输出差异巨大,若发现构建产物中的链接不符合预期,先确认quartz.config.yaml中crawl-links的markdownLinkResolution取值,再对照 quartz/util/path.test.ts 的link strategies测试组逐条核验。 - 利用测试作为行为文档:
path.test.ts的typeguards、transforms、link strategies、resolveRelative四个测试组是路径系统最精确、最可执行的规格说明,任何对路径语义的疑问都应优先在此求证。
通过本文的梳理可以看到,Quartz 并没有把"路径"当作可以随意对待的字符串,而是用品牌类型建立了严格的四类名义类型,并用一套清晰的转换函数与测试矩阵将其固化下来。理解这套系统,是深入 Quartz 插件开发与链接调试的坚实基础。
【免费下载链接】quartz🌱 a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites项目地址: https://gitcode.com/GitHub_Trending/qua/quartz
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考