主题设计
主题继承、模板数据、资源构建与视觉验收。
BookX 主题负责把编译器整理好的内容显示为网页。Markdown 解析、稳定内容 ID、文档关系、排序与路由由编译核心负责;主题维护 HTML、CSS 和浏览器交互。
这份指南从一个继承 Paper 的最小主题开始,再介绍页面字段、资源合并和视觉验收。修改主题不需要改变文章或文档格式。
先选合适的修改范围
| 目标 | 推荐入口 |
|---|---|
| 调整现有主题颜色、圆角、透明度或文章列数 | 工作台中的站点主题外观设置 |
| 保留阅读布局,换字体与局部样式 | 创建继承主题,覆盖 CSS |
| 修改首页或某一种页面结构 | 覆盖对应 layout 或 partial |
| 重新设计全部页面 | 自行提供完整模板,同时保留内容与路由协议 |
工作台自身的配色与公开站点主题彼此独立。这里讨论的是构建到 dist/ 的站点主题,不是 internal/studio/src/ 中的管理界面。
阅读顺序
- 主题目录与继承:建立最小主题,理解文件覆盖规则。
- 模板与页面数据:使用站点、内容、分页、文档树和归档字段。
- 资源合并与验证:声明脚本入口,处理子路径并检查构建产物。
- 视觉语言:统一阅读布局、配色、移动端和交互反馈。
内置主题
| 主题 ID | 风格 |
|---|---|
paper | Paper 阅读布局,提供纸白、雾青、夜幕、清透四种配色 |
mono | 黑白出版物风格 |
blush | 樱花粉与莓色 |
collage | 拼贴、贴纸与手工层次 |
cyber | 黑黄科幻界面 |
terminal | 深色终端、青色强调与细网格 |
内置子主题通过继承复用基础布局和交互,各自覆盖视觉差异。开发时优先在独立自定义目录中修改,避免把站点个性化和内置主题升级混在一起。
主题必须守住的边界
- 使用模板提供的 URL,不根据标题重新生成 slug,也不硬编码部署子路径。
- 使用传入的可见内容,不从源文件重新读取草稿、私有或仅本地内容。
- 每页有明确的主内容区域和一个一级标题,正文保留语义结构。
- 图片和代码要能在窄屏阅读;动画应尊重减少动画设置。
- 先验证根域名,再验证 GitHub Pages 子路径;修改源文件后重新构建,不直接编辑生成目录。
内容编写与预览的关键约定见使用注意事项。
主题目录与继承
主题根目录由 bookx.yml 的 theme.dir 指定,默认是 themes。theme.name 使用该根目录下的文件夹名称;清单中的 name 是给人阅读的展示名称。
最小继承主题
创建以下两个文件:
themes/my-theme/
├── theme.yml
└── assets/
└── style.css
theme.yml:
name: My Theme
version: 1
description: 基于 Paper 的自定义阅读主题
extends: paper
giscus_theme: light
bundle:
js: [main.js, archive.js, docs-menu.js, toc.js]
css: [style.css, reading.css]
assets/style.css:
@import url("./base.css");
:root {
--accent: #4d7778;
}
.page-heading h1 {
letter-spacing: -0.02em;
}
base.css、reading.css 和上述脚本可从 Paper 继承,不必复制。这个例子只修改样式,没有声明外观设置面板。
再修改站点配置:
theme:
name: my-theme
dir: themes
运行 bookx validate 和 bookx build 后预览。工作台只扫描主题根目录的直接子目录,清单必须可解析且有 name、description,主题才会出现在选择列表里。
三类文件
| 目录 | 职责 | 例子 |
|---|---|---|
layouts/ | 完整页面入口 | home.html、post.html、doc.html |
partials/ | 可复用的页面片段 | head.html、header.html、pagination.html |
assets/ | 浏览器使用的资源 | CSS、JS、图片、字体 |
未覆盖的布局、局部模板和资源继续使用父主题版本。继承可以有多层,但不能形成循环;同名文件以最靠近当前子主题的版本为准。
覆盖模板是替换整个文件,不是对父文件做文本补丁。建议先复制对应文件,再只修改需要调整的部分。覆盖 CSS 入口后也要保留必要的基础导入。
页面入口
当前页面使用的 layout 包括:
- 首页与博客:
home、list、post。 - 分类和标签:
taxonomy-index,分类/标签详情复用list。 - 文档:
docs-index、doc。 - 附属页面:
archive、search、projects、friends、404,旧链接跳转使用redirect。
文件名以 .html 结尾,内容定义 layout 模板。继承主题只需覆盖有差异的文件;完全独立的主题需要覆盖站点会生成的所有页面入口。是否生成文档、项目和好友页由内容与配置决定,导航应使用对应的启用状态。
接入站点外观设置
清单未声明 appearance 时,不会自动拥有内置主题的外观面板。若需要配色预设,可参考 Paper 的 appearance.presets 结构,并显式声明 bundle.css;缺少 CSS 合并入口会导致校验失败。
外观配置会作用于共享的布局类名和 CSS 变量。自行重做结构时,应确认资料位置、文章列数与分区颜色确实反映到页面上,不能只显示一组不起作用的设置。
bundle 同样不自动继承。它的列表是完整入口列表,具体规则见资源合并与验证。
模板与页面数据
BookX 使用 Go 的 html/template。每个布局接收一份页面数据,其中 .Site 是全站信息,.Page 是当前页面信息,其他字段按页面类型提供。不要假定每个页面都有 .Post、.Doc 或 .Pagination。
一个列表布局
在继承 Paper 的主题中,下面可以作为 layouts/list.html,复用父主题的页头、页脚和分页:
{{ define "layout" }}
{{ template "head" . }}
{{ template "header" . }}
<main class="site-frame page-main">
<header class="page-heading">
<h1>{{ .Page.Title }}</h1>
<p>{{ .Page.Description }}</p>
</header>
<ul>
{{ range .Posts }}
<li><a href="{{ .URL }}">{{ .Title }}</a> <time>{{ .DateText }}</time></li>
{{ end }}
</ul>
{{ template "pagination" . }}
</main>
{{ template "footer" . }}
{{ end }}
range 和 with 会改变当前的 .。如果在循环中需要访问页面根数据,使用 $,例如 $.Site.Title。普通标题和文本保留模板自动转义,不要为了方便拼接而转换成不受检查的 HTML。
通用字段
| 字段 | 用途 |
|---|---|
.Site.Title、.Site.Description | 站点名称与介绍 |
.Site.HomeURL、.Site.BlogURL、.Site.ArchiveURL | 已处理部署子路径的导航地址 |
.Site.DocsEnabled、.Site.ProjectsEnabled、.Site.LinksEnabled | 是否展示对应入口 |
.Site.DocCollections | 顶部文档合集列表 |
.Site.AssetsURL | 未合并主题资源的 URL 前缀 |
.Site.BundleCSSURL、.Site.BundleJSURL | 已生成的资源合并地址,未启用时为空 |
.Site.SearchDataURL | 静态搜索索引地址 |
.Page.Title、.Page.Description、.Page.URL | 当前页面标题、描述与地址 |
.Page.Canonical、.Page.Kind | 正式规范地址与页面类型 |
.Year | 构建内容对应的年份,用于页脚等展示 |
这些 URL 字段已经处理站点子路径,不要再手工拼接一次 base_path。也不要把 /assets/theme/ 或 /blog/ 写死到模板中。
文章、首页与分页
列表页的 .Posts 已完成排序与分页,只负责逐项渲染。首页使用 .HomePosts 获取按当前布局分页数量限制的最近文章;首页旧 .Posts 只保留最近 4 篇以兼容旧主题。文章总数使用 .Site.PostCount,不能用首页列表长度代替。
文章详情使用 .Post 获取元信息,.Content 输出已经编译的正文,.Newer 和 .Older 是可选前后文章。.TOC 提供标题的 ID、Text 和 Level。分类和标签链接可使用文章的 .CategoryURL、.TagLinks,无需自己从名称生成 URL。
.Pagination 包含当前页 Current、总页数 Total、PrevURL、NextURL 与 PageURLs。每个页码还包含 Current 和 Ellipsis 状态;省略项不是链接。直接复用父主题 pagination 可保持长页码列表、键盘访问和子路径行为一致。
文档数据
文档列表页使用 .Collections。文档详情的 .Collection 是当前合集,.Doc 是当前页面,.Sidebar 是已经整理好的导航树,.Breadcrumbs 是面包屑。
导航节点的 Children 表示下级,Active 和 Open 表示选中与展开状态。节点有标题但没有 URL 时,渲染为可展开目录或纯文字,不要制造一个不存在的首页链接。面包屑 URL 为空时也应输出文字。
顶级合集保留首页;子目录关闭首页后仍可有下级内容。主题应使用编译器给出的树,不要遍历全部文档后自行重建父子关系。页内目录与左侧文档树也应分别渲染。
归档分批展示
新主题使用 .Archive.Years 渲染当前静态页,每个分组有 Year、全年总数 Count 和本页 Posts。Count 不能换成 len .Posts,因为一个年份可能跨过多批。
归档首页的 .Archive.NextDataURL 指向下一批 JSON;没有更多数据时为空。编译器每批最多输出 50 篇,JSON 中只包含列表字段及下一批地址,主题不需要自己判断年份文件名或拼接 JSON 路径。
共享 archive.js 依赖以下模板标记:
| 标记 | 位置 |
|---|---|
data-archive-groups | 所有年份的容器 |
data-archive-year | 年份区块,值为年份 |
data-archive-id | 每个文章链接,值为稳定内容 ID |
data-archive-load 和 data-next | 加载区及下一批 JSON URL |
data-archive-more、data-archive-status | 加载按钮与状态提示 |
data-archive-pagination | 完成后需要隐藏的静态分页容器 |
脚本还复用 archive-year、archive-year-label、archive-year-posts 等结构类名。若保留共享加载逻辑,建议从 Paper 的 archive.html 开始修改,保证静态与追加内容的结构一致。
失败时保留重试和静态分页;全部加载完成后隐藏按钮与分页,仅显示完成提示。旧 .ArchiveYears 仍是完整列表,供尚未接入分批加载的自定义主题使用。
评论、搜索与 SEO
复用 head、comments 以及搜索相关模板,可以保留规范地址、分享信息、稳定评论关联和按需加载行为。评论是否启用以 .Comments 数据为准,不根据页面类型硬编码开启。
搜索索引由 .Site.SearchDataURL 提供,打开搜索时再请求即可。不要在每个页面初始化时额外下载全部搜索数据;归档也使用自身的精简分片,不用搜索索引冒充归档数据。
完整字段以源码的 internal/bookx/models.go 为准,现有模板位于 themes/paper/。新增页面需求若需要新的内容关系,应先在编译核心建立清晰的数据协议,再由主题渲染。
资源合并与验证
主题源文件保存在 assets/,构建时复制并按清单生成合并资源。最终用户使用主题不需要安装 Node.js,合并工具随 BookX 一起运行。
显式声明完整入口
继承 Paper 并保留共享交互时:
bundle:
js: [main.js, archive.js, docs-menu.js, toc.js]
css: [style.css, reading.css]
入口相对当前主题的 assets/,先按父子继承规则解析同名文件,再按声明顺序合并。列表是完整入口列表,不会自动追加父主题脚本;bundle 本身也不自动继承。
如果继承 Terminal 并保留首页时钟与头像交互,JS 入口还需要 clock.js、portrait.js。新增自己的脚本时,把实际存在的文件追加到列表。不要同时在模板里再次加载已进入合并文件的脚本。
可以只合并 CSS 或 JS。未声明的一类仍按原始模板加载;旧自定义主题完全不声明 bundle 时保留原加载行为。
页头引用
构建后合并文件使用带内容哈希的名称。继承内置 head 就会自动引用它们;自行覆盖页头时,使用编译器提供的 URL:
{{ if .Site.BundleCSSURL }}
<link rel="stylesheet" href="{{ .Site.BundleCSSURL }}">
{{ else }}
<link rel="stylesheet" href="{{ .Site.AssetsURL }}style.css">
<link rel="stylesheet" href="{{ .Site.AssetsURL }}reading.css">
{{ end }}
{{ if .Site.BundleJSURL }}
<script defer src="{{ .Site.BundleJSURL }}"></script>
{{ end }}
这是引用片段;若支持未合并的 JS,还要保留所需的独立脚本分支。最小主题示例已经声明 JS 合并入口,可直接继承完整页头。
CSS 依赖和图片路径
本地 @import 会展开,媒体条件和层叠规则会保留。图片、字体等相对 URL 会按合并文件的位置改写,依赖必须位于主题 assets/ 内。
CSS 中优先使用相对资源地址,例如 url("./images/pattern.svg"),并在同目录下提供文件。外部地址不会自动下载。硬编码 /assets/... 会从域名根开始,不能自动适应项目子路径。
CSS 不压缩、不混淆类名;合并后的格式可能因解析重写变化,原样式文件仍保留。不要手动修改 dist/assets/theme/ 中的资源来修复源文件问题。
JavaScript 的约定
参与合并的 .js、.mjs 可以通过明确相对文件名导入本地模块,使用 import、export 共享值。独立脚本需要调用的接口应显式暴露到 window,不要依赖其他文件的局部变量。
脚本在 DOM 解析后运行,找不到对应元素时应直接退出。避免依赖 document.currentScript、import.meta.url 或原 <script async> 的执行时机。远程模块、裸包名和 JS 中直接导入 CSS 不属于该入口协议;第三方脚本继续独立加载。
HTML 和 JS 会压缩,JS 局部变量会缩名。不要依赖函数名称反射,也不要把密钥、口令或令牌放进任何前端文件。动态展示标题、分类等普通文本时使用 textContent,保留转义边界。
本地开发与构建
在工作区运行:
bookx validate
bookx build
bookx serve
需要监听变化时,在另一个终端运行 bookx watch -drafts=false。watch 负责重新构建,serve 负责访问;源文件变化后刷新页面。验证命令会检查内容、资源和生成页面,失败时按报错中的源文件或资源路径修正。
根域名与子路径都要验证
先用 base_path: "" 构建,再在临时工作区配置 base_path: /preview 构建。第二次访问地址应包含 /preview/,重点检查导航、正文图片、搜索和归档 JSON,不能只检查首页颜色。
每轮至少检查:
- 首页,文章列表,文章详情,分类与标签。
- 文档合集首页、多级目录、关闭首页的子目录和面包屑。
- 归档首屏、跨批次同年合并、全部完成后分页隐藏,以及静态第 2 页。
- 桌面、平板和手机下的长标题、代码块、表格和图片。
- 键盘焦点、目录抽屉、图片弹窗、减少动画,以及无 JS 时的内容入口。
没有项目、好友或公开文档时,相关导航不应指向不存在的页面。修改后应检查最终合并脚本,不能只在源码单文件环境中测试交互。
常见构建问题
| 现象 | 优先检查 |
|---|---|
| 主题没有出现在工作台 | 是否位于主题根目录直接子级,清单是否有名称与描述 |
| 找不到布局或 partial | 覆盖文件是否定义了正确模板,父主题是否存在 |
| CSS 或模块导入失败 | 相对路径、文件大小写、依赖是否越过 assets 边界 |
| 子路径下图片或搜索失败 | 是否硬编码根路径或重复追加部署子路径 |
| 归档只能看到首批内容 | JS 列表是否包含 archive.js,共享模板标记是否保留 |
| 动作执行两次 | 原始脚本是否与合并文件重复加载 |
| 修改样式没有变化 | 是否重新构建,页面是否引用新的内容哈希文件 |
资源解析、合并或产物校验失败时,构建停止并保留上次成功产物。应修复源码后重新构建,不要通过手改生成文件绕过错误。
视觉语言
主题可以有自己的字体、色板和装饰,但阅读、导航和反馈应保持一致。先让文章、文档和手机页面可用,再增加首页视觉效果。
阅读宽度与空间
Paper 基础布局使用统一的站点宽度变量:
:root {
--site-width: min(60vw, 1080px);
}
.site-frame {
width: var(--site-width);
margin-inline: auto;
}
这只是桌面基础值,窄屏需要对应断点覆盖。首页、列表、归档与文档页面应保持整体边界一致;长标题、表格、代码块和连续 URL 不能把页面撑出横向滚动条。
内置布局的文章卡片支持两列或三列,手机收为单列。三列每页固定 9 篇,两列使用站点分页设置;不要用 JavaScript 再删除一部分卡片来模拟分页。
配色与层次
Paper 的纸白、雾青、夜幕、清透是同一布局的不同配色。其他主题可以使用黑白、粉色或终端风格,不需要强制套用暖色系。
为页面画布、内容块、正文、次要文字、强调色和边框分别建立稳定变量。选中、悬浮、键盘焦点及错误状态也需要明确反馈。避免直接把浅色主题的文字颜色带入深色背景。
当前内置主题页头和页脚与页面共用背景,页头保留阴影与背景模糊,滚动后使用导航色的 30% 透明混合。下拉菜单与手机抽屉保留足够实的底色,避免正文透出干扰阅读。页脚不再添加顶部横线。
边框和透明度
边框是否出现由主题层级需要决定。Mono 可以使用清楚的直线,Terminal 保留低对比灰青色细轮廓;不应为了“少边框”而让可点击区域或键盘焦点无法辨认。
设置背景透明度时,只对背景应用透明混合,不要直接降低整个容器的 opacity,否则文字、图片和控件也会一起变淡。正文图片与作者头像保留原色,不添加统一变色滤镜。
文档与正文目录
文档左侧树中,同级条目文字对齐,子级每层缩进 14px,展开图标紧随名称。选中使用文字色与下划线,不改变字号或字重,避免展开或选中时布局跳动。
右侧本页目录来自编译器的标题锚点,随阅读滚动高亮;窄屏移到正文前。固定页头不能遮住锚点标题。文档正文、前后章节和评论沿正文列同宽排列。
归档与加载状态
年份和文章列表保持固定的视觉关系,同一年跨批次时继续追加到原列表。加载中按钮要禁用重复提交,失败时显示可操作的重试提示。
全部加载完成后保留完成提示,隐藏加载按钮和底部分页。没有 JavaScript、加载失败或尚未完成时仍能访问静态分页。动态追加的文章要使用与首批静态文章相同的样式。
移动端和键盘
检查手机窄屏下的页头、文档抽屉、表格和分页。点击目标需要留出足够空间;抽屉、弹窗应能通过 Esc 或关闭按钮退出,并把焦点交还给打开入口。
按钮使用 button,跳转使用 a,不要仅在 div 上绑定点击事件来替代语义控件。触发动态加载时保留状态反馈,避免键盘使用者丢失阅读位置。
动画遵循 prefers-reduced-motion,没有对应页面元素时不启动动画循环或计时器。装饰不应挡住链接、正文选取和触摸滚动。
完成设计后按资源合并与验证检查真实构建页面。
讨论这篇内容
评论由 GitHub Discussions 提供 · 打开讨论仓库