深入探究 Hugo 短代码

总结一些使用和编写短代码的时候碰到的坑。
本文属于 Deep dive into Hugo 系列:
  1. 如何在 Hugo 中添加自定义 CSS
  2. 深入探究 Hugo 代码高亮
  3. 深入探究 Hugo 短代码 (本文)
  4. 续・深入探究 Hugo 短代码:我今天还就非得把这个脚注写出来不可

本文基于 hugo v0.109.0+extended,Markdown 渲染引擎为 Goldmark

文档:

参数

常规参数

我最喜欢看的 Hugo 自带 shortcodes 代码之一是 figure.html,对照着它修改自己的 figure.html 是我学习自定义 Hugo 的起点。

容易忘记的东西主要是参数可以这么定义:(flexible / positional or named parameters)

{{ $var := .Get "var" | default (.Get 0) }}
{{ $var := .Get "var" | default "something" }}

也可以这么定义:(positional parameters)

{{ $var := .Get 0 }}
{{ $var := index .Params 0 }}

也可以在不需要设置默认值的时候跳过定义直接使用:(named parameters)

{{ .Get "var" }}... {{ end }}        {{/* 必须参数 */}}
{{ with .Get "var" }}...{{ end }}    {{/* 可选参数 */}}
{{ if .Get "var" }}... {{ end }} 

但是不能在同一个 shortcode 的定义部分中混用 positional 和 named 参数。

自闭合标签中的一切用户输入内容都算作 .Params,没有 .Inner

以及,可以通过写 ``输入包含换行的参数或者一些难以转义的参数(比如 \)。

使用列表作为单个参数

在更新我的分栏 shortcode 时遇到了这个问题。

这个讨论串里有一个(在写博文时)非常简洁的分栏 shortcode,我最近有需求,因此仔细研究了一下。

原文中的代码是这样的:

{{ $cols := split .RawContent "||" }}

{{ range $cols }}
   <div class="content-column">
   {{ . | markdownify }}
   </div>
{{ end }}

但是我写分栏几乎都是用来多语言对照的,所以还需要指定每栏的语言代码。那么问题来了,我原本的分栏代码是 外层(行)内层(列)分开写,指定语言的时候在列那一级里设置一个普通的参数就可以了。然而在这个新的代码中,列是一个用来循环的列表,长度是不确定的,所以指定语言代码的参数也必须是能够跟它一起循环的列表,不然就没法一一对应了。

那么问题来了,怎么写呢?

经过了一整天的尝试,我得出了以下两个解决方案。准确地说,是我很快地就得到了第一个方案,然后经过了一整天的尝试,终于得到了第二个(我更喜欢的)方案。

方案一

这个方案就是简单粗暴地把所有参数都看作语言代码参数,不允许其它参数的存在,这样产生的参数就会自然地形成一个列表。当然,如果多写了一两个,由于循环时参照的是列的索引,也不会有任何问题。

Shortcode 代码:

{{ $cols := split .Inner "||" }}
{{ $lang := .Params }}

<div class="row">
{{ range $indCol,$col := $cols }}
   <div class="column" {{ with $lang }} lang="{{ index $lang $indCol }}"{{ end }}>
   {{ . | $.Page.RenderString (dict "display" "block") }}
   </div>
{{ end }}
</div>

使用:

{{< colx zh-Hans en ja >}}
你好世界
||
Hello world
||
こんにちは
{{< /colx >}}

结果:

你好世界

Hello world

こんにちは

方案二

这个方案的原理是用户输入一串由某种分隔符组合在一起的文本,再在定义参数时分开使之成为列表。这么看来似乎是非常明显的解决方案,但我一开始并没有意识到,如果直接在使用 shortcode 时用户输入一个列表,Hugo 是认不出来的。下详。

Shortcode 代码:

{{ $cols := split .Inner "||" }}
{{ $lang := .Get "lang" | default ( .Get 0 ) }}
{{ $lang  = split $lang "," }}

<div class="row">
{{ range $indCol,$col := $cols }}
   <div class="column" {{ with $lang }} lang="{{ index $lang $indCol }}"{{ end }}>
   {{ . | $.Page.RenderString (dict "display" "block") }}
   </div>
{{ end }}
</div>

使用:(都不能有空格)

{{< cols lang="zh-Hans,en,ja" >}}  {{/* lang= 可省略 */}}
你好世界
||
Hello world
||
こんにちは
{{< /cols >}}

结果:

你好世界

Hello world

こんにちは

用户输入列表的问题

首先,不能输入含有空格的没有被引号括起来的东西,比如 lang=[zh en],因为,第一,不能输入没有被引号括起来的 [(就这点而言,lang=[zh,en]也一样),第二,参数默认以空格分隔,即使能输入,这也会变成好几个(数量不确定的)参数。(其实似乎也可以用 $cols 的长度去……但是这实在太麻烦了。)

其次,一切用引号括起来的参数都会被认为是纯文本,因此如果输入 lang="[zh en]"(或 lang="[zh,en]"),那么它在 shortcode 里看起来跟正常的列表长得一模一样,直到您去 {{ index "[zh en]" 0 }},得到 9191 是什么呢?没错,[ASCII 代码。也就是说,您得到了一个长度为 7 的纯文本,它的第一项是 91,第二项是 122(!)。(同样地,lang="zh,en" 在经过 split 处理之前,第一项是 122。)
在 shortcode 代码中,可以通过写 Golang 方程的方式确认这一点,但在写博文的时候是没用的:{{ printf "%T" "[zh en]" }},得到 string。谁能想到呢?

如果输入 lang="zh en",然后 {{ $lang := .Get "lang" }} {{ $lang := slice $lang }},就会得到一个看起来又长得没错的列表:[zh en],但它的长度为 1,第一项是 zh en

总结就是,任何情况下,都不能直接输入列表作为单独一个参数。

作为参考,在 shortcode 里一个正常的列表长这样:(注释是渲染出来的网页内容)

{{ slice "zh" "en" }}
{{/* [zh en] */}}

{{ index ( slice "zh" "en" ) 0 }}
{{/* zh */}}

{{ printf "%T" ( slice "zh" "en" ) }}
{{/* []string <-- 列表 */}}

编写

编辑:本节原本的内容作废,点这里回顾。我为此新写了(半篇)博文,链接:续・深入探究 Hugo 短代码:我今天还就非得把这个脚注写出来不可#空格太多

两种调用方式

编辑:本节原本的内容作废,点这里回顾。我为此新写了(另外半篇)博文,链接:续・深入探究 Hugo 短代码:我今天还就非得把这个脚注写出来不可#短代码分隔符

在任何地方正确渲染 Markdown 内容(几乎)

文档:

您可能会想这不就是一个 markdownify 的事儿吗?直到您看到了这两段话:

To keep the wrapping p tags for a single paragraph, use the .Page.RenderString method, setting the display option to block.

If the resulting HTML is two or more paragraphs, Hugo leaves the wrapping p tags in place.

所以如果只给 markdownify 一行字,它是不会加上 <p> tag 的,除非写一些疯狂的丑陋的我很不喜欢的补丁(而且我测试过了,链接中的代码也不能解决下面提到的脚注问题),或者依赖不知道什么时候才能解决的 Issue。但是

.RenderString is a method on Page

所以直接写 {{ .Inner.RenderString }} 也是不可以的,甚至这个页面上的实例代码也根本无法单独成立(minimal reproducible example,MRE)。于是您在 Hugo Discourse 里查阅了半天,终于发现了:

If you add options to the mix, I think it’s easier to reason about if you use the pipe syntax, e.g.

{{ .Text | $.Page.RenderString $options }}

虽然它依旧不是一个 MRE,但至少能提供一些头绪。

所以,最简单的实现方式就是编写 md.html 为:

{{ .Inner | $.Page.RenderString (dict "display" "block") }}

然后在任何(任何,包括 raw HTML tag 里面)需要渲染 Markdown 的地方使用:

{{< md >}}...{{< /md >}}

那么问题来了,虽然大部分时候这么设置没问题,但是偶尔(比如在 raw HTML tag 里)想渲染一小段话但是不加 <p>,可以吗?
在另外一些补课之后,发现只需要添加一个条件即可。修改 md.html 为:

{{ $block := .Get "block" | default "true" }}
{{ $optBlock := cond (ne $block "true") (dict "display" "inline") (dict "display" "block") }}
{{ .Inner | $.Page.RenderString $optBlock }}

(其实只定义 $block 应该也足够了,但那样最后一行就会要写一个很长的条件式,不方便读,所以我把条件额外拆成了一个变量。)
然后在需要渲染 inline Markdown 的地方使用:

{{< md block="false" >}}...{{< /md >}}

比如:

{{< fold >}}
{{< highlight >}}
code
{{< /highlight >}}
{{< md >}}
markdown
{{< /md >}}
{{< figure  >}}
{{< /fold >}}
点击展开:什么,
I'm free!

免费自由了! 1


  1. 下次一定。 ↩︎

I did not understand but I was shocked

两年过去了,现在您终于(!)可以自由自在地写 Markdown 了。除了,您已经看见了,没法写与整篇文章保持一致的脚注1,单层也不行,换成 % 括号也不行。

例外可能之一:脚注

以下几个例子中,要不完全无法生成脚注,要不生成的脚注排序范围仅为当前 shortcode,因此一篇文章里会出现多个 ID 为 1 的脚注,导致链接全部失效。

代码 1:

{{< md >}}
**我~~免费~~自由了!** [^2]

[^2]: 再下次一定。
{{< /md >}}

效果 1:

免费自由了! 1


  1. 再下次一定。 ↩︎

代码 2:

{{< md >}}
**我~~免费~~自由了!** [^3]
{{< /md >}}

[^3]: 再再下次一定。

效果 2:

免费自由了! [^3]

代码 3:

{{% md %}}
**我~~免费~~自由了!** [^4]

[^4]: 再再再下次一定。
{{% /md %}}

效果 3:

免费自由了! 1


  1. 再再再下次一定。 ↩︎

代码 4:

{{% md %}}
**我~~免费~~自由了!** [^4]
{{% /md %}}

[^5]: 算了,没救了。

效果 4:

免费自由了! [^5]

例外可能之二:小标题

编辑:重写本小节。点这里回顾初始版本

一个我才意识到的事实是,下面的例子在这个博客里没有问题,是因为 Diary 主题用的页面目录并不是 Hugo 自带的目录({{ .TableOfContents }}),而是原作者自己重新写了一个目录布局文件。而如果一个主题确实使用了 Hugo 自带的目录,那么就会出现各种各样的问题。最近我在摆弄另外一个 Hugo 网站(wiki)的时候就碰到了。

作为参考,Joe Mooring 的这篇文章详细地展示了各种目录写法:Tables of content - Veriphor。Diary 用的是 Method 4: Parse content。

由于在本网站上无法展示失败例子,我找了一篇别人写的博客,详细地解说了各种失败例和唯一能够成功的方法,链接如下: A hack way to use shortcode headings in the Hugo TOC存档)。

唯一能够成功的方法即是,在使用 %% 调用的短代码里,用 Markdown 语法写标题,那么标题就能成功加入 {{ .TableOfContents }}

这包含了两种情况,首先,如果想在短代码的布局文件中添加每次调用时都自动产生的标题,必须用 Markdown 语法写(## ...),并始终以 %% 方式调用(例外情况:跟脚注一样,作为嵌套内层时,需要以 <> 方式调用;而嵌套最外层需要以 %% 调用,并且不包含 markdownifyRenderString 方程)。
其次,如果想在使用短代码时,在 Markdown 内包含标题,只能使用以 %% 方式调用的短代码。

我据此修改的代码在这里:loikein/hugo-book/layouts/shortcodes/section2.html。至于更具体的解释和成功失败对比例子,请看本系列的下一篇文章(很明显,使用的例子是脚注而不是目录):续・深入探究 Hugo 短代码:我今天还就非得把这个脚注写出来不可

原本的成功例子

代码 1:

{{< md >}}
#### Heading1

Some text...
{{< /md >}}

效果 1:

Heading1

Some text…

代码 2:

{{< fold "Is this real life?" >}}
{{< md >}}
#### Heading2

Some text...
{{< /md >}}
{{< /fold >}}

效果 2:

点击展开:Is this real life?

Heading2

Some text…

代码 3:(新增)

{{< fold "Is this real life?" >}}
{{< md >}}
### Heading3

Some text...
{{< /md >}}
{{< /fold >}}

效果 3:

点击展开:Is this real life?

Heading3

Some text…


  1. 就像这样。 ↩︎