Hugoのshortcodeでmarkdownify関数が正しく動作しなかった

# はじめに

あるサイトで利用しているHugo(gohugoio)のMarkdownコンテンツファイルを別のサイトにコピーしたところ、shortcodeが正しく動作しなくなりました。

shortcodeを利用したい理由はtableをレンダリングしたいためで、参考資料に載せている[How Create Bootstrap Tables in Hugo](https://www.mybluelinux.com/how-create-bootstrap-tables-in-hugo/)をほぼそのままコピーしています。

環境の違いなどはあると思いますが、根の深そうな問題にみえたので、デバッグをした時のメモをまとめておきます。

:::note
最終的にHugoのDiscourseに[記事](https://discourse.gohugo.io/t/the-markdownify-function-calls-asciidoctor/51411)を投稿してみました。
:::

# 環境

* Ubuntu 22.04.4 LTS (amd64版)
* Hugo v0.133.1+extended linux/amd64 BuildDate=unknown

以下のサイトBのファイルを、サイトAのサブディレクトリ以下にコピーしています。

## 検証用プロジェクト

最終的にGitHubに問題を再現するためのHugoプロジェクトを登録しました。

* [github.com/YasuhiroABE/hugo-shortcode-issue](https://github.com/YasuhiroABE/hugo-shortcode-issue)

以下ではこれは使わずに実際に利用しているサイトA, Bのファイルを中心に検証した結果をまとめています。

## サイトA (個人のメインサイト)

長く利用していて比較的複雑な構成になっていて、content/ディレクトリの内部は日本語と英語のasciidoctor形式のファイルが多く含まれています。

共通のlayoutはthemeとして作成していて、コンテンツ個別の制御は直下のlayout/ディレクトリの中で行っています。

```text:内部のディレクトリ構造
$ tree -L 3
.               
├── content
│   ├── _index.en.adoc
│   ├── _index.ja.adoc
...
├── data
│   ├── en
│   │   ├── menu
│   │   └── profile
│   └── ja
│       ├── menu
│       └── profile
├── config.toml
├── i18n
│   ├── en.toml
│   └── ja.toml
...
├── layouts
│   ├── _default
...             
│   └── shortcodes              
│       ├── bootstrap-table.html
│       └── markdownify.html    
...
└── themes
    └── myweb
        ├── archetypes
        ├── assets
        ├── data
        ├── i18n
        ├── layouts
        ├── LICENSE
        ├── README.md
        ├── static
        └── theme.toml
```

## サイトB

アプリケーションを説明するだけの content/_index.{ja,en}.md ファイルが存在するだけのプロジェクトです。

themesディレクトリの中も空になっています。


```text:内部のディレクトリ構造
$ tree -L 3
.                                         
├── archetypes                                 
│   └── default.md                                    
├── assets                                             
│   └── sass                                          
│       ├── style.scss            
│       └── syntax.scss                          
├── content                               
│   ├── _index.en.md                           
│   └── _index.ja.md                                  
├── data                                               
├── hugo.toml                                         
├── i18n                          
│   ├── en.toml      
│   └── ja.toml          
├── layouts              
│   ├── _default     
│   │   ├── baseof.html                                                                   
│   │   ├── list.html                                                                      
│   │   ├── _markup                                   
│   │   └── single.html
│   ├── partials
│   │   ├── footer.html
│   │   ├── header.html
│   │   └── head.html
│   └── shortcodes
│       ├── bootstrap-table.html
│       └── markdownify.html
```



# 参考情報

1. [How Create Bootstrap Tables in Hugo](https://www.mybluelinux.com/how-create-bootstrap-tables-in-hugo/)
2. [【Hugo】Shortcodesの内側のMarkdownのレンダリング](https://zenn.dev/ymd_h/articles/6a8b173d55512c) (Zenn)
3. [Qiita Goでスタックトレースを実装](https://qiita.com/tsukasaI/items/8e093d0898578dabe3fb)
4. [https://github.com/sanity-io/litter](https://github.com/sanity-io/litter)
5. [https://discourse.gohugo.io/t/how-to-render-both-shortcode-and-markdown-in-shortcode/47740/2](https://discourse.gohugo.io/t/how-to-render-both-shortcode-and-markdown-in-shortcode/47740/2)
6. [https://discourse.gohugo.io/t/shortcodes-inner-markdownify-and-safehtml/42621/5](https://discourse.gohugo.io/t/shortcodes-inner-markdownify-and-safehtml/42621/5)

資料1をそのままコピーしてきたshortcodeを利用しています。

```html:layouts/shortcodes/bootstrap-table.html
{{ $htmlTable := .Inner | markdownify }}
{{ $table_class := .Get "table_class" }}
{{ $thead_class := .Get "thead_class" }}
{{ if .Get "caption" }}
    {{ $caption := .Get "caption" }} 
    {{ $old_cap := "<table>" }}
    {{ $new_cap := printf "<table>\n<caption>%s</caption>" $caption }}
    {{ $htmlTable = replace $htmlTable $old_cap $new_cap }} 
{{ end }}
{{ $old_class := "<table>" }}
{{ $new_class := printf "<table class=\"%s\">" $table_class }}
{{ $htmlTable = replace $htmlTable $old_class $new_class }}
{{ $old_thead := "<thead>" }}
{{ $new_thead := printf "<thead class=\"%s\">" $thead_class }}
{{ $htmlTable = replace $htmlTable $old_thead $new_thead }}
<div class="table-responsive">
{{ $htmlTable | safeHTML }}
</div>
```

:::note
shortcodeについて調べると"| markdownify"を使わないようにという記述をみかけます。
このコードはMarkdown(goldmark)が出力したHTMLコードを編集する必要があるため、markdownifyを利用しています。
:::

# 現象

サイトBの_index.ja.mdと_index.en.mdをサイトAのcontent/app.ja.md, content/app.en.mdのようにコピーしています。

必要なshortcodeはそのままlayout/shortocodes/の中にコピーしています。

結果としてサイトAの方ではコンテンツ中にmarkdown形式のままテキストが埋め込まれてしまっています。

```markdown:オリジナルのソースコード
{{< bootstrap-table table_class="table table-striped table-hover" thead_class="table-dark" >}}
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [https://k8s.io/](https://k8s.io/) |
| Storage Service (Rook) | v1.14.10 | [https://rook.io/](https://rook.io/), also see the [PV & PVC](#persistent-volumes-pv--pvc) section. |
{{< /bootstrap-table >}}
```

次のようなtableタグで囲まれたHTMLが生成されることを期待しています。

```html:サイトBの正常なHTMLコード
<div class="table-responsive"><table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Component</th>
<th>Version</th>
<th>Additional Information</th>
</tr>
</thead>
<tbody>
<tr>
<td>Kubernetes</td>
<td>v1.29.5</td>
<td><a href="https://k8s.io/" class="link-primary" rel="external" target="_blank">https://k8s.io/</a></td>
</tr>
<tr>
<td>Storage Service (Rook)</td>
<td>v1.14.10</td>
<td><a href="https://rook.io/" class="link-primary" rel="external" target="_blank">https://rook.io/</a>, also see the <a href="#per
sistent-volumes-pv--pvc" class="link-primary">PV &amp; PVC</a> section.</td>
</tr>
</tbody>
</table>
</div>
```

実際には以下のように部分的に変更されたものが出力されてしまっています。

```html:サイトAのレンダリングされていないHTMLコード
<div class="table-responsive">
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [<a href="https://k8s.io/" class="bare">https://k8s.io/</a>](<a href="https://k8s.io/" class="bare">https:
//k8s.io/</a>) |
| Storage Service (Rook) | v1.14.10 | [<a href="https://rook.io/" class="bare">https://rook.io/</a>](<a href="https://rook.io/" cla
ss="bare">https://rook.io/</a>), also see the [PV &amp; PVC](#persistent-volumes-pvpvc) section. |
</div>
```

Markdown形式の部分はレンダリングされていませんが、URLだと認識された文字列はanchorタグで囲まれています。

もともとサイトAを元にサイトBを作った時系列的な流れがあるので、config.toml、hugo.tomlファイルの内容はほぼ同一です。

サイトAではAsciidoctorをメインで利用していて、他にもshortcodeを利用しています。

サイトBはMarkdown形式のファイルのみを含むようになっています。

# デバッグ

## おおまかな原因の推定

問題のあるHugoプロジェクトからファイルを削除するなどして、解決するポイントがないか確認してみます。

結果としてcontent/ディレクトリにMarkdown形式のファイルだけを残しても問題は解決しませんでした。他の*.adocや*.md形式のコンテンツファイルから影響を受けているわけではないことが分かりましたが、あまり芳しくありません。

layoutファイルの中を変更したり、いろいろ試してみましたが、何がポイントなのか判然としませんでした。

## コードの解析

githubからmasterブランチを取り出して、デバッグコードを埋め込んだバイナリを作っています。

```bash:go
$ git clone https://github.com/gohugoio/hugo.git
$ cd hugo
$ go build -tags extended
$ ./hugo env
hugo v0.134.0-DEV-504a23184f035a0de816cc29070e5d0920e23ed0+extended linux/amd64 BuildDate=2024-09-01T16:25:10Z
GOOS="linux"
GOARCH="amd64"
GOVERSION="go1.22.6"
OptionParser::InvalidOption: invalid option: --embedded
  Use --trace for backtrace.
github.com/sass/libsass="3.6.6"
github.com/webmproject/libwebp="v1.3.2"
```

# 2つのプロジェクトをビルドした時の違い

簡単に``fmt.Println()``を埋め込んでビルドした``hugo``コマンドを実行して、public/ディレクトリにHTMLファイルを出力させてみた時の動作を比較すると、サイトAの方ではRawデータがshortcodeに渡されています。

サイトBでは正しくmarkdownifyによって変換されています。

```text:正常系の処理状況(サイトB)
transform.go] Markdownify() called
[page__per_output.go] RenderString() called
[page__per_output.go] RenderString() args: [
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [https://k8s.io/](https://k8s.io/) |
| Storage Service (Rook) | v1.14.10 | [https://rook.io/](https://rook.io/), also see the [PV & PVC](#persistent-volumes-pv--pvc) section. |
]
[page__per_output.go] c() called, type of pco.po.p.m is: &{  0xc00039e138 0xc000845cb0 {[]} { {    { }  }     false false false false false false false 0}  false 0xc000180310 0xc0007a7e60 0xc0007f2080 0xc000002f00}
[page__content.go] cachedContent.getOrCreateScope() called with scope:  
[template.go] templateExec.ExecuteWithContext(): templ.Name() _default/_markup/render-link.html
```

異常系ではMarkdownify()がすぐに処理されていない結果を返却しています。

```text:異常系の処理状況(サイトA)
[transform.go] Markdownify() called
[page__per_output.go] RenderString() called
[page__per_output.go] RenderString() args: [
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [https://k8s.io/](https://k8s.io/) |
| Storage Service (Rook) | v1.14.10 | [https://rook.io/](https://rook.io/), also see the [PV & PVC](#persistent-volumes-pv--pvc) section. |
]
[page__per_output.go] c() called, type of pco.po.p.m is: &{  0xc000033e4c 0xc000a6ac60 {[]} { {    { }  }     false false false false false false false 0}  false 0xc0005c0850 0xc000bbadb0 0xc000bb9080 0xc0009a2f00}
[page__content.go] cachedContent.getOrCreateScope() called with scope:  
[transform.go] Markdownify() returning helpers.BytesToHTML(bb):  | Component | Version | Additional Information |
```

参考資料に上げた``showStackTrace()``を使って探っていくと、hugolib/page__content.goにある``RenderString()``の戻り値に違いがあることに気がつきます。

```text:サイトB(正常系)でmarkup/goldmark/convert.goに仕掛けたスタックトレースの抜粋
---[goldmark/convert.go] begin----
file=/home/yasu/git/hugo/markup/goldmark/convert.go, line=55, func=github.com/gohugoio/hugo/markup/goldmark.showStackTrace
file=/home/yasu/git/hugo/markup/goldmark/convert.go, line=302, func=github.com/gohugoio/hugo/markup/goldmark.(*goldmarkConverter).Convert
file=/home/yasu/git/hugo/hugolib/page__per_output.go, line=435, func=github.com/gohugoio/hugo/hugolib.(*pageContentOutput).renderContentWithConverter
file=/home/yasu/git/hugo/hugolib/page__content.go, line=1054, func=github.com/gohugoio/hugo/hugolib.(*cachedContentScope).RenderString
file=/home/yasu/git/hugo/hugolib/page__per_output.go, line=226, func=github.com/gohugoio/hugo/hugolib.(*pageContentOutput).RenderString
file=/home/yasu/git/hugo/tpl/transform/transform.go, line=180, func=github.com/gohugoio/hugo/tpl/transform.(*Namespace).Markdownify
file=/usr/lib/go-1.22/src/reflect/value.go, line=596, func=reflect.Value.call
...
```

サイトA(異常系)の方では、RenderString()は呼ばれますが、markup/goldmark/convert.goにあるshowStackTrace()は呼ばれていませんでした。

この結果を元にhugolib/page__content.goのRenderString()の戻り値をチェックすると、正常系ではgoldmarkによってHTMLに変換されていましたが、異常系では変換されていませんでした。

先ほどのスタックトレースから、hugolib/page__per_output.goの中でgoldmarkのConvert()が呼ばれていないようにみえます。

サイトAでasciidoctorとmarkdownが混在している環境から、markdownだけに変更すると無事にShortcode(bootstrap-table)が期待どおりに処理されました。


```golang:hugolib/page__content.goのデバッグコード周辺のコード
	} else {
		fmt.Println("[hugolib/page__content.go] RenderString() pageparser.HasShortcode(contentToRender) is false")
		// pco -> pageContentObject, pco.po -> pageOutput, pco.po.p -> pageState
		fmt.Println("[hugolib/page__content.go] RenderString() pco.renderContentWithConverter: ", pco.renderContentWithConverter)
		c, err := pco.renderContentWithConverter(ctx, conv, []byte(contentToRender), false)
		if err != nil {
			return "", pco.po.p.wrapError(err)
		}

		rendered = c.Bytes()
	}
```

pco.renderContentWithConverter()の実体はhugolib/page__per_output.goに記述されています。

```golang:page__per_output.goのrenderContentWithConverter()改造後コード
func (pco *pageContentOutput) renderContentWithConverter(ctx context.Context, c converter.Converter, content []byte, renderTOC bool) (converter.ResultRender, error) {	
	fmt.Println("[page__per_output.go] renderContentWithConverter() c.Convert: ", c.Convert, " string(content): ", string(content), " renderTOC: ", renderTOC)
	if bytes.Contains(content, []byte("1.29.5")) {
		litter.Options{HidePrivateFields: false}.Dump(c)
	}
	r, err := c.Convert(
		converter.RenderContext{
			Ctx:         ctx,
			Src:         content,
			RenderTOC:   renderTOC,
			GetRenderer: pco.renderHooks.getRenderer,
		})
	return r, err
}
```

このコードをサイトAの成功時(markdownのみのコードに変更)と失敗時で比較すると次のようになります。

```text:成功時のログ
[page__per_output.go] renderContentWithConverter() c.Convert: 0x18f5640 string(content): 
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [https://k8s.io/](https://k8s.io/) |
| Storage Service (Rook) | v1.14.10 | [https://rook.io/](https://rook.io/), also see the [PV & PVC](#persistent-volumes-pv--pvc) section. |
 renderTOC: false
&goldmark.goldmarkConverter{ // p0
 md: &goldmark.markdown{ // p1
 ...
```

```text:失敗時のログ
[page__per_output.go] renderContentWithConverter() c.Convert: 0x18f5640 string(content): 
| Component | Version | Additional Information |
|-----------|---------|-------------------------|
| Kubernetes | v1.29.5 | [https://k8s.io/](https://k8s.io/) |
| Storage Service (Rook) | v1.14.10 | [https://rook.io/](https://rook.io/), also see the [PV & PVC](#persistent-volumes-pv--pvc) section. |
 renderTOC: false
&internal.AsciidocConverter{ // p0
 Ctx: converter.DocumentContext{
...
```

ここで失敗時にはgoldmarkではなく、asciidoctorがmarkup言語として選択されていることが分かります。


## Shortcodeの挙動について

参考資料に上げている日本語の資料でも触れられていますが、shortcodeは最も外側で``{{% %}}``と``{{< >}}``を使った場合では挙動が異なることになります。

* [https://gohugo.io/content-management/shortcodes/#shortcodes-with-markdown](https://gohugo.io/content-management/shortcodes/#shortcodes-with-markdown)

> Shortcodes with Markdown 
Shortcodes using the % as the outer-most delimiter will be fully rendered when sent to the content renderer. This means that the rendered output from a shortcode can be part of the page’s table of contents, footnotes, etc.

``{{% %}}``を使った場合には内部にmarkdownの記述が含まれるものとして扱われると説明されています。

> Shortcodes without Markdown 
The < character indicates that the shortcode’s inner content does not need further rendering. Often shortcodes without Markdown include internal HTML:

``{{< >}}``を使った場合は内部はmarkdownではなくテキストとして扱われると説明されています。

問題は2つのサイトで挙動に違いがある点とドキュメントに書かれているように``{{< >}}``で囲まれているのにサイトBでは正常に動作していて、``{{% %}}``に変更するとエラーになります。

改めて検索して、[https://discourse.gohugo.io/t/how-to-render-both-shortcode-and-markdown-in-shortcode/47740/2](https://discourse.gohugo.io/t/how-to-render-both-shortcode-and-markdown-in-shortcode/47740/2) を読むと、ますます混乱しました。

これらの記事が示す解決策は、**shortcodeで{{% %}}を使い、unsafe = true を設定する**、ことのように思われます。

shortcodeでgoldmarkを利用した場合はHTMLタグを出力しないということなので、これを許可するunsafe = trueはほぼ必須の設定項目のように思えます。

サイトA, Bの両方に次の変更を加えました。

```toml:hugo.tomlファイルから該当箇所の抜粋
[markup]
  [markup.tableOfContents]
    ordered = false
    endLevel = 4
    startLevel = 2
  [markup.goldmark]
    [markup.goldmark.renderer]
      unsafe = true
    [markup.goldmark.parser]
      [markup.goldmark.parser.attribute]
        block = true
```

この結果、どちらのサイトでも挙動は変化せず、サイトBは正しく動作し、サイトAは異常な動作のままでした。

## Asciidoctorが内部レンダラとして呼ばれてしまった理由

もう一度スタックトレースを見直して、どうして``c.Convert``が正しくないものを呼ぶのか確認しました。

例えば次のようにconvをmarkdownのConverterで上書きしてあげると期待したとおりに動作します。

```golang:page__content.go RenderString()でconvを上書きする
conv, err = pco.po.p.m.newContentConverter(pco.po.p, "markdown")
```

どうやらpcoが本来はshortcodeの呼び出し元コンテンツをポイントするはずなのに、トップレベルのコンテンツになってしまっているようです。

コードが正しいとすると``opts.Markup``オブジェクトに"markdown"が入っていればそれで解決するはずなのですが、空になってしまっているのが原因のようです。

空になっている理由はコピー元の定義時に空だからということのようです。

```golang:hugolib/page.goからの抜粋
var defaultRenderStringOpts = renderStringOpts{
	Display: "inline",
	Markup:  "", // Will inherit the page's value when not set.
}
```

Markdownify()からRenderString()を呼び出すところをみていくと、常にSite.Homeを親として呼び出していることが分かります。

```golang:tpl/transform/transform.goからの抜粋
func (ns *Namespace) Markdownify(ctx context.Context, s any) (template.HTML, error) {
	home := ns.deps.Site.Home()
	if home == nil {
		panic("home must not be nil")
	}
	ss, err := home.RenderString(ctx, s)
	if err != nil {
		return "", err
	}

	// Strip if this is a short inline type of text.
	bb := ns.deps.ContentSpec.TrimShortHTML([]byte(ss), "markdown")

	return helpers.BytesToHTML(bb), nil
}
```

例えば次のように改造します。

```golang:改造後のtransform.go
func (ns *Namespace) Markdownify(ctx context.Context, s any) (template.HTML, error) {
	home := ns.deps.Site.Home()
	if home == nil {
		panic("home must not be nil")
	}
	renderOpts := map[string]any{ "Display": "inline", "Markup": "markdown", }
	ss, err := home.RenderString(ctx, renderOpts, s)
	if err != nil {
		return "", err
	}

	// Strip if this is a short inline type of text.
	bb := ns.deps.ContentSpec.TrimShortHTML([]byte(ss), "markdown")

	return helpers.BytesToHTML(bb), nil
}
```

このコードはMarkdownify()が常にMarkdownを処理するのであれば、バグとして修正可能かもしれません。

しかし現実にadoc形式やrST形式のみで構成されたサイトであれば、shortcodeを各markup言語で記述していることも想定されます。

例えばAsciidoctorのみで構成されたサイトは、次のようなShortcodeが利用可能です。

```markdown:content/_index.adocファイルに埋め込まれたShortcode
= Shortcode in Asciidoctor

{{< mbtable table_class="table-info" >}}
|===
| foo | bar

| baz
| bim
| ===
{{< /mbtable >}}
```

これはlayout/shortcodes/bootstrap-table.htmlを変更することなく、次のようなHTMLに変換されます。

```html:変換されたbootstrap-tableのHTML
<div class="table">
<table class="table-info">
  <thead>
      <tr>
          <th style="text-align: left">foo</th>
          <th style="text-align: left">bar</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: left">baz</td>
          <td style="text-align: left">bim</td>
      </tr>
  </tbody>
</table>
</div>
```

このような使い方をしたい・している、ということもあると思うので、単純にバグとして切り捨てることもできないのかなぁと考えています。

最終的には markdownify のような各マークアップ言語に対応したレンダラーを指定できるような関数を増やさないと多くのニーズを満たせいないかなと思い、少し悩ましい結論になってしまいました。

以上