Obsidian-Halo同步插件开发日志:文章标题链接锚点一致性

2025 年 12 月 16 日
1 次浏览
7722 字数

前言

在 Obsidian 中,在编辑器里链接一篇文章的某个小标题时,例如:

### 4.2 Pjax 下全页面加载的脚本引入规范

它生成的锚点的策略是将非中文的字符进行 url 编码,链接格式如下:

[4.2 Pjax 下全页面加载的脚本引入规范](/archives/TkSvBakI#4.2%20Pjax%20下全页面加载的脚本引入规范)

而我的 halo 主题的目录是使用 tocbot 前端库自动生成的,它的默认标题锚点链接的默认生成策略是将非中文的不安全字符简单替换为 - 字符。

https://lihouwei.com/archives/TkSvBakI#4-2-Pjax-下全页面加载的脚本引入规范

问题

二者的不一致性,导致了在 Obsidian 和 halo 文章同步时,对标题锚点链接的处理产生了麻烦。即便是转换,由于 tocbot 这种粗暴替换策略导致数据失真,也只能进行单向的转换。

解决

tocbot 的锚点链接转换策略改成和 Obsidian 编辑器一致,则能减少不必要的转换。缺点是,编码后的空格等字符确实不如 - 字符直观,在浏览器的地址栏可能看起来没那么直观。

分析

要实现这个其实很简单,关键是搞清楚 Obsidian 对于标题锚点的生成策略,之前简单的概括为 " 对非中文字符进行 url 编码 " 是否恰当,需要求证一下。

这让我想起了在之前的开发中(Obisidian内部引用路径处理问题)使用到的一个 Obsidian 的 api,用于生成文档内部链接的函数 app.fileManager.generateMarkdownLink,这个函数是在 Obsidian 里进行实现的,Obsidian 的 ts 库只是引入它的类型声明。所以,在 Obsidian 的开发者模式下,输入 app.fileManager.generateMarkdwonLink 就能看到并点击跳转到函数的定义源码:

e.prototype.generateMarkdownLink = function (e, t, n, i) {
  var r,
    o = this.app,
    a = o.vault.getConfig("useMarkdownLinks"),
    s = !a,
    l = o.metadataCache.fileToLinktext(e, t, s) + (n || "");
  if ((e.path === t && n && (l = n), a)) {
    var c = JC(l);
    r =
      "md" === e.extension
        ? "[".concat(i || e.basename, "](").concat(c, ")")
        : "[".concat(i || "", "](").concat(c, ")");
  } else
    i && i.toLowerCase() === l.toLowerCase() && ((l = i), (i = null)),
      (r = i ? "[[".concat(l, "|").concat(i, "]]") : "[[".concat(l, "]]"));
  return r;
};

t.prototype.fileToLinktext = function (e, t, n) {
  void 0 === n && (n = !0);
  var i = this.vault.getConfig("newLinkFormat"),
    r = "md" === e.extension && n ? ru(e.path) : e.path;
  if ("absolute" === i) return r;
  if ("relative" === i) {
    for (
      var o = "", a = tu(t);
      "" !== a && "/" !== a && 0 !== r.indexOf(a + "/");

    )
      (o = "../" + o), (a = tu(a));
    return 0 === r.indexOf(a + "/") ? o + r.substr(a.length + 1) : o + r;
  }
  var s = "md" === e.extension && n ? e.basename : e.name,
    l = lS(s),
    c = this.getLinkpathDest(l, t);
  return c && 1 === c.length && c[0] === e
    ? s
    : "md" === e.extension && n
    ? ru(e.path)
    : e.path;
};

t.prototype.getLinkpathDest = function (e, t) {
  if ("" === e && t && (f = this.vault.getAbstractFileByPath(t)) instanceof zC)
    return [f];
  var n = e.toLowerCase(),
    i = eu(n),
    r = null;
  if (
    (i.contains(".") && (r = this.uniqueFileLookup.get(i)),
    r ||
      ((i = eu((n = (e + ".md").toLowerCase()))),
      (r = this.uniqueFileLookup.get(i))),
    !r)
  )
    return [];
  if (i === n && 1 === r.length) return r.slice();
  var o = tu(t).toLowerCase();
  if (n.startsWith("./") || n.startsWith("../")) {
    if ((n.startsWith("./../") && (n = n.substr(2)), n.startsWith("./")))
      "" !== o && (o += "/"), (n = o + n.substring(2));
    else {
      for (; n.startsWith("../"); ) (n = n.substr(3)), (o = tu(o));
      "" !== o && (o += "/"), (n = o + n);
    }
    for (var a = 0, s = r; a < s.length; a++) {
      if ((m = (f = s[a]).path.toLowerCase()) === n) return [f];
    }
  }
  n.startsWith("/") && (n = n.substr(1));
  for (var l = 0, c = r; l < c.length; l++) {
    if ((m = (f = c[l]).path.toLowerCase()) === n) return [f];
  }
  if (e.startsWith("/")) return [];
  for (var u = [], h = [], p = 0, d = r; p < d.length; p++) {
    var f, m;
    (m = (f = d[p]).path.toLowerCase()).endsWith(n) &&
      (m.startsWith(o) ? u.push(f) : h.push(f));
  }
  return u.sort(gQ), h.sort(gQ), u.concat(h);
};

function lS(e) {
  var t = e.indexOf("#");
  return -1 === t ? e : e.substring(0, t);
}

function ru(e) {
  var t = e.lastIndexOf(".");
  return -1 === t || t === e.length - 1 || 0 === t ? e : e.substr(0, t);
}

function JC(e) {
  return e.replace(/[\\\x00\x08\x0B\x0C\x0E-\x1F ]/g, function (e) {
    return encodeURIComponent(e);
  });
}

最终定位到源码位置,也就是这个 JC 函数:

function JC(e) {
  // 仅对以下特定字符进行 encodeURIComponent 编码:
  // 1. 反斜杠 \
  // 2. 控制字符(\x00, \x08, \x0B, \x0C, \x0E-\x1F)
  // 3. 空格
  return e.replace(/[\\\x00\x08\x0B\x0C\x0E-\x1F ]/g, function (e) {
    return encodeURIComponent(e);
  });
}

此函数不是对整个字符串进行 encodeURIComponent 编码,而是选择性编码。它仅对反斜杠、控制字符和空格进行百分号编码,而中文字符、大多数标点符号(如 !.,: 等)均保持原样

实现

接下来的实现就简单了,只需要将 tocbot 的 headingObjectCallback 函数定义与 Obsidian 一致即可:

import * as tocbot from "tocbot";

// 核心:定义与 Obsidian 一致的编码函数
function obsidianSlugify(text: string): string {
  // 依照 Obsidian 的内部逻辑:仅编码、替换指定控制字符和空格
  return text.replace(/[\\\x00\x08\x0B\x0C\x0E-\x1F ]/g, (str: string) => {
    console.log(`成功转换 ${str} => ${encodeURIComponent(str)}`)
    return encodeURIComponent(str);
  });
}


export function generateToc() {
  const content = document.getElementById("post-content");
  const titles = content?.querySelectorAll("h1, h2, h3, h4");
  if (!titles?.length) {
    const tocContainer = document.querySelector(".toc-container");
    tocContainer?.parentElement?.remove();
    return;
  }
  tocbot.init({
    tocSelector: ".toc-container",
    contentSelector: "#post-content",
    headingSelector: "h2, h3",
    extraListClasses: "border-l border-dark/2 dark:border-light/1 pl-1 list-none",
    extraLinkClasses: "flex flex-row z-10 w-full items-baseline left-[-1px] relative text-sm py-1 ps-3 pe-2 transition-all duration-200 text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100",
    activeLinkClass: "text-primary-400 font-semibold",
    collapseDepth: 6,
    headingsOffset: 100,
    scrollSmooth: false,
    scrollSmoothOffset: 0,
    // 2. 关键:使用回调函数应用自定义编码
    headingObjectCallback: (obj: Object, node: HTMLElement): void | Object => {
      // 获取标题的原始文本(优先使用 node 的文本内容)
      const titleText = node.textContent as string;
      // 目录链接 a 便签的超链接值:<a href='#obj.id'>目录</a> 调用 Obsidian 编码函数
      // @ts-ignore
      obj.id = obsidianSlugify(titleText);
      // 标题标签元素的 id 值:<h1 id=''>标题</h1>
      node.id = titleText;
      return obj
    },
  });
}

注意点

上述实现里,我们修改了 tocbot 的参数 headingObjectCallback,使得目录 a 标签的链接和 Obsidian 生成的链接一致,即 obsidianSlugify(titleText),同时,对于这个编码后的链接对应的锚点 id,实际上是解码后的原文本,即 titleText

暂无标签