给这个博客搭三级分类(板块→分类→文章)时趟出来的完整路径,包含主题不支持的功能怎么覆写、URL 结构变更怎么不伤旧链接。全部配置和代码都是线上运行的原件。

1. 目录树即 URL 树

Hugo 的层级完全由 content 目录决定,_index.md 让每层变成可浏览的页面:

content/posts/
├── _index.md                    # /posts/
├── tech/_index.md               # /posts/tech/
├── tech/prompt-engineering/_index.md
├── tech/prompt-engineering/patterns/_index.md
└── tech/prompt-engineering/patterns/core-patterns.md
                                # /posts/tech/prompt-engineering/patterns/core-patterns/

_index.md 只需要最简 front matter:

---
title: 模式与技巧
date: 2026-10-17T10:50:00+08:00
description: 提示词的可复用结构模式。   # 会显示在分类页头部
---

排序控制:列表页按 date 倒序。想让子分类排在文章前面,把子分类 _index.md 的日期设为比文章晚即可(同一页面里 union 合并后一起排序)。

2. 日期的一个隐形坑

buildFuture = false 时,front matter 日期在未来 → 文章静默不构建,无警告无报错。排查方式:

hugo --gc 2>&1 | grep Pages   # 页面数对不上就查日期

本站两次"文章消失"都是这个原因(写作时把发布时间填到了未来)。

3. 菜单下拉:主题不支持,覆写 partial

PaperMod 的菜单循环不渲染 children——嵌套菜单项会被静默丢弃。解法:在站点级覆写 layouts/_partials/header.html(站点 layouts 优先于主题),把父项渲染成原生 <details>:

{{- if .HasChildren }}
<details class="menu-item menu-item-has-children">
  <summary class="menu-parent" title="{{ .Title | default .Name }}">
    <span>{{- .Name -}}<svg class="menu-caret">...</svg></span>
  </summary>
  <ul class="submenu">
    {{- range .Children }}
    <li><a href="{{ .URL | absLangURL }}">
      <span>{{- .Name -}}</span></a></li>
    {{- end }}
  </ul>
</details>
{{- else }} ... {{- end }}

选 <details> 而不是 <a>+JS 的理由:点击展开是浏览器原生行为,Safari/手机/无 JS 环境都不失效(这是本站四轮 bug 换来的结论,见《评估驱动》)。

4. 卡片上的分类标签:从路径推导

列表卡片要显示"这篇属于哪个板块",Hugo 的 .FirstSection 会返回 posts 而不是 tech(它取 home 下第一层)。可靠做法是拆文件路径:

{{- $l1seg := index (split .File.Path "/") 1 }}   {{/* posts/tech/... → tech */}}
{{- $l1page := site.GetPage (printf "/posts/%s" $l1seg) }}
<span class="entry-cat cat-{{ $l1seg }}">{{ $l1page.Title }}</span>

注意 .File.Path 是相对 content 根的完整路径(含 posts/ 前缀),索引从 1 开始取第二段。

5. URL 变更的无损迁移:aliases

目录一挪,URL 全变。旧链接的救星是 front matter 里的 aliases(Hugo 会在旧路径生成 meta-refresh 跳转页)。批量迁移脚本:

import re, os

def merge(path, new_aliases):
    text = open(path).read()
    m = re.match(r'^---\n(.*?)\n---\n', text, re.S)
    fm = m.group(1)
    am = re.search(r'^aliases:\s*\[(.*?)\]\s*$', fm, re.M)
    if am:   # 已有 aliases → 合并去重
        existing = [a.strip().strip('"\'') for a in am.group(1).split(',') if a.strip()]
        for a in new_aliases:
            if a not in existing: existing.append(a)
        fm = fm[:am.start()] + 'aliases: [' + ', '.join(f'"{a}"' for a in existing) + ']' + fm[am.end():]
    else:
        fm += f'\naliases: [{", ".join(chr(34)+a+chr(34) for a in new_aliases)}]'
    open(path, 'w').write('---\n' + fm + '\n---\n' + text[m.end():])

for root, _, files in os.walk('content/posts/tech'):
    for f in files:
        if f.endswith('.md') and f != '_index.md':
            p = os.path.join(root, f)
            old = '/posts/' + p[len('content/posts/tech/'):].replace('.md', '/')  # ⚠ 注意去掉 .md
            merge(p, [old])

注释里那个 ⚠ 是真实的血泪:第一版忘了 .replace('.md', ''),生成的别名带 .md 后缀,导致文章级旧链接全部 404 而分类页正常——排查了很久。迁移后必须抽查:

curl -s https://site/posts/old-path/ | grep -o 'content="0; url=[^"]*"'
# 每个旧 URL 都应输出指向新路径的跳转

6. 面包屑与 taxonomy

ShowBreadCrumbs = true 自动生成 首页 / posts / tech / … 层级链;categories/tags 平铺 taxonomy 与目录树互补(树管导航,标签管横向关联)。两者不冲突。


这套结构的全部代码在本站仓库的 layouts/ 里原样运行。静态站的"动态"能力,大部分藏在目录结构和覆写模板里。