Hugo自定义TOC模板及滚动监听

Hugo自定义TOC模板及滚动监听

要自定义Hugo的TOC模板,还挺麻烦的,主要是Hugo的模板语言语法,那是人看的吗

一个文章目录,有几个基本要素:

  1. 目录锚点,可以点击跳转
  2. 目录层级,控制目录的展示

Hugo内建默认的TOC模板,实现了上面的要素,比较简单:

{{ .TableOfContents}}

这个模板仅仅能用而已。对于较长的目录,以及多层级的目录都有点无能为力。

如果想要让目录更加灵活,可以自定义TOC,并且实现滚动监听。

一般Hugo博客的TOC引用在layouts/_default/baseof.html里面,不用大改,只需要修改引用模板的部分即可:

 1  {{ if default false (default .Site.Params.BookToC .Params.BookToC) }}
 2  <aside class="book-toc" >
 3    <div class="book-toc-content">
 4      {{ template "toc" . }} 
 5    </div>
 6  </aside>
 7  ... // 上半部分展示了TOC的html组成,无需修改
 8
 9  {{ define "toc" }}
10      {{ partial "new-toc" . }}
11  {{ end }}
12  // 下半部分定义了`toc`模板,这是修改过的,默认是`docs/toc`,
13  // 指向主题里`layouts/partials/docs/toc.html`

所以,如果想自定义TOC,除了自定义模板外,修改baseof.html关于模板的引用才可以生效。

对于不同的Hugo主题,目录结构可能有所差异,还需要修改其它文件夹里如single.html文件中对于TOC模板的引用,如果未达到预期效果,可仔细排查一下。本文针对的是hugo-book这个主题

当然,其它主题也可能已经支持本文所讨论的内容:-)。


自定义TOC模板 #

和上面对应,新的TOC模板应该为new-toc.html,位于项目layouts/partials目录(⚠️非主题目录)。

模板受到 AC Dustbin-TOC in Hugo启发1,内容不详细展开,简单阐述几个要点:

目录层级的问题 #

本博客默认读取到5级标题。可以通篇修改正则表达式中的内容,控制读取到的目录级别。

`(?s)<h[1-5].*?>.+?</h[1-5]>`

中的1-5表示显示1到5级标题。如果只想显示到4级标题,将5改为4即可。

特殊字符的转义问题 #

读取目录时,Hugo会将特殊字符如('"等进行html转义。

此外,Hugo使用goldmark来渲染markdown,goldmark默认也会对这些特殊字符转义,可以在全局配置里关闭:

1  markup:
2    goldmark:
3       extensions:
4         typographer:
5            disable: true

更多信息,参考: https://gohugo.io/getting-started/configuration-markup/#typographer

在解析目录标题的时候,需要将其反转义,已避免出现类似于

new String(&amp;quot;abc&amp;quot;)

的目录。

Hugo使用htmlUnescape来反转义:

{{- $header := $header | safeHTML  | plainify | htmlUnescape  -}}

锚点标签的渲染问题 #

baseof.html里定义的TOC的html结构是:

1<aside>
2    <div class="book-toc-content">
3        {{template}}
4    </div>
5</aside>

模板定义的html结构是:

 1<div id="toc-new">
 2    <ul class="nav">
 3        <li class="nav-item">
 4            <a id="" href=""></a>
 5            <ul class="nav">
 6                <li class="nav-item">
 7                    <a id="" href=""></a>
 8                    ...
 9                </li>
10            <ul>
11        </li>
12    </ul>
13</div>

模板定义了一个嵌套列表,外围是一个idtoc-newdiv。目录内容由一个a标签展示。标签里的内容是重点。

如果想实现滚动监听,为a标签添加id属性是必须的,这样才有滚动事件触发后找到对应目录的前提。

还有一个前提:

因为滚动的是文章主体,所以页面只能判定当前离页面顶端最近的标题是哪一个,而要通过标题找到TOC里的目录,那么TOC目录里的id要和正文的标题有某种联系(或者由标题属性计算出来)。这是本文的处理思路。

目录的id #

上面分析了,目录的id应该和正文的标题有所联系。这样方便滚动时定位TOC目录。

1{{- $lid := replaceRE `[\(\)-\.\@\?\";= ]`  "" $header  -}}
2<a  id="{{ add "t" (trim $lid " ")}}" href="#{{- $cleanedID -}}">
3    {{- $header -}}
4</a>

上面的代码,处理掉了标题中的空格和特殊字符,并且已字符t开头,避免id不能已数字开头的问题。

为了后续使用js操作元素的时候方便。

完整的模板代码: https://gist.github.com/wangy325/f7664932443aaf3495bdad610eff80d9

TOC样式优化 #

定义完模板后,目录的基本雏形就已经出来了:

使用自定义模板生成的目录

还需要修改一下css样式。Hugo支持自定义样式,在不影响主题样式的前提下,配置_custom.scss即可实现。样式文件放在项目assets目录下:

 1#toc-new  ul {
 2    list-style: none;
 3    padding: 0px;
 4    margin: 0;
 5    overflow:hidden;
 6    white-space:nowrap;
 7}
 8
 9#toc-new ul ul {
10    padding-inline-start: 1rem;
11}
12
13#toc-new ul li {
14    margin: .65em 0;
15    position: relative;
16    text-overflow:ellipsis;
17    overflow:hidden;
18}

基于上述样式的目录为:

配置样式后的目录

上述样式还会将过长的目录以...的形式省略,而不会显示横向的滚动条。

完整的样式表: https://gist.github.com/wangy325/3e03a36f679bef6ed0f98a7838108c9f

为TOC添加滚动监听 #

现在,是时候为TOC添加滚动监听2了。

前面说过,模板生成的TOC每个a标签的id属性由目录内容计算来。这样为了方便滚动时找到对应的TOC目录。

首先,我们需要为页面添加一个滚动监听事件:

1window.addEventListener("scroll", () => tocTrack())

接着,需要获取文档的所有标题信息,用于标记当前页面的滚动位置:

1const listAllHeadings = () => {
2  const headlines = document
3    .querySelectorAll("article h1, article h2, h3, h4, h5");
4  const head = [].slice.apply(headlines).filter(function (item) {
5    return item.getAttribute("id") != null
6  })
7  return head
8}

上面的代码获取了article类(正文)中1-5级标题,并去除了id为空的-主要是文档大标题。

当滚动页面时,需要计算出当前页面上最近的标题:

1  for (let heading of has) {
2    if (heading.offsetTop - document.scrollingElement.scrollTop > 20) {
3      break
4    }
5    currentHeading = heading
6  }

上述代码的意思是,当前标题距离页面顶部的距离与文档的滚动距离差距在20px的时候,认为这个标题就是当前正在阅读的标题。

获得了当前的标题,就可以获得当前标题对应的目录了:

1 let anchorId
2  try {
3    anchorId = currentHeading.innerText.slice(0, -2)
4  } catch (e) {
5    // console.log(e)
6    return
7  }
8  let sps = anchorId.replace(/[\(\)-\.\@\"\?;= ]/g, '')  
9  anchorId = "t" + sps

这里获取id的方式,和模板里是一致的。

获取到id后,就可以操作DOM元素了:

1 var toc_active = document.querySelectorAll(`#toc-new .nav-item #${anchorId}`)
2  removeAllOtherActiveClasses()
3  Array.from(toc_active, v => v.classList.add("active"))

上述代码,移除了其他“激活”的a标签,并且给当前正在阅读的a标签添加“active”类信息。

实际上,应该使用querySelect()方法,并使用Element.classList.add("active")方法,但是试了不生效,无奈只能使用querySelectAll()方法。

完整的js代码: https://gist.github.com/wangy325/136a81bd4ef350629869bb6ebc6e1fca

以上,当前浏览的目录就会带上“active”类信息,就可以使用样式操作高亮了。

1#toc-new li  a.active {
2    color: #05b;
3    background-color: aliceblue;
4}

次级目录隐藏与显示 #

有了“active”类信息,除了操作高亮,还可以自动显示和隐藏次级目录。这样对于较长目录的文档,可以解决目录垂直滚动的问题: 较少的目录,规避了这个问题。

 1// 隐藏次一级目录
 2#toc-new li > ul {
 3    display: none;
 4}
 5// 展开
 6// + ~ 兄弟选择器
 7// :has 父类选择器
 8#toc-new li > a.active ~ ul {
 9    display: inherit;
10}
11#toc-new .nav:has( a.active) {
12    display: inherit;
13}

最后的完成的效果图:

完整的TOC效果


  1. 博主现已弃坑Hugo。我折腾的时候,也有弃坑的想法:-) ↩︎

  2. 参考几篇文章, 这篇用处最大,不过使用了jQuery。 ↩︎