目录

折腾 | 用数据文件优化短代码的使用

本文解决的是 Hugo 页面里结构化内容难维护的问题:把原本塞在 Markdown 里的 Shortcode 参数迁移到 Data Files 中,让内容数据、页面结构和展示代码分离。现在这个思路不只用在书影记录,也扩展到了书刊、影剧、摘录链接和足迹年度表格这些页面里。

适用场景

适合在 Hugo 中维护书单、影单、友链、项目列表、旅行足迹、年度清单等重复内容。只要内容条目的字段相对固定、数量会持续增加,就可以考虑用 Data Files 替代一条条手写 Shortcode。

如果你的页面只有三五条内容,直接写 Markdown 最简单;但如果一年下来会积累几十本书、几十部电影、很多条视频链接,甚至还要跨年份展示,那就很适合把「数据」从正文里拆出去。

背景

网站上原来有一个书影游页面,用来记录我看过的书和电影。为了显示效果,我一开始写了一个 mediacard Shortcode,每条记录都在 Markdown 里调用一次。

大概长这样:

1
2
3
4
5
6
{{< mediacard title="大唐双龙传" 
    coverurl="..." link="..." 
    author="黄易" type="24-01" 
    starscore="★★★" greystar="☆☆" 
    intro="..." 
>}}

刚开始记录不多的时候,这种方式还挺直观。但后来问题越来越明显:

  • Markdown 文件越来越长,一条记录就是一大段 Shortcode;
  • 书和电影混在页面里,想按年份、月份整理很麻烦;
  • 如果卡片样式要改,虽然可以改 Shortcode,但字段结构本身还是散在正文里;
  • 后来又想记录文章、视频、播客摘录,继续堆 Shortcode 会越来越乱;
  • 足迹页面也有类似问题,年度、月份、城市、活动这些信息本质上也是结构化数据。

所以这次折腾的核心不是「写一个更厉害的短代码」,而是反过来想:页面正文应该只描述页面,重复条目应该变成数据。

现在的数据文件结构

现在本站用到的数据文件主要放在 data/ 目录下,其中和这篇文章最相关的是四个:

1
2
3
4
5
data/
├── books.yaml         # 书刊清单
├── movies.yaml        # 影剧清单
├── mental_links.yaml  # 精神食粮 / 摘录链接
└── footprint.yaml     # 足迹年度表格:出游、书影游、海报墙

这里有一个和最初版本很不一样的变化:现在数据不再是一个扁平列表,而是优先按年份分组。

最早我只是把所有书塞进 books.yaml,再靠 type: 24-08 这种字段过滤年份。现在的结构更接近这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
"2025":
  - title: 大唐双龙传
    coverurl: https://neodb.social/m/book/2021/09/17dd81c9b9-0e2d-47fe-9b08-30b4c732249f.jpg
    link: https://neodb.social/book/7TpGOqhdp2Q87oZn8wj0xc
    author: 黄易
    type: 25-01
    starscore: "★★★"
    greystar: "☆☆"
    intro: 以隋末唐初群雄割据的动荡局面为背景,叙述了扬州城里的寇仲和徐子陵由两个小混混成长为两个武林绝顶高手的历程。一言以蔽之,大唐双龙奇遇记。
"2024":
  - title: 某霍格沃茨的魔文教授
    type: 24-01

这种结构有两个好处:

  1. 文件打开后,一眼就能定位到某一年;
  2. 渲染年度页面时,可以直接 index site.Data.books "2025",不用每次遍历全量数据再筛选。

当然,type 字段还是保留了,因为它承担的是「月份归档」的职责。也就是说,年份由外层 key 负责,月份由 type: YY-MM 负责。

四类数据分别管什么

1. books.yaml:书刊清单

books.yaml 用来放读过的书、网文、杂志等,核心字段是:

1
2
3
4
5
6
7
8
9
"2025":
  - title: 鬼吹灯
    coverurl: https://neodb.social/m/book/2021/11/018eff31ee-9fb7-4f62-91d1-5f5b609d64c3.jpg
    link: https://book.douban.com/subject/34452623/
    author: 天下霸唱
    type: 25-06
    starscore: "★★★★★"
    greystar: ""
    intro: 鬼吹灯是一个系列形式的文字冒险故事,以一本家传的秘书残卷为引。

这里我没有把字段设计得特别复杂,基本还是围绕一张卡片需要什么来定:封面、链接、作者、月份、评分和短评。这样数据文件虽然朴素,但和页面展示是一一对应的。

2. movies.yaml:影剧清单

movies.yamlbooks.yaml 结构几乎一样,只是 author 在这里更像「导演 / 主创」。保持字段一致有一个现实好处:书刊和影剧可以共用同一个渲染 partial,不需要写两套卡片模板。

1
2
3
4
5
6
7
8
9
"2025":
  - title: 嗜血法医 第一季 (2006)
    coverurl: https://neodb.social/m/movie/2021/11/27101089f8-d0e8-4532-b500-3b1a6412617c.jpg
    link: https://neodb.social/tv/season/2GvfGAZ9FdHG3qwhLpNANz
    author: Michael Cuesta/托尼·戈德温
    type: 25-02
    starscore: "★★★★"
    greystar: "☆☆"
    intro: 对这种主角略变态且有心理剖析的剧很有兴趣。

字段统一之后,模板里只需要接收一个 items 列表,然后按卡片样式循环渲染即可。对我来说,这比为书和电影分别维护两个 Shortcode 更舒服。

3. mental_links.yaml:文章、视频、播客摘录

后来我发现,「书影游」其实不只应该包括书和电影。平时看到的一些文章、视频、播客,也很值得留下索引,所以又单独拆了一个 mental_links.yaml

它的字段更轻一些:

1
2
3
4
5
6
"2025":
  - type: 25-08
    category: 自媒体摘录
    title: 一个值得回看的内容标题
    url: https://example.com/
    source: 某位作者

这里没有封面、评分和短评,因为链接摘录更像一个「稍后回看」列表。我只关心它是什么类型、叫什么、来自哪里、链接在哪。

4. footprint.yaml:足迹年度表格

footprint.yaml 则是另一个方向:它不是卡片流,而是年度表格。现在里面按分类组织:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
travel:
  "2025":
    title: "2025 出游"
    rows:
      - month: "一月"
        events:
          - city: "香港"
            activity: "钓鱼翁徒步"
      - month: "二月"
        events:
          - city: "厦门"
            activity: "小队两日游"
reading:
  "2025":
    title: "2025 书影游"
    rows:
      - month: "一月"
        events: []
poster:
  "2025":
    title: "2025 海报墙"
    rows:
      - month: "一月"
        events: []

这里的关键是 travel / reading / poster 三个分类。它们共用相同的年度表格结构:每一年有一个 title,下面是十二个月,每个月可以有多个 events

这样做之后,足迹首页、年度出游、年度书影游、海报墙这些页面就有了同一套数据底座。页面要怎么展示是一回事,但「这一年有哪些月份、每个月有哪些事件」这件事,不再散落在多个 Markdown 文件里。

页面结构怎么变了

数据拆出去以后,Markdown 页面就清爽多了。以 content/pages/footprint/2025/reading.md 为例,它现在主要只保留页面元信息和一小段说明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
+++
title = "2025 书影游"
description = "2025 年读过的书、看过的影剧和收藏的内容摘录。"
date = "2025-01-01"
layout = "footprint-year"
year = 2025
category = "reading"
comment = false
toc = false
hiddenFromHomePage = true
hiddenFromSearch = false
aliases = ["/pages/mentalfood/"]
+++

## 2025 年书影游

这一页汇总 2025 年读过的书刊、看过的影剧,以及值得回看的文章、视频和播客摘录。

这里最重要的是两个 front matter 字段:

  • year = 2025:告诉模板当前页面要拿哪一年的数据;
  • category = "reading":告诉模板当前页面属于足迹里的哪个分类。

也就是说,页面不再负责列出所有书和电影,只负责声明「我是 2025 年的书影游页面」。真正的数据读取和渲染,交给 layout 和 partial。

底层模板怎么串起来

1. 年度足迹页面读取 data/footprint.yaml

layouts/pages/footprint-year.html 会先从 front matter 里拿到年份和分类:

1
2
3
4
{{- $year := printf "%v" (.Params.year | default "2025") -}}
{{- $category := .Params.category | default "travel" -}}
{{- $categoryData := index site.Data.footprint $category | default dict -}}
{{- $data := index $categoryData $year | default dict -}}

这里其实就是两次 index:先通过 travel / reading / poster 找到分类,再通过 2025 / 2024 / 2023 找到年份。

如果是出游或海报墙页面,模板会把 $data.rows 渲染成年度表格;如果是 reading 页面,则会走另一条分支:

1
2
3
4
5
6
{{- if eq $category "reading" -}}
  <section class="footprint-detail-flow" aria-label="书影游记录">
    {{- dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
    {{- partial "mentalfood/year.html" (dict "year" $year) -}}
  </section>
{{- end -}}

这段逻辑的意思是:reading.md 自己的 Markdown 正文照常渲染,然后再把这一年的书、影、摘录都交给 mentalfood/year.html 这个 partial 来补上。

2. mentalfood/year.html 聚合三份数据

layouts/partials/mentalfood/year.html 只关心一件事:给定一个年份,把这一年的书刊、影剧和摘录分别取出来。

1
2
3
4
{{- $year := printf "%v" (.year | default "2025") -}}
{{- $books := index site.Data.books $year | default slice -}}
{{- $movies := index site.Data.movies $year | default slice -}}
{{- $links := index site.Data.mental_links $year | default slice -}}

拿到数据后,它会渲染三个折叠块:

  • 书刊:partial "mentalfood/media-list.html"
  • 影剧:同样复用 partial "mentalfood/media-list.html"
  • 摘录:partial "mentalfood/link-list.html"

我比较喜欢这个拆法:年度页面只负责「这是哪一年」,year.html 负责「这一年有哪些数据」,具体列表长什么样再交给更小的 partial。每一层都只做一件事,后面要改样式或新增字段时比较不容易牵一发动全身。

3. media-list.html 统一渲染书和影

书和影之所以能共用 media-list.html,是因为它们的字段结构保持一致。渲染时大概就是循环 items

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{{- $items := .items | default slice -}}
{{- $empty := .empty | default "本年度暂无记录。" -}}
{{- $count := 0 -}}
<div class="media-list">
  {{- range $items -}}
    {{- $title := .title | default "" -}}
    {{- if $title -}}
      {{- $count = add $count 1 -}}
      {{- $cover := .coverurl | default "" -}}
      {{- $link := .link | default "" -}}
      <article class="media">
        {{- if $cover -}}
          <div class="media-cover" style="background-image:url({{ $cover }})" aria-hidden="true"></div>
        {{- else -}}
          <div class="media-cover media-cover-placeholder" aria-hidden="true"><span>{{ substr $title 0 1 }}</span></div>
        {{- end -}}
        <div class="media-meta">...</div>
      </article>
    {{- end -}}
  {{- end -}}
</div>
{{- if eq $count 0 -}}
  <p class="mentalfood-empty">{{ $empty }}</p>
{{- end -}}

这样一来,新增一本书或一部电影时,我只需要补 YAML;卡片怎么排版、封面怎么显示、评分怎么着色、没有封面时怎么兜底,都是模板负责。

4. medialist 仍然保留,但已经不是主入口

现在年度书影游页面主要走 footprint-year.html + mentalfood/year.html 这套新结构。不过原来的 medialist Shortcode 还保留在主题里:

1
2
3
{{- $dataFile := .Get 0 -}}
{{- $yearPrefix := .Get 1 -}}
{{- $mediaData := index site.Data $dataFile -}}

它最初的使用方式是:

1
{{< medialist "books" "25" >}}

不过要注意:medialist 是早期「扁平数据 + 年份前缀过滤」阶段留下的过渡层,而现在主数据已经改成了外层按年份分组。因此新页面不再依赖它,主要使用 layout + partial 直接读取 site.Data.books "2025" 这种结构。这样 Markdown 里就不用再写展示逻辑,页面也更接近「声明年份和分类」的角色。

为什么要按年份分组

最开始用 Data Files 时,我只想着「不要把数据写在 Markdown 里」。当时把 books.yamlmovies.yaml 做成一个大列表,再用 type 前缀筛选年份,已经比原来好很多。

但随着数据越来越多,这个结构又开始显得有点笨:

  • CMS 表单里不好按年份折叠;
  • 模板每次都要遍历全量数据;
  • 想检查某一年有没有漏记,需要在长列表里搜索;
  • 后续足迹页面本来就是年度维度,书影数据也应该跟它对齐。

所以现在改成了外层年份 key:"2025" -> 列表。这样既方便 Hugo 模板 index,也方便 Pages CMS 把每一年做成独立字段。

Pages CMS 也接进来了

这次数据结构调整还有一个现实原因:我现在已经把博客的很多编辑入口搬到了浏览器里。.pages.yml 里把这几份数据文件都配置成了可编辑表单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
- name: data
  label: Data 数据
  type: group
  items:
    - name: books
      label: 书刊清单
      path: data/books.yaml
    - name: movies
      label: 影剧清单
      path: data/movies.yaml
    - name: mental_links
      label: 精神食粮链接
      path: data/mental_links.yaml
    - name: footprint
      label: 足迹表格
      path: data/footprint.yaml

并且每类数据都有对应的组件定义。例如书刊和影剧都要求 type 使用 YY-MM 格式:

1
2
3
pattern:
  regex: "^[0-9]{2}-[0-9]{2}$"
  message: "请使用 YY-MM 格式,例如 25-09。"

这一步挺关键的。只靠 YAML 文件当然也能维护,但字段一多之后,人总会写错。把它接进 Pages CMS 后,常用字段变成表单,type 这类容易写错的字段加上格式校验,日常更新就顺手很多。

也就是说,Data Files 解决的是「数据不要混在正文里」;Pages CMS 进一步解决的是「编辑数据时不要每次都手写 YAML」。

最终效果

现在新增一条书影记录,大致流程是:

  1. 打开 Pages CMS 的 Data 数据入口;
  2. 选择 books.yamlmovies.yaml
  3. 找到对应年份,比如 2025
  4. 新增一条记录,填标题、封面、链接、作者、月份、评分和简评;
  5. 保存后由 Hugo 构建,年度书影游页面自动渲染出来。

新增一条出游足迹也类似:只需要改 data/footprint.yaml 里对应分类、年份、月份下的 events,页面表格会自动更新。

对我来说,最明显的变化是:Markdown 页面终于不用承担所有事情了。它只保留页面说明和少量结构,真正会持续增长的内容都进入了数据文件。后续无论是按年份展示、换卡片样式,还是把同一份数据复用到别的页面,都会轻松很多。

小结

这次折腾从一个很小的问题开始:书影记录页面里 Shortcode 太多,维护起来很烦。最初的解决方案是把书和电影拆到 data/books.yamldata/movies.yaml,再用 medialist 按年份筛选。后来随着网站结构继续演进,这套思路扩展成了现在的四份核心数据文件:

  • data/books.yaml:书刊;
  • data/movies.yaml:影剧;
  • data/mental_links.yaml:文章、视频、播客等摘录;
  • data/footprint.yaml:出游、书影游、海报墙的年度表格。

最终我更想保留的是这个边界:数据放在 Data Files,页面声明年份和分类,layout / partial 负责组织与渲染,Pages CMS 负责降低编辑门槛。

这种方案不一定适合所有博客,但对于个人站点里那些「会不断增加、字段又比较固定」的内容来说,确实舒服很多。它没有让 Hugo 变复杂,反而让 Hugo 静态站点最擅长的事情更清楚了:用朴素的数据文件,生成稳定、可迁移、容易维护的页面。

参考资料

(2025-08-28@深圳)