Hugo Stack 主題魔改集錦

能跑就行

增加「返回頂部」按鈕

=> 具體請見 Hugo Stack 主題創建返回頂部按鈕

增加移動端 TOC

我的設想是:

  1. 點擊 TOC 按鈕后,從底部滑出目錄欄
  2. 點擊 任意標題、目錄欄外或叉號,目錄欄關閉
  3. 向下滑動頁面時,按鈕隱藏,向上滑動一段后按鈕顯示

HTML 部分

同樣寫入 layouts/partials/footer/custom.html

點擊顯示
<!-- ToC button -->
<div id="toc-button" class="toc-btn">
  <button>
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
      <path
        d="M8 6L21 6.00078M8 12L21 12.0008M8 18L21 18.0007M3 6.5H4V5.5H3V6.5ZM3 12.5H4V11.5H3V12.5ZM3 18.5H4V17.5H3V18.5Z" />
    </svg>
  </button>
</div>

<!-- ToC drawer -->
<div id="toc-drawer" class="toc-drawer">
  <div class="drawer-header">
    <!-- icon of TOC -->
    {{ partial "helper/icon" "hash" }} 
    <h2> TABLE OF CONTENT</h2>
    <button id="close-drawer" class="close-btn"></button>
  </div>
  <div class="widget--toc">
    <!-- Use hugo generated ToC -->
    {{ .TableOfContents }}
  </div>
</div

<!-- ToC Overlay -->
<div id="drawer-overlay" class="overlay"></div>

首先是拷貝 back-to-top 的按鈕部分,然後是上滑的目錄欄和剩餘陰影部分,這裏可以參考 partials/widget/toc.html

CSS 部分

寫入 assets/scss/partials/footer.scss

點擊顯示
/* Buttons CSS */
.button-float {
    padding: 8px;
    border: none;
    border-radius: 8px;
    background-color: var(--button-float-bg);
    box-shadow: 0 3px 5px var(--button-float-shadow);
    cursor: pointer;
    width: 36px;
    height: 36px;
    display: flex;
    justify-content: center;
    align-items: center;
    transition: all 0.3s ease;

    &:hover {
        background-color: var(--button-float-bg-hover);
        box-shadow: 0 5px 8px var(--button-float-shadow-hover);
        transform: translateY(-2px);
    }

    svg {
        width: 16px;
        height: 16px;
        stroke: var(--button-float-arrow);
        transition: stroke 0.3s ease;
    }

    &:hover svg {
        stroke: var(--button-float-arrow);
    }
}

/* Back-to-top button */
.back-to-top {
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 1000;
    display: none;

    button {
        @extend .button-float;
    }
}

/* ToC button */
.toc-btn {
    position: fixed;
    bottom: 60px;
    right: 20px;
    z-index: 1001;
    display: none;

    @media (max-width: 1023px) {
        display: block;
    }

    body.homepage &,
    body.template-archives &,
    body.template-search & {
        display: none !important;
    }

    button {
        @extend .button-float;
    }
}

/* ToC drawer */
.toc-drawer {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 60%;
    background: var(--button-float-bg);
    border-radius: 12px 12px 0 0;
    transform: translateY(100%);
    transition: transform 0.3s ease-in-out;
    z-index: 1001;
    display: flex;
    flex-direction: column;

    .widget--toc {
        border-radius: 0px;
        box-shadow: none;
    }
}

.drawer-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 14px;
    border-radius: 12px 12px 0 0;
    border-bottom: 2px dashed var(--body-background);
    font-size: 14px;
    font-weight: bold;
    color: var(--button-float-arrow);
    background: var(--button-float-bg-hover);

    h2 {
        margin: 10px 0;
    }
}

.close-btn {
    background: none;
    border: none;
    font-size: 20px;
    cursor: pointer;
    color: var(--button-float-arrow);
}

/* ToC background overlay */
.overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(5px);
    z-index: 1000;
    display: none;
}

合并了 back-to-toptoc-button 的樣式,減少代碼重複。另外使用現有的 widget--toc 樣式,單獨更改了部分屬性讓顯示不至於突兀。

JS 部分

同樣的路徑,合并了 back-to-top 的監聽和動作:

點擊顯示
<script>
    document.addEventListener("DOMContentLoaded", () => {
        const backToTop = document.getElementById("back-to-top");
        const tocButton = document.getElementById("toc-button");
        let lastScrollY = window.scrollY;
        let lastShowY = window.scrollY; // Stores last Y position when buttons were shown

        window.addEventListener("scroll", () => {
            let currentScrollY = window.scrollY;

            if (currentScrollY < lastScrollY) { // User is scrolling up
                if (lastShowY - currentScrollY > 50) { // Ensure user scrolled up 50px
                    tocButton.style.display = "";
                    // Ensure back to top display only after 400px
                    if (window.scrollY > 400) {
                        backToTop.style.display = "block";
                    }
                    // Adjust TOC button position if both are visible
                    // if (backToTop.style.display != "none" && tocButton.style.display != "none") {
                    //     backToTop.style.bottom = "60px";
                    //     tocButton.style.bottom = "20px";
                    // // }
                }
            } else {
                // User is scrolling down, reset reference position
                lastShowY = currentScrollY;
                backToTop.style.display = "none";
                tocButton.style.display = "none";
            }

            lastScrollY = currentScrollY; // Update last scroll position
        });

        // Back to Top Click Event
        backToTop.addEventListener("click", function () {
            window.scrollTo({ top: 0, behavior: "smooth" });
        });
    });

    // Toc Drawer open and close
    document.addEventListener("DOMContentLoaded", () => {
        const tocButton = document.getElementById("toc-button");
        const tocDrawer = document.getElementById("toc-drawer");
        const drawerOverlay = document.getElementById("drawer-overlay");
        const closeDrawer = document.getElementById("close-drawer");
        const tocLinks = document.querySelectorAll("#toc-drawer li a"); // Get all TOC links


        function openTOC() {
            tocDrawer.style.transform = "translateY(0)";
            drawerOverlay.style.display = "block";
        }

        function closeTOC() {
            tocDrawer.style.transform = "translateY(100%)";
            drawerOverlay.style.display = "none";
        }

        tocButton.addEventListener("click", openTOC);

        closeDrawer.addEventListener("click", closeTOC);
        drawerOverlay.addEventListener("click", closeTOC);

        tocLinks.forEach(link => {
            link.addEventListener("click", closeTOC);
        });
    });
</script>

碰到的一些問題

所有頁面都會顯示 ToC

完成三個部分后發現不止 post 頁面含有 ToC 按鈕,所有頁面都出現了(這點在返回按鈕中沒有任何問題),所以在 CSS 中額外添加:

.toc-btn {
    ...
    body.homepage &,
    body.template-archives &,
    body.template-search & {
        display: none !important;
    }
}

archivessearch 分別都有名稱,主頁沒有,因此需要在 layouts/_default/baseof.html 中添加:

-   <body class="{{ block `body-class` . }}{{ end }}">
+   <body class="{{ block `body-class` . }}homepage{{ end }}">

桌面端 ToC 重複

最後修改的是去除桌面端的 ToC 按鈕(我看有的操作是全部替換原有的目錄欄,但我還挺喜歡這種直觀的顯示)。一開始我想通過一個 JS 判斷文檔中是否已有 ToC widget 以決定 ToC button 的顯示(基本邏輯同下滑不顯示),但想想就很麻煩,於是轉用 CSS,畢竟寬度縮小后 ToC widget 就消失了,可以參考這個。不過找了半天只找到 right-sidebar breakpoint 變化,於是索性手動輸入數字:

.toc-btn {
    ...
    @media (max-width: 1023px) {
        display: block;
    }
}

顯示與隱藏的時機

現在的設定是 ToC 默認顯示,因此點擊進入一篇文章后會立刻看見按鈕,只能説有好(能夠預覽結構)有壞(顯得很突兀),反之也是。所以總之現在能跑,就這樣不改動了。

增加評論功能

本來的首選是 Remark42 (誰讓它名字裏有「宇宙的答案」呢🙄),但搞了半天總之不是很方便管理,於是轉向了 Twikoo ,按照官方文檔一步一步設定基本沒有問題(雖然期間碰見 MongoDB URL 設定有誤,版本鎖定等亂七八糟,但忘記怎麽解決地解決了,之後碰上再説吧)。

沒想到 Github 爲我保留了這不知道怎麽解決的解決:

[comments.twikoo]
envId = "my-envID"
region = "my-region"
- path = "/twikoo"
+ path = "window.location.pathname"
lang = "en"

至於版本,直接搜索當前版本號然後就可以找到地方更改了。

評論表情 Stickers

參考文章: 嘰嘰乞乞:Twikoo評論系統的個性化設置

第一眼看到某站的表情作爲默認選項簡直立刻生理不適,火速搜尋並更改為 Blobcat,保證了一些 San 值。

可爱泡泡猫

可爱泡泡猫

增加外鏈符號

參考文章: 第三夏尔 | Third Shire:Hugo Stack主题装修笔记

給外鏈添加了一個意味「離開本站」的符號,同時一并把超鏈接設定為 rel="noopener noreferrer nofollow"

增加字數統計

更改設定

第一步需要確定 config.toml 中含有中日韓字符設定開啓,即 hasCJKLanguage = true

我另外在 params.toml 中增加了一行 wordCount = true 方便後續變動(雖然好多月以後看這個似乎和 readingTime 互相搞混但也不知道怎麽一回事……)

添加代碼

/layouts/partials/article/components/details.html 中添加代碼:

點擊顯示
{{ $showWordCount := .Params.wordCount | default (.Site.Params.article.wordCount) }}
...
{{ if $showWordCount}}
    <div>
        {{ partial "helper/icon" "word-count" }}
        <time class="article-time--reading">
            {{ T "article.wordCount" .WordCount }}
        </time>
    </div>
{{ end }}

本質上還是拷貝原有的時間顯示,所以也沿用 Style 的設定。

更改代碼塊樣式

參考文章: Naive Koala:Hugo Stack 魔改美化

我做了些改動,主要是:

  1. 如果沒有填寫具體語言,返回 TEXT 而不是默認的 FALLBACK
  2. 符合樣式增加了一個 toolbar,實在不喜歡那個飄浮的拷貝按鈕

寫在原有的 main.ts裏:

點擊顯示
        ...
        /**
         * Add copy button to code block
        */
        const highlights = document.querySelectorAll('.article-content div.highlight');
        const copyText = '✂ Copy';
        const copiedText = '✔ Copied';

        highlights.forEach(highlight => {
            const codeBlock = highlight.querySelector('code[data-lang]');
            // console.log("Raw data-lang:", codeBlock?.getAttribute('data-lang'));
            if (!codeBlock) return;

            // Get language
            const rawLang = codeBlock.getAttribute('data-lang');
            const lang = (rawLang === "fallback" || !rawLang) ? "TEXT" : rawLang.toUpperCase();
            // console.log("Final language:", lang); 
            const langTag = createButton(lang, 'languageTagButton');

            // New copy button
            const copyButton = createButton(copyText, 'copyCodeButton');

            // Create toolbar
            const toolbar = document.createElement('div');
            toolbar.classList.add('toolbar');
            toolbar.appendChild(langTag);
            toolbar.appendChild(copyButton);

            highlight.insertBefore(toolbar, highlight.firstChild);

            // Copy function
            copyButton.addEventListener('click', () => {
                navigator.clipboard.writeText(codeBlock.textContent)
                    .then(() => {
                        copyButton.textContent = copiedText;

                        setTimeout(() => {
                            copyButton.textContent = copyText;
                        }, 1000);
                    })
                    .catch(err => {
                        alert(err);
                        console.error('Copy failed:', err);
                    });
            });
        });

        // helper function
        function createButton(text, className) {
            const button = document.createElement('button');
            button.innerHTML = text;
            button.classList.add(className);
            return button;
        }

        new StackColorScheme(document.getElementById('dark-mode-toggle'));

另外把原先的 assets/scss/partials/layout/article.scsshighlight 部分改掉了:

點擊顯示
    .highlight {
        max-width: 102% !important;
        padding: 0px;
        position: relative;
        border-radius: 8px;
        margin-left: -8px !important;
        margin-right: -2px;
        box-shadow: var(--shadow-l1) !important;

        // keep Codeblocks LTR
        [dir="rtl"] & {
            direction: ltr;
        }

        pre {
            margin: initial;
            padding: 15px 25px;
            margin: 0;
            width: auto;
            border-radius: 0 0 8px 8px;
        }

        .toolbar {
            position: relative;
            width: 100%;
            height: 30px;

            background-color: var(--code-toolbar-color);
            z-index: 10;
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 6px 16px;

            border-top-left-radius: 8px;
            border-top-right-radius: 8px;

            .languageTagButton {
                background: none;
                border: none;
                font-size: 12px;
                font-family: var(--code-font-family);
                color: var(--code-button-color);
                cursor: default;
            }

            .copyCodeButton {
                background: none;
                border: none;
                color: var(--code-button-color);
                font-size: 12px;
                cursor: pointer;
            }
        }
    }

總之感覺這個 code 部分的樣式有很多,讓人暈頭轉向,但是能跑就行,嗯 (ˉ﹃ˉ)

多語言設定

其實我不是很能理解 Hugo 的多語言結構。我的理解是它按照 站點/語言/頁面 排序,而我的設想是 站點/頁面/語言,這樣一來頁面即文章是更高一級的存在。這樣 文章 - 語言 是一對多的關係,主頁是唯一的。現在這個關係是建立在 站點 上,因此不同語言有不同版本的主頁,導致無法統計獨特文章(例如某篇我想用西語寫而不是中文,這樣一來如果不相應創建西語站點那麽就看不到那篇文章)。

然後花了很多時間折騰這個獨特的 URL,但是非常複雜又不符合 Hugo 自己的簡便方法非常不便捷,因此作罷選擇了 它提供的方法

我的倔强讓我想讓中文主頁也顯示英語(以維持首頁一致性),這個無法通過 config 完成,一種「能跑就行」的策略是去 i18n 裏把目標語言改成和英文一樣 ㄟ( ▔, ▔ )ㄏ

Timeout 超時設定

在我瘋狂往巴黎游記添加許多照片后(~150MB),Cloudflare Build 報錯:

點擊查看 Error
Error: error building site: render: failed to render pages: [...]: execute of template failed: template:
partials/article-list/default.html:3:7: executing "partials/article-list/default.html" at <partial "article/
components/header" .>: error calling partial: partial "article/components/header" timed out after 30s. This is 
most likely due to infinite recursion. If this is just a slow template, you can try to increase the 'timeout' 
config setting.

看到這無情的 most likely due to infinite recursion ,心中不免一涼,讓我找哪裏循環引用無疑是大海撈針,於是寄希望於後者。

搜尋 Hugo Stack 的 Issues,找到有人有 相同問題 。於是嘗試在 config.toml 中修改設定 timeout = 180 然而并沒有任何反應;另外覺得是 Cloudflare 的問題,又用 Github pages render 卻也是同樣問題。

那個階段發現有用的 work round 有:

  • 把長文章拆分成幾個部分,有時有效
  • 使用批量工具壓縮圖片,但會很模糊
  • 本地 Build 完成之後上載到另一個 branch,Cloudflare 不選擇 framework 可以部署

真的很麻煩又不穩定,有一回突然發瘋把 timeout = 100000 寫上,結果大家都很順利了(。現在看 Cloudflare Build 大概需要 50 秒,不知道未來該怎麽辦。

我覺得主要問題還是在 Hugo Stack 的 Picture Gallery 特點,似乎在為不同媒介準備不同大小的縮略圖,因此需要耗費很多時間。不知道未來是否會有更多問題,以及如何改善,但現在還能跑那就先這樣…

Shortcode 短代碼

摺叠内容

參考文章: Stackoverflow: Add collapsible section in hugo 林中阴影:在 Hugo 中折叠部分内容

摺叠效果

這裡是一些 Markdown 內容

  • 列表項目
  • 另一個列表項

注意:

  • 應使用 區塊模式(Block Shortcode), 即 {% shortcode %}。如果使用 内聯模式(Inline Shortcode),即 {< shortcode >} 會導致無法讀到 Summary 内容(實際使用時應爲雙括號)。

Neodb 卡片

參考文章: 眠于水月间:引用 NeoDB 条目 椒盐豆豉:Hugo 装修小记之二

可能參考代碼沒有匹配 Hugo 版本(目前基於 v0.141.0),套用時報錯:

ERROR deprecated: data.GetJSON was deprecated in Hugo v0.123.0 and will be removed in Hugo 0.142.0. 
use resources.Get or resources.GetRemote with transform.Unmarshal.

於是進行了一番修改,另外把 dbTypedbUuid 分開查找。

點擊顯示
{{ $dbUrl := .Get 0 }}
{{ $dbApiBase := "https://neodb.social/api" }}
{{ $dbType := "" }}
{{ $dbUuid := "" }}

<!-- Extract item type and UUID -->

{{ if (findRE `^.*neodb\.social\/.*` $dbUrl) }}
    {{ $dbType = replaceRE `.*neodb.social\/([^\/]+)\/.*` "$1" $dbUrl }}
    {{ $dbUuid = replaceRE `.*neodb.social\/[^\/]+\/([^\/]+)` "$1" $dbUrl }}
{{ end }}

<!-- Construct full API URL -->
{{ $fullApiUrl := printf "%s/%s/%s" $dbApiBase $dbType $dbUuid }}

<!-- Fetch data from API -->
{{ $remoteResource := resources.GetRemote $fullApiUrl }}

{{ if $remoteResource }}
    {{ $dbFetch := $remoteResource | transform.Unmarshal }}

...

可愛的效果,這樣是不是看書的動力更足了呢 ┌( ´_ゝ` )┐

8.9
突如其来的寂静笼罩了地球。这事实上比噪音更加可怕。有一会儿,什么也没有发生。 巨大的飞船一动不动地挂在空中,覆盖了地球上的每个国家。 在黯然退场之前,地球首先被改造成了最终极的声音重放器件,这是有史以来建造过的最伟大的播音系统。但伴之而来的不是演奏会,不是音乐,没有开场号曲,而仅仅是一条简短的信息。 “地球人,请注意了。”一个声音说,这声音堪称完美,仿佛来自四声道系统,完美得无懈可击,失真度低得能让勇敢的男人洒下眼泪。 “这里是银河超空间规划委员会。诸位无疑已经知道,银河系边远地区的开发规划要求建造一条穿过贵恒星系的超空间快速通道,令人遗憾的是,贵行星属于计划中预定毁灭的星球之一。毁灭过程将在略少于贵地球时间两分钟后开始。谢谢合作。”
book


總之,目前的功能和樣式與我的期望沒什麽大差別了。希望還是專注於内容而不是站點本身吧(。另外 Git 真是個好東西,完全彌補了差記性,很值得一番研究……

All rights reserved
Last updated on Apr 09, 2025
Built with Hugo
Theme Stack designed by Jimmy

TABLE OF CONTENT