跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

定制站点

站点级配置:品牌、导航、布局、搜索、多语言、多版本、打印与 Agent 输出。

本栏目覆盖站点级配置:hugo.yml 里的参数、data/ 下的数据文件、assets/ 下的样式入口。单个页面的写法与 front matter 见创作内容

按改动目标查找

改动目标 对应页面
站名、Logo、favicon 品牌外观
配色、深浅色模式、字体 品牌外观
顶栏菜单与下拉 导航与菜单
侧栏宽度、图标密度、目录深度 布局与页面类型
首页与落地页 首页与落地页
全文检索与索引范围 全文检索
命令面板里的条目 命令面板
快捷键 键盘导航
新增一门语言 多语言
多版本站点与归档横幅 多版本
标签与分类 分类体系
编辑本页、最后修改、贡献者 仓库与页面信息
打印与整章导出 打印支持
llms.txt 与每页 .md 输出 Agent 支持
某个参数的类型与默认值 配置总览

评论、分析与部署需要接入外部服务,见维护管理

1 - 配置总览

主题真正会读的每一个站点参数:类型、默认值、去哪一页改。查参数从这里开始。

站点参数的唯一归属页。主题读取的每个键在下面某张表里有一行,给出类型、默认值与一句说明,并链接到讲它的指南页。指南页只给可粘贴的片段,不重复定义。页面级参数(front matter)见页面参数

表格按功能分组,每组一个 ##,锚点可以引用,例如 /zh/docs/customize/config/#sidebar默认值一栏空着表示主题没有默认值:不配置该功能就不生效。

hugo.yml 的分层

OINK 站点配置有四类键,改哪一层取决于改动目标:

例子 谁定义的
Hugo 原生顶层键 baseURL title languages markup outputs taxonomies module Hugo 本身,行为见 gohugo.io
params 顶层 logo offline_search github_repo version page_width comments 主题读取的站点级选项
params.ui.* navbar_enabled sidebar_width_min typography pager_types 外壳、导航与阅读界面
params.<运行时> mermaid plantuml drawio markmap 各内容运行时自己的开关与端点

最小的可用配置只需要前两层:

hugo.yml
title: 产品文档
baseURL: https://docs.example.com/
defaultContentLanguage: zh
enableGitInfo: true

module:
  imports:
    - path: github.com/pgsty/oink
  hugoVersion:
    extended: true
    min: 0.160.1

params:
  offline_search: true
  github_repo: https://github.com/example/product-docs

配置原则

  • 主题默认保守,只写要改的键。交互功能(本地搜索、图片缩放、评论、反馈、深浅色菜单)默认关闭,主题不替站点做策略决定。从一份「完整配置」逐条删减,比按需添加更容易留下用不上的键。

  • 没有主题总开关。不存在 oink.enabled,也没有 params.oink.* 命名空间,更没有在「Docsy 外壳」与「OINK 外壳」之间切换的选项。这一页查不到的开关即不存在。

  • 非法值告警并回退到文档里写明的默认值params.ui.typography: solarizedinvalid params.ui.typography "solarized" (allowed: technical | system) -- using "technical",站点照常构建;footer_style: thinpage_width: hugesection_index: grid 同理。一个笔误因此只降级一个设置,而不是让 hugo server 下每个 URL 都返回 HTTP 500。它也不会因此静悄悄上线:所有发布关卡都带 --panicOnWarning 构建,那条警告在那里仍然是硬失败。

  • 有一条警告保留取值而不是丢弃它。主题读出的 theme_color 若在它自己的画布上低于 AA 正文对比度(4.5:1),颜色照常生效 —— 自定义画布或品牌强制色是作者的决定 —— 但会说出来,并打印可以让它闭嘴的 ignoreLogs id。把它当建议而不是拒绝:要么换个更深的颜色,要么加一行配置,在你做出选择之前发布关卡会一直卡住构建。只有解析不出来的十六进制才会被真正丢弃,那种情况和其他非法值一样回退到默认配色。

  • 主题自身从不中断构建。它的模板里没有任何 errorf:每个非法值都走上面的告警并回退。需要外部端点的功能——PlantUML、Draw.io、Algolia——缺少端点时告警并保持关闭,因为主题不会代为连接公共服务;残缺的上游署名告警并略去整条声明,因为半条读起来和完整的一模一样。真正会中断构建的来自 Hugo 而非主题:解析不到目标的内容引用,以及低于 module.hugoVersion.min 的 Hugo 版本。

页面级覆盖优先级

Hugo 的 .Param 查找让大部分参数可以逐页覆盖,优先级从高到低:

  1. 页面自己的 front matter;
  2. 祖先分区 _index.md 里的 cascade(离页面越近越优先);
  3. 站点 params

写进 front matter 时要去掉 ui. 前缀。 站点上的 params.ui.scroll_spy 在页面里就写成 scroll_spy。front matter 里出现 ui: 块的话,里面的键没有人读,也没有人报错——某个设置看着没生效时,先对照页面参数核一遍键名。

content/docs/wide-reference.md
---
title: 宽版参考
page_width: wide
navbar_enabled: false
footer_style: slim
scroll_spy: true
---

分区级用 cascade 一次设定整棵子树:

content/docs/_index.md
---
title: 文档
cascade:
  type: docs
  footer_style: slim
  feedback: true
---

覆盖用于真实的内容差异。逐页重建一套视觉系统的配置,会在主题升级后失配。

三项 goldmark 前置

Hugo 不会 把主题模块的 markup 配置合并进站点,这三项必须写在站点自己的 hugo.yml 里,否则属性行、组件 HTML 与数学公式都不工作:

hugo.yml
markup:
  goldmark:
    parser:
      # 块级图片可以带属性行({caption=…}、编号图)
      wrapStandAloneImageWithinParagraph: false
      attribute:
        block: true
    renderer:
      # `{{% … %}}` 型 shortcode 输出的 HTML 必须保留
      unsafe: true
    extensions:
      passthrough:
        enable: true
        delimiters:
          block: [['\[', '\]'], ['$$', '$$']]
          inline: [['\(', '\)']]
  highlight:
    # 代码高亮用 class 输出,深浅色才能各用一套配色
    noClasses: false
  tableOfContents:
    endLevel: 4

attribute.block 时,{.fields} {.steps} {caption=…} 会原样显示成文字;缺 passthrough\(x\) 不会变成公式;缺 unsafe 时步骤与卡片的结构会被转义。

renderer.unsafe: true 同时允许 Markdown 正文里的原始 HTML 通过,面向的是受信任的作者,不是投稿过滤器。内容来自不可信来源时,审查应放在提交流程里。

站点身份与品牌

Hugo 原生顶层键:

title , string
站名,显示在顶栏、<title> 与页脚
baseURL , string
生产域名;子路径部署时带上路径段
copyright , string
版权行的兜底值,params.copyright 未设时按 HTML 原样渲染
enableGitInfo , boolean , defaultfalse
打开后才有「最后修改」与 commit 信息
enableRobotsTXT , boolean , defaultfalse
生成 robots.txt
enableEmoji , boolean , defaultfalse
允许 :smile: 简码

主题参数:

params.logo , string , defaulticons/logo.svg
品牌图标,可指向 assets/ 资源或 static/ 路径,见品牌外观
params.wordmark , string
横向字标;设置后顶栏用它替代「图标 + 站名」
params.description , string
站点描述,页面没有 description 时作为 meta 兜底
params.copyright , string 或 map
字符串按 Markdown 渲染;map 接受 authors from_year to_yearpresent 表示今年)
params.footer_center_info , string , defaultPowered by Oink
页脚中间的行内 Markdown,设为空字符串即隐藏
params.author , string 或 map
RSS 的作者;map 接受 nameemail
params.ui.theme_color , 字符串
#rgb/#rrggbb 十六进制色,为外壳的强调底着色;正文链接与行内代码不受影响 —— 见品牌外观
params.ui.theme_color_dark , 字符串 , default派生
强调色的暗色一半;省略时从 theme_color 提亮派生,直到在暗色画布上达到 AA

favicon 没有参数:主题按约定名扫描 static/favicon.ico favicon.svg favicon-NxN.png apple-touch-icon.png apple-touch-icon-NxN.png),见品牌外观

外壳类型与栏目根

外壳按 页面 type 生效,不看路径。文档可以放在任意目录,再用 cascade 给它 type: docs

params.ui.shell_types , list , default[docs, book, blog, swagger]
哪些 type 使用带侧栏的阅读外壳,见布局与页面类型
params.ui.docs_section , string , defaultdocs
文档栏目的根目录名,只用于导航解析
params.ui.blog_section , string , defaultblog
博客栏目的根目录名
params.ui.docs_sidebar_root , enum , defaultsection
section 时 docs 页的侧栏根是文档栏目;home 时是站点首页。非法值告警并回退
params.ui.quick_links , list , default[docs_section, blog_section]
命令面板空查询时列出的顶层菜单 identifier,见命令面板
params.ui.sidebar_root_enabled , boolean , defaulttrue
允许子分区用 sidebar_root_for: self 自成一棵侧栏树
params.ui.sidebar_root_menu , boolean , defaulttrue
侧栏顶部显示栏目切换器;只有一个入口时退化为普通链接
params.ui.section_index , enum , defaultlist
栏目首页子页列表样式:listcards,可按分区覆盖
params.ui.section_index_columns , integer , default2
section_index: cards 时的列数

博客

七个键决定博客栏目的样子。它们作用于 params.ui.blog_section 指定的栏目,每一个都能通过博客根目录的 front matter 或 cascade 按栏目覆盖。

params.ui.featured_image , enum , defaultnone
文章正文里怎么渲染自己的题图:none 不渲染,banner 在标题上方框出一张 16:9 的图,wash 把它铺在文章头部背后、只留十分之一的不透明度,hero 把它作为外壳自己的通栏背景铺开并把开头下移——单页与栏目列表页都一样。用的就是这一页在卡片与 og:image 里已经在用的那张图,两处不会打架。没有题图的文章在任何模式下都不渲染任何东西
params.ui.blog_index , enum , defaultlist
博客栏目列表页的形态:list 是行列表,cards 是内容卡片网格,卡片带 16:9 题图、日期与栏目行,以及三行摘要,table 是每篇一行的紧凑表格——整个栏目一次列全,不按年分组,也不分页。按年分组、分页与 manual_linklistcards 下行为一致
params.ui.blog_index_columns , integer , default3
blog_index: cards 时的列数;md 到 xl 之间恒为两列,md 以下一列,不受此值影响
params.ui.blog_index_size , integer , default12
listcards 索引每页的文章数;table 形态总是列全。12 能被 2、3、4 整除,卡片行不会缺角
params.ui.blog_index_toggle , boolean , defaultfalse
让读者从索引工具栏在列表、卡片、表格之间切换。默认关闭,因为它会把三种形态都放进文档——隐藏的那些不加载图片,但标记是真实存在的
params.ui.toc_style , enum , defaultfixed
右栏的呈现方式:fixed 是钉在视口上的面板,flow 是跟随内容流、从文章开头处开始、滚动后才钉住的宽面板
params.ui.toc_taxonomies , boolean , defaulttrue
右栏的分类词云。既没有目录也没有词云的右栏不会渲染任何东西

作者与系列是 taxonomy 而不是参数,见分类法写博客

params.ui.navbar_enabled , boolean , defaulttrue
是否渲染站点顶栏,可用页面顶层 navbar_enabled 覆盖,见导航与菜单
params.ui.navbar_autohide , boolean , defaultfalse
顶栏收到视口上方,指针进入唤醒区才出现;小于 768px 或粗指针时不生效
params.ui.footer_style , enum , defaultfat
fat 多列网格 + 版权行,slim 只有版权行,none 不渲染。非法值告警并回退
params.ui.dark_mode , boolean 或 map , defaultfalse
true 同时启用深色调色板与主题控件;只要控件写 dark_mode: { show_menu: true }
params.ui.breadcrumb , boolean , defaulttrue
面包屑;设为 false 关闭。顶层分区本来就省略只有一级的面包屑
params.ui.page_context_menu.enable , boolean , defaulttrue
标题旁的页面操作拆分按钮
params.ui.page_context_menu.assistant_links , boolean , defaultfalse
显示「在 ChatGPT / Claude 中打开」;读者点击时完整 URL 会离开本站
params.ui.page_context_menu.links , list , default[]
自定义外部操作,url 支持 {url} {title} {markdown_url} 占位符
params.ui.github_stars , string 或 number
顶栏 GitHub 徽标上的星数,本地常量,不发请求
params.ui.alt_site , map
单语言站在页脚显示的姊妹站链接,必填 label 与绝对 http(s)url

胖页脚的列数据来自 data/footer/<语言>.yaml,不是参数,见导航与菜单

侧栏

params.ui.sidebar_menu_compact , boolean , defaulttrue
只展开当前分支与邻近条目
params.ui.sidebar_menu_foldable , boolean , defaulttrue
允许读者展开/折叠分区
params.ui.sidebar_menu_truncate , integer , default2000
一个分区最多渲染的条目数,超出截断
params.ui.sidebar_cache_limit , integer , default500
站点页数超过它就复用共享导航标记,active 状态改由浏览器还原
params.ui.sidebar_width_min , integer , default220
桌面端拖拽调宽的下限,像素
params.ui.sidebar_width_max , integer , default480
拖拽调宽的上限,像素
params.ui.sidebar_item_overflow , enum , defaultellipsis
ellipsis 长标题省略,wrap 换行
params.ui.sidebar_icon_policy , enum , defaultall
图标密度:all 全部、groups 只有根与有子页的节点、none 全不显示。非法值警告并回落 all
params.ui.sidebar_expand_levels , integer , default2
默认展开的树层级数
params.ui.sidebar_headings , boolean 或 integer , defaultfalse
只对 type: book 生效:在侧栏当前行下展开标题分支;整数取值 2–4,true 等于 2
params.ui.sidebar_enabled , boolean , defaulttrue
左侧栏;设为 false 关掉,通常按页面而不是按站点设置
params.ui.taxonomy_icons , map
按分类复数名指定右栏分组图标,例如 tags: fa-solid fa-tags

侧栏怎么用见布局与页面类型;目录树本身由 content/ 的结构决定,见组织内容

目录 TOC

右栏大纲的层级由 Hugo 原生配置决定,主题只控制跟踪行为:

markup.tableOfContents.startLevel , integer , default2
Hugo 原生:收录的最高标题级别
markup.tableOfContents.endLevel , integer , default3
Hugo 原生:收录的最低标题级别
params.ui.scroll_spy , boolean , defaultfalse
滚动位置跟踪;设为 true 打开活动项高亮

单页隐藏大纲用 front matter notoc: true,见页面参数

翻页与页尾

页尾组件顺序固定为分享 → 反馈 → 页面信息 → 翻页 → 评论,五者独立开关;反向链接在右栏目录旁。

params.ui.share , list , default[]
页尾分享目标,按给定顺序渲染,取值来自 x bluesky mastodon facebook linkedin reddit hackernews telegram whatsapp line pinterest weibo chatgpt claude email copy。为空则不出现分享栏。每一项都是纯粹的 intent 链接——没有 SDK、没有 iframe、没有第三方脚本、没有分享计数,见写博客。未知目标告警并丢弃
params.ui.pager_types , list , default[docs, book, blog]
哪些 type 显示上一页/下一页;单页用 front matter pager: false 退出。未知 type 告警并丢弃
params.ui.annotation , boolean , defaulttrue
正文末尾的「最后修改」与出处区块;上游署名由页面的 upstream_link 一族键驱动,见页面参数
params.ui.backlinks , boolean , defaultfalse
在右栏目录旁以「反链」组列出链接到本页的页面,构建时从普通链接派生,见导航与菜单
params.ui.translation_notice , 语言代码或 false , defaultfalse
权威版本的语言代码,译文页据此显示一条指回原文的说明;页面写 translation_notice: false 退出
params.ui.reading_time , boolean , defaultfalse
页面标题下显示阅读时长
params.ui.book_draft_banner , boolean , defaultfalse
Book 草稿页开头额外加一条横幅

本地搜索默认关闭;打开后命令面板才会出现(顶栏放大镜、Cmd/CtrlK/\)。

params.offline_search , boolean , defaultfalse
生成每语言一份本地索引并启用命令面板,见全文检索
params.offline_search_on_serve , boolean , defaulttrue
hugo server 预览时也构建索引,预览行为与线上一致;站点极大时设 false 跳过以加快本地重建
params.offline_search_index , enum , defaultcontent
索引范围,逐级累加:title heading summary content。非法值告警并使用 content
params.offline_search_summary_length , integer , default70
summary 档摘录截断的字数
params.offline_search_max_results , integer , default10
结果条数上限,同时约束 Lunr 与中文子串兜底
params.ui.landing_search , boolean , defaulttrue
layout: landing 页面是否保留搜索入口
params.ui.command_palette.commands , list , default[]
自定义命令,每条二选一:url 或内置 action;见命令面板
params.gcs_engine_id , string
Google 可编程搜索引擎 ID,启用后引入外部服务
params.search.algolia , map
Algolia DocSearch,必须显式给出 appId apiKey indexName,缺一则告警并保持 DocSearch 关闭

自定义命令的每条记录只接受 id title description icon keywords url action 七个键;id 必须匹配 ^[a-z][a-z0-9_-]*$,且不能与内置动作 ID 重名。分语言的标题写在 languages.<lang>.params.ui.command_palette.commands

键盘

params.ui.keyboard_nav , boolean , defaulttrue
单键导航(WASD/方向键走树、j/k 跳标题、q/e 翻页、面板与外壳开关)。设为 false 后运行时不进包,见键盘导航

图片缩放

params.ui.image_zoom , boolean , defaultfalse
允许正文图片点击放大;页面用 front matter image_zoom 覆盖。非布尔告警并回退

哪些图片会成为缩放候选见图片

字体排版

params.ui.typography , enum , defaulttechnical
technical 用随主题分发的 Inter / Chakra Petch / IBM Plex Mono;system 只用平台字体栈,不请求品牌字体。非法值告警并回退
params.ui.fonts , map
ui body heading code display meta print 七个角色指定字体族。主题校验名称但不加载字体文件;每份列表都应以通用字体族收尾
params.page_width , enum , defaultnormal
外壳整体宽度:normal wide full,可逐页覆盖
params.reading_width , enum , defaultnormal
Book 页正文的阅读行宽:slim normal wide,不影响外壳

读者系统已有字体,或者站点已经用 @font-face 声明时,可以直接写 params.ui.fonts。随站点分发字体文件与更底层的排版调整仍走 SCSS/CSS 入口,见 品牌外观

评论与反馈

params.comments.enable , boolean , defaultfalse
站点级评论开关,页面用 front matter comments 覆盖,见启用评论
params.comments.type , string , defaultgiscus
目前只有 giscus 会真正渲染
params.comments.giscus.repo , string
承载讨论的 GitHub 仓库,必填
params.comments.giscus.repoId , string
仓库 ID,必填
params.comments.giscus.category , string
讨论分类名,必填
params.comments.giscus.categoryId , string
讨论分类 ID,必填
params.comments.giscus.mapping , string , defaultpathname
页面与讨论的映射方式
params.comments.giscus.term , string
mappingspecificnumber 时的讨论标题或编号;不设置时不输出这个属性
params.comments.giscus.strict , string , default0
严格标题匹配
params.comments.giscus.reactionsEnabled , string , default1
显示主贴表情
params.comments.giscus.emitMetadata , string , default0
向父页面发送讨论元数据
params.comments.giscus.inputPosition , string , defaulttop
输入框在评论列表上方还是下方
params.comments.giscus.theme , string , defaultauto
giscus 主题,auto 跟随站点深浅色
params.comments.giscus.lightTheme , string , defaultlight
浅色模式下使用的 giscus 主题或自定义 CSS URL
params.comments.giscus.darkTheme , string , defaultdark
深色模式下使用的 giscus 主题或自定义 CSS URL
params.comments.giscus.loading , string , defaultlazy
iframe 加载策略
params.comments.giscus.lang , string , default按站点语言推导
giscus 界面语言。不设置时中文站解析为 zh-CN / zh-TW / zh-HK,其它语言取主语言代码,giscus 不支持则回落 en
params.comments.giscus.ariaLabel , string , defaultComments
评论区容器的 aria-label;默认值是英文,多语言站点需按语言各写一份
params.comments.giscus.errorMessage , string , defaultComments could not be loaded.
加载失败时显示的文字;默认值是英文,多语言站点需按语言各写一份
params.ui.feedback.enable , boolean , defaultfalse
页尾「这页有帮助吗」两个按钮;无后端,有 gtag 时记录结构化事件
params.ui.feedback.reasons , boolean , defaulttrue
选「否」后展开四个可选原因

四个 giscus 必填项缺任意一个,评论区就不渲染:不报错,也不出现。

仓库链接与页面信息

params.github_repo , string
内容仓库 URL,解析「编辑本页」「查看历史」「新建子页」「提文档 issue」,见仓库与页面信息
params.github_project_repo , string , defaultgithub_repo
产品仓库 URL,用于「提项目 issue」与顶栏 GitHub 入口
params.github_branch , string , defaultmain
编辑链接指向的分支
params.github_subdir , string
内容站在 monorepo 里的子目录
params.path_base_for_github_subdir , string 或 map
源路径重写;map 形式接受 fromto
params.github_url , , default
已移除,改写 params.github_repo。那份负责提示替代键名的迁移登记表已经删掉,所以旧键现在只是一个没人读的键
params.ui.lastmod_commit , enum , defaultsubject
「最后修改」后面附什么:subject commit 标题、hash 短哈希、none 不附。非法值告警并回退
params.images , string 数组 , default
站点级社交卡片:页面自己没有封面时用它填 og:image;只进元数据,不会渲染成列表缩略图
params.upstream_source , 字符串 , default
声明了 upstream_link 的页面默认使用哪个 data/upstreams 记录;页面 front matter 可以覆盖
params.upstream_modified , 布尔 , defaultfalse
上游材料是否经过改编的站点默认值;页面可以覆盖,没有 upstream_link 时不渲染署名
params.default_featured , , default
已移除,改写 params.images 或栏目 cascade 里的 images。同上,旧键现在只是一个没人读的键

内容运行时

Mermaid、KaTeX、ECharts、Infographic、Asciinema、Swagger UI 与 Redoc 按内容自动检测,只有用到它们的页面、且只在该页的 HTML 输出里加载,没有站点开关。需要开关或外部端点的只有这几个:

params.markmap , boolean , defaultfalse
站点级启用思维导图围栏,见思维导图
params.mermaid , map
透传给 mermaid.initialize() 的配置;键名全小写,深色模式自动覆盖 theme
params.plantuml.enable , boolean , defaultfalse
启用 PlantUML 围栏,见 PlantUML
params.plantuml.svg_image_url , string
PlantUML 服务的 SVG 端点,启用时必填,缺失则告警并保持 PlantUML 关闭
params.plantuml.svg , boolean
用内联 SVG 而不是 <img> 渲染
params.drawio.enable , boolean , defaultfalse
启用 .drawio.svg 图片的编辑按钮,见 Draw.io
params.drawio.drawio_server , string
Draw.io 编辑器地址,启用时必填,缺失则告警并保持 Diagrams.net 关闭
params.highlight_classes , boolean , defaulttrue
代码高亮输出 Chroma class;设 false 回到 Hugo 的行内样式
params.ui.code_copy , boolean , defaulttrue
代码块的复制按钮;设为 false 全局去掉,围栏上的 copy= 仍然优先

数学公式不需要参数,只需要 passthrough 前置

输出格式

主题声明自定义输出格式,但 不替站点打开:要哪种就在 outputs 里写哪种。成本 较高的聚合输出与机器可读输出始终需要显式选择。

hugo.yml
outputs:
  home: [HTML, markdown, LLMS, NAVJSON]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]
格式 产物 说明
HTML index.html 交互形态,必选
markdown index.md 每页的纯 Markdown 版本,页面操作里的「复制 Markdown」「查看源码」依赖它,见 Agent 支持
LLMS llms.txt 主题声明的纯文本格式,通常只挂在 home
LLMSFULL llms-full.txt 顶层栏目 opt-in:按侧栏阅读顺序拼接同一份逐页 Markdown,每种语言一份全文包
NAVJSON navigation.json 首页 opt-in:每种语言把侧栏 / 翻页使用的导航权威序列化一次,由 schema/nav.v1.schema.json 校验
print _print/index.html 主题声明的整分区打印页,见打印支持
BookManifest book.json Book 根 opt-in,向 EPUB/PDF 打包工具交接的 JSON;本身不是电子书
RSS index.xml Hugo 原生,挂在 section 上让每个栏目都有订阅源

LLMSFULLBookManifest 写在对应顶层栏目的 front matter outputs 中, NAVJSON 写在 outputs.home。完整示例与限制见 Agent 支持书籍出版

打印输出的两个参数:

params.print.toc , boolean , defaulttrue
打印页开头生成目录;设为 false 不生成
params.print.section_break_wordcount , integer , default50
打印页中一节多少词以上才另起一页

多语言与版本

语言用 Hugo 原生的 languages 块定义,主题只读它建立的翻译关系:

defaultContentLanguage , string , defaulten
不带路径前缀的首要语言
languages.<lang>.label , string
该语言的自称,显示在语言菜单里
languages.<lang>.locale , string
完整 locale,用于 <html lang> 与 SEO
languages.<lang>.weight , integer
语言顺序,也是点击语言图标时的循环顺序
languages.<lang>.title , string
该语言的站名
languages.<lang>.direction , string , defaultltr
RTL 语言设为 rtl

写作侧的对等文件、锚点对齐与缺译回退见多语言

版本相关参数:

params.version , string
当前站点变体的版本标识(不一定是 Git ref),见多版本
params.version_menu , string , defaultVersion
版本菜单的标题
params.version_menu_pagelinks , boolean
切版本时先尝试目标站点的同一路径
params.versions , list
版本条目:version url kindname: '---' 是分隔线
params.archived_version , boolean
顶部显示「这是归档版本」横幅
params.url_latest_version , string
归档横幅里指向最新版的链接
params.time_format_blog , string , default2006-01-02
博客日期格式,按语言覆盖
params.time_format_default , string , default2006-01-02
其它日期格式,按语言覆盖

其它

taxonomies , map
Hugo 原生:启用 tag: tags / category: categories,见分类体系
params.taxonomy.page_header , list
只在文章头部显示这几种分类;不设则显示全部
services.googleAnalytics.id , string
Hugo 原生:分析脚本只在生产构建注入,见分析与 SEO
module.hugoVersion.min , string , default0.160.1
主题声明的 Hugo 下限,低于它构建失败
module.hugoVersion.extended , boolean , defaulttrue
必须是 Hugo Extended(要编译 SCSS)

通过生成式 Schema 获得编辑器补全

主题在其 schema/ 目录下携带两个生成的 JSON Schema:校验站点 hugo.yamlsite-params.schema.json 与校验页面 front matter 的 front-matter.schema.json。它们是主题自身 hugo.yaml 默认值(注释即悬浮文档) 与参数扫描注册表的投影;主题 CI 会重新生成并在漂移时失败,因此它们永远不会与你 pin 的主题版本相左。

配合 VS Code YAML 扩展,在设置中映射站点 Schema:

.vscode/settings.json
{
  "yaml.schemas": {
    "https://raw.githubusercontent.com/pgsty/oink/main/schema/site-params.schema.json": "hugo.yaml"
  }
}

把 URL 里的 main 换成你的发布 tag,与 go.mod 的 pin 保持一致。front matter 补全取决于你的 Markdown 工具链,用同样方式指向 front-matter.schema.json 即可。 front-matter Schema 刻意不带类型约束,因为 sharetheme_color 这类键在常规 类型之外还接受裸布尔退出。

验证配置变更

改完配置跑一次严格构建:

hugo --printPathWarnings --panicOnWarning

输出 Total in … 且没有 ERROR / WARN 才算通过。常见报错与原因:

报错片段 原因
invalid params.ui.typography 预设只有 technicalsystem
invalid footer_style … (allowed: fat | slim | none) 页脚形态写错,报错会指出是哪个页面
invalid page_width … (allowed: normal | wide | full) 页宽写错
invalid params.ui.section_index … (allowed: list | cards) 栏目首页样式写错
invalid params.offline_search_index 索引范围只有 title heading summary content
params.plantuml.enable requires an explicit params.plantuml.svg_image_url 开了 PlantUML 却没给端点
params.drawio.enable requires an explicit params.drawio.drawio_server 开了 Draw.io 却没给服务地址
params.search.algolia requires explicit appId, apiKey, and indexName Algolia 三项必须齐全
params.ui.image_zoom must be a boolean 写成了字符串 "true"
theme_color … is not a #rgb or #rrggbb hex color 值不是十六进制颜色,保留默认配色
theme_color … reads at about N:1 against the theme's … canvas 建议性告警:颜色照常生效,消息里带着让它闭嘴的 id
theme_color_dark … has no theme_color to pair with 只设了暗色一半而没有有效的 theme_color;该值被忽略,两种模式都保留默认配色
command … must define exactly one of url or action 自定义命令同时给了 urlaction,或两个都没给
invalid params.ui.sidebar_icon_policy …; using all 只是警告,但取值拼错了

配置改动还要至少验证三件事:每种语言各一页、缺译页的回退、生产 baseURL 下的链接(子路径部署容易漏)。

主题声明的 Hugo 下限是 0.160.1。OINK 的持续测试工具链固定为 Hugo Extended 0.165.0;配置改动只使用这个固定版本测试一次,不再运行版本矩阵:

# 输出必须包含 v0.165.0+extended
hugo version
hugo --printPathWarnings --panicOnWarning

下限版本写在主题的 hugo.yamltheme.toml 里,站点自己的 module.hugoVersion.min 应与它一致。它仍是消费站兼容性声明,不再是第二个常规 CI 测试项。

2 - 品牌外观

替换站名、Logo、favicon、主色、深浅色与字体,只需改配置与两个 SCSS 入口文件。

本页覆盖站点外观:站名与 Logo 写在 hugo.yml,配色与字体走 SCSS 入口,页宽与页脚形态是参数。前提是站点已能构建(十分钟上手)。

需要改动的文件有四个:hugo.ymlstatic/ 下的图标、assets/scss/_variables_project.scssassets/scss/_styles_project.scss不要改主题目录里的文件:主题是 Hugo Module,升级时整个目录会被替换。

站名

站名出现在顶栏、浏览器标题与页脚。多语言站每种语言各写一个:

hugo.yml
title: 产品文档

languages:
  en:
    title: Product Docs
    label: English
    locale: en-US
    weight: 1
  zh:
    title: 产品文档
    label: 简体中文
    locale: zh-CN
    weight: 2

顶层 title 是兜底,languages.<lang>.title 优先。

主题默认使用自带的 assets/icons/logo.svg。替换步骤是把图标文件放进站点的 assets/static/,再在配置里指向它。

hugo.yml
params:
  logo: images/product-mark.svg
  wordmark: logo.svg
  • params.logo 是方形图标,顶栏、侧栏与页脚共用。放在 assets/ 下会经过 Hugo 资源管线(可指纹化),放在 static/ 下按原样发布;两种写法都是相对 assets/static/ 根的路径。
  • params.wordmark 是横向字标。设置后顶栏用它替代「图标 + 站名」,窄屏放不下时回落到 params.logo。不设置则保持「图标 + 站名」。

源 SVG 应紧贴图形边缘裁切,否则各处尺寸对不齐。SVG 必须带 viewBox,颜色继承 currentColor,或者在深浅色下都有足够对比度。

本站两个参数都不设:顶栏用主题自带的 assets/icons/logo.svg 搭配以展示字体渲染的站名。

favicon

favicon 没有参数。主题扫描站点 static/ 目录里的约定文件名,发现哪个就在每个页面输出对应的 <link>

文件 生成的链接
static/favicon.ico rel="icon"
static/favicon.svg rel="icon" type="image/svg+xml"
static/favicon-32x32.png rel="icon"sizes,按尺寸升序输出
static/apple-touch-icon.png rel="apple-touch-icon"
static/apple-touch-icon-180x180.png rel="apple-touch-icon"sizes

够用的最小组合是 favicon.ico + favicon.svg + apple-touch-icon.png。带尺寸后缀的文件必须是正方形(NxN),否则不会被识别。

这些文件用任意图形工具生成即可。主题不需要 Node.js,Hugo 只发布 static/ 里已经存在的文件。

Web App Manifest 一类的额外 head 元数据不在扫描范围内,用 layouts/_partials/hooks/head-end.html 钩子自行输出;要改变发现规则本身(换目录、增加文件名),在站点 layouts/ 下覆盖 layouts/_partials/favicons.html

主色与配色

配色分两层:Bootstrap 的语义色(编译期 Sass 变量)和 OINK 的品牌层(运行期 CSS 自定义属性)。

先改语义色,它决定按钮、链接、提示块的色调:

assets/scss/_variables_project.scss
$primary: #315f8f;
$secondary: #b4762e;
$success: #2c7a4b;
$warning: #9a6700;
$danger: #b42318;

这个文件在 Bootstrap 与 OINK 默认值 之前 加载,是覆盖 Sass 变量的位置。需要引用 Bootstrap 已定义的变量或 map 时,改用 _variables_project_after_bs.scss

品牌层是一组 CSS 自定义属性,浅色和深色 必须成对覆盖,否则一种模式下会漏色:

assets/scss/_styles_project.scss
:root {
  --td-brand-copper: #a66722;
  --td-brand-mark-from: #1d588c;
  --td-brand-mark-to: #a66722;
}

[data-bs-theme='dark'] {
  --td-brand-copper: #e0a35c;
  --td-brand-mark-from: #7fb8e8;
  --td-brand-mark-to: #e0a35c;
}

可覆盖的品牌属性有 --td-brand-elev(浮层底色)、--td-brand-silk(次要文字)、--td-brand-copper--td-brand-copper-dim(强调色与它的弱化版)、--td-brand-line-strong(分隔线)、--td-brand-header-bg(顶栏背景)、--td-brand-shadow-sm / --td-brand-shadow-md(阴影)、--td-brand-mark-from / --td-brand-mark-to / --td-brand-mark-gradient(品牌渐变)。

分区主题色

上面的品牌配色决定整站的颜色。theme_color 是它旁边一件更小的乐器:一个十六 进制色,为外壳的强调底着色,让读者不用被告知也知道自己身在站点的哪一块。

hugo.yml
params:
  ui:
    theme_color: '#6d28d9'
    theme_color_dark: '#a78bfa' # 可选

它按分区写比按站点写有用得多。写进分区根的 cascade,整个分区就有了身份 —— 藏青的文档、紫色的博客、橙色的教程 —— 而站点默认仍是品牌色:

content/blog/_index.md
cascade:
  theme_color: '#6d28d9'
  theme_color_dark: '#a78bfa'

Hugo 会把这些 cascade 值同时解析到分区首页与子页,因此只声明这一对即可。同一对 解析结果既驱动页面强调色,也驱动根切换器里该分区的图标。

它作用于:侧栏选中行、以及指针划过其它行时那一层更灰的底、hover 淡铺、 页面目录的药丸与那条会走的轨道和光点、指针落在其上的 Book 章节小标题、标签与 徽章的 hover、内容卡片 hover 时的外边、分享按钮 hover 时的实心底、文本选中、 焦点环,以及侧栏根切换器里每个分区的图标。

它刻意不作用于:正文链接、外链、行内代码。这些是阅读约定,不是品牌表面 —— 一页密集的标识符在任何分区都该读成「代码与正文」,链接在哪里都该看起来像链接。 这也是强调色单独占一个自定义属性、而不是去重刷 Bootstrap 链接色的原因。

暗色一半是可选的。省略时,从亮色向白提亮,直到在暗色画布上达到 AA 正文对比度, 所以只填一个颜色的作者不可能产出不可读的暗色配色。派生结果不再符合期望的品牌 色相时,再自己指定暗色一半。亮色才是主键:单独设置 theme_color_dark,或者把它放在 一个非法的 theme_color 旁边,两种模式都不会着色 —— 主题会发出警告并保留默认配色, 而不是只给暗色模式上色。

彩色栏目里的某一页可以用主题的裸布尔惯例谢绝颜色:front matter 写 theme_color: false 即让该页退出继承的栏目色(含继承的暗色一半),静默回到默认 配色,不产生警告。其他非十六进制取值(数字、true、颜色名)都会告警。

对比度是检查,不是强制

主题会把你的颜色放在它自己的画布上读,低于 AA 正文对比度(4.5:1)就告警。 颜色照常生效:自定义画布或品牌强制色是你的决定。告警里带着能让它闭嘴的 ignoreLogs id;而发布构建带 --panicOnWarning,所以在你要么调深颜色、 要么关掉检查之前,关卡会一直卡住。

这个检查是拿颜色对着页面画布读的。有些交互表面会同时把它用作文字与半透明淡铺; 例如可点击的实心徽章在 hover 时,是强调色文字压在 12% 的同色淡铺上。这一对比 画布检查更紧。如果颜色只是刚好过线,还要检查这些表面,必要时再调深一档。

Hugo 按键合并参数:某一页在同时设了 theme_color_dark 的分区里只覆盖 theme_color,会继承那个暗色。要么两个都覆盖,要么都不覆盖。

深浅色模式

主题默认 不显示 深浅色控件。开启方式:

hugo.yml
params:
  ui:
    dark_mode: true

开启后顶栏出现一个主题控件:点击在浅色与深色之间切换,悬停或键盘聚焦展开「跟随系统 / 浅色 / 深色」。读者的选择存在浏览器本地,没有选择时跟随 prefers-color-scheme。切换脚本在首屏绘制前设置好 data-bs-theme,不会出现主题闪烁。

只要深色调色板、不要控件时写 dark_mode: { show_menu: false, enable: true }dark_mode: false(默认)两者都不启用。

自定义组件在两种模式下都要给出可读的悬停、聚焦、禁用、选中状态,正文对比度至少 4.5:1、大号文字 3:1。

字体

字体有两档预设,在构建期决定,不涉及 JavaScript:

hugo.yml
params:
  ui:
    typography: technical # technical | system
  • technical(默认):界面与正文用随主题分发的 Inter(可变字重,拉丁 / 西里尔 / 希腊 / 越南语子集,中文与 emoji 落到平台字体),标题装饰用 Chakra Petch,代码用 IBM Plex Mono。字体文件都是本地的,不请求 Google Fonts。
  • system:界面、展示、元数据、打印与等宽角色全部回到平台字体栈,浏览器不请求品牌字体。字体文件仍随主题分发,只是不被引用。

非法取值告警并回落到 technical,普通 hugo server 照常可用;发布门禁开着 --panicOnWarning,这类告警在那里才是硬失败。选中的值写入 <html data-td-typography="…">,可在浏览器中确认。

自定义字体

字体角色是七个 CSS 自定义属性,覆盖它们即可,不必查找组件选择器:

属性 配置键 用在哪
--td-ui-font-family ui 导航、控件与界面文字
--td-body-font-family body 正文与博客
--td-heading-font-family heading 正文标题
--td-code-font-family code 代码与终端
--td-display-font-family display 字标与展示型大标题
--td-meta-font-family meta 技术标签与元数据
--td-print-font-family print 打印正文

ui 是主字体:body 经它解析,heading 又经 body 解析,所以只写 ui 一行,界面、正文与标题一起换掉。

在配置里换

只是想换一套字体族,不必碰 SCSS,写 params.ui.fonts 即可:

hugo.yml
params:
  ui:
    fonts:
      # 主字体:界面、正文、标题一起跟着走
      ui: "'Source Han Sans SC', 'PingFang SC', sans-serif"
      # 等宽要带中文兜底,否则中英混排的代码块会对不齐
      code: "'Sarasa Mono SC', 'Noto Sans Mono CJK SC', monospace"

这里写的是字体族名,不是字体文件。主题不会因为这个键去下载或加载任何字体:所写的族必须是读者机器上已有的,或者站点自己在样式表里 @font-face 声明过的。所以每个列表都要以通用族(sans-serifmonospaceserif)收尾——读者没有你写的字体时,落到那里。

取值只放行纯粹的字体族语法:带引号的名字、裸标识符、允许前导连字符(-apple-system),以及任何文字系统写成的名字(苹方 合法)。分号、花括号、括号、url()、尖括号一律不过关。未知角色或不合法取值只告警并单独丢弃,同一份 map 里其余的行照常生效。什么都不设时,<head> 里连这个 style 元素都不会出现。

该块在样式表之后输出,这正是作者字体能在同等优先级下压过 typography 预设的原因。

在样式表里换

要自带字体文件,或者只给某一类内容换字体,仍然走样式表。把 .woff2 放进站点 static/webfonts/,在项目样式里声明字面,再改写角色:

assets/scss/_styles_project.scss
@font-face {
  font-family: 'My Sans';
  font-display: swap;
  font-style: normal;
  font-weight: 400 800;
  src: url('../webfonts/my-sans-variable.woff2') format('woff2');
}

:root {
  --td-ui-font-family: 'My Sans', 'Noto Sans SC', sans-serif;
  --td-body-font-family: var(--td-ui-font-family);
  --td-heading-font-family: var(--td-ui-font-family);
  --td-display-font-family: var(--td-heading-font-family);
}

角色按 CSS 规则继承,只给某类内容换字体也不必复制组件选择器:

assets/scss/_styles_project.scss
body.td-blog {
  --td-body-font-family: 'My Serif', 'Noto Serif SC', serif;
  --td-heading-font-family: var(--td-body-font-family);
}

等宽字体要带中文兜底,否则中英混排的代码块会对不齐:

assets/scss/_styles_project.scss
:root {
  --td-code-font-family: 'My Mono', 'Sarasa Mono SC', 'Noto Sans Mono CJK SC', monospace;
}

从 Docsy 迁移过来的站点不必改写法。旧的 Sass 变量仍然喂进对应角色,写在 _variables_project.scss 里照样生效,优先级高于预设默认值:

旧 Sass 变量 喂给的字体角色 说明
$td-fonts-serif --td-ui-font-family / --td-body-font-family Docsy 的界面字体栈,赋值给 $font-family-sans-serif
$font-family-sans-serif --td-ui-font-family / --td-body-font-family 项目给出自己的栈时,technical 预设不再把 Inter 放在它前面
$font-family-base --td-ui-font-family / --td-body-font-family Bootstrap 的正文变量,经 --bs-body-font-family 进入角色
$headings-font-family --td-heading-font-family 不设置时标题继承正文角色
$font-family-code --td-code-font-family 代码、终端与 pre / code / kbd
$td-font-family-monospace --bs-font-monospace 赋值给 $font-family-monospace
$font-family-monospace --bs-font-monospace system 预设下,项目的显式取值优先于平台等宽栈

Docsy 的三个 Google Fonts 变量 $td-enable-google-fonts$td-google-font-name$td-web-font-path 主题已不再读取。它们留在 _variables_project.scss 里不影响构建,也不产生任何效果:随主题分发的是 Inter、Chakra Petch 与 IBM Plex Mono,两档预设都不向 Google Fonts 发请求。打印角色 --td-print-font-family 跟随正文角色,主题不为纸张单独提供字体。

YAML 里只接受字体族名。远程字体 URL 与任意 CSS 都不接受:字体文件与样式必须是可审查的本地输入,一次普通构建不会因为字体发出任何网络请求。

页宽

hugo.yml
params:
  page_width: normal # normal | wide | full

page_width 控制外壳整体宽度,可逐页或按分区 cascade 覆盖。Book 页另有一个 reading_widthslim / normal / wide),改的是正文阅读行宽,不是外壳。 两个键取值非法都会在普通预览中告警并回退;带 --panicOnWarning 的发布构建会失败。

hugo.yml
params:
  ui:
    footer_style: fat # fat | slim | none
  copyright:
    authors: '[产品团队](https://example.com/)'
    from_year: 2026
    to_year: present
  footer_center_info: 'Powered by [Oink](https://oink.pgsty.com)'
  • fat(默认):多列链接网格 + 版权行;
  • slim:只有版权行;
  • none:不渲染页脚。

页面 front matter(含分区 cascade)可以覆盖它,本站的文档栏目用的是 footer_style: slim。无法识别的取值在普通预览中告警并回退到 fat,严格发布构建 拒绝这条警告。

多列网格的数据在 data/footer/<语言>.yaml,写法见导航与菜单。配了 fat 但没有数据时自动降级成 slim,可以先开启再补内容。

params.copyright 接受 Markdown 字符串,或 authors / from_year / to_year 三键的 map(present 表示今年)。footer_center_info 是页脚中间的行内 Markdown,显式设为空字符串即隐藏中间区域。

SCSS 入口与不该做的事

站点的 SCSS 覆盖进入主题的同一个样式包,生产构建仍然只有一份带指纹与完整性校验的样式表。三个入口文件放在站点 assets/scss/ 下:

文件 什么时候用
_variables_project.scss 在 Bootstrap 与 OINK 默认值之前设置 Sass 变量($primary、字体变量)
_variables_project_after_bs.scss 设置依赖 Bootstrap 已有定义的变量或 map
_styles_project.scss 在主题组件样式之后写选择器与 CSS 自定义属性

编译顺序是:Bootstrap 函数 → 项目变量 → OINK 默认值与 Bootstrap → Bootstrap 之后的项目变量 → OINK 组件与品牌层 → 项目样式。

CSS 接口有明确边界。字体那一节的七个字体角色与 --td-brand-* 品牌属性是公开接口,主题在小版本之间保持它们的名字与含义。组件别名(如 --td-asciinema-font-family)只承诺在该组件范围内有效,未在文档中记录的 --td-shell-* 一类变量是实现细节,随时可能改名或消失。

不该做的事:

  • 不改主题目录里的任何文件(hugo mod 会覆盖);
  • 不单独 @import 主题的内部 partial,它们不是公开的 Sass 接口,导入顺序可能变化;
  • 不为了改一个颜色去覆盖 baseof.html。有设计变量就用变量,没有再写作用域尽量小的选择器;
  • 不引用远程样式表或字体 CDN。

需要额外的第三方 CSS 时,用 layouts/_partials/hooks/head-end.html 钩子发布本地资源,不在 Markdown 里写 <link>

验证

hugo --printPathWarnings --panicOnWarning
  • 构建输出 Total in …,没有 ERROR / WARN;
  • 页面源码里 <html> 上有 data-td-typography="technical"(或所选的预设);
  • 浏览器中顶栏显示自己的 Logo 与站名,标签页图标是自己的 favicon;
  • 切到深色模式再看一遍正文、表格、提示块、代码块与焦点框。配色改动容易只在一种模式下验证过;
  • 换一种语言,确认站名随之切换。

字体是否已替换,用浏览器开发者工具查任意一段正文的 font-family:应当是自己声明的字面,而不是 Inter

3 - 首页与落地页

用一份本地 YAML 组合首页:Hero、卡片、能力面板、时间线、定价、案例、下载。任意页面也能用同一套分区做成落地页。

首页不是模板,是一份数据:data/home/<语言>.yaml 里的 sections 列表决定页面从上到下有哪些分区,每个分区的内容在同一份文件里按名字取。普通页面加 layout: landing 也能用同一套分区。

分区全部在服务端渲染。价格、star 数、截图、头像、下载状态都必须在 Hugo 启动前就存在于仓库中,没有分区会在浏览器里取数据。

从 Docsy 的 blocks/* 首页迁移过来的站点要重写首页:主题没有 blocks/coverblocks/sectionblocks/feature 这些 shortcode,保留它们会让构建报 template for shortcode "blocks/cover" not found。两条出路是本页讲的 data/home/<语言>.yaml,或者给一个普通页面加 layout: landing

首页的数据来源

首页的内容文件只留标题与描述:

content/_index.zh.md
---
title: OINK
description: 本地优先、仅依赖 Hugo 的技术文档主题
---

分区数据按语言分文件:

首页数据

  • data/
    • home/
      • en.yaml英文站首页
      • zh.yaml中文站首页

查找顺序是 data/home/<当前语言>.yamldata/home/en.yaml → 单语言站点的 data/home.yaml

文件结构只有两层:一个 sections 列表,加上被列表引用的同名键。

data/home/zh.yaml 的骨架
sections:
  - hero          # 用 hero: 键的数据
  - capabilities
  - type: cards   # 用 cards 分区,但读 release: 键的数据
    key: release
  - cta

hero: { … }
capabilities: { … }
release: { … }
cta: { … }

这是本站首页的写法,完整文件见仓库的 data/home/zh.yaml

最小可用首页

粘贴下面这段,替换文字与链接即可发布。链接写成不带前导斜杠的站内路径,主题会补上当前语言前缀(docs/start//zh/docs/start/)。

data/home/zh.yaml
sections:
  - hero
  - cards
  - cta

hero:
  eyebrow: 本地优先 · 仅依赖 Hugo
  title_lines:
    - words:
        - { text: PGSTY OINK }
  lead: 组件写在 Markdown 里,资源随主题分发,一份内容产出四种输出。
  image:
    light: images/hero-light.webp
    dark: images/hero-dark.webp
    alt: OINK 工程文档插图
  actions:
    - { label: 十分钟上手, url: docs/start/, icon: fa-solid fa-rocket, style: primary }
    - { label: 看组件, url: docs/components/, style: ghost }

cards:
  eyebrow: 能做什么
  title: 工程文档需要的都在里面
  columns: 3
  items:
    - title: Markdown 原生组件
      desc: 提示块、标签页、参数表、文件树都是 Markdown 语法的一部分。
      icon: fa-solid fa-cubes
      url: docs/components/
    - title: 四态输出
      desc: HTML、打印、Markdown、RSS,同一份内容不丢信息。
      icon: fa-solid fa-file-export
      url: docs/customize/agents/
    - title: 本地优先
      desc: 字体、图标、搜索、图表运行时全部随主题分发,不连 CDN。
      icon: fa-solid fa-plug-circle-xmark
      url: docs/about/features/

cta:
  title: 从一个能跑的双语站点开始。
  text: 从 OINK Starter 起步,替换项目身份与内容,再发布上线。
  label: 开始使用
  url: docs/start/
  style: primary

Hero

Hero 是首屏,唯一一个带大标题与配图的分区。

data/home/zh.yaml
hero:
  eyebrow: PROJECT 1.0 · 本地优先         # 标题上方的小字,带状态点
  title_lines:                            # 逐行控制的大标题
    - words:
        - { text: PGSTY OINK }
  lead: 一句话说清这是什么。                 # 支持行内 Markdown 与 <br>
  note: 无需 Node.js                       # 带图标的补充行
  note_icon: fa-solid fa-circle-check
  title_size: 4.25rem                     # 只接受 rem / em / px
  image:
    light: images/hero-light.webp
    dark: images/hero-dark.webp           # 只给一个时深浅色共用
    alt: 首屏插图
  media:
    ratio: '1fr 240px'                    # 文案与配图的列宽
    max_width: 240px
    hide_below: md                        # sm | md | lg | xl 以下隐藏配图
  actions:
    - { label: 开始使用, url: docs/start/, icon: fa-solid fa-rocket, style: primary }
    - { label: GitHub, url: 'https://github.com/pgsty/oink', external: true, style: ghost }
  detail: { label: 看看它长什么样, url: docs/about/showcase/ }

不写 title_lines 时用 title,两者都没有时用站点标题。配图是 CSS 背景图,alt 有值时容器带 role="img",无值时对辅助技术隐藏。

align: center 是纯文字的居中首屏:文案块加宽居中,标题自动平衡换行,note 挪到按钮下方。两者同时出现时,普通预览会告警并回退到 start 以保留图片;严格 发布构建拒绝这条警告。

分区注册表

22 种分区,名字用连字符(旧数据里的下划线会被规范化)。除 Hero 之外,每种都共用 eyebrow / title / desc(或 text)三个抬头字段与一个 class

类型 放什么
hero 首屏:大标题、按钮、跟随主题的配图
metrics 数字事实,可选计数动画与来源链接
capabilities 左右交替的能力叙事 + 专用视觉面板
principles 编号的产品原则
cards 通用卡片集合:功能、场景、入口
logo-wall 工具与伙伴,网格或纯 CSS 跑马灯
gallery 截图墙
testimonials 引语与署名
contributors 人、角色、头像与链接
faq 折叠或平铺的问答
markdown 一段自由 Markdown
cta 结尾的行动号召
pricing 价格档位卡片
pricing-compare 档位功能对比矩阵
command-box 一条可复制的命令
steps 有序流程,可带命令
timeline 带日期的里程碑
code-plate 展示面板里的代码
preview 一段 Markdown 源码与它渲染出来的样子并排
case-study 案例:指标 + 引语 + 出处
download 一个或多个 data/download/ 记录
bar-chart 不用图表 JS 的数值对比

写错类型名不会静默消失:构建时给一条 unknown section type 警告并跳过该分区。CI 里加上 --panicOnWarning 即变成构建失败。

常用分区的最小写法

卡片与能力面板是最常用的两种。cardscolumns 控制列数:

data/home/zh.yaml
cards:
  title: 应用场景
  columns: 4
  link_label: 了解详情
  items:
    - title: 书籍出版
      meta: 长篇
      icon: fa-solid fa-book-open
      desc: 编号图表式例、交叉引用、索引与整本打印。
      url: docs/write/book/

capabilities 是一屏一条能力,右边配一块结构化的视觉面板,visual.type 只能是 shellcomponentscodeimagecard 五种之一:

data/home/zh.yaml
capabilities:
  eyebrow: 价值主张
  title: 工程文档所需的能力,开箱即用
  items:
    - ref: 01 / 工程文档
      title: 为工程师与文档站设计
      url: docs/start/
      motto: 从第一次构建到长期维护都没有额外阻力
      bullets:
        - '开箱即用的[部署上线](docs/admin/deploy/)体验'
        - '自带[全文检索](docs/customize/search/)与[多语言](docs/customize/i18n/)'
      value: 内容团队把时间用在文档上,而不是重复搭站点。
      visual:
        type: code
        title: build.sh
        lines:
          - { class: c, prefix: '# ', text: 一条命令,一份确定性输出 }
          - { class: p, prefix: '$ ', text: hugo --gc --minify }
          - { class: ok, prefix: '✓ ', text: public/ 可以部署 }
另外十种场景分区的最小 YAML

这些片段摘自主题仓库的可执行回归夹具 tests/site/data/landing/demo/en.yaml,字段名可照抄。

metrics:
  title: 事实
  animate: true
  items:
    - { value: 2189, compact: true, label: Stars, source: { label: 本地 CI 数据, url: 'https://example.org/' } }
    - { value: 32, suffix: '+', label: 语言 }

command-box:
  title: 安装
  code: hugo mod get github.com/pgsty/oink
  lang: bash
  note: 复制按钮由按需加载的 Landing 运行时提供。

steps:
  title: 三步上线
  items:
    - { title: 克隆, desc: 复制文档站仓库。 }
    - { title: 配置, desc: 改三处配置。, cmd: { code: hugo server } }
    - { title: 发布, desc: 推上 GitHub Pages。 }

timeline:
  title: 项目历程
  items:
    - { date: '2024', title: 原型, desc: 第一批数据驱动分区。 }
    - { date: '2026', title: 场景组件, desc: Landing 成为可复用外壳。 }

code-plate:
  title: 页面配置
  aria_label: 示例配置
  lang: yaml
  code: |
    layout: landing
    landing: pricing

preview:
  title: 所写即所得
  file: guide.md            # 源码面板抬头里的文件名,默认 page.md
  source: |                 # 右侧用站点自己的渲染钩子渲染这段 Markdown
    > [!TIP] 只用 Markdown
    > 提示块、步骤、标签页,都是普通语法。

    1. 写 Markdown
    2. 运行 `hugo`
    {.steps}

case-study:
  title: 迁移结果
  stats:
    - { value: 12, label: 可复用分区 }
    - { value: 0, label: 远程请求 }
  quote: “一份 YAML 取代了一个定制页面模板。”
  source: 站点维护者

pricing:
  title: 价格
  tiers:
    - name: 社区版
      price: 免费
      period: 永久
      desc: 完整开源能力。
      features: [全部组件, 社区支持]
      cta: { label: 下载, url: docs/start/ }
    - name: 专业版
      featured: true
      price: ¥24K
      period: /年
      features: [优先响应, 发布包]
      cta: { label: 联系我们, url: 'mailto:example@example.org' }

pricing-compare:
  title: 档位对比
  tiers: [社区版, 专业版]
  groups:
    - name: 支持
      rows:
        - { name: 优先响应, cells: [N, Y] }
        - { name: 年费, price_row: true, cells: [免费, ¥24K] }

download:
  title: 下载
  keys: [prd5]

bar-chart:
  title: 构建耗时
  unit: 
  items:
    - { label: 首次构建, value: 12.3, group: cold }
    - { label: 热缓存, value: 1.6, group: warm, note: 同一台机器上的重复构建。 }

download 分区消费的就是发布与下载页里那份 data/download/<key>.yaml,不引入第二套版本模型。

任意页面做落地页

普通内容页加两行 front matter 即成为落地页:全宽画布,保留顶栏、命令面板与页脚,去掉侧栏与目录。

content/pricing.zh.md
---
title: 价格
layout: landing
landing: pricing
---

数据放在与首页平行的目录下,同样按语言分文件:

落地页数据

  • data/
    • landing/
      • pricing/
        • en.yaml
        • zh.yaml

非首页落地页按这个顺序查找数据。全部找不到时,普通预览告警并渲染没有分区的 Landing 外壳;严格发布构建拒绝这条警告:

  1. 页面 front matter 里的 sections
  2. data/landing/<key>/<精确语言>.yaml
  3. 单文件 data/landing/<key>.yaml 里的精确语言条目;
  4. 英文或无语言后缀的记录。

数据量小时可以写在 front matter 里,但 landing:sections: 互斥

content/pricing.zh.md
---
title: 价格
layout: landing
sections:
  - type: hero
    data:
      title: 只用 Hugo 发布产品页面
      actions:
        - { label: 阅读文档, url: docs/, style: primary }
  - type: download
    data: { title: 下载, keys: [prd5] }
  - cta
---

分区条目写法

sections 的每一项可以是一个类型名字符串,也可以是一个 Map:

作用
type 分区类型;省略时用 key 当类型
key 从哪个键取数据,默认与 type 同名;同一种分区用两次时用它区分
data 内联数据,不再到顶层查找键
id 分区的锚点 ID,默认由 key / type 生成
enabled: false 停用这个分区,保留数据
partial 换成站点自己的 partial。属于本地模板约定,不是可移植的 Landing 数据

多语言与本地事实

叙事文字优先分语言文件(zh.yaml / en.yaml)。共享的事实记录也可以在字段级回退:<字段>_<精确语言><字段>_<主语言><字段>,语言标签里的 - 规范化成 _。中文站解析 title_zh_cntitle_zhtitle。不接受 camelCase 后缀。

分区里的显示文字是站点数据,不是主题的 i18n 字符串。只有跑马灯暂停、定价状态这类主题自带控件用翻译键。多语言站点的整体配置见多语言

落地页外壳上的几个可选事实也是本地的,写在 hugo.yml 里,运行时不会去取它们:

hugo.yml
params:
  offline_search: true
  ui:
    landing_search: true          # 布尔;只有站点开了 offline_search 才显示命令面板
    github_stars: 2189            # 已提交的数字,不请求 GitHub API
    alt_site: { label: English site, url: 'https://example.com/' }

页脚不属于首页数据:它读 data/footer/<语言>.yaml(单语言站点用 data/footer.yaml),本站两种语言各一份。data/home/<语言>.yaml 里残留的 footer 键会在普通预览中告警并忽略,--panicOnWarning 会拒绝它并提示新位置。 写法见导航与菜单

输出形态

输出 呈现
HTML 完整的静态分区内容,再按需加载 landing.js 做渐显、计数、复制与主题图片切换
打印 内容保留,跑马灯之类的动态面变成静态网格,控件移除
Markdown 标题、正文、列表、表格与代码,不带组件 class
RSS 不输出 Landing 分区

禁用 JavaScript 后服务端文档仍然完整。跑马灯的副本轨道不进无障碍树,暂停用的是不依赖 JavaScript 的复选框;读者开启减少动态效果偏好时,移动与渐显关闭。

验证

  1. 构建零告警:hugo --printPathWarnings --panicOnWarning。类型写错、数据键不存在、landingsections 同时出现都在这一步暴露。
  2. 打开首页与落地页,逐个分区对照数据文件,每种语言各看一遍。
  3. 禁用 JavaScript 后刷新:内容仍在,只是没有动效。
  4. 深浅色各看一遍,确认 image.light / image.dark 都给对。
  5. 部署到子路径时,确认站内链接与图片都带上了前缀。

4 - 导航与菜单

配置顶栏菜单与下拉、栏目切换器、面包屑、页面操作、翻页器和页脚链接。

本页覆盖读者在页面之间移动的入口:顶栏菜单、栏目切换器、面包屑、页面操作、上一页 / 下一页与页脚。侧栏树与目录属于布局与页面类型

导航没有第二套信息架构:顶栏来自 Hugo 的 menus.main,侧栏来自 content/ 的目录结构。主题不读 docs.jsonnavigation.yaml 一类的并行导航树。

顶栏菜单

顶层入口写在各语言的 menus.main 里:

hugo.yml
languages:
  zh:
    menus:
      main:
        - identifier: docs
          name: 文档
          pageRef: /docs
          weight: 20
        - identifier: blog
          name: 博客
          pageRef: /blog
          weight: 50
        - identifier: download
          name: 下载
          pageRef: /download
          weight: 60
          params:
            icon: fa-solid fa-download

weight 越小越靠前。pageRef 指向站内页面,url 指向外链;外链自动加 target="_blank"rel="noopener noreferrer",并带一个外链角标。identifier 是配置里引用这个入口的稳定标识(quick_linkssidebar_root_menu 按它匹配),name 按语言翻译,identifier 不翻译。

菜单项也可以挂在页面 front matter 上,适用于「这一页本身就是一个顶层入口」:

content/download/_index.md
---
title: 下载
menu:
  main:
    weight: 30
---

顶栏右侧的 GitHub 入口 不是 菜单项,它来自 params.github_project_repo(未设时回落 params.github_repo)。标识为 github 的菜单项会被菜单区跳过,写了也不显示。改变这个入口的目标要改仓库参数,见仓库与页面信息

下拉菜单

用 Hugo 的 parent 建立父子关系,只支持一级子项

hugo.yml
menus:
  main:
    - identifier: docs
      name: 文档
      pageRef: /docs
      weight: 20
    - identifier: docs-start
      parent: docs
      name: 快速上手
      pageRef: /docs/start
      weight: 10
      params:
        icon: fa-solid fa-rocket
        description: 从 OINK Starter 起步,分层定制并部署
    - identifier: docs-components
      parent: docs
      name: 组件
      pageRef: /docs/components
      weight: 20
      params:
        icon: fa-solid fa-cubes
  • 每个条目都是独占一行的一个图标加一个标题,整个面板是一列宽度适中的 纵向列表。子项的 params.description 只是配置数据,面板不会渲染它。
  • 父级本身是一个普通链接:悬停或键盘聚焦展开面板,点击或回车进入父级页面。没有单独的展开箭头,触屏读者落到父级页面,该页正文同样列出这些链接。
  • 键盘:向下箭头展开并聚焦第一项,Esc 关闭并把焦点还给链接,点击面板外部关闭。
  • 0.5 的 params.columns 参数已退役:设置它会发出构建警告,面板保持单列。
  • 再深一层会发出构建警告并降级成静态分组标题,不会 生成三级悬浮菜单。更深的层级放进侧栏。

菜单图标

小于 lg 时菜单项只剩图标,每个顶层入口都应有一个。图标按这个顺序解析:

  1. 目标页面 front matter 里的 icon
  2. 菜单项自己的 params.icon
  3. 按 identifier / 分区名匹配的内置默认值(docs blog examples community about download github 等);
  4. 都没有时用 fa-solid fa-link

图标写成一对 Font Awesome class,主题本地提供免费版字体:

hugo.yml
menus:
  main:
    - identifier: handbook
      name: 运维手册
      pageRef: /handbook
      weight: 40
      params:
        icon: fa-solid fa-screwdriver-wrench

标签菜单

顶层入口指向 taxonomy 页面(/tags//categories/)时不需要手工配置子菜单:面板自动渲染「标签 + 数量」的 chip 网格,按数量降序排列。

hugo.yml
menus:
  main:
    - identifier: tags
      name: 标签
      pageRef: /tags
      weight: 60

分类怎么启用见分类体系

顶栏控件

顶栏高 50px,从左到右是:品牌(Logo 或字标)、菜单区、搜索、版本、语言、主题、GitHub。首页和 Landing 页面最右侧还固定保留抽屉菜单按钮。顶栏在所有布局上渲染;文档、博客和分类页使用相同控件,但没有这个 Landing 抽屉。

顶栏分为桌面完整形态与紧凑图标形态:

视口 状态
lg 及以上 完整:品牌、带文字的菜单项、各工具控件;首页/Landing 最后是抽屉按钮
小于 lg 紧凑:品牌保留,其余全部右对齐成图标
小于 md 顶栏右侧只留搜索与抽屉按钮;版本、语言、主题与快捷键帮助仍在页脚最底层栏中

各控件的开关不在这里:搜索图标要 params.offline_search(见全文检索),版本菜单要 params.versions(见多版本),语言菜单在配置了两种及以上语言时自动出现(见多语言),主题控件要 params.ui.dark_mode(见品牌外观)。

自动隐藏

hugo.yml
params:
  ui:
    navbar_autohide: true

开启后顶栏离开正常流、停在视口上方,指针进入原位置上方 60% 的中间区域(或键盘焦点进入)才滑出,并且覆盖在正文之上,不把正文顶下去。左右各 64px 不属于唤醒区,避免盖住折叠后的侧栏与大纲恢复按钮。

小于 768px、粗指针或纯触屏时自动停用,顶栏始终可见。页面 front matter 顶层的 navbar_autohide 或分区 cascade 可以逐段覆盖。

关闭顶栏

hugo.yml
params:
  ui:
    navbar_enabled: false

也可以只关闭某一页或某一段:

content/docs/_index.md
---
title: 文档
cascade:
  navbar_enabled: false
---

关闭后主题补回原本由顶栏承担的界面:移动端子导航、侧栏顶部的品牌与搜索行、大纲轨道上的工具按钮。这个开关适用于必须独占视口的页面,不作为常规排版偏好。本站的文档栏目使用它:文档页依靠侧栏导航,顶栏是多余的一行。

栏目切换器

侧栏顶部那一行是栏目切换器,决定当前显示哪棵树。入口集合按顺序去重构造:所有顶级栏目 → 全站所有 sidebar_root_for: self 的分区 → 当前解析出的根。

hugo.yml
params:
  ui:
    sidebar_root_enabled: true
    sidebar_root_menu: true

让一棵大子树自成一个根(带版本的 API 参考、独立手册),在它的 _index.md 里:

content/docs/api-v2/_index.md
---
title: API 参考 v2
sidebar_root_for: self
sidebar_root_link_self: true
---

self 让这个分区索引与它的后代都用这棵新树;children 把索引留在父树里,只约束后代。让某个顶层分区不出现在切换器里,在它的 front matter 里设 sidebar_root_menu: false

只有一个入口时切换器退化成一个无边框链接,两个及以上才是下拉菜单。切换器下面的树仍然把栏目首页本身作为第一个链接:切换器选一棵树,根链接选一篇文档。

面包屑与页面操作

普通内容页标题上方是面包屑行,这一行右端同时承载页面操作。顶层分区省略只有一级、与标题重复的面包屑,操作按钮的位置不变。

hugo.yml
params:
  ui:
    breadcrumb: false

面包屑标签取本地化的 linkTitle,层级与侧栏一致。

页面操作菜单

页面操作是标题行末尾的拆分按钮:左半边一键复制本页 Markdown(成功后变成绿色对勾),右侧箭头展开完整菜单。菜单分两组,上半组是取走内容,下半组是改动与产出:

操作 出现条件
复制 Markdown 文本 站点开了 markdown 输出格式
在 ChatGPT 中打开 page_context_menu.assistant_links: true
在 Claude 中打开 同上
查看 Markdown 源码 markdown 输出格式
查看编辑历史 params.github_repo 能解析出源文件路径
编辑本页 params.github_repo
新建子页面 params.github_repo
提交文档 issue params.github_repo
提交项目 issue params.github_project_repo
打印整个分区 分区开了 print 输出格式
hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      assistant_links: false
      links: []

助手入口默认关闭:读者点击时,完整的当前 URL(含 query 与 fragment)会随本地化提示词发给第三方,页面正文不上传。开启前确认 URL 里没有敏感信息,并在隐私说明里披露这个边界。页面可以用布尔型 front matter assistant_links 收紧站点策略,不能反过来替站点开启。

自定义外部操作排在菜单最后,url 支持三个已 URL 编码的占位符:

hugo.yml
params:
  ui:
    page_context_menu:
      links:
        - name: 询问内部助手
          icon: fa-solid fa-wand-magic-sparkles
          url: https://assistant.example.com/new?source={markdown_url}&title={title}

可用占位符:{url}(页面完整地址)、{title}(页面标题)、{markdown_url}(Markdown 版地址)。

在博客根分区及其一级子分区上,左半边变成 RSS 订阅链接,菜单里仍保留「复制 Markdown 文本」。没有 Markdown 输出的页面去掉左半边,箭头变成带文字的「操作」按钮。

这些操作同时是命令面板里的条目。

翻页器

正文末尾的上一页 / 下一页是两个文本链接,顺序与侧栏可见树一致:根页 → 第一篇 → 直到最后一篇。根页没有上一页,末页没有下一页。站点提供 data/docs_nav.json 时,这棵显式树同时决定翻页顺序,以及该文件声明过的 docs / book 栏目的栏目索引顺序——侧栏、翻页器与索引不会再把同一批子页排出三种顺序。文件没有声明的栏目,以及没有这个文件的站点,仍然沿内容树走。见布局与页面类型

hugo.yml
params:
  ui:
    pager_types: [docs, book, blog]

pager_types 只接受 docsbookblog 三个值,其它取值告警并丢弃。单页退出用 front matter:

content/docs/appendix.md
---
title: 附录
pager: false
---

同一份顺序也写进 <head>:有上一页 / 下一页时输出 <link rel="prev"><link rel="next">,供浏览器与爬虫识别阅读序列。

页面源码
<link rel="prev" href="/zh/docs/customize/home/">
<link rel="next" href="/zh/docs/customize/layout/">

翻页只在 HTML 输出中生效。打印、Markdown 与 RSS 既没有翻页链接,也没有这两个 rel 关系。

翻页器是页尾四件套的第三件(反馈 → 页面信息 → 翻页 → 评论),顺序固定,四者独立开关。

右栏可以列出有哪些页面链接到这一页:一个带链接图标的「反链」组,排在目录下方、分类标签云上方,默认展开;低于 xl 断点时,它随目录一起进入侧栏抽屉。从搜索落到这一页的读者由此看到哪些页面认为它值得指向,也看到它在站点其余部分里的位置。默认关闭,由站点打开:

hugo.yml
params:
  ui:
    backlinks: true

单页用 front matter 覆盖,分区用 cascade 覆盖它下面的所有页面:

content/docs/_index.md
---
title: 文档
cascade:
  backlinks: true
---

索引在构建时从作者本来就在写的东西里派生:页面源码里的普通 Markdown 链接,以及 ref / relref shortcode。没有新语法要学,没有内容要迁移,也不需要 JavaScript——列表就在 HTML 里。扫描前先剥掉代码围栏与行内代码;指向同一个目标的多个链接合并成一条;自链接、外链、mailto: 与同页锚点都不计入。判断目标页面时去掉 fragment,每种语言各有一张互不相干的图,中文页面不会出现在英文页面下面。条目按稳定页面路径排序,同样的内容每次构建出同样的顺序;没有任何页面链进来时整个区块不渲染——没有标题,也没有空容器。

前八条直接可见,其余折进原生的「再显示 N 条」disclosure,避免被大量引用的页面把右栏撑满,其中不涉及 JavaScript。每一条都带来源页面的描述,悬停时显示。

读源码有一处已知遗漏:写在自定义 shortcode 参数里或原始 <a href> 里的 URL 不会成为一条边;解析不出来的目标被静默丢弃,不发告警。它是导航增强,不是链接检查器,查断链仍然要用链接检查器。

非布尔取值告警并回落到关闭,hugo server 照常可用,加了 --panicOnWarning 的构建会停在这里。

页面的 Markdown 输出带同一份列表,前缀是「反链:」。RSS 省略它,print 输出格式连同整个右栏一起省略。

本站全站开启了它:看本页右栏的「反链」组就是实际效果;被引用最多的配置总览一页,列出了四十多个入链,其中大部分收在「再显示 N 条」里。

页脚形态由 params.ui.footer_style 决定(fat / slim / none,见品牌外观)。fat 的多列链接网格读 data/footer/<语言>.yaml。它不是菜单,主题没有 menus.footer

data/footer/zh.yaml
brand:
  name: 产品文档
  tagline: 一段简短的**支持 Markdown 的**说明。
  slogan: 贴近产品,给出明确答案。
columns:
  - title: 文档
    links:
      - { label: 快速上手, url: /zh/docs/start/ }
      - { label: 组件, url: /zh/docs/components/ }
  - title: 项目
    links:
      - { label: GitHub, url: https://github.com/pgsty/oink, external: true }
      - { label: 发布记录, url: /zh/blog/release/ }
  • brand.namebrand.logo 不写时回落到站点自己的品牌名、Logo 与字标;taglineslogan 渲染 Markdown。
  • 站内 url 相对当前语言根解析;external: true 在新标签页打开并带 rel="noopener noreferrer"
  • 网格列数等于数据里的列数。
  • 单语言站可以使用 data/footer.yaml
  • 配了 fat 但没有数据时自动降级成 slim,可以先开启再补内容。

fat 页脚的版权行右端有一个折叠箭头,收起或恢复它上方的链接栅格。默认展开,读者的选择存在 localStorage 的 td-footer-collapsed 键里,跨页面保留;slimnone 没有这个按钮,它也与专注模式无关。

只要页脚有渲染,最底层栏右侧就固定保留同一组图标:版本、语言、主题、快捷键帮助。各菜单向上展开;版本按钮只显示分支图标,完整版本名仍保留在选项中。fat 页脚的折叠箭头排在这四项之后。侧栏不再重复这组控件,footer_style: none 则连同页脚一起移除底栏。

版权行与中间那句说明由参数控制,见配置总览

验证

hugo --printPathWarnings --panicOnWarning

改完导航要检查这几处:

  • 构建没有 Navbar menu … supports one interactive child level 警告;出现它说明菜单嵌了三层;
  • 桌面端:父级菜单点击进入父级页面,悬停展开面板,Esc 关闭面板;
  • 窗口缩到 lg 以下:每个顶层入口仍有图标,没有图标的项在这个宽度下是空白;
  • 缩到 md 以下:首页与 Landing 顶栏右侧只剩搜索和抽屉按钮;版本、语言、主题与快捷键帮助固定在 footer 最底层栏;
  • 侧栏顶部的切换器列出所有顶级栏目,当前项有选中标记;
  • 任意文档页按 E / Q 翻页,顺序与侧栏一致,页面源码里有对应的 rel="prev" / rel="next"
  • 打开反向链接后,grep td-backlinks public/<某个被链接的页面>/index.html 能找到这个区块,而没有页面链进来的页面里完全没有这段标记;
  • 打开页面操作菜单,确认该出现的项都在,不该出现的没有(例如未配置 github_project_repo 时的「提交项目 issue」)。

5 - 布局与页面类型

用 type 决定一页用哪种外壳,再调侧栏宽度与图标、目录深度、栏目首页样式和页宽。

本页覆盖页面骨架:有没有侧栏、侧栏多宽、目录收几级、栏目首页是列表还是卡片。内容放在哪个目录见组织内容,这里只讲外壳。

规则是 外壳看 type,不看路径。文档可以放在 content/ 下的任何位置,只要给它 type: docs

外壳类型

params.ui.shell_types 列出哪些 type 使用带侧栏的阅读外壳:

hugo.yml
params:
  ui:
    shell_types: [docs, book, blog, swagger]
type 外壳
docs 文档外壳:左侧栏(栏目切换器 + 目录树)+ 正文 + 右栏大纲
book 文档外壳,另加编号目标、reading_width 阅读行宽与草稿横幅
blog 文档外壳,侧栏默认展开,标题行左半边是 RSS
swagger 文档外壳,正文交给 Swagger UI 或 Redoc,见 API 文档
其它 type 普通页面:顶栏 + 单栏正文 + 页脚,没有侧栏

分类页与标签页(taxonomy / term)不在这张表里,但也走同一套外壳。

给一棵子树指定 type 用 cascade,这是把文档放在任意路径的做法:

content/handbook/_index.md
---
title: 运维手册
type: docs
cascade:
  type: docs
---

栏目根只是导航起点

hugo.yml
params:
  ui:
    docs_section: docs
    blog_section: blog

这两个键 不决定外壳,只告诉主题文档树与博客树的根在哪,用于解析侧栏根、快捷入口与默认图标。上面 content/handbook/ 的例子照样有文档外壳,docs_section 保持 docs 不影响它。

需要让 docs 页的侧栏根变成站点首页,而不是文档栏目时:

hugo.yml
params:
  ui:
    docs_sidebar_root: home # home | section

取值只有这两个。其它值在普通预览中告警并使用 section,严格发布构建通过 --panicOnWarning 拒绝这条警告。

文档挂在站点根

以文档为主的站点可以把 docs 分区发布到 URL 根路径,源码仍然放在 content/docs/ 下。这需要三段配置一起给出。

第一段用 Hugo 原生的 permalinks 去掉 URL 里的 docs/ 段:

hugo.yml
permalinks:
  page:
    docs: /:sections[1:]/:slug/
  section:
    docs: /:sections[1:]

第二段让物理站点根索引仍可作为链接目标,但不再争抢同一个输出路径。每种语言的站点根索引(content/_index.mdcontent/_index.zh.md)都要写:

content/_index.zh.md
---
title: 产品文档
build: { render: link }
---

第三段把侧栏根声明为站点首页,让侧栏与翻页共用同一棵树:

hugo.yml
params:
  ui:
    sidebar_root_enabled: true
    docs_sidebar_root: home

docs_sidebar_root: home 之后,站点首页的所有顶层分区都会进入这棵树。博客、社区、下载这类不属于阅读序列的概览分区,在自己的 _index.md 里设 toc_root: true 退出,它们既不出现在树里,也不成为翻页目标:

content/blog/_index.md
---
title: 博客
toc_root: true
---

文档此时与博客、社区等分区共享 URL 根路径。构建加 --printPathWarnings,发布前解决所有重复目标。

落地页

任意页面加 layout: landing 即使用落地页布局:顶栏 + 分区拼装的正文 + 页脚,没有侧栏。数据写法见首页与落地页

hugo.yml
params:
  ui:
    landing_search: true

landing_search: false 会把搜索入口从落地页外壳里去掉,其它页面不受影响。

侧栏

侧栏树来自 content/ 的目录结构,按 weight 排序,有 linkTitle 时用它作为标签。可调的是密度与尺寸:

hugo.yml
params:
  ui:
    sidebar_menu_compact: true
    sidebar_menu_foldable: true
    sidebar_menu_truncate: 2000
    sidebar_width_min: 220
    sidebar_width_max: 480
    sidebar_item_overflow: ellipsis # ellipsis | wrap
    sidebar_expand_levels: 2
  • sidebar_menu_compact 只展开当前分支及邻近条目;设为 false 时整棵树全展开。
  • sidebar_menu_foldable 允许读者手动展开 / 折叠分区。博客栏目默认展开;某个分区要默认收起,在它的 _index.md 里写 sidebar_expanded: false
  • sidebar_expand_levels 是默认展开的层级数。
  • sidebar_menu_truncate 是单个分区最多渲染的条目数,避免上千页的目录把 HTML 撑到不可用。
  • sidebar_width_min / sidebar_width_max 是桌面端拖拽调宽的上下限(像素)。读者调整后的宽度存在浏览器本地,双击分隔条恢复默认。
  • sidebar_item_overflow 默认 ellipsis(长标题省略),中文长标题多的站点可以改 wrap 换行。

折叠状态、宽度与滚动位置保存在读者本地,按语言隔离。小于 md 时侧栏变成带遮罩的抽屉。

单页去掉侧栏用 front matter:

content/docs/fullscreen-report.md
---
title: 全屏报告
sidebar_enabled: false
---

显式导航树 data/docs_nav.json

侧栏树默认从 content/ 推导。站点也可以给出一份显式导航清单,三个条件同时成立时主题改用它渲染:

  • 站点存在 data/docs_nav.json 且其中有 sections 键;
  • 页面的 type 是 docsbook
  • 解析出的侧栏根不是站点首页。

文件是一棵嵌套的节点树。每个节点用 page 指向内容路径,url 是它的链接,children 是子节点;active_path_by_url 记录每个 URL 对应的祖先链,供当前项高亮使用:

data/docs_nav.json
{
  "sections": [
    {
      "page": "/docs/start",
      "url": "/docs/start/",
      "children": [{ "page": "/docs/start/install", "url": "/docs/start/install/" }]
    }
  ],
  "active_path_by_url": {
    "/docs/start/install/": ["/docs/start/"]
  }
}

URL 在比较前去掉语言前缀,一份文件服务所有语言。

这棵树同时决定翻页顺序,侧栏与上一页 / 下一页不会出现两种排序。sections 为空数组 时告警并回退到内容树;page 指向不存在的页面时告警并跳过该项。严格发布构建拒绝 任一警告。带 manual_link 的占位节点与 sidebar_divider 分隔行留在侧栏里,但不会 成为翻页目标。

适用场景是导航顺序由外部工具生成的站点,例如从 Sphinx toctree 迁移过来、需要冻结既有章节顺序的手册。顺序由 content/weight 维护时不需要这个文件。

侧栏图标密度

页面 front matter 里的 icon 会出现在侧栏。叶子页全部带图标会降低可读性,用密度策略控制:

hugo.yml
params:
  ui:
    sidebar_icon_policy: groups # all | groups | none
取值 效果
all 每个有图标的条目都显示(未设置时的兼容默认值)
groups 只有根节点和有子页的节点显示图标
none 侧栏不显示条目图标

非法取值只发警告并回落到 all,不让构建失败。本站使用 groups

在侧栏里展开标题

Book 页可以在侧栏当前行下展开 h2–h4 分支,便于在长章节内跳转:

hugo.yml
params:
  ui:
    sidebar_headings: 3 # false | true | 2 | 3 | 4

整数指定展开到第几级(2–4),true 等于 2(只展开 h2),false 关闭。取值超出 范围时普通预览告警并关闭标题分支,严格发布构建拒绝这条警告。只对 type: book 的页面生效,且只在侧栏当前行下展开。

目录 TOC

右栏大纲由 Hugo 从 Markdown 标题生成,收录层级是 Hugo 原生配置:

hugo.yml
markup:
  tableOfContents:
    startLevel: 2
    endLevel: 4
    ordered: false

主题只管跟踪行为:

hugo.yml
params:
  ui:
    scroll_spy: false

默认 关闭 滚动跟踪。设为 true 开启后,大纲绘制连续轨道、高亮当前区段并标出位置。读者可以整体折叠右栏,状态存在本地。小于 xl 时右栏隐藏,大纲内容移进侧栏抽屉。

单页隐藏大纲用 front matter notoc: true

只有进入 Hugo 目录的标题才出现在大纲里:Markdown 型 shortcode({{%/* … */%}})输出的标题会进,普通 shortcode({{</* … */>}})输出的通常不会。结构性标题应留在 Markdown 里。

栏目首页样式

_index.md 的分区会自动列出子页。两种样式:

hugo.yml
params:
  ui:
    section_index: cards # list | cards
    section_index_columns: 2
  • list(默认):每个子页一个标题 + 描述段落;
  • cards:网格卡片,读子页的 title(或 linkTitle)、descriptionicon

可以按分区覆盖。非法取值在普通预览中告警并回退,发布门禁带 --panicOnWarning 时拒绝这条警告:

content/docs/components/_index.md
---
title: 组件
section_index: cards
section_index_columns: 3
---

相关的页面级开关:no_list: true 不列子页;simple_list: true 只输出一个无描述的项目符号列表;子页设 hide_summary: true 把自己从列表里去掉。不要手写子页清单:手写的清单会与侧栏失同步。

页宽

hugo.yml
params:
  page_width: normal # normal | wide | full

normal 是常规阅读宽度,wide 放宽正文栏,full 铺满视口。可以逐页或按分区覆盖;宽表格、大图与 API 参考页常用 wide

content/docs/api/reference.md
---
title: 接口参考
page_width: wide
---

Book 页另有一个 reading_widthslim / normal / wide),改的是正文本身的 阅读行宽,不动外壳。两个键取值非法都会在普通预览中告警并回退,严格发布时失败。

顶栏与页脚开关

顶栏与页脚属于逐页的布局决定,写在 front matter 顶层(不在 ui 下),可以用分区 cascade 一次设定:

content/docs/_index.md
---
title: 文档
cascade:
  navbar_enabled: false
  footer_style: slim
---

行为见导航与菜单品牌外观,键的定义见页面参数

验证

hugo --printPathWarnings --panicOnWarning
  • 构建输出 Total in …,没有 ERROR / WARN;
  • 新建的 type: docs 页面有左侧栏。没有则检查 cascade 是否覆盖到该页,以及 shell_types 是否包含这个 type;
  • 拖动侧栏分隔条,刷新后宽度保留,双击恢复默认;
  • 窗口缩到 md 以下时侧栏变成抽屉且可关闭,缩到 xl 以下时大纲移进抽屉;
  • 栏目首页的卡片数量与侧栏子页数量一致;
  • page_width: wide 的页面比相邻页面宽;
  • 文档挂在站点根时,hugo --printPathWarnings 没有重复输出路径的告警。

6 - 全文检索

打开本地搜索,控制索引体积与结果排序,让中文查询也能命中。

OINK 的搜索是本地搜索:Hugo 在构建时给每种语言生成一份 JSON 索引,读者的浏览器下载它,在本地完成检索。不需要爬虫、账号、CDN,也不需要联网。主题默认不启用,一行配置即可开启。

搜索的入口是命令面板,打开方式与面板的其余内容见命令面板

打开本地搜索

hugo.yml
params:
  offline_search: true

这一个键决定索引、Lunr 运行时与搜索对话框是否进入页面。三个条件同时成立时页面才带上它们:

  • params.offline_search 为真;
  • 页面是首页,或者用了外壳布局(docs / book / blog / swagger,见布局与页面类型),或者是开着 params.ui.landing_search 的落地页;
  • 当前输出不是打印。

任何一条不成立,构建就不往这个页面里放对话框、索引引用与 Lunr。这些资源不是被隐藏,而是不生成。

hugo server 下索引默认 也会生成,预览行为与线上一致。站点极大、每次改动都重建全站索引明显拖慢预览时,把它关掉:

hugo.yml
params:
  offline_search: true
  # 预览时跳过索引构建,只在超大站点上需要
  offline_search_on_serve: false

控制索引体积

offline_search_index 决定每个页面往索引里写多少内容,因此同时决定两件事:读者能否搜到正文里的词,以及第一次搜索要下载多大的文件。

hugo.yml
params:
  offline_search: true
  offline_search_index: summary
  offline_search_summary_length: 70
  offline_search_max_results: 10
取值 索引进去的内容 什么时候用
title 标题、标签、分类、search_keywords 只靠标题定位的超大站
heading 上面这些 + 页内各级标题 标题写得足够具体时
summary 上面这些 + 描述与摘要 千页级站点;本站使用这一档
content 上面这些 + 全文纯文本 默认值,几百页以内适用

其它取值在普通预览中告警并使用 content;严格发布构建因 invalid params.offline_search_index 失败。

offline_search_summary_length 是结果行里摘要的截断长度(默认 70),offline_search_max_results 是结果条数上限(默认 10)。这几个键的完整定义在配置总览

每种语言一份索引,预算是未压缩 2 MiB、gzip 512 KiB。

读者搜第一个词之前要先下载整份索引。超过这个量级就把 offline_search_indexcontent 降到 summary

调整排序

页面在 front matter 里影响自己的排名:

content/docs/reference/pgsql.zh.md
---
title: PostgreSQL 参数
search_keywords: [postgres, postgresql, pg, 数据库参数, GUC]
search_boost: 1.5
---

search_keywords 是额外的匹配词,可以写一个字符串,也可以写数组。它是这两个键里更有用的一个:读者搜 pgGUC 即可命中标题只写着「PostgreSQL 参数」的页面。检索时关键词的权重仅次于标题,高于正文。

search_boost 是最终得分的正数乘子,默认 1.0,作用在文本匹配得分之上。1.5 不会把页面固定在第一位,只让它在本来就匹配的结果里前移。零、负数与非数字会告警并按 1.0 处理。

整节的默认值用 cascade 一次设定:

content/docs/_index.zh.md
---
title: 文档
cascade:
  search_boost: 1.25
---

页面自己写的值覆盖继承来的值。本站 docs/ 下的页面按这种方式使用 search_keywords:每页列出中文说法、英文原词与配置键名。

把页面挡在索引外

content/internal/draft-plan.zh.md
---
title: 内部计划
search_exclude: true
---

search_exclude 是唯一写法。已移除的 exclude_searchexcludeSearch 不再被 读取,因此不能保护页面;迁移检查器会报告它们。正文为空的页面不进索引。

索引是任何人都能下载的静态 JSON 文件,不是访问控制。

不该公开的内容不要放进站点,也不要用 search_exclude 保护它。

中文与 CJK

Lunr 不能可靠地给中文分词。面板在查询里检测到 CJK 字符时整条切到子串匹配:逐篇比对标题、关键词、页内标题、描述、正文,命中哪一层给哪一层的分,最后同样乘上 search_boost。两条路径的排序规则一致。

三点需要知道:

  • 中文查询是 子串 匹配。搜「主从复制」只命中连续出现这四个字的位置,搜「复制主从」没有结果。
  • search_keywords 对中文站的收益因此最大:把读者可能使用的同义说法、英文原词、缩写都写进去。
  • 输入法组字期间面板不重算结果,文字上屏后才检索,中文输入不会逐字母刷新结果。

中文搜不到内容时,先确认中文页面进了中文那份索引(见下面的验证),再考虑分词问题。

可选:在线搜索

本地搜索之外,主题保留了两个在线搜索集成,默认关闭。同一时间只启用一种:配置了多个入口时构建告警 You have more than one site-search option configured

启用在线搜索意味着接受对应服务的抓取方式、可用性与隐私边界,这些应写进站点的隐私说明。

Algolia DocSearch

hugo.yml
params:
  search:
    algolia:
      appId: YOUR_APP_ID
      apiKey: YOUR_SEARCH_ONLY_KEY
      indexName: YOUR_INDEX

三个值必须都显式写出,缺一个构建中断:OINK 不会回退到其它项目的公共索引。DocSearch 的 JS 与 CSS 随主题内置,不从 CDN 加载,但每次检索请求都发到 Algolia。需要真实的密钥与索引才能工作,此处不渲染。

Google 可编程搜索

hugo.yml
params:
  gcs_engine_id: YOUR_ENGINE_ID

还需要给结果准备一个落地页:

content/search.md
---
title: 搜索结果
layout: search
---

搜索框把查询提交到 <baseURL>/search/?q=…,结果由 Google 的脚本在那个页面上渲染,需要访问 cse.google.com。同样需要外部服务,此处不渲染。

验证

  1. 构建,确认每种语言各生成了一份索引:

    hugo --printPathWarnings --panicOnWarning
    ls public/offline-search-index.*

    开发构建下文件名是 offline-search-index.zh.json,生产构建加指纹,形如 offline-search-index.zh.7ab….json。一种语言一个文件,缺少某个文件说明那种语言的页面没进索引。

  2. 查看索引内容,这是排查「中文搜不到」的第一步:

    python3 -c "import glob,json; f=sorted(glob.glob('public/offline-search-index.zh*.json'))[0]; \
      d=json.load(open(f)); print(f, len(d)); print(d[0])"

    条目数应接近中文页面数,keywordsboost 字段能看到写进 front matter 的值。

  3. 打开站点,按 /,分别用一个英文词与一个中文词各搜一次。结果按内容根分组,每组的名字是面包屑的第一段。

  4. 子路径部署(站点挂在 https://example.com/docs/ 这类路径下)时,打开浏览器开发者工具的网络面板,确认索引请求带上了子路径。索引请求打到域名根目录并返回 404、页面其余部分正常,是「搜索没结果」最常见的原因。

  • 命令面板 — 搜索的入口,以及面板里的命令与页面动作
  • 键盘导航 — 打开搜索与打开命令的四个单键
  • 多语言 — 分语言索引与缺译回退
  • 配置总览offline_search* 各键的完整定义
  • 页面参数search_keywords / search_boost / search_exclude

7 - 命令面板

一个对话框同时承担页面搜索、页面动作与站点命令:如何打开、包含哪些分组、如何添加自定义命令。

命令面板是站点唯一的模态入口:搜索页面、复制本页 Markdown、切换语言、切换版本、跳转到站点自定义链接,都在这一个对话框里完成。它随本地搜索一起装配:params.offline_search 关闭时,面板连同索引与 Lunr 都不进入页面,见全文检索

打开面板

打开方式 打开成什么
点顶栏或侧栏的搜索框 完整搜索态
/ Ctrl + K 完整搜索态;再按一次关闭
/ 完整搜索态
反斜杠键 纯命令态(等于预填了 >
f / c 同上两者,由键盘导航提供
在框里输入 > 开头的查询 纯命令态

/、反斜杠、fc 都是裸单键,会给输入让行:焦点位于 input、textarea、select 或 contenteditable 中,以及正在用输入法组字时,按键作为普通字符输入。带修饰键的 /Ctrl + K 没有这个限制,在输入框里也能打开面板。

面板内: 选择,Enter 执行,Esc 关闭并把焦点交还给打开它的控件。

面板内容

不输入任何内容时,面板按固定顺序列出四组:

分组 内容 谁决定
快速链接 顶栏一级菜单里选出的几个入口 params.ui.quick_links
页面操作 复制 Markdown、查看 Markdown 源码、编辑本页、查看修改历史、新建子页、提 issue、打印整节 仓库配置与本页是否有 Markdown 输出
偏好设置 切换版本 → 切换语言 → 切换主题 站点是否配了多版本、多语言、深浅色菜单
命令 打开 GitHub 仓库,之后是站点自定义命令 params.github_project_repo(缺省回退到 github_repo)与 ui.command_palette.commands

偏好设置三项的顺序与顶栏控件一致(版本、语言、主题),面板与顶栏是同一个次序。选中「切换语言」这类项后,面板不立即跳转,而是就地展开可选项,再选一次。

输入文字时,先是页面结果,按内容根分组(分组名是面包屑的第一段,组间顺序跟随顶栏一级菜单的顺序),命令与动作合并成一组排在最后。

> 开头时只列命令与动作,不查页面。不确定某个功能在哪个菜单里时用它定位。

不可用的项在能说明原因时仍然列出。站点没有配置仓库地址,「编辑本页」会带着「不可用」的说明留在列表里,而不是消失。

快速链接从 Hugo 主菜单里按 identifier 选取,不另写一份清单:

hugo.yml
params:
  ui:
    quick_links: [docs, blog]

值是 menus.main 里条目的 identifier。不写这个键时默认取文档栏目与博客栏目(params.ui.docs_sectionblog_section)。菜单本身怎么配见导航与菜单

自定义命令

站点自己的命令写在 params.ui.command_palette.commands 下,排在内建命令之后,顺序即书写顺序:

hugo.yml
languages:
  zh:
    params:
      ui:
        command_palette:
          commands:
            - id: theme_issues
              title: OINK 问题反馈
              description: 报告或查看主题与文档问题
              url: https://github.com/pgsty/oink/issues
              icon: fa-brands fa-github
              keywords: [缺陷, 支持, 路线图]

上面是本站在用的那一条。字段共七个。写入其它键或无效记录时,普通预览告警并 丢弃该命令,严格发布构建拒绝这条警告:

  • id 必填,小写字母开头,只能用小写字母、数字、下划线和短横线;不能与内建动作 ID 重名。
  • title 显示在面板里;description 是它下面那行小字;icon 是一对 Font Awesome class。
  • keywords 是数组,参与匹配但不显示,用于收纳读者可能输入的检索词。
  • urlaction 有且只能有一个url 只接受 http/https 的完整地址、站内路径,或 # 开头的页内锚点;带主机名的地址在新标签打开。action 引用一个内建动作 ID。
不要用 action: 给内建动作起别名

内建动作已经在面板里,再包一层会让同一个功能以两个名字出现两次。

多语言站点把命令写在 languages.<lang>.params.ui.command_palette.commands 下,标题与关键词才能本地化。顺序由默认语言的那份清单决定:其它语言里同 id 的条目只覆盖字段,新增的 id 追加在末尾。各语言的命令顺序因此一致,读者换语言时命令不会换位置。

配置只能给出链接或引用内建动作,不能注入 JavaScript 回调:面板读取的是一份纯数据清单。

页面动作

面板里的「页面操作」与文档标题旁的拆分按钮是同一套实现:同一份动作描述、同一段 URL 生成逻辑、同一个执行器。按钮左半边复制本页 Markdown,右侧箭头展开全部动作。

整组关闭,或只在某些页面关闭:

hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      # 打开后才会出现「在 ChatGPT / Claude 中打开」
      assistant_links: false
      links: []

enable: false 只移除标题旁的按钮,面板里的对应项保留,面板本身就是命令入口。单页用 front matter 的 page_context_menu: false 覆盖。

assistant_links 默认关闭,原因是读者点击时 当前页面的完整 URL(含查询串与锚点)会被发送到第三方,页面正文不会上传。这是站点级的选择,页面 front matter 里的 assistant_links 只能把它收紧,不能替站点打开。

links 是额外的外部动作,只出现在标题旁的菜单里,不进面板:

hugo.yml
params:
  ui:
    page_context_menu:
      links:
        - name: 在站内讨论区提问
          url: https://github.com/pgsty/oink/discussions/new?title={title}
          icon: fa-solid fa-comments

{url}{title}{markdown_url} 三个占位符会被替换成当前页面的值。

「编辑本页」「查看修改历史」「提 issue」这些动作是否可用,取决于仓库相关的配置,见仓库与页面信息;「复制 Markdown」「查看 Markdown 源码」需要页面开了 markdown 输出,见 Agent 支持

同一个对话框,两条独立的数据来源:

  • 页面结果 来自本地搜索索引。索引未生成或下载失败时,面板照常打开、照常执行命令,页面那部分显示「页面索引暂不可用,操作仍可使用」。
  • 命令与动作 来自页面里内嵌的一段 JSON 清单,不需要网络。

打印态不装配面板,打印输出里没有它;关闭 offline_search 后同样没有面板,此时 fc 静默,不影响正常输入。

验证

  1. 构建后确认命令清单进了页面:

    grep -o 'id="oink-action-manifest"' public/zh/docs/customize/panel/index.html

    没有这一行说明本地搜索没启用,或者这个页面不在外壳布局里。

  2. 打开站点按下 /Ctrl + K,什么都不输入:应该看到快速链接、页面操作、偏好设置、命令四组,顺序如上。

  3. 输入 >:只剩命令与动作。新加的命令应该排在「打开 GitHub 仓库」之后。

  4. 切到另一种语言重复第 3 步,确认命令的标题变了、顺序没变。

  5. 打印预览(/Ctrl + P)里不应该出现任何面板痕迹。

8 - 键盘导航

全部单键快捷键、它们何时让行给输入,以及按站点或按页面关闭的方法。

OINK 的交互式页面自带一套单键快捷键:WASD 在侧栏树中移动,J K 在标题间跳转,Q E 翻页,另有几个单键切换主题、语言与命令面板。默认开启,所有绑定都给输入让行,可以按站点或按页面关闭。

键盘导航不维护第二套状态:树的展开折叠复用侧栏原有的箭头按钮,逐节跳转读取右栏目录,切换语言与主题复用命令面板的同一批动作。键盘操作的顺序与鼠标操作的顺序因此一致。

侧栏

按键 行为
W S 焦点移到上一个 / 下一个可见项
A D 折叠 / 展开分组;叶子节点上 A 跳到父级,D 无动作
Enter Space G 打开焦点所在的页面
Esc 退出树,焦点回到正文

四个字母键不需要先进入树:焦点还在正文时按 S,以当前页在侧栏里的那一项为起点下移一格并落焦。焦点行整行加深底色,比「当前页」的底色深一档,用于区分当前页与焦点位置。

窄屏侧栏收进抽屉、或桌面侧栏被折叠时,第一次按这四个键先展开侧栏。页面没有侧栏树时静默。

方向键 只在焦点已经进入侧栏后 才作用于树,正文里保持浏览器原生滚动。RTL 语言下 随阅读方向对调,A D 恒等于「折叠 / 展开」。

阅读

按键 行为
J K 沿页面目录跳到下一节 / 上一节
N 首页专用:跳到下一个顶层分区(首页 J 的助记别名)
Q E 上一篇 / 下一篇
H 专注阅读模式:隐藏 / 恢复导航外壳

J K 的目标序列与右栏目录同源,落点与点击目录一致。跳转是固定 100 ms 的缓动滑行,与距离无关;连续按键不必等上一段动画结束。已经读到某一节内部一段距离后,K 先回到本节起点,再按一次才跳到上一节。页面没有标题时退化为一小段滑动。

Q E侧栏树的可视顺序 翻页,不按日期。栏目入口页本身也是树里的一项,博客的栏目边界因此表现为「上一专栏最后一篇 → 下一专栏入口页 → 下一专栏第一篇」。折叠起来的分支不在这个顺序里:翻页顺序与焦点移动顺序是同一个。页面没有侧栏树时回退到页尾翻页器,没有翻页器时用 <head> 里的 rel=prev/next

H 在首页只隐藏顶栏与页脚,在文档页同时隐藏左右栏与浮动按钮。状态记录在当前标签页的会话中,首帧之前恢复,用 Q E 连续翻页不丢状态、不闪烁。外壳隐藏时 WASD 不会把焦点送入不可见的侧栏。

外观、语言与路由

按键 行为
L Y 循环切换语言(两个键等价)
T 亮 / 暗模式切换
R 在首页与顶栏的同源一级入口之间循环

这三个键在任何交互式页面上都有效,不限于文档外壳。单语言站点的 L、关闭深浅色菜单后的 T、只有一个一级入口时的 R 都静默。R 只在同源的一级菜单项之间循环,外链与顶栏上的工具控件不参与。

按键 行为
F/ 打开命令面板的完整搜索态
C 或反斜杠键 打开命令面板的纯命令态
KCtrlK 打开面板;再按一次关闭

/ 和反斜杠属于搜索功能本身,关闭键盘导航后仍然可用;F C 是键盘导航提供的别名,指向同一个面板实例。部分非美式键盘布局上反斜杠不易按到,在面板里输入 > 前缀同样进入纯命令态。面板里有什么见命令面板

保留不占用的键

? 保留不绑定。速查卡挂在页脚最底层栏的问号按钮上,鼠标悬停、键盘聚焦或触摸都能打开,列出当前页面实际可用的按键:单语言站点看不到切换语言那一行。

G GShiftG 和数字键同样保留,可能用作将来的跳转序列。

快捷键的让行规则

所有绑定都是裸单键,凡是可能和输入或弹层冲突的场合一律禁用:

  • 焦点在 input、textarea、select 或 contenteditable 区域里;
  • 正在用输入法组字(中文站的硬约束);
  • 按住修饰键时:C 仍是复制,Shift 仍归浏览器;
  • 命令面板或别的对话框开着,键盘归那个弹层。

评论区在 iframe 中,键事件不冒泡到页面,无需额外隔离。

焦点顺序与无障碍

  • 跳转链接:进入页面后第一次按 Tab 出现的就是「跳转到主要内容」,一步跳过顶栏和侧栏。
  • 真实焦点:树内导航移动的是真正的 DOM 焦点,不是虚拟光标。屏幕阅读器因此读出链接名与「当前页」标记,Enter 是链接的原生行为,Tab 顺序没有被改写。
  • 高对比度:焦点行的底色在 forced-colors 模式下失效,退化为系统高亮色描边。
  • 减弱动效prefers-reduced-motion 打开时,逐节跳转与翻页滚动改为瞬时定位,不做滑行。
  • 速查卡里的键帽与正文里的按键组件是同一套样式。

关闭

全站关闭:

hugo.yml
params:
  ui:
    keyboard_nav: false

单页关闭(交互密集的演示页常常需要),或者用 cascade 按整节关闭:

content/docs/playground.zh.md
---
title: 交互演练场
keyboard_nav: false
---

这个键只接受布尔值。写成 "false" 或其它值时,普通预览告警并使用站点默认值; 严格发布构建因 params.ui.keyboard_nav must be a boolean 失败。完整定义见 配置总览

关闭后运行时不进入 JavaScript bundle,而不是加载后再判断。/、反斜杠和 K 属于搜索,仍然可用;页脚折叠链接栅格的箭头不受影响。

验证

  1. 构建后确认速查卡按钮在页面里:

    grep -c 'td-shell-keyboard__trigger' public/zh/docs/customize/keyboard/index.html

    关闭键盘导航且没开本地搜索时,这个按钮整个不生成。

  2. 打开一篇文档,光标停在正文里连按 S:侧栏里应该从当前页那一项开始逐项下移,正文不动。

  3. E 若干次,核对翻页顺序与侧栏从上到下的顺序一致;折叠一个分组再翻,被折叠的页面应该被跳过。

  4. 点进搜索框,按 J:页面 不应该 滚动,字符正常输入。使用中文输入法输入时同理。

  5. 系统里打开「减弱动态效果」,再按 J:应该瞬间定位,没有滑行。

9 - 多语言

增加一种语言、并排放置译文、按语言配置菜单与界面文案,并对齐中英标题锚点。

OINK 使用 Hugo 的多语言模型,不额外引入目录约定:配置一个 languages 块,译文与原文并排放在同一个目录里,用文件名后缀区分。以下内容覆盖单语言站点扩展为双语站点需要改动的位置,以及双语站点的两处易错点:资源归属与标题锚点。

启用第二种语言

hugo.yml
defaultContentLanguage: en

languages:
  en:
    label: English
    locale: en-US
    weight: 1
    title: OINK
    params:
      description: A Hugo theme for engineering docs
  zh:
    label: 简体中文
    locale: zh-CN
    weight: 2
    title: OINK
    params:
      description: 为工程而设计的 Hugo 文档主题
      time_format_default: 2006年1月2日
      time_format_blog: 2006年1月2日

上面是本站在用的配置。四个字段的作用:

  • label 是语言选择器里显示的名字,用该语言自己的文字书写:写 简体中文,不是 Chinese
  • locale 是标准语言标签,会进 <html lang>hreflang 备用链接和 Open Graph 元数据。
  • weight 同时决定语言排序和选择器的轮换顺序,小的在前。
  • params 是语言级覆盖:这里没写的键继承全局同名值。日期格式通常需要按语言各写一遍。

默认语言不带路径前缀(英文在 /docs/…),其它语言各占一个前缀(中文在 /zh/docs/…)。默认语言也需要前缀时加 defaultContentLanguageInSubdir: true。这会改变全站 URL,已上线的站点要同时配好重定向。

文件命名与资源

译文与原文并排放置,用后缀区分,Hugo 靠相同的基础文件名把它们认成同一页的两个语言版本:

  • content/docs/
    • install.md英文
    • install.zh.md中文
    • _index.md
    • _index.zh.md

页面包同理:index.mdindex.zh.md 放在同一个目录里。

页面包里的资源遵循一条规则:文件名不带语言后缀的资源由所有语言共享,带语言后缀的资源只属于那种语言。

  • content/docs/install/
    • index.md英文页
    • index.zh.md中文页
    • topology.webp两种语言都能用
    • screenshot.zh.webp只有中文页能用

正文里引用带后缀的资源时 写不带后缀的名字![截图](screenshot.webp),Hugo 会按当前语言解析。

这条规则有一个推论:页面包里只有 index.zh.md、没有英文对等页时,不带后缀的资源不会分给中文页,它们归属默认语言,而默认语言在这个包里没有页面。此时所有资源都必须带 .zh. 后缀,本站 docs/ 下的中文页面包即是如此。

哪些内容需要翻译:

  • 翻译titledescription、摘要、菜单标签、标签名、图片 alt、提示块正文、shortcode 里面向读者的参数。
  • 保持一致:日期、weight、别名,以及任何影响路由的元数据。两边不一致会导致侧栏顺序在两种语言下不同。
  • 不翻译:命令、配置键、文件名、URL、版本号、产品名、shortcode 名。

按语言分开的配置

三处内容不在 content/ 里,需要各语言各写一份。

菜单 写在各自语言下:

hugo.yml
languages:
  zh:
    menus:
      main:
        - identifier: docs
          name: 文档
          pageRef: /docs
          weight: 20

identifier 两种语言必须一致:命令面板的快速链接与搜索结果分组顺序都按它匹配。菜单的完整写法见导航与菜单

首页数据 按语言取文件:data/home/en.yamldata/home/zh.yaml。当前语言没有对应文件时回退到 en.yaml;单语言站点用一个 data/home.yaml 即可。见首页与落地页

界面文案:主题自带 32 份完整界面语言包,即 Docsy 支持的 31 个 locale 文件名,再加通用 zh。每份语言包都以目标语言覆盖 OINK 的全部 192 个键, 不再依赖生成的英文 fallback。zhzh-cn 使用简体中文,zh-tw 使用繁体 中文;完整 locale 与占位符契约见架构。 要改某一条,在站点自己的 i18n/ 下建同名文件,只写要覆盖的键:

i18n/zh.yaml
ui_search: 搜索文档

如果需要兼容 Hugo 0.160.x,并且地区化中文语言包同时存在,请为非默认的通用 zh 语言保留具体的 locale: zh-CN。从 Hugo 0.161 起,相同配置也可以使用裸 locale: zh

缺译回退与语言选择器

语言选择器的图标本身是一个链接:点击它按 weight 顺序切到下一种语言(在末尾回到第一种),悬停或键盘聚焦才展开列出全部语言的菜单,触摸屏上菜单不展开,点按即切换。双语站点因此一次点击即可来回切换。

菜单始终列出全部配置的语言,不论当前页有没有译文:

  • 目标语言有译文 → 跳到那一页;
  • 目标语言没有译文 → 跳到那种语言的 首页

回退到首页优于把读者送进 404。代价是读者不一定察觉自己被送到了首页,双语站点应当把「每个页面都有对等译文」作为约束来检查,而不是依赖回退。

缺译不会用原文填充

中文页面不存在时,中文站里就没有这一页:侧栏、搜索索引、翻页顺序都不包含它。

搜索索引也按语言分开:读者在中文页面搜索只命中中文内容。中文查询采用 CJK 子串匹配,细节见全文检索

标题锚点要对齐

Hugo 从标题文本生成 ID,中文标题生成中文 ID:/docs/install/#prerequisites/zh/docs/install/#前置条件 指向同一个位置,却是两个互不相通的锚点,跨语言的深链、目录与页内跳转都会失效。

做法是在译文标题里显式写出原文的 ID:

install.zh.md
## 前置条件 {#prerequisites}

两条纪律:

  1. ID 从 英文页渲染出来的 HTML 里取,不要凭标题文本推断。标题里含行内代码、徽章或 shortcode 时,生成的 ID 与标题文本不一致。
  2. 中英对应页面的标题数量、顺序、ID 必须一致。确实需要在中文里加一节时,给它一个独立、稳定、不与英文冲突的 ID。

本站用一个脚本把这条约束变成 CI 检查,比对的是渲染后的 HTML 而不是源码:

node scripts/check-doc-translations.mjs --public public

新页面从建立时就写显式英文 {#id},成本低于事后回补。

从右向左的语言

在语言下声明书写方向:

hugo.yml
languages:
  ar:
    label: العربية
    locale: ar
    direction: rtl
    weight: 3

<html dir> 随之改变,主题额外加载 Bootstrap 的 RTL 样式表。主题自身的 CSS 全部使用逻辑属性(margin-inline-start 而不是 margin-left),镜像布局自动完成。站点自己写的 CSS 同样要用逻辑属性,否则 RTL 下会错位。

验证

  1. 构建,确认两种语言的产物和索引都在:

    hugo --printPathWarnings --panicOnWarning
    ls public/index.html public/zh/index.html
    ls public/offline-search-index.*
  2. 检查 hreflang:每个页面的 <head> 里,每种语言各一条 rel="alternate",外加一条指向自己的 rel="canonical"

    grep -o 'rel="alternate" hreflang="[^"]*"' public/zh/docs/index.html
  3. 在有译文的页面上展开语言选择器并选择另一种语言,确认停在同一篇文档;在没有译文的页面上重复一次,确认落到目标语言的首页而不是 404。

  4. 两种语言各搜一次同一个概念,确认都有结果。

  5. 双语站点把标题对齐检查接进 CI,见上一节的脚本。

10 - 多版本

配置版本切换菜单与归档横幅,并选择多个版本在域名上的部署布局。

产品有多个受支持版本时,文档通常也要分版本。主题提供两项功能:顶栏的版本切换菜单,与旧版本站点上的归档提示横幅。部署布局由站点决定:主题不做跨版本的单次构建,每个版本是一次独立的 Hugo 构建。

版本切换菜单

params.versions 里列出要出现在菜单里的版本。这个列表非空时,顶栏工具区出现一个分支图标的菜单,页脚最底层栏出现同样内容的纯图标向上菜单。

hugo.yml
params:
  # 当前站点是哪个版本
  version: v2.1
  # 菜单的无障碍名称;底栏触发器仍只显示图标
  version_menu: v2.1
  versions:
    - version: v2.1
      url: https://docs.example.com
    - version: v2.0
      url: https://v2-0.docs.example.com
    - version: v1.9
      url: https://v1-9.docs.example.com

菜单项默认显示 version 的值,写了 name 就显示 name。当前项标成选中态,判定方式是条目的 version 等于 params.version,或者条目的 url 等于站点的 baseURL,两者满足其一即可。

没写 url 的条目显示为不可点击的灰项,可用作分节标题;name: '---' 是一条分隔线(分隔线上写 url 会告警)。name 支持行内 Markdown:

hugo.yml
params:
  versions:
    - name: '**当前版本**'
    - version: v2.1
      url: https://docs.example.com
    - name: '---'
    - name: '**历史版本**'
    - version: v1.9
      url: https://v1-9.docs.example.com

同一份列表也是命令面板里「切换版本」的数据来源,菜单与面板不会不一致。

version_menu_pagelinks: true 会把当前页面的路径拼到目标版本的 URL 后面,读者切换版本时 停在同一篇文档

代价是目标版本不一定有这个页面:文档结构在版本间会演进,旧版本没有新增的页面,读者切换过去就是 404。本站关闭这个选项。

单个条目可以覆盖全局设置:

hugo.yml
params:
  version_menu_pagelinks: true
  versions:
    - version: v2.1
      url: https://docs.example.com
    - version: v1.9
      url: https://v1-9.docs.example.com
      pagelinks: false # 这一版结构差异大,只跳首页
判断依据是文档结构的稳定程度,不是版本号的距离

结构稳定时开启,结构变动大时关闭。跳到版本首页多一步操作,仍优于 404。

归档横幅

不再维护的旧版本站点上,向读者说明这是一份快照:

hugo.yml
params:
  archived_version: true
  version: v1.9
  url_latest_version: https://docs.example.com

archived_version: true 时,每个文档页与书籍页正文顶部出现一条横幅,写明当前版本已不再积极维护,并给出指向 url_latest_version 的链接。文案随站点语言本地化,无需自行编写;version 是横幅里显示的版本号。

横幅只出现在文档与书籍页面上,博客和落地页没有。

params.versionparams.versions 的区别

两个键名字相近,职责不同:

  • params.versions一张跨站点的清单:菜单里能跳到哪些版本,各自的地址是什么。它描述的是其它站点。
  • params.version 是当前这次构建自己的版本标识。它决定菜单里哪一项被标成选中、归档横幅里显示什么版本号,data/download/*.yaml 没写 version 时也以它兜底(见发布与下载页)。

它不一定是 Git 引用。需要一个能解析的发布 tag(例如安装命令里引用的那个)时,另设一个自己的参数,不要复用 params.version。这两个键的完整定义在配置总览

多版本部署布局

布局 baseURL 特点
子域名 https://v1-9.docs.example.com/ 各版本相互独立,互不影响;需要给每个版本配 DNS 与证书
子路径 https://docs.example.com/v1.9/ 单域名,SEO 权重集中;需要托管方支持按路径路由到不同产物

每个版本是一次独立构建:从对应的 Git 分支或 tag 检出内容,用那一版自己的 hugo.yml 构建,产物发布到对应地址。当前版本的站点把 versions 列全,旧版本的站点在列全之外再加上归档横幅。

子路径部署时 baseURL 必须包含那段路径

否则搜索索引、页面动作与资源链接都指向域名根目录:页面看上去正常,搜索却没有结果。这是子路径部署最常见的故障,部署细节见发布上线

验证

  1. 构建后确认版本菜单进了页面:

    grep -c 'nav-version-menu' public/zh/docs/customize/versions/index.html

    params.versions 为空或未配置时,菜单整个不生成。

  2. 看当前版本有没有被标成选中:

    grep -o 'nav-hover-menu__option is-active[^>]*' public/index.html

    一条都没有,说明 params.versionversions 里的 version 字段对不上,或者 baseURL 与该条目的 url 不一致(注意结尾斜杠)。

  3. 逐个访问菜单里的链接。开启 version_menu_pagelinks 时,在一篇旧版本不存在的文档上试一次,确认落点可以接受。

  4. 归档站点上打开任意文档页,横幅应该在正文最上方,语言与站点一致,链接指向最新版本。

  5. /Ctrl + K 打开命令面板,「切换版本」列出的应该是同一份清单。

11 - 分类体系

用 tags / categories 给页面加一条横跨目录的索引:术语页、筛选芯片、右栏分类云与顶栏分类面板都是自动的。

目录树只有一条路径,分类体系(taxonomy)给页面加第二条:同一篇 PostgreSQL 备份文档既在「运维」目录下,又能从「备份」标签页找到。启用它只需要 Hugo 的 taxonomies: 配置,术语页、筛选芯片、右栏分类云与顶栏分类面板都由主题自动生成,无需编写模板。

本页带着一个分类:标题下面的「分类: 定制站点」一行,以及右栏目录下面那组带计数的芯片,都不需要在页面上写配置。

启用分类法

分类法由 Hugo 决定,主题不额外提供开关。在 hugo.yml 顶层taxonomies:,键是单数名、值是复数名:

hugo.yml
taxonomies:
  tag: tags
  category: categories

这是本站的配置。三点需要注意:

  • 写了 taxonomies: 之后它就是 完整列表,不是追加。想在自定义分类法之外保留 tags / categories,必须把它们一起列出来。
  • 复数名同时是 URL 段:/zh/tags//zh/categories/
  • 全部关闭:disableKinds: [taxonomy, term]

加一个自己的分类法,例如按产品模块归类:

hugo.yml
taxonomies:
  tag: tags
  category: categories
  module: modules

分类法的显示名:tag tags category categories module modules 这六个键在主题的每个语言文件里都有本地化标题(中文分别是「标签」「分类」「模块」)。其它分类法用复数名的 humanize 结果(productsProducts)。要自己定名字,在 content/<复数名>/_index.md_index.zh.md 里写 title / linkTitle,主题会优先用它:

content/modules/_index.zh.md
---
title: 产品模块
linkTitle: 模块
---

为页面添加标签

front matter 里的键名用 复数名taxonomies 的值那一列),值始终是列表,只有一项也要写成列表:

content/docs/ha/patroni.zh.md
---
title: Patroni 高可用
description: 用 Patroni 管理 PostgreSQL 主从切换。
categories: [高可用]
tags: [PostgreSQL, Patroni, 故障切换]
---

整个栏目共用一个分类时,写在栏目首页的 cascade 里,无需每页重复:

content/docs/customize/_index.zh.md
---
title: 定制站点
linkTitle: 定制站点
icon: fa-solid fa-sliders
cascade:
  categories: [定制站点]
---

本站 docs 的六个栏目都是这样配置的。页面自己写 categories: 会覆盖 cascade,不合并:要在栏目分类之外再加一个,两个都要写出来。

页面上的术语行

文档页与博客页在标题、摘要下面渲染一行已分配的术语,链接指向对应的术语页,本页顶部的「分类: 定制站点」即是。这一行的容器是 .taxonomy-terms-article,按分类法另带一个 .taxo-<复数名> 类,单独调样式时用这两个选择器。

默认列出该页的 全部 分类法,只有 authorsseries 这两个保留复数除外——它们各自有专门的呈现面(署名行与系列横幅),再列一遍标签等于把同一件事说两遍。在 page_header 里点名,就能把它放回去。

只想显示其中几种、并固定顺序:

hugo.yml
params:
  taxonomy:
    page_header: [categories]

这一项由配置总览收录。它不能用来隐藏这一行,见限制

主题认识名字的两个分类法

authorsseries 就是普通的 Hugo taxonomy,按普通方式声明——主题不为它们增加任何参数。主题增加的是各自的一套呈现,所以「声明」本身就是全部开关:

hugo.yml
taxonomies:
  category: categories
  tag: tags
  author: authors
  series: series
复数名 声明之后打开了什么 term 页变成什么
authors 文章头部的头像与带链接的名字、列表行上的名字、feed 里每位作者一条 <dc:creator> 作者主页:显示名取 term 页的链接标题(有 linkTitle 用它,否则用 title),description 是一句话介绍,正文是长介绍,头像取题图解析器为这一页选中的那张
series 正文上方一条横幅,写明系列名、本篇位置、下一篇,以及折在 <details> 里的完整列表 系列引言,成员按阅读顺序排列,而不是最新在前

两者的完整说明与各自需要的 front matter 在写博客。这里只提两件事:

  • 主题刻意不设 data/authors 文件。作者主页就是 term 页本身,因此不存在第二份权威跟它打架。
  • 系列 term 页是唯一不按时间倒序排列的 term 页。写了 series_weight 的成员按升序排在前,其余按日期升序跟在后。term 页没法把顺序交给 Hugo,所以主题自己算一次,两处呈现读同一份结果。

标签页与分类页

每种分类法生成两级页面:

页面 URL 内容
分类法列表页 /zh/categories/ 标题是分类法的本地化名(「分类」),下面是全部术语的筛选芯片,每枚带计数,第一枚是「全部」
术语页 /zh/categories/定制站点/ 标题是「分类: 定制站点」,下面按日期倒序列出该术语的全部页面,样式与博客列表一致

中文术语的 URL 使用中文字符(浏览器地址栏显示 定制站点,HTML 里是百分号编码),Hugo 不做拼音转写。需要 ASCII URL 时改用英文术语,再在 content/categories/<术语>/_index.zh.md 里用 title 给它一个中文显示名,这是 Hugo 的术语页内容文件机制。

术语页在内容树里没有固定位置,它借用一个:某术语的成员全部位于同一个顶层栏目下时,术语页用那个栏目渲染侧栏树与根链接,读者从文档里点进标签仍留在文档导航中;成员跨栏目时回退到站点级的树。筛选芯片里的「全部」按同一规则处理:只有一个栏目时指向该栏目首页,跨栏目时指向分类法列表页。

筛选芯片只出现在分类法列表页;术语页上换成右栏的分类云。

右栏的分类云

文档页、博客页与术语页的右栏(目录下面)每种分类法一组,芯片带计数,可折叠。这一组是自动的,没有开关:定义了分类法且当前范围内有术语时就会出现。

计数 不是全站计数,而是按顶层栏目统计:先看页面的 type 有没有同名栏目(type: docs 的页面用 /docs/ 这棵树),没有就用页面所在的顶层栏目。博客页上的「标签: release 4」说的是博客里有 4 篇,不是全站有 4 篇。

图标按复数名配置:

hugo.yml
params:
  ui:
    taxonomy_icons:
      categories: fa-solid fa-folder
      tags: fa-solid fa-tags
      modules: fa-solid fa-cubes

categoriestags 的默认值就是上面那两个,其它分类法默认 fa-solid fa-shapes。图标是一对 Font Awesome class,与站点其它地方的图标写法一致。

顶栏菜单里的分类面板

主菜单里指向分类法列表页的条目,会自动变成一块术语芯片面板(按用量降序,带计数),无需手写下拉项:

hugo.yml
languages:
  zh:
    menus:
      main:
        - identifier: tags
          name: 标签
          pageRef: /tags
          weight: 60

pageRef: /tags 与旧式的 url: /zh/tags/ 都能识别:URL 形式的菜单先解析成本站页面再判断类型,从旧配置迁移时不必改写法。菜单本身的其它写法见导航与菜单

双语标签

Hugo 的分类按语言分开统计、分开链接:/categories//zh/categories/ 是两棵互不相干的树,中文页只进中文那棵。术语要在各自语言的 front matter 里各写一遍:

content/docs/ha/patroni.md
categories: [High availability]
tags: [PostgreSQL, Patroni, failover]
content/docs/ha/patroni.zh.md
categories: [高可用]
tags: [PostgreSQL, Patroni, 故障切换]

两条要注意:

  • 同一个词在两种语言里写成同样的字符串(例如 release),得到的仍然是 /categories/release//zh/categories/release/ 两个术语页,各自只统计本语言的页面。不要为了统一而在中文页里写英文词:右栏芯片会显示英文。
  • 分类法的显示名会跟着语言走(上面那六个内置键),但 术语名不会:术语就是你在 front matter 里写的那个字符串,主题不翻译它。英文页里写 高可用,英文站的芯片上显示的就是 高可用

多语言站点的其余部分见多语言

按内容类型开关

主题没有「文档显示、博客不显示」这类开关,控制点是给哪些页面打标签。本站的做法:

内容 categories tags 效果
content/docs/** 栏目级 cascade(「定制站点」等六个) 不打 术语行只有一行「分类」
content/blog/** 每篇写(releaseoink 每篇写(OinkRelease 术语行两行,右栏两组芯片

让整个栏目从分类里消失:删掉栏目首页 cascade 里的 categories,不需要别的配置。让某一页不进分类:在它自己的 front matter 里写 categories: [],空列表覆盖 cascade。

验证

页面上看三处:

  • 本页标题下面有一行「分类: 定制站点」;
  • 右栏目录下面有按分类法分组的芯片,每枚带计数;
  • 打开 /zh/categories/ 能看到全部术语的筛选芯片,点任一枚进入术语页。

命令行上查产物:

hugo -d public
ls public/zh/categories/          # 每个术语一个目录
grep -c 'taxonomy-term' public/zh/docs/customize/index.html

主题仓库自带一个针对性检查,验证「不写 taxonomies: 就不生成分类页」与「术语页在中英文下标题正确」两件事:

cd ~/pgsty/oink && python3 bin/check-taxonomy.py

限制

  • page_header: [] 不会 隐藏术语行:空列表被当作未设置,回落到「列出全部分类法」。要去掉这行,就不要给这些页面打标签,或在 assets/scss/_styles_project.scss 里隐藏 .taxonomy-terms-article
  • 右栏分类云没有开关,也没有条数上限;术语数量很多的站点应当减少分类法,配置层面没有裁剪手段。
  • 术语页没有跨语言对等关系:语言切换在术语页上不保证落到「同一个术语的另一种语言」。

12 - 仓库与页面信息

把「编辑当前页面」「提交文档议题」「查阅编辑历史」接到你的仓库,并在页尾显示最后修改时间、贡献者与反馈组件。

面包屑行右侧的 操作菜单 里与仓库有关的条目,由几个 github_* 参数推导;页尾的「最后修改」信息行来自 git 历史。前提是内容存放在一个 GitHub 风格的仓库里。

操作菜单里所有跟仓库有关的条目,都由这几个键推导出来:

hugo.yml
params:
  github_repo: https://github.com/pgsty/oink.pgsty.com # 文档源码仓库
  github_project_repo: https://github.com/pgsty/oink # 产品仓库(可选)
  github_branch: main # 默认 main
  github_subdir: '' # 仓库根到 Hugo 站点根的路径

上面是本站的真实配置。填好之后,本页的操作菜单里这几条指向:

菜单条目 目标
编辑当前页面 …/edit/main/content/docs/customize/repository.zh.md
查阅编辑历史 …/commits/main/content/docs/customize/repository.zh.md
添加子页面 …/new/main/content/docs/customize?filename=change-me.md&value=<模板>
提交文档议题 …/issues/new?title=仓库与页面信息
提交项目议题 https://github.com/pgsty/oink/issues/new

几点约定:

  • github_repo 指向内容所在的仓库,不是主题仓库。写主题仓库会把读者的改动引到错误的位置。省略它时,上表五条全部消失。
  • github_project_repo 是第二个仓库,接收产品缺陷而非文档错误的议题。读者难以区分两者时不要配置它。
  • github_branch 默认 main,填的是内容分支,不是部署分支,也不是 Pages 自动生成的分支。
  • github_subdir 是仓库内路径。站点源码在仓库根目录时留空;放在子目录(例如仓库里同时有代码和 website/)时填 website

这几个键都可以在站点、单语言、栏目 cascade 或页面 front matter 上设置,内容来自多个仓库时用得到。键的完整定义在配置总览

内容来自另一个仓库

把一棵子树从上游仓库挂进来时,用栏目 cascade 覆盖仓库参数,再用 path_base_for_github_subdir 告诉主题:先去掉本地路径前缀,剩下的部分接到 github_subdir 后面。

content/reference/_index.zh.md
---
title: 上游参考
cascade:
  github_repo: https://github.com/OWNER/UPSTREAM
  github_project_repo: https://github.com/OWNER/UPSTREAM
  github_subdir: docs
  path_base_for_github_subdir: content/reference
---

content/reference/api/client.md 因此映射到上游的 docs/api/client.md

path_base_for_github_subdir 的值是正则;源文件名与本地不同名时改用 from / to 映射,例如把每个栏目的 _index.md 对到上游的 README.md

content/reference/_index.zh.md
path_base_for_github_subdir:
  from: content/reference/(.*?)/_index.md
  to: $1/README.md

OINK 把 .md.zh.md 并排放在同一个目录里,两种语言共用同一个路径前缀,正则里不需要语言目录。改完从叶子页、栏目首页、两种语言各点一次「编辑当前页面」:正则去掉的部分过多时,生成的 URL 看上去合理,实际是 404。

关闭其中几条

菜单里每个条目都带一个稳定的操作 ID:

菜单条目 操作 ID
复制 Markdown 文本 copy_markdown
查阅 Markdown 源码 view_markdown
在 ChatGPT / Claude 中打开 open_chatgpt / open_claude
查阅编辑历史 view_history
编辑当前页面 edit_page
添加子页面 create_child_page
提交文档议题 create_issue
提交项目议题 create_project_issue
打印完整章节 print_section

托管服务不支持某条时,用 CSS 隐藏:

assets/scss/_styles_project.scss
.td-page-actions__item[data-oink-action='create_child_page'] {
  display: none;
}

命令面板用的是同一批 ID,隐藏菜单条目不会让它从面板里消失。全站用不上的目标应当从配置里省略对应的键,而不是用 CSS 遮盖:CSS 只能隐藏链接,不能把错误的链接改对。

整个菜单也可以按页面关闭,front matter 写 page_context_menu: false,见页面参数

「添加子页面」预填的新页面模板来自主题的 assets/stubs/new-page-template.md;站点在自己的 assets/stubs/new-page-template.md 放一份同名文件即可替换成自己的骨架。

最后修改时间

这一行的数据来自 git,不是文件的 mtime。打开 Hugo 的 git 支持:

hugo.yml
enableGitInfo: true
params:
  github_repo: https://github.com/pgsty/oink.pgsty.com
  ui:
    lastmod_commit: subject # subject | hash | none

页尾出现「最后修改 2026年8月17日 · <commit 主题> (a1b2c3d)」,commit 部分链到 …/commit/<hash>lastmod_commit 三个取值:

取值 显示
subject(默认) commit 主题 + 缩写 hash
hash commit a1b2c3d
none 只有日期,不链 commit

写别的值时普通预览告警并使用 subject;严格发布构建会因 invalid params.ui.lastmod_commit 失败。

两点注意:

  • CI 必须有足够的 git 历史。浅克隆(fetch-depth: 1)取不到文件的最后一次提交,日期会缺失或错误。GitHub Actions 里设 fetch-depth: 0
  • 未提交的文件没有 git 时间。本地预览新写的页面时这一行不出现。

git 历史不可用时不要用构建时间代替「最后修改」,构建时间不是内容的修改时间。

这一行属于 页面信息(Annotation) 组件,默认开启,位置在反馈之后、翻页器之前。整页关闭写 annotation: false

这一行不是页面信息区块的全部。同一个区块还会渲染两种来源说明,都由页面 front matter 驱动,不需要覆盖模板:

  • 上游署名:页面改写自别处时写 upstream_link,配上 upstream_nameupstream_copyrightupstream_licenseupstream_notice 四个必填键,页尾出现一条带作品、版权人、许可证与完整声明链接的署名行;再写 upstream_modified: true 追加一条「本地已修改」。
  • 译文说明params.ui.translation_notice 写权威版本的语言代码,译文页就显示一条指回原文的说明;以本语言原创的页面写 translation_notice: false 退出。

这两族键的完整定义见页面参数

确实需要自定义时,三个覆盖点各管一层:

覆盖哪个 partial 改什么
layouts/_partials/annotation-items.html 增删或重排这些行,保留主题的标记、图标、打印规则与无障碍标签
layouts/_partials/page-meta-lastmod.html 换掉这些行的渲染标记
layouts/_partials/page-annotation.html 换掉整个区块的外层容器

页尾的组成

五个组件的顺序是固定的,所有阅读型布局共用一份实现:

顺序 组件 主题默认 页面开关
1 分享 Share 关(params.ui.share 为空) share: false,或页面自己的列表
2 反馈 Feedback feedback: true / false
3 页面信息 Annotation annotation: false
4 翻页器 Pager docs / book / blog 开 pager: false
5 评论 Comments 配置完整时开 comments: false

顺序对应读者读完最后一段之后依次会做的事:把这页递出去、说一句有没有帮上忙、看看它从哪来、翻到下一页、加入讨论。分享排在最前,因为它是唯一朝外的一块,而且一个决定要把文章转给别人的读者,在被问「这页怎么样」之前就已经决定了。分享栏的配置见写博客

评论的配置在启用评论

反馈组件

一行问题、两个按钮:「这篇文档解决了你的问题吗?」→ 是 / 否。选「否」再展开四个可选原因。默认关闭:

hugo.yml
params:
  ui:
    feedback:
      enable: true
      reasons: true # 选「否」后是否追问原因

只给文档栏目开,用 cascade(博客通常只留评论):

content/docs/_index.md
---
title: 文档
cascade:
  feedback: true
---

行为边界:

  • 点击即完成,没有输入框、没有提交按钮、没有登录。
  • 选择按「页面 + 语言」写进浏览器 localStorage,读者回访时还能看到并修改自己的选择。
  • 站点已有 Google Analytics(gtag)时,发送 docs_feedback 事件,字段 resultsolved / not_solved)、page_pathlanguage;选原因时再发一次,多带 reasonrefinement: true,便于和首次计数区分。没有 analytics 时组件照常工作,只是不上报,它不需要任何后端。
  • 本页启用了评论时,反馈结果下面会多一条「在评论区补充详情」的锚点链接。反馈与 giscus 是两条独立的数据流,主题不会代替读者写评论。

本页在 front matter 里写了 feedback: true(docs 栏目默认关闭),页尾可以看到真实的组件。

贡献者墙

contributors shortcode 渲染一面 GitHub 头像墙,数据来自站点 data/ 目录下的一个文件,不在构建期访问 GitHub

data/contributors.yaml
items:
  - github: Vonng
    name: Ruohang Feng
    role: 主题作者
  - github: pgsty
    name: Pigsty
    role: 项目组织
  - github: gohugoio
    role: 静态站点生成器
    avatar: /icons/logo.svg
源码
{{</* contributors */>}}

字段:github 必填并校验为合法 GitHub 用户名;重复时告警并跳过后项,严格发布构建 拒绝这条警告。name 缺省等于 githubrole 可选;url 缺省是 https://github.com/<github>avatar 可选,不填时渲染成首字母占位块,不发任何 网络请求,填写时必须是 http(s):// 或站内根相对路径。

多套名单写多个数据文件,用 data= 指定:

源码
{{</* contributors data="maintainers" */>}}

在 Markdown 与 RSS 输出里,头像墙降级成一串 - [@handle](url) — role 的列表。

本站没有 data/contributors.yaml

上面的例子因此不在本页渲染。放一个数据文件进 data/ 就能看到效果。

验证

  • 点开本页面包屑行右侧的操作菜单,「编辑当前页面」应该指向 github.com/<你的仓库>/edit/<分支>/<源文件路径>,路径要与仓库里的实际路径逐段对应。
  • 从栏目首页(_index.md)再点一次:栏目首页最容易被 path_base_for_github_subdir 的正则改错。
  • 页尾应有「最后修改」行;本地新建、尚未 git commit 的页面没有这一行是正常的。
  • 命令行核对生成的链接:
hugo -d public
grep -o 'data-oink-action="edit_page" href="[^"]*"' \
  public/zh/docs/customize/repository/index.html

13 - 打印支持

单页交给浏览器的 Cmd/Ctrl+P,整个栏目用 print 输出格式合成一份连续文档。

单页打印不需要配置:外壳(侧栏、目录、顶栏、按钮)都带 d-print-none,浏览器的 Cmd/Ctrl+P 得到的是一份干净的正文。主题因此没有页面级的「打印本页」按钮。

需要配置的是另一件事:把一整个栏目(或一整本书)连同全部子页面合成一份带目录的连续文档。以下内容覆盖启用方式、打印视图的结构,以及排除页面的做法。

启用整章打印

print 是主题声明的自定义输出格式,主题不替站点打开它。在站点自己的 hugo.yml 里给 section 加上:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]

这是本站的配置。outputs 的每个键是 整体替换 而不是合并:加 print 时要把该类型原本有的格式(HTMLRSSmarkdown)一起写全,漏一个就丢一种输出。

开启后,每个栏目多出一个 URL。路径段 _print 在最前面,语言前缀之后:

页面 打印视图
/zh/docs/customize/ /zh/_print/docs/customize/
/zh/docs/ /zh/_print/docs/
/zh/blog/release/ /zh/_print/blog/release/

页面操作菜单里同时出现「打印完整章节」,命令面板里也能搜到同一条(操作 ID print_section)。它打印的是 当前栏目:在 /zh/docs/customize/print/ 这页点它,得到的是整个「定制站点」栏目,不是这一页。

打印视图的结构

打开上面任意一个链接,从上到下是:

  1. 一条提示条:「这是本节的多页打印视图。点击此处打印。返回本页常规视图。」它带 d-print-none,只在屏幕上出现,不进纸。
  2. 栏目标题与摘要。
  3. 全栏目目录,条目编号是 1:2:2.1: 这样的层级号,链接指向文档内的锚点。
  4. 每个页面依次排列,标题变成 1 - 配置总览 这种「编号 - 标题」,描述作为导语,正文原样渲染。

页面顺序是侧栏顺序(weight),子栏目递归展开。第二页起每页都另起一页;第一页是否另起一页,取决于栏目首页自己的正文是否超过 50 个词:首页只有一句话时不单独占一张纸。阈值可以调整:

hugo.yml
params:
  print:
    section_break_wordcount: 120

不需要那份目录:

hugo.yml
params:
  print:
    toc: false

也可以只对某个栏目关闭,写在栏目首页 front matter 里:

content/docs/components/_index.zh.md
---
title: 组件
print:
  toc: false
---

把某些页面排除在外

纯链接页、只有一段跳转说明的页、体积巨大的截图页进纸意义不大。给它们写 no_print

content/docs/about/showcase.zh.md
---
title: 示例站点
no_print: true
---

它只影响整章打印视图,页面自己的 HTML 与浏览器 Cmd/Ctrl+P 不受影响。侧栏分隔项(sidebar_divider)也自动排除。

组件在打印态的形态

打印是四态输出之一,每个组件都有确定的打印形态。整章打印视图与浏览器打印单个页面,规则一致:能交互的降级成静态,可折叠的一律展开

组件 打印形态
提示块 静态块,折叠型(- / + / DETAILS)全部展开;边框转灰、去底色
标签页 标签条消失,所有面板依次展开,每个面板带自己的标题
代码块 去掉复制与展开按钮,取消最大高度与滚动,长行改为自动折行
表格 满宽静态表,取消横向滚动;表头在跨页时重复
图片 图与图注保留,缩放相关的属性被剥掉,宽度收进版心
画廊 网格改为竖排堆叠
文件树 静态面板,目录全部展开,分栏停在构建期宽度
参数表 完整定义列表,两种形态一致
公式 静态渲染的 KaTeX / MathML
Mermaid · Markmap · PlantUML 照常渲染成图:打印视图仍是一张 HTML 页,这几个运行时照常加载
ECharts · Infographic 降级成围栏源码块,不渲染图表
Asciinema · OpenAPI 一行带标题的静态链接,录像或规范地址可见;三套运行时都不加载
卡片 / 步骤 / 徽章 / 按键 静态呈现,内容不变

页面外壳不进纸:侧栏、目录、顶栏、页面操作菜单、反馈组件、标题旁的锚点链接、行内复制按钮。

上表里靠浏览器端运行时绘制的那三种图(Mermaid、Markmap、PlantUML),触发打印前要确认它们已经绘制完成。

浏览器打印样式

主题自带一层 @media print 规则,单页打印与整章打印共用:

  • 纸张 A4,页边距 18mm 16mm 20mm;正文 10.5pt,强制浅色配色。
  • 字体切到 --td-print-font-family 这个排印令牌,见品牌外观
  • 标题不与正文分家(break-after: avoid-page),段落与列表项保留 3 行孤行 / 寡行控制。
  • 表格、图片、块引用、提示块、卡片、标签页尽量不跨页断开;代码块允许跨页,但会自动折行而不是截断。
  • 链接加下划线、转深蓝色,不会在链接后面打印出 URL 文本。需要这个行为的站点自己加:
assets/scss/_styles_project.scss
@media print {
  .td-content a[href^='http']::after {
    content: ' (' attr(href) ')';
    font-size: 0.85em;
    word-break: break-all;
  }
}
  • 收起的 <details> 一律展开:折叠的提示块与文件树目录在纸上是完整的。

自定义排版写在 assets/scss/_styles_project.scss@media print 块里,不需要改模板。

替换打印模板

需要改结构(例如给每页加页眉、换编号格式)时,覆盖最窄的那个 partial,都在 layouts/_partials/print/ 下:

Partial 负责
print/render.html 整章视图的骨架:提示条、目录、递归内容
print/page-heading.html 文档开头的标题与导语
print/content.html 单个页面在整章视图里的呈现
print/toc-li.html 目录里的一行

后三个支持 按内容类型 分化:建 print/page-heading-blog.htmlprint/content-book.html,主题会优先用带类型后缀的那个。

整本书的打印(type: book)走另一条路径:章节编号、图表编号与交叉引用都保持全书连续,见书籍出版

验证

hugo -d public
ls public/zh/_print/docs/          # 每个栏目一个目录

再看页面:

  • 浏览器打开 /zh/_print/docs/customize/,确认目录条数等于栏目页数(减去 no_print: true 的页)。
  • 在这个视图里按 Cmd/Ctrl+P,打印预览里应当看不到提示条、顶栏与任何按钮。
  • 找一页含标签页与折叠提示块的(例如标签页),确认预览里所有面板都展开。
  • 打印一份 PDF 通读分页情况,阈值不合适时调整 section_break_wordcount

14 - Agent 支持

每一页多产出一份 .md,站点根目录多一份 llms.txt,读者可以把当前页交给 ChatGPT 或 Claude。

HTML 页面里有侧栏、脚本与样式,模型读它要先剥掉这层外壳。OINK 让同一份内容再产出一份纯 Markdown:每页一个 .md,站点根目录一份 llms.txt 索引,页面上一个「复制 Markdown 文本」按钮。三者都是构建期产物,没有运行时服务,也不需要内容协商。

这三件事都要站点自己在 outputs 里声明,主题不替站点打开。另有两样同样需要显式打开的产物,服务于一次要读不止一页的 agent:每个栏目一份全文包,每种语言一棵导航树。

每页一份 .md

markdown 是 Hugo 的内置输出格式。把它加进需要的页面类型:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]

这是本站的配置。outputs 的每个键是 整体替换 而不是合并:加 markdown 时要把该类型原本有的格式(RSSprint)一起写全,漏一个就丢一种输出。

URL 规律是在页面 URL 后面接 index.md

页面 Markdown
/zh/docs/customize/agents/ /zh/docs/customize/agents/index.md
/zh/docs/customize/(栏目首页) /zh/docs/customize/index.md
/zh/(站点首页) /zh/index.md

每个 HTML 页的 <head> 里同时有一条发现用的链接,抓取工具不必推断 URL:

<link rel="alternate" type="text/markdown" href="https://oink.pgsty.com/zh/docs/customize/agents/index.md">

.md 的内容

不是把渲染好的 HTML 转回 Markdown,而是 你写的源码:front matter 换成一个 H1 标题加一段引用式摘要,其后是正文原文,shortcode 就地展开成各自的 Markdown 形态。

/zh/docs/customize/print/index.md 的开头
# 打印支持

> 单页交给浏览器的 Cmd/Ctrl+P,整个栏目用 print 输出格式合成一份连续文档。

---

LLMS index: [llms.txt](/zh/llms.txt)

---

单页打印不需要配置:外壳(侧栏、目录、顶栏、按钮)都带 `d-print-none`,浏览器的 `Cmd/Ctrl+P` 得到的是一份干净的正文。

原生 Markdown 形态的组件(提示块、表格、参数表、图片属性行、代码围栏、数据围栏)在 .md 里原样保留源码,模型读到的与你写下的是同一份内容。栏目首页在正文之后还会附一份 Section pages: 子页链接清单。

shortcode 形态各有确定的降级:徽章变成强调文本或链接,按键变成 Ctrl + K标签页变成一段段 **标签名** 小节,参数表变成条目列表。每个组件页的「输出形态」小节写了它自己那一行。

站点没有开 LLMS 输出时,上面那条 LLMS index: 不会出现:主题不指向未发布的文件。

llms.txt

llms.txt 是站点根目录的一份纯文本清单,告诉模型「这个站有什么、机器可读版本在哪」。给 首页 加上 LLMS 输出格式即可生成:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]

多语言站点每种语言各一份:/llms.txt/zh/llms.txt。内容是自动生成的站点索引:

/zh/llms.txt(节选)
# OINK

> 本地优先、仅依赖 Hugo 的技术文档主题

## Site index

- [Home page](https://oink.pgsty.com/zh/index.md)
- [文档](https://oink.pgsty.com/zh/docs/index.md): OINK 是一套本地优先的 Hugo 文档框架……
- [博客](https://oink.pgsty.com/zh/blog/index.md): Docsy 文章、OINK 工程实践与 OINK 发布注记

## Documentation index

- [简介](https://oink.pgsty.com/zh/docs/about/index.md): 一套从 Docsy 演化而来的本地优先 Hugo 文档框架……
  - [亮点特性](https://oink.pgsty.com/zh/docs/about/features/index.md): 逐条列出 OINK 与普通 Hugo 主题的差别……
  - [案例](https://oink.pgsty.com/zh/docs/about/showcase/index.md): 找到最接近你的生产案例……
- [快速上手](https://oink.pgsty.com/zh/docs/start/index.md): 从官方 OINK Starter 建立本地基线,再分层定制。

## Site locales

- [English](https://oink.pgsty.com/index.md)
- [简体中文](https://oink.pgsty.com/zh/index.md)

三段的来源:Site index 是本语言首页加站点主菜单(menus.main,条目有 Markdown 版就链 Markdown 版,带 description 的顺带写上);Documentation indexdocs 栏目的子栏目及其下一层页面,缩进表示层级,每行附上该页的 descriptionSite locales 是站点配置里的全部语言。指向站外的菜单条目(GitHub、issue 跟踪器)会被剔除:它们属于导航外壳,不是本站内容。

改进 llms.txt 的入手处是主菜单与各栏目首页的 description,不是这个模板。

全文包

每页一份 .md 适合已经知道自己要读哪一页的 agent;想通读整本手册的 agent 只能一页页爬。LLMSFULL 输出把这件事压成一个文件:每个顶层栏目一份 llms-full.txt,按阅读顺序装下该栏目的每一页。它是 OINK 0.8.0 的新增能力,栏目不主动要就不生成。

开关在栏目首页自己的 front matter 里,不在站点配置:

content/docs/_index.md
---
title: Docs
outputs: [HTML, print, RSS, markdown, LLMSFULL]
---

front matter 里的 outputs 会整体替换站点级列表,所以要把该栏目原本有的格式写回去:这里漏掉 markdownprint,栏目首页就少一种输出。front matter 按语言分开,双语站点要在 _index.zh.md 里同样写一遍,才有中文的全文包。

产物是每种语言一份,落在栏目根下——/docs/llms-full.txt/zh/docs/llms-full.txt。顺序就是侧栏与翻页器呈现的阅读顺序:docsbook 栏目声明了 data/docs_nav.json 显式树时以显式树为准,否则按内容树的 weight。侧栏里藏起来的页面(toc_hide)同样不进包。

每一页前面有一条带来源 URL 的分隔,其后的正文与该页自己的 .md 逐字节相同:

/zh/docs/llms-full.txt(节选)
================
Source: https://oink.pgsty.com/zh/docs/customize/print/index.md
================

# 打印支持

> 单页交给浏览器的 Cmd/Ctrl+P,整个栏目用 print 输出格式合成一份连续文档。

================
Source: https://oink.pgsty.com/zh/docs/customize/agents/index.md
================

# Agent 支持

Source: 指向该页的 Markdown 输出;页面没有 .md 输出时回退到它的 HTML 地址。

只有顶层栏目能带全文包。写在更深一层的栏目上会告警——「LLMSFULL output requires a top-level section」——并且什么都不产出:hugo server 照常能用,加了 --panicOnWarning 的发布构建则会停在这里。

只要有栏目开了全文包,llms.txt 就会多出一段 ## Full-text bundles,列出本语言的全部全文包:发现入口仍在 agent 本来就会抓的那个文件里。

本站的文档栏目已经开启:https://oink.pgsty.com/zh/docs/llms-full.txt 是全部中文文档,一次抓取。

导航 JSON

侧栏是站点的目录,读得懂它的 agent 可以先规划路线再抓正文。NAVJSON 输出把它变成数据:每种语言一份 navigation.json,放在语言根目录下。和全文包一样,它是 OINK 0.8.0 新增、默认关闭,由站点在首页打开:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS, NAVJSON]

这会产出 /navigation.json/zh/navigation.json。这棵树就是侧栏与翻页器读的那一棵——docsbook 栏目声明了 data/docs_nav.json 显式树时以显式树为准,其余按内容树的 weight

/zh/navigation.json(节选)
{
  "baseURL": "https://oink.pgsty.com/",
  "language": "zh",
  "root": {
    "children": [
      {
        "children": [
          {
            "description": "每一页多产出一份 .md,站点根目录多一份 llms.txt……",
            "id": "/docs/customize/agents/",
            "kind": "page",
            "markdown": "https://oink.pgsty.com/zh/docs/customize/agents/index.md",
            "title": "Agent 支持",
            "url": "https://oink.pgsty.com/zh/docs/customize/agents/"
          }
        ],
        "id": "/docs/",
        "kind": "section",
        "title": "文档",
        "url": "https://oink.pgsty.com/zh/docs/"
      }
    ],
    "id": "/",
    "kind": "home",
    "title": "OINK",
    "url": "https://oink.pgsty.com/zh/"
  },
  "schemaVersion": 1
}
含义
id 去掉语言前缀的页面路径,同一页在每种语言里 id 相同
url 该语言下 HTML 页面的绝对地址
markdown 该页 .md 的绝对地址,只有页面确实产出 .md 时才有
title 导航标题(linkTitle,回退到 title
description 页面的 description,有才写
kind 真实页面是 homesectionpage;占位条目是 externallink
children 有序子节点,有子节点才写

数组顺序就是契约,weight 不会被序列化:顺序已经算好了,消费方再排一次只会与它来源的侧栏对不上。

占位条目保持侧栏里的样子:manual_linkexternal 节点,URL 照作者写的原样带出;manual_link_relreflink 节点,引用已经解析好。两者都没有页面身份,因此既没有 id 也没有 markdown。侧栏分隔线与 Hugo 从不渲染的页面会被略去,它们的子节点留在原位。

契约带版本:schemaVersion1,JSON Schema 随主题仓库发布,见 schema/nav.v1.schema.json——要消费这个文件就拿它做校验。站点发布了它时,llms.txt 的站点索引里会列出本语言的 navigation.json

本站已开启:https://oink.pgsty.com/zh/navigation.json 就是这棵树的实例。

页面上的 Agent 动作

面包屑行右侧的操作菜单里,跟 Agent 有关的是四条:

条目 做什么 出现条件
复制 Markdown 文本 抓取本页 .md 写进剪贴板(悬停时预取,点击后无明显等待) 本页有 markdown 输出
查阅 Markdown 源码 新标签页打开 .md 本页有 markdown 输出
在 ChatGPT 中打开 带一句提示词跳转到 ChatGPT assistant_links: true
在 Claude 中打开 同上,跳转到 Claude assistant_links: true

前两条只要开了 markdown 输出就存在。「复制」是拆分按钮的左半边(剪贴板图标),复制成功后短暂显示一个对勾。

后两条默认关闭,要显式打开:

hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      assistant_links: true

打开之后的边界:读者点击时,运行时用浏览器地址栏里的完整 URL(含真实域名、查询串与锚点)拼一句提示词,中文站是「请阅读 的内容,以便我就此向你提问。」,随后跳转到对方站点。离开本站的只有这个 URL,页面正文不会被上传,后续内容由对方自行抓取。URL 里不要放机密信息,站点也应当在隐私说明里披露这条第三方边界。

页面可以收紧站点策略,不能反向打开:front matter 里 page_context_menu: { assistant_links: false } 关掉本页的助手链接;站点没开时页面写 true 不会生效。整个菜单按页关闭用 page_context_menu: false,见页面参数

命令面板里也能搜到这两条助手动作(用的是同一份动作清单),见命令面板

按页面退出 .md 输出

在页面 front matter 里重写 outputs。它同样是整体替换,只写要保留的格式:

content/legal/terms.zh.md
---
title: 服务条款
outputs: [HTML]
---

要保留 RSS、只去掉 Markdown,就把其它格式列全:

content/blog/_index.zh.md
---
title: 博客
outputs: [HTML, RSS, print]
---

自定义输出

主题用 layouts/all.md 渲染 Markdown 输出,用 layouts/index.llms.txt 生成 llms.txt,两种可选输出则由 layouts/list.llmsfull.txtlayouts/index.navjson.json 负责。站点在自己的 layouts/ 下放同名文件即可整体替换,但 先考虑更窄的做法

  • 按内容类型layouts/blog/single.mdlayouts/docs/list.md 这样带类型的路径只影响那一类内容,主题的打印模板即按此分化(layouts/blog/single.print.html)。查模板查找顺序确认你的组合。
  • 按 shortcode:站点自己的 shortcode 可以加输出格式专属模板,让它在 Markdown 输出里给出更适合机器读的形式。
  • 按页面:少数高价值页面手写内容,成本低于改模板。

llms.txt 的内容由站点结构决定,改模板之前先确认问题不在主菜单或 description。替换 index.navjson.json 还意味着接手 nav.v1 契约:你自己产出的内容仍要能通过 schema/nav.v1.schema.json 的校验。

验证

hugo -d public
ls public/zh/llms.txt public/zh/docs/customize/agents/index.md
ls public/zh/docs/llms-full.txt public/zh/navigation.json   # 开了才有

线上或本地预览用 curl

$ curl -s http://localhost:1313/zh/docs/customize/agents/index.md | head -5
# Agent 支持

> 每一页多产出一份 .md,站点根目录多一份 llms.txt,读者可以把当前页交给 ChatGPT 或 Claude。

$ curl -sI http://localhost:1313/zh/llms.txt | head -3

$ curl -s http://localhost:1313/zh/docs/llms-full.txt | head -3
================
Source: http://localhost:1313/zh/docs/index.md
================

再检查四处:

  • 任一页 HTML 的 <head> 里有 rel="alternate" type="text/markdown"
  • 面包屑行右侧的复制按钮点击后粘贴,得到的是 Markdown 而不是 HTML;
  • llms.txt 里没有指向站外的链接;
  • 开了这两种输出的话:llms-full.txt 里每一页都以一行 Source: 开头,同一页在各语言 navigation.json 里的 id 相同。

限制

  • 主题产出的机器可读表面是四种构建期文件:每页 .mdllms.txt,以及需要显式打开的、每个顶层栏目一份的 llms-full.txt 与每种语言一份的 navigation.json。站点地图仍是 Hugo 自己的 sitemap.xml
  • 全文包属于顶层栏目,没有整站一份的 llms-full.txt:想读全站的 agent 按栏目逐个读,清单在 llms.txt 里。
  • LLMSLLMSFULLNAVJSON 都声明为非替代格式,所以它们都不会出现在 <head>alternate 链接里,也没有对应的页面操作;它们靠约定俗成的路径与 llms.txt 里的条目被发现。
  • 服务端内容协商(同一个 URL 按 Accept: text/markdown 返回 Markdown)不属于主题范围,要做在托管层。
  • Markdown 输出走 源码 路径:只在浏览器端由 JavaScript 生成的内容(运行时绘制的图表)在 .md 里是围栏源码,不是图。