開發 Hugo 專案
Hugo 開發本身是用 Go 語言寫的「Hugo 語言」,意思就是這些語法全都是 Hugo 獨有,且這些語法通常都是使用 Go 語法建立的。Go 雖然是靜態語言,但是 Hugo 語言本身其實像是動態語言,因此不需要宣告變數型態。
開發環境設定
- 模板語法高亮
- prettier + prettier-plugin-go-template,Hugo 官方的 gotmplfmt 目前還不好用
- .vscode/settings.json 設定語言解析
"files.associations": {
"**/layouts/**/*.json": "html"
}, - myhugofixer 避免 AI 開發使用 deprecated API
模板系統
沒接觸過模板的人可能有點搞不懂,就是 Hugo 透過這些模板直接渲染 HTML,因此 Hugo 模板沒有程式語言的那些上下文,會自己從模板順序依序往下渲染。Hugo 只在乎基礎模板,從 baseof.html 開始渲染,根據目錄結構找到 home.html(原 index.html)、page.html(原 single.html)等等,其他 partial 模板只是在呼叫時用到。
Hugo 透過 Go 語言的高併發特性多個頁面同時渲染,因此你無法設定頁面渲染順序。唯一能控制的只有輸出類型,你可以自定義 outputFormats 的 weight 以設定不同輸出類型的先後順序。
每個變數的 life cycle 只在當前模板,即使是同一個 HTML 頁面的不同模板,變數也會被清空,要在不同模板、不同頁面共享變數,唯一的方式只有 Store 方法。
模板查找
需要搞懂這幾個:
- 必須先理解前面講的頁面種類
- .Page.Path 也要搞懂,其實就是相對於 content 目錄的路徑
- Template types 是 Hugo 渲染頁面的基礎
- 現在你才能開始讀 template lookup order
- 相關的還有 New template system 和 output 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$.Sitesite
前兩種一樣,多了 dollar sign 可以讓你從迴圈中取得「當前 template 的 context」,小寫的 site 則是「當前頁面的 site」,如果在迴圈或其他 context 語法裡面可能會取用到別的 site。
便箋 Scratch Pad
hugo.Store 和 .Page 和 newScratch 都有 scratch pad 功能,文檔是這樣介紹的:
Returns a globally scoped “scratch pad” to store and manipulate data.
為什麼要寫這種奇怪的內容?直白的寫跨 template 儲存變數不是很好嗎?完美體現文檔到底有多爛。
讀取設定檔
語法高亮
有以下幾個選項:
- 使用內建的 Chroma,缺點是語法偵測很爛,複雜的語言如 C 就亂標一通
- 外部 JS,使用 highlight.js/prism.js,網路上教學很多,不推薦,因為拖累客戶端的效能。
- shiki syntax highlighting,這和外部 JS 一樣是修改已經構建完成的 HTML,最大的差別是 shiki 採用 VS Code 引擎因此更正確,還有 shiki 是伺服器端渲染,完全沒有客戶端效能問題。整合方式請參考使用 Hugo 的 Shiki 開發者寫的文章
我的建議是這樣:
- 正確的 Chroma 無閃爍的亮暗模式切換、無 JS 環境都支援載入的方案請見此 PR,這是最適合 Hugo 生態系,自定義最輕鬆,負擔最低最不需要手動維護的方式
- 除非特殊需求否則不要自己搞 shiki,請等哪天官方支援
- Shiki 基於 TextMate,這已經被回報不夠準確12,我認為花力氣換到不夠好的替代品不值得
- 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 很多人用,西瓜偎大邊這句話的含金量還在提高。
- https://discourse.gohugo.io/t/nested-shortcode-rendering/56012/
- https://gohugo.io/content-management/shortcodes/#notation
- https://discourse.gohugo.io/t/arbitrarily-nested-shortcodes/30424
- https://discourse.gohugo.io/t/html-shortcodes-are-rendered-as-markdown-when-nested-inside-markdown-shortcodes-is-there-a-workaround/38573
- https://discourse.gohugo.io/t/nested-shortcodes-render-markdown-in-html-content-file/45536
- https://discourse.gohugo.io/t/how-to-render-both-shortcode-and-markdown-in-shortcode/47740
頁面內容判斷
hasShortcode 可以判斷頁面是否使用指定 shortcode,而且在 .Content render 之前就可以判斷,但是 .Store 方式不行。
要 trigger .Content evaluate 可以使用 {{ $noop := .WordCount }} 完成。
循環引用
無解,只能用戶自行避免。我在多個官方說法都看到同樣的結論。
偵錯
Hugo 除錯別無他法只能把變數印出來:
{{ $var }}{{ debug.Dump $var }}{{ highlight (jsonify (dict "indent" " ") $var) "json" }}{{ site.Store }}+{{ site.Get }}{{ printf "type: %T, val: %s" $var $var }}{{ warnf "%s $var" }}
效能
檢測:hugo --templateMetrics --templateMetricsHints
優化:通常都是頁面又 walk 一遍其他頁面導致 O(N^2) 複雜度,不然預設 O(N) 加上 Hugo 平行處理的機制沒什麼好優化了,頂多就是把檢測找到的複雜模板改用 partialCached 快取處理,然後資料結構基本觀念要有,不要大型數據還在用 range 跑或是用迴圈 merge 大型 dict。
條件判斷
and 和 or 都是短路判斷,cond 不是。
路徑判斷
專案設計應該永遠都使用 logical path(也就是檔案在 content 目錄的相對路徑)而不是 URL 判斷,因為 Hugo 有很強大的 URL 自訂功能,用硬編碼的 URL 判斷完全毀了這個功能。
自製額外輸出
Hugo 是模板語言因此輸出是預設的那些模板,要額外設定輸出,例如 JSON 文件或者 llms.txt,你要
- 建立 layouts/llms.txt
- Outputs 設定 home section 新增 llms
- outputFormats 設定 llms 區
全文教學請見 How to add llms.txt to a Hugo Blog 和 Adding 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 控制是否進入。