Skip to main content

開發 Hugo 專案

Hugo 開發本身是用 Go 語言寫的「Hugo 語言」,意思就是這些語法全都是 Hugo 獨有,且這些語法通常都是使用 Go 語法建立的。Go 雖然是靜態語言,但是 Hugo 語言本身其實像是動態語言,因此不需要宣告變數型態。

開發環境設定

模板系統

沒接觸過模板的人可能有點搞不懂,就是 Hugo 透過這些模板直接渲染 HTML,因此 Hugo 模板沒有程式語言的那些上下文,會自己從模板順序依序往下渲染。Hugo 只在乎基礎模板,從 baseof.html 開始渲染,根據目錄結構找到 home.html(原 index.html)、page.html(原 single.html)等等,其他 partial 模板只是在呼叫時用到。

Hugo 透過 Go 語言的高併發特性多個頁面同時渲染,因此你無法設定頁面渲染順序。唯一能控制的只有輸出類型,你可以自定義 outputFormats 的 weight 以設定不同輸出類型的先後順序。

每個變數的 life cycle 只在當前模板,即使是同一個 HTML 頁面的不同模板,變數也會被清空,要在不同模板、不同頁面共享變數,唯一的方式只有 Store 方法。

模板查找

需要搞懂這幾個:

  1. 必須先理解前面講的頁面種類
  2. .Page.Path 也要搞懂,其實就是相對於 content 目錄的路徑
  3. Template types 是 Hugo 渲染頁面的基礎
  4. 現在你才能開始讀 template lookup order
  5. 相關的還有 New template systemoutput formats

模板使用

inline template/partial

請務必善用 inline template/partial 功能,這是 Hugo 唯一一個類似函式的東西。inline template 適用於同一檔案模組化和重用,雖然 inline template 全局可用,但是在全局用 inline template 會讓維護變的困難。

template and partial

partial 和 template 最大的差別是 partial 可以選擇回傳變數或者直接渲染 HTML 內容,template 只能直接渲染 HTML。

不推薦使用 partial 作為函式回傳變數,因為 partial 沒辦法清楚定義輸入輸出,也沒有 early-return/goto 等等機制,每個 partial 也限制只能有一個 return,這大幅限制維護和可讀性。

partialCached 不適用於 inline partial,只適用於獨立檔案的 partial。

block

block 定義模板後原地立刻執行,只是一個縮寫,使用頻率屈指可數的語法糖。

_markup 目錄

此目錄用於自訂特定元素的渲染管道 (render hook),例如圖片、heading 或程式碼塊等等。

快取資料夾

Hugo 大量使用快取,包含使用 GetRemote 取得的圖片以及 hugo mod 安裝的主題都會被快取。

找到資料夾的方式是在專案目錄中使用 hugo config | grep cachedir,以 macOS 為例會在 ~/Library/Caches/hugo_cache

變數

沒有要講什麼特別的,重要的是小寫 page site 可以直接存取目前頁面的 page 和 site,所以不要再寫一堆 dollar sign。

各種不同的 .Site 用法

取得全站物件有幾種方式

  • .Site
  • $.Site
  • site

前兩種一樣,多了 dollar sign 可以讓你從迴圈中取得「當前 template 的 context」,小寫的 site 則是「當前頁面的 site」,如果在迴圈或其他 context 語法裡面可能會取用到別的 site。

便箋 Scratch Pad

hugo.Store.PagenewScratch 都有 scratch pad 功能,文檔是這樣介紹的:

Returns a globally scoped “scratch pad” to store and manipulate data.

為什麼要寫這種奇怪的內容?直白的寫跨 template 儲存變數不是很好嗎?完美體現文檔到底有多爛。

讀取設定檔

不支援讀取

語法高亮

有以下幾個選項:

  1. 使用內建的 Chroma,缺點是語法偵測很爛,複雜的語言如 C 就亂標一通
  2. 外部 JS,使用 highlight.js/prism.js,網路上教學很多,不推薦,因為拖累客戶端的效能。
  3. shiki syntax highlighting,這和外部 JS 一樣是修改已經構建完成的 HTML,最大的差別是 shiki 採用 VS Code 引擎因此更正確,還有 shiki 是伺服器端渲染,完全沒有客戶端效能問題。整合方式請參考使用 Hugo 的 Shiki 開發者寫的文章

我的建議是這樣:

  1. 正確的 Chroma 無閃爍的亮暗模式切換、無 JS 環境都支援載入的方案請見此 PR,這是最適合 Hugo 生態系,自定義最輕鬆,負擔最低最不需要手動維護的方式
  2. 除非特殊需求否則不要自己搞 shiki,請等哪天官方支援
  3. Shiki 基於 TextMate,這已經被回報不夠準確12,我認為花力氣換到不夠好的替代品不值得
  4. Shiki 會比 Chroma 好是因為 Chroma 更爛,Shiki 的優勢不足以彌補維護負擔,且開發時的預覽體驗糟糕。

Shortcode 語法

Shortcode 有兩種呼叫方式,{{< >}} {{% %}},區別是百分比符號的會在 Markdown 渲染前預先放到 Markdown 之後才跟著整個頁面一起渲染,因此有些奇怪的問題就要靠他解決。

Shortcode 出現換行

Trim space! Trim space! Trim space! Always remember to trim space!

巢狀 shortcode

這是老生常談的問題,巢狀 shortcode 的困難點在於不知道哪個要用 Markdown 哪個要當作純 HTML,注意 {{< >}}{{% %}} 語法選用,並且 shortcode 內部最好都要 trim spaces 避免空白被當成 markdown 渲染,而且即使做到這樣也不見得都順利可用。

比如說要建立 steps + step 組合型 shortcode,steps 是外部容器, step 是每個步驟,對於開發者來說,他有這些組合可能

  • 兩種shortcode notation {{< >}} {{% %}}
  • 是否使用 .Page.RenderString

如果要支援 shortcode 可渲染出 footnote/TOC,正確答案是外部使用 {{% %}},內部使用 {{< >}},並且都不要使用 .Page.RenderString

這種東西在文檔隻字不提,我是所有排列組合測試一次才知道該怎麼做。還沒完,當你想做到 step 裡面包更多 shortcode、被 shortcode 包、支援 nesting shortcode 內容支援和 GoldMark 交互(支援 footnote),這就有得你搞,請注意這不是什麼過分的需求,比如說在 steps 裡面放 alert,或是 tabs/tab 組合時,一個 tab 放源碼,一個 tab 放 result,這些在 Hugo 裡面都很困難。

作為用戶也很麻煩,要用 {{< >}} 還是 {{% %}} 完全沒有任何記憶點,你只能純靠死背或是每次都翻文檔。翻文檔就算了,nesting 很多時候需要 indent 讓你好閱讀和編輯,但是 {{% %}} 會把內容丟給 GoldMark,因此 indent 還不能超過兩層(四個空隔),.InnerDeindent{{% %}} 實測完全沒用,這一切都要用背的或是踩過才知道。

更糟糕的是 nesting 一定要開啟 markup.goldmark.renderer.unsafe = true,因為內部 shortcode 通常是被渲染成 HTML 交給外部 shortcode 處理,這代表 unsafe 這個避免寫手注入 HTML 內容的限制一定會被關掉。開發者麻煩,用戶麻煩,寫手也麻煩。

甚至 AI 對於兩種語法是毫無概念的,AI 只知道照本宣科實際用還是亂用,因為你特別問他他會知道然後把概念唸一遍給你聽,但是不特別問他他就依照機率推斷概率永遠是 {{< >}} 遠大於 {{% %}},永遠都讓你用 {{< >}} 語法,因此就算問 AI 也沒用。

最糟糕的就是這些東西如果文檔清楚說明大家可以 link 一個頁面所有人要用到的時候去看就好了,但是沒有,Hugo 的文檔就是一坨,這代表所有 Hugo 知識都只能作為個人碰過才知道而不是看完文檔就知道可以避免。

反過來說關於 npm 的 footnote 的問題 AI 大部分都能解決,因為 JS 很多人用,西瓜偎大邊這句話的含金量還在提高。

頁面內容判斷

hasShortcode 可以判斷頁面是否使用指定 shortcode,而且在 .Content render 之前就可以判斷,但是 .Store 方式不行。

要 trigger .Content evaluate 可以使用 {{ $noop := .WordCount }} 完成。

循環引用

無解,只能用戶自行避免。我在多個官方說法都看到同樣的結論。

偵錯

Hugo 除錯別無他法只能把變數印出來:

  1. {{ $var }}
  2. {{ debug.Dump $var }}
  3. {{ highlight (jsonify (dict "indent" " ") $var) "json" }}
  4. {{ site.Store }} + {{ site.Get }}
  5. {{ printf "type: %T, val: %s" $var $var }}
  6. {{ warnf "%s $var" }}

效能

檢測:hugo --templateMetrics --templateMetricsHints

優化:通常都是頁面又 walk 一遍其他頁面導致 O(N^2) 複雜度,不然預設 O(N) 加上 Hugo 平行處理的機制沒什麼好優化了,頂多就是把檢測找到的複雜模板改用 partialCached 快取處理,然後資料結構基本觀念要有,不要大型數據還在用 range 跑或是用迴圈 merge 大型 dict。

條件判斷

andor 都是短路判斷cond 不是。

路徑判斷

專案設計應該永遠都使用 logical path(也就是檔案在 content 目錄的相對路徑)而不是 URL 判斷,因為 Hugo 有很強大的 URL 自訂功能,用硬編碼的 URL 判斷完全毀了這個功能。

自製額外輸出

Hugo 是模板語言因此輸出是預設的那些模板,要額外設定輸出,例如 JSON 文件或者 llms.txt,你要

  1. 建立 layouts/llms.txt
  2. Outputs 設定 home section 新增 llms
  3. outputFormats 設定 llms 區

全文教學請見 How to add llms.txt to a Hugo BlogAdding llms.txt & markdown output to your Hugo site,有了這兩個範例,其他輸出類型就可以依樣畫葫蘆完成。

如果額外輸出沒有渲染連結,請建立該輸出專用的 link render hook,比如建立了 home.fuse-search.json,他的 render hook 應該用 render-link.fuse-search.json,規則請見文檔

輸出 PDF

官方不支援,但是可以透過 resources.PostProcess + Pagedjs 自己做。

翻譯回退 (language fallback)

使用 module.mounts 完成,這是官方推薦做法lang.Merge 只幫你找到頁面不會渲染。

網站作者

請用官方推薦做法完成。建議完全放棄 .Params 的網站作者因為 .Params 方案無法設定多作者,未來要新增多作者就會造成出現兩套作者系統。

判斷頁面是否有 ToC

請用 {{ if in .TableOfContents "<li>" }} 判斷。

見微知著,Hugo 麻煩的地方就是連這種東西都要人發文問才知道最佳做法,而且所有人用法都不一樣,你就知道 Hugo 開發有多大的麻煩和混亂。我甚至看過有人直接比較字數,一個一個數空的 .TableOfContents 回傳 33 個字。

設定合併

Hugo 有些設定會深層合併,有些只有表層、有些不合併,見文檔

多版本

Hugo 直到現在還是沒有 versioning 的文檔,只有官方範例可以看,但是這個 example 寫的很怪全都是雜訊,根本就沒人需要用到 fallback(X 在 v2 不存在 fallback 到 v1 的 X),也不會有人在 frontmatter 特別設定該文件要限定在哪個版本,九成專案都是一個版本一個資料夾,這 example 放了一堆進階用法結果全是雜訊。

具體設定方式我已經寫在 hugo-yore 的文檔裡面,這裡就不再重複了。

具體使用方式是:

{{- with .Rotate "version" }}
<div>
<!-- 版本切換按鈕,顯示當前版本 -->
<button>
{{- replace site.Version.Name "v" "" }}
</button>

<!-- 版本選單列表 -->
<div>
{{- range . }}
<a
href="{{ .RelPermalink }}"
class="{{ if eq .Site.Version.Name $.Site.Version.Name }}active{{ end }}">
<span>{{ .Site.Version.Name }}</span>
</a>
{{- end }}
</div>
</div>
{{- end }}

由於 Hugo 預設 version 是 v1.0.0 所以 .Rotate 永遠都有結果,因此外層還需要一個 if-else 控制是否進入。

Footnotes

  1. https://github.com/microsoft/vscode/issues/50140

  2. https://www.mail-archive.com/dev@netbeans.apache.org/msg09401.html