今天要解的問題

kiwi-walk 是我用 Hugo 架、部署在 Cloudflare Pages 的部落格。鐵人賽 30 天結束後,維持這習慣繼續更新,開了一個系列「Kiwi 的每日筆記」(series 代號 kiwi-daily),Day 的編號接著鐵人賽往下算。

系列頁 /series/kiwi-daily/ 原本和其他系列頁一樣,是一長串由舊到新的文章卡片,分頁顯示。

對一個每天一篇的連載來說,這個版面有些不習慣:

  • 看不出哪天有寫、哪天斷了
  • 看不出這些文章分別是工程、讀書還是看展
  • day 的數字不是很重要
  • 去除掉系列的內部串連
  • 加入月曆,每天的 domain logo

「domain logo」指的是每個分類(engineer、books、art……)各自的 kiwi logo,站上的分類徽章已經在用。改完之後長這樣:

月曆在左欄,10/17 是今天以淡色底標示,選取的 10/24 有外框;右欄列出 Day39 到 Day43 五張文章卡片,Day40 有外框

想法與取捨

上面這些需求有三處要先講清楚,Claude 開工前先問了:

  • 月曆放哪裡:放在 /series/kiwi-daily/ 頁首,而且取代原本的卡片列表,不是疊在上面。另一個選項是塞進 /series/ 總覽的那張卡片裡,沒有選。
  • 「去除掉系列的內部串連」:指文章底部的上一篇/下一篇。原本系列文章的上下篇會在系列內走 Day N−1、Day N+1;這次 kiwi-daily 的文章改回在同分類內切換。鐵人賽系列維持原樣,那 30 篇是照順序讀的。
  • 點一天之後:選了「在月曆下方展開那篇的預覽」,而不是直接跳到文章。

先看畫面,再寫程式

方案寫好之後我沒有直接放行,而是說「給我看畫面」。第一張 mockup 之後又來回七次,每一次的起點都是我看了畫面後的一句話:

我說的改成
這樣要如何跳轉到前幾個月一次一個月,標題兩側 ‹ ›,月份記在網址 #2026-10
文章會有 cover 嗎預覽加上封面
會是跟 engineer 裡面的 list 一樣長相嗎預覽直接用文章列表的卡片
dayN in the corner 可以不用預覽卡片不放右上角的 Day
月曆針對今天需有特色今天要和其他日子不同
不需要有 “今天” 的文字拿掉「今天」兩個字
用底色標示今天,用框框表示日期的選取今天=淡色底,選取=外框

最後一輪把兩種狀態分開了。前一版的今天是實心圓日期加外框,而選取中的日子也是外框。改成「今天」用底色、「選取」用框框之後,兩者可以疊在同一格(今天就是選取的那天),也可以各在一格。

做出來之後:月曆太大,改成兩欄

第一版是月曆佔滿整個內文寬度,預覽在下面。實際做出來之後我的反應是:

月曆太大了,做成兩欄,左邊月曆,右邊顯示,顯示的話,需顯示此天的前後兩天,共五篇

960px 寬的版面,七欄的格子每格超過 100px,一個月五列就是一整個螢幕,點了日子還要往下捲才看得到文章。改成左欄 340px 的小月曆、右欄列文章,月曆在桌機上固定在畫面裡(sticky),右邊捲動時它不會跑掉。

格子縮小之後,原本格子裡「Day35」這行小字放不下(再小就低於 11px),所以 Day 移回卡片右上角。這和前面「dayN in the corner 可以不用」是相反的;當時拿掉是因為格子裡已經有 Day,現在格子沒有了,就放回卡片上,並在交付時講明。

接著右欄從「前兩後二」改成「前一後三」:

改成前一後三的貼文

我實作時加了一個沒人要求的行為:選到最後幾天時,後面不夠三篇,就往前補,維持五篇。這個被打回來:

剛發現最後幾天的,沒有遵守 前1後3,若是後面沒有文章,不需要填補前面的

「固定五篇」是 Claude 自己加的,我沒有要求,所以拿掉。現在選最新一篇時,右欄就只有前一篇和它自己;選第一篇時,則是它和後面三篇。

最後一個調整是頁首的介紹,原本兩行半的說明,我要求縮成一句「2026 iThome 鐵人賽 30 天後的每日續更」。

手機上兩欄疊成一欄:月曆在上,五張卡片在下,卡片保留小封面但拿掉摘要。

手機版:月曆在上,10/24 有外框;下面五張卡片 Day39 到 Day43,只有封面、分類、標題和日期

實作

月曆的格子在 Hugo 模板裡算好

月曆是 layouts/partials/series-calendar.html,整個月的格子都在 build 時產生,JavaScript 只負責切換顯示。先把系列文章依日期分組,再算出從第一篇到最後一篇橫跨幾個月:

{{- $posts := .RegularPages.ByDate }}
{{- with $posts }}

{{- /* date (2006-01-02) → posts on that day */}}
{{- $byDate := dict }}
{{- range $posts }}
  {{- $d := .Date.Format "2006-01-02" }}
  {{- $byDate = merge $byDate (dict $d ((index $byDate $d) | default slice | append .)) }}
{{- end }}

{{- $first := index $posts 0 }}
{{- $last := index $posts (sub (len $posts) 1) }}
{{- $start := time.AsTime (printf "%s-01T00:00:00+08:00" ($first.Date.Format "2006-01")) }}
{{- $nMonths := add (mul (sub $last.Date.Year $first.Date.Year) 12) (sub (int $last.Date.Month) (int $first.Date.Month)) 1 }}

Hugo 的模板沒有 while 迴圈,所以先算出月數 $nMonths,再用 seq 跑。$byDate 是「日期字串 → 那天的文章」,同一天有兩篇也放得下。$start 是第一篇那個月的 1 號,時區寫死 +08:00,和文章 front matter 的時區一致。

每個月的格子:

{{- range $i := seq (sub $nMonths 1) 0 }}
{{- $m := $start.AddDate 0 $i 0 }}
{{- $ym := $m.Format "2006-01" }}
// ...
    {{- /* Monday-first: Go's Weekday has Sunday = 0. */}}
    {{- $lead := mod (add (int $m.Weekday) 6) 7 }}
    {{- range seq $lead }}<span class="cal-blank" aria-hidden="true"></span>{{ end }}
    {{- range $d := seq ($m.AddDate 0 1 -1).Day }}
    {{- $date := printf "%s-%02d" $ym $d }}
    {{- with index $byDate $date }}
    {{- $p := index . 0 }}
    {{- $info := partial "category-info.html" $p }}
    // ...
    <a class="cal-day has-post" href="{{ $p.RelPermalink }}" data-date="{{ $date }}"
       aria-label="{{ int $m.Month }}/{{ $d }}{{ with $p.Params.day }} Day{{ . }}{{ end }}:{{ $p.Title | plainify }}"

seq (sub $nMonths 1) 0 倒著數,所以最新的月份排最前面。一週從星期一開始,Go 的 Weekday 星期日是 0,(weekday + 6) % 7 換成星期一是 0,就是月初前面要空幾格。這個月有幾天,是「下個月 1 號往回一天」的 .Day。

有文章的格子是一個真的 <a>,連到那篇文章。沒有 JavaScript 時它就是一般連結,所有月份從新到舊疊著顯示,頁面一樣能用。Day 寫在 aria-label 裡,螢幕閱讀器讀得到。

分類 logo 怎麼找,原本寫在分類徽章 category-chip.html 裡:先看 categories taxonomy,再看檔案在 content/blogs/<key>/ 的哪一層,最後退回 section。月曆也要同一套判斷,所以把它抽成 category-info.html,回傳 key、分類設定和 logo 網址,兩邊都呼叫它:

{{- $cat := index site.Params.categories $key -}}
{{- $logoURL := "" -}}
{{- if $cat -}}
  {{- $logo := "" -}}{{- if not (hasPrefix $cat.logo "http") -}}{{- $logo = resources.Get $cat.logo -}}{{- end -}}
  {{- if $logo -}}{{- $logoURL = $logo.RelPermalink -}}{{- else -}}{{- $logoURL = $cat.logo | absURL -}}{{- end -}}
{{- end -}}

{{- return (dict "key" $key "cat" $cat "logo" $logoURL) -}}

抽出來之後,改動前後各 build 一次整站比對:522 頁的 HTML 完全相同,確定分類徽章沒有被這次重構影響。

文章卡片放在 <template>

「右邊顯示的卡片要和列表一樣」,最直接的做法就是不要另外寫一份卡片:每一天的卡片用列表同一個 partial 在 build 時產生,放進 <template>,等使用者點了再複製出來。

{{- range $date, $ps := $byDate }}
<template id="cal-post-{{ $date }}" data-date="{{ $date }}">
  {{- range $ps }}
  {{- partial "list-entry.html" (dict "page" . "list" $term "class" "post-entry" "isSeries" true "showDay" true) }}
  {{- end }}
</template>
{{- end }}

<template> 的內容不會顯示、也不會載入裡面的圖片,所以每一篇的卡片都放進頁面,也不會一開始就把每張封面都抓下來。list-entry.html 原本用 isSeries 同時決定「去掉標題的 DayN 前綴」和「右上角放 Day」,這次多一個 showDay,沒給的時候等於 isSeries,其他系列頁的行為不變。

點一天:前一後三,不往前補

點擊、換月、標今天都在 assets/js/series-calendar.js。所有有文章的日期排好序放在 postDates,點了某天就從裡面切一段:

var BEFORE = 1; /* posts listed before the selected day */
var AFTER = 3;  /* and after it */

function select(date) {
    var at = postDates.indexOf(date);
    // ...
    /* One before, the day, three after; fewer near either end. */
    var lo = Math.max(0, at - BEFORE);
    var size = at + AFTER + 1 - lo;
    var frag = document.createDocumentFragment();
    postDates.slice(lo, lo + size).forEach(function (d) {
        var tpl = document.getElementById('cal-post-' + d);
        if (!tpl) {
            return;
        }
        var cards = tpl.content.cloneNode(true);
        if (d === date) {
            cards.querySelectorAll('article').forEach(function (a) {
                a.classList.add('is-selected');
            });
        }
        frag.appendChild(cards);
    });
    preview.replaceChildren(frag);
}

slice 超過陣列尾端時自己會停,所以選到最新一篇時只剩前一篇和它自己,不需要特別處理。前一版「往前補到五篇」的寫法是 Math.min(at - BEFORE, postDates.length - size),就是那個 min 讓視窗往前滑,拿掉它就是現在的行為。選到的那天,卡片加上 is-selected,CSS 給它系列的強調色外框。

今天要在瀏覽器裡算

網站是 build 好的靜態檔,build 的那天不是讀者打開的那天,「今天」只能在瀏覽器算。讀者不一定在台灣,所以固定用台北時間:

/* en-CA formats as YYYY-MM-DD. */
var today = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date());

// ...
/* Today's post, else the latest one before today, else the first. */
function bestDate(dates) {
    var best = null;
    dates.forEach(function (d) { if (d <= today) { best = d; } });
    return best || dates[0] || null;
}

en-CA 的日期格式剛好是 2026-10-17,和格子上的 data-date 一樣,字串直接比大小就是日期比大小。這個站會先排程好未來幾天的文章(buildFuture 開著,排程的文章 build 時就上線),所以打開頁面時預設選的是「今天那篇,沒有就今天之前最近的一篇」,不是最新一篇。

換月時用 history.pushState 改網址的 #2026-10,而不是直接改 location.hash:後者會讓瀏覽器捲到那個 id 的位置,每按一次 ‹ 頁面就跳一下。另外聽 popstate,按上一頁會回到前一個月。

文章底部的上下篇

layouts/partials/post_nav_links.html 原本是:有系列就一律改走系列順序。現在看系列的 _index.md:

{{- with (.GetTerms "series") }}
{{- $series := index . 0 }}
{{- if ne $series.Params.seriesPrevNext false }}
{{- $pages = $series.Pages.ByDate }}
{{- end }}
{{- end }}

content/series/kiwi-daily/_index.md 設了 seriesPrevNext: false,它的文章就保留前面算好的同分類清單;鐵人賽沒設,照舊走 Day N±1。同一個檔案也設了 calendar: true(畫月曆)和 hideAutoList: true(不畫卡片列表)。系列頁不再分頁,原本的 /series/kiwi-daily/page/2/ 在 _redirects 裡 301 回系列頁。

踩到的坑

上線後月曆沒反應:CSP 擋掉 inline script

月曆上線後,正式站的系列頁長這樣:

正式站上的系列頁:沒有左右欄,11 月和 10 月的月曆上下疊著,沒有箭頭,也沒有右側的文章

這正是「沒有 JavaScript」時的樣子:所有月份疊著,沒有 ‹ ›,點日子直接進文章。本機 hugo server 一切正常。打開正式站的 console:

Executing inline script violates the following Content Security Policy directive 'script-src 'self' https://www.googletagmanager.com https://sean22492249-gmail-com.disqus.com https://*.disquscdn.com https://static.cloudflareinsights.com'. Either the 'unsafe-inline' keyword, a hash ('sha256-ZWsHNtTjZSXAU59kpmNWnRCft60SSzaymoazMaxg/Ds='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.

月曆的 JavaScript 第一版是直接寫在 partial 最後的 <script> 裡。同一段時間,站上另一項改動把全站所有 inline script(GA、主題切換、漢堡選單、圖片放大……)都搬成 assets/js/ 底下的檔案,然後把 Content-Security-Policy 從「只回報」改成強制,script-src 不含 'unsafe-inline'。那邊搬的時候月曆還不存在,月曆這邊寫的時候 CSP 還只是回報模式,兩邊各自測都沒問題,合在一起 inline script 就被擋了。本機看不出來,是因為 CSP 寫在 Cloudflare Pages 的 static/_headers,hugo server 不會送這個標頭。

修法是照其他 script 的做法,把它搬成 assets/js/series-calendar.js:

{{- $calJS := resources.Get "js/series-calendar.js" | minify | fingerprint }}
<script src="{{ $calJS.RelPermalink }}" integrity="{{ $calJS.Data.Integrity }}" crossorigin="anonymous"></script>

fingerprint 讓檔名帶內容的雜湊,integrity 讓瀏覽器檢查檔案沒被換過;同網域的檔案符合 script-src 'self'。

驗證時,為了不再被「本機沒有標頭」騙一次,用一個小 Python 伺服器送出 _headers 裡那一行 CSP,再開 build 好的頁面:月曆會動、console 沒有錯誤。另外掃了一次 build 出來的所有 HTML,除了 JSON-LD 之外已經沒有 inline 的 <script>。

小結

/series/kiwi-daily/ 現在是月曆:一眼看出每天的進展,多少的工程、書與展的書寫。點擊可以查看,那時候的我在為什麼忙碌著。

這次的版面幾乎每一項都是看了畫面才定的:要不要封面、Day 放哪裡、今天和選取怎麼分、月曆多大。還是要有畫面跟操作,我才能明確知道我要什麼,希望之後會慢慢能透過文字去想像。