Markdown博客站内引用问题

2022-12-13 ⏳5.6分钟(2.2千字) 🕸️

使用 Markdown 写博客绕不过引用链接的问题。源文件扩展名是.md,但转换后会变成.html。为了正常跳转,写站内引用链接时只能使用 HTML 文件对应的路径。但这样一来,在编辑的时候就没法跳转到对应的.md文件了。最近抽空给 pandoc 写了个 lua-filter,算是比较完美地解决了这个问题。今天分享给大家。

举个例子说明一下。比如我们有如下目录结构:

demo
├── bar
│   └── baz.md
└── foo.md

我们想在baz.md文件中引用foo.md文件。如果不做任何处理,我们需要在baz.md[foo](../foo.html)。因为foo.md最终会转换成foo.html,如果写成[foo](../foo.md),读者就无法正常打开foo.html文件。但如果照顾了读者,写成[foo](../foo.html),在编辑bar.md的时候就没法切换到foo.md文件,因为扩展名不匹配。

我用过 hugo 框架,它会把 Markdown 转换成文件夹。比如foo.md最终会变成foo/index.html。这样做的好处是不需要在引用的时候指定扩展名,可以直接写成[foo](../foo/)。但依然无法在编辑器内切换到对应的foo.md文件。

问题的关键是不论链接写成[foo](../foo.html)还是[foo](../foo/),都是对应转换后的 HTML 文件,并没有对应 Markdown 源文件。解决这个问题的终极方案是在 Markdown 文件中直接引用其他.md文件路径,然后在生成 HTML 文件的时候再将其转换成.html文件对应的路径。

本站使用 pandoc1,而 pandoc 支持使用 lua-filter 扩展 Markdown 转 HTML 的功能。今天我们就使用 lua-filter 将.md链接转换成对应的.html链接。

最简单的 lua-filter 如下:

return {{
  Link = function (link)
    -- do something
    return link
  end
}}

它需要返回一个 table,每个元素都是 pandoc 对象。pandoc 对象的 key 对应 Markdown 的语法单元。比如我们希望处理所有链接,所以需要给Link设置回调函数。

讲道理 lua-filter 还是比较复杂的,本文只用到一点皮毛。有志深入研究的读者可以仔细阅读官方文档2

Link对象最重要的属性是target,里面保存的就是链接的目标地址。Markdown 中的 [foo](../foo.md) 对应的target属性就是../foo.mdLink的其他属性请参考官方3

接下来的问题就是怎么在 lua 中将.md转换成.html。这里不能无脑把所有的.md都替换成.html,我们有可能真得会引用外站的 Markdown 文件。我想到的处理方案是只看站内引用,也就是检查链接是否以http开头。只对站内链接做转换。但这种办法也有问题,就是无法引用站内 Markdown 文件了。考虑到我的平台会把所有站内.md转换成.html文件,而且不允许直接访问 Markdown 源文件,所以问题不大😜

lua 的语法比较古怪,跟 C 家族语言差距比较大。注释以--开头。不相等用~=表示,而不是常见的!=

字符串截取子串使用sub函数,第一个参数表示开始位置,第二个表示子串长度。lua 还有一个特殊的地方是下标从1而非0开始。

此外,sub函数还支持传入一个参数。sub(3)表示跳过前两个字符,sub(-3)表示从结尾提取长度为3的子串。而更复杂的sub(1,-3)则表示提取从开始到倒数第3个位置为止的子串,也就是剔除最后3-1个字符的子串。

我们需要的 lua 代码如下:

-- 检查链接开头不是 http
-- 而且结尾必须是 .md
if t:sub(1,4) ~= "http" and t:sub(-3) == ".md" then
  -- 剔除最后的 md 再附加 .html
  link.target = t:sub(1,-3).."html"
end
return link

在转换的时候需要使用--lua-filter参数指定 lua 文件路径:

pandoc --lua-filter $LUA_FILTER bar.md -o bar.html

问题到这里基本就解决了。我们在写 Markdown 文件时可以直接根据相对路径填写对应的.md文件路径。pandoc 在转 HTML 文件时会自动将.md转换成.html,同时解决了读者和作者双方的跳转问题。

注意,如果你的 pandoc 不是最新版本,则可能会碰到超链接<a>变成<embed>的问题,具体可以参考这个4。最新版本的 pandoc 修复了这个问题。

上面的方案还有一点小暇疵,那就是写链接的时候必须指定名字。也就是说[foo](../foo.md) 里面方括号中的foo不能省略,不太方便。最简单的方案是用圆括号内的地址来填充方括号的内容。如果写成[](../foo.md)效果跟写成[../foo.md](../foo.md)一样就好。

这个功能也可以通过 lua 实现。我们在上面代码中return之前插入下面的代码就可以了:

if #link.content == 0 then
  link.content = pandoc.List:new({pandoc.Str(link.target)})
end

这样一来引用起来就更加方便。

相对路径虽然方便,维护上也很容易,却不利于 SEO。说白了就是别人转载了你文章,里面的相对链接就失效了。为了尽可能多地增加外链,最好还是把所有相对路径改为绝对路径。最好把站内路径全都转换成完整的URL。但这么一来不但会引入大量的维护成本,还会再一次导致无法在编辑器内跳转的问题。

但使用绝对链接也会带来新的问题。比如你想给博客做个镜像托管在 GitHub 或才 Cloudflare 上。只要域名发生变化,所有的站内链接都会失效。现在我的博客已经不再生成绝对链接了。

既然可以用 lua-filter 修改连接的后缀,那就一定可以将相对链接改为 URL。我们一鼓作气,完整地解决这个问题。

这里有两个核心问题。第一个是在 lua 脚本中获取当前 Markdown 文件的路径。第二个是根据当前路径和相对路径计算完整 URL。

第一个问题查了半天,它隐藏在PANDOC_STATE这个全局变量的input_files属性里。这是一个数组,文档没细讲它的构成。但实验发现数组的第一个元素就是 Markdown 文件的路径。所以文件路径如下:

local p = PANDOC_STATE.input_files[1]

第二个问题需要自己实现路径替换函数,因为 lua 标准库没有路径计算相关的函数😂

算法也比较简单。假设当前路径为a=/a/b/c/d.md,相对路径为b=../../../1.md,我们希望得到/1.md。对a来说,/d.md不参与计算,应该事先跳过。然后遍历b,每碰到一个../就将其剔除同时还要把a对应的路径也向上移动一级。整个过程如下:

            b a
../../../1.md "/a/b/c"
   ../../1.md "/a/b"
      ../1.md "/a"
         1.md ""

但还有一些特殊情况需要处理,比如应该剔除b中的./前缀等。

逻辑上没有问题了,编码时还会遇到另一个拦路虎find函数。lua 中查看子串使用find函数,但这个函数只能从左向右查。而我们处理当前路径的时候是从右向左查的。我搜了一下,发现大家是先把字符串反过来,然后从左往右处理,最后再反转回去😂完整代码如下:

-- realpath convert path with .. to absolute path
--
-- "/1.md" == realpath("a.md", "1.md")
-- "/1.md" == realpath("/a.md", "1.md")
-- "/1.md" == realpath("/a.md", "./1.md")
-- "/1.md" == realpath("/a/b.md", "../1.md")
-- "/1.md" == realpath("/a/b/c.md", "../../1.md")
-- "/1.md" == realpath("/a/b/c/d.md", "../../../1.md")
function realpath(a, b)
  if a:sub(1,1) ~= '/' then a = '/' .. a end
  c = a:reverse()
  _,j = c:find("/")
  c = c:sub(j+1,-1)
  if b:sub(1,2) == "./" then
    b = b:sub(3)
  end
  while (true) do
    _,i = b:find("../")
    _,j = c:find("/")
    if i == nil or j == nil then break end
    b = b:sub(i,-1)
    c = c:sub(j+1,-1)
  end
  if b:sub(1,1) ~= '/' then b = '/' .. b end
  return c:reverse()..b
end

上面的 lua-filter 修改链接的部分需要改成:

-- 提取环境变量
local envs = pandoc.system.environment()
local url = envs["site_url"]
local p = PANDOC_STATE.input_files[1]
-- 计算完整路径
t = realpath(p, t)
-- 拼接完整URL
t = url .. t
link.target = t:sub(1,-3).."html"

到这里才算完整解决了 Markdown 博客系统的引用链接问题。希望能对大家有启发。


  1. ./blog-internal.html↩︎

  2. https://pandoc.org/lua-filters.html#lua-filter-structure↩︎

  3. https://pandoc.org/lua-filters.html#type-link↩︎

  4. https://github.com/jgm/pandoc/issues/7639↩︎