织光成纱
把光织成纱——Lumenveil 自研主题从 v0.1.1 到 v0.1.3 的开发手记:版本演进、commit、设计决策、踩坑与待办。配套上一篇「Hugo + Lumenveil 搭建记录」使用——那篇讲站点部署,这篇讲主题本身。
上一篇「Hugo + Lumenveil 搭建记录」讲的是站点部署——baseURL、canonical、缓存、反代、生产静态化。这篇换视角:主题本身。版本怎么演进,每个 commit 加了什么,做了哪些设计决策,踩过哪些坑。写这篇的时候主题在 v0.1.3。
设计原则(4 条,从第一天定下来)
- 极简,淡色优先;
- 首页 + 列表页带渐入动画;
- 文章页干净,不抢内容;
- 模板结构扁平,方便改。
这四条没变过。后面所有的功能/重构/裁剪都是围绕这四条做取舍。
主题结构(v0.1.3 当前)
<theme-repo>/
├── archetypes/ # 新建内容的模板
├── assets/ # 编译期资源(CSS/JS)
├── docs/ # 文档 + 截图(README 用)
│ └── screenshots/
│ ├── post.png # 文章页 light
│ ├── post-dark.png # 文章页 dark
│ ├── posts.png # 归档页 light
│ └── posts-dark.png # 归档页 dark
├── exampleSite/ # 示例站点(脱敏参考)
├── layouts/
│ ├── 404.html
│ ├── archives/ # 文章 section
│ ├── baseof.html # 全局骨架
│ ├── _default/ # 兜底模板
│ ├── home.html # 首页
│ ├── home.json # search 索引
│ ├── page/ # 独立页
│ ├── page.html # 独立页兜底
│ ├── _partials/ # 内部小件(head/header/footer)
│ ├── partials/ # 可复用 partial
│ │ ├── comments.html # Artalk 集成(v0.1.3 新增,~314 行)
│ │ └── photoswipe.html # 图片灯箱
│ ├── section.html
│ ├── shortcodes/ # 自定义 shortcode
│ ├── single.html # 通用文章页兜底
│ ├── taxonomy.html # 分类列表
│ └── term.html # 单分类
├── static/ # 直接拷的静态资源
├── theme.toml # 主题元信息(name/license/homepage/demosite/tags)
├── hugo.toml # 示例配置
├── README.md + README.zh.md
└── LICENSE
铺开看就是三个区:模板(layouts/)、资源(assets/ + static/)、文档(docs/ + README*)。
迭代时间线
v0.1.1 —— 起始版
不是从零开始写的,是两天 Vibe Coding 出来的第一版。基础功能一次性铺好:
模板层
home.html/section.html/taxonomy.html/term.html:带渐入动画的列表类页面。archives/single.html/single.html/page/single.html:文章页和独立页,干净不抢内容。home.json:search 索引(站内搜索的 JSON 数据源)。baseof.html+_default/:全局骨架和兜底。partials/photoswipe.html:图片灯箱。shortcodes/:自定义 shortcode(相册/折叠/引用块之类)。
样式系统
- glass UI 风格——卡片半透明、背景模糊、细边框。
- CSS 变量全栈:
- 颜色:
--accent-2、--ink-1..3、--line - 圆角:
--radius-md、--radius-lg、--radius-xl - 玻璃:
--glass-bg - 字体:
--font-sans、--font-mono
- 颜色:
- 全部走变量,方便换肤/继承(v0.1.3 接 Artalk 时直接复用这套变量)。
交互
- 渐入动画双轨:
data-reveal+ IntersectionObserver(首页 / 列表 / 分类 / 标签页,进视口触发).fade-up(CSS 加载即淡入上浮,0.7sboth)
- 主题切换:localStorage key
lumenveil-theme,值light|dark,页面加载前 inline script 从 localStorage 读dataset.theme,避免 dark→light 闪烁。比 URL query 可靠——刷新、跨页面、用户手动切都稳。
仓库
- GitHub
github.com/<author>/<theme-repo>,main 分支。 - 站点侧用 symlink:
/var/www/<site>/themes/lumenveil→/<hugo-themes>/<theme-repo>。改完cd /var/www/<site> && hugo --minify重建即生效,systemd 静态服务自动刷新。
v0.1.2 —— 全内容页渐入
反馈:用户打开关于页,发现没有首页那种渐入。
调查:不是 bug,是设计——渐入动画(fade-up / data-reveal)只挂在首页 / 列表 / 分类 / 标签页。文章页(single.html、archives/single.html)和独立页(page/single.html)里 fade/reveal 代码数量是 0。
决策:用户要求所有内容页 + 未来新页面都加渐入。做成持久方案——直接改主题模板,不在内容里 hack。
实现:给 <article class="article"> 加 .fade-up。
| 页面类型 | 模板 | 改动 |
|---|---|---|
| 独立页 | layouts/page/single.html | 加 .fade-up |
| 文章页(archives section) | layouts/archives/single.html | 加 .fade-up |
| 通用文章页 | layouts/single.html | 加 .fade-up |
关键教训:Hugo 模板有优先级。文章页(archives section)实际走的是 layouts/archives/single.html,不是 layouts/single.html。第一次只改 single.html,结果关于页生效(走 page/single.html),welcome 文章页不生效——就是这个原因。
改主题模板要给所有可能命中的 section 模板(
single/archives/page)都改。要么全改,要么用_default兜底。
v0.1.3 —— Artalk 评论集成
反馈:博客要接评论。
选型:Artalk(自托管、Go 后端、Markdown 评论、私有部署)。没选 Giscus(要 GitHub 账号),没选 Twikoo(要 MongoDB)。Artalk 最轻——一个 Go 二进制 + sqlite。
新增 partial:layouts/partials/comments.html,~314 行。
关键设计:
config-driven——三个参数:
service(站点名)、server(API 域名)、site(Artalk 站点名)。主题使用者填自己站点的值,不硬编码 example.com。样式全栈走 lumenveil CSS 变量:
- 颜色 / 字体 / 圆角 / glass 全部用
--accent-2、--ink-1..3、--line、--radius-md/lg/xl、--glass-bg、--font-sans/mono。 - Artalk 默认是白色圆角卡片,跟 dark theme 不搭。覆盖后 dark 下用 ink-1/2/3 层次,light 下用白色 + 浅灰边框,整体玻璃感延续主题。
- 颜色 / 字体 / 圆角 / glass 全部用
Send button 走
.button--ghost风格——transparent bg + cyan border + cyan text,hover 填充。和主题其他 CTA 一致。隐藏 Artalk 默认装饰:
.atk-arrow-down-icon/.chevron/.widget-style的 SVG。换成本主题的 chevron 风格(细线段 + 1px ink)。.atk-header走 article 的.post-meta字体(mono + uppercase + letter-spacing .07em)。“0 条评论” 这种 meta 看起来像页面 meta,不是组件 meta。容器对齐:
.artalk容器width: 100%; max-width: 1060px(=.article-layout820 + 240),margin: 48px auto 0。上下模块间距固定 48px,左右跟 article 卡片完全对齐。
修改:layouts/archives/single.html——把 comments.html 包进 <div class="article-layout">,让它走 grid 跟 .article-main 完全对齐。
插入位置:<article> 末尾 </article> 之前、post-nav 之后。即正文 + share + TOC 容器后再放评论块。
主题部署侧的踩坑(记在这里方便主题使用者避坑,不展开详细排查):
- defer 时序 bug:Artalk.init 在 Artalk.js 加载前跑 → 用
DOMContentLoaded包。 - Hugo jsonify /
printf "%q"和--minifyJS minifier 冲突(单引号包双引号还报错)→ 改用显式"..."包裹。 - Artalk Go v2.10.0 CORS middleware 漏 ACAO → npm 反代层注入
add_header Access-Control-Allow-Origin修。 - Artalk site 在 fresh install 时 API 全因
err_no_site失败 → 用户得去/sidebar/引导新建站点。
推送——3 个 commit 到 GitHub:
commits-c272c5b docs(theme): refresh Archives screenshots and posts alt text ← typo "atl" → "alt"
commits-41eb47f docs(theme): update Articles screenshots and README for Artalk comments
commits-7e8e21b feat(theme): add Artalk comments partial with aligned layout
推到 lumenveil 仓库的文件:
layouts/partials/comments.html(新增)layouts/archives/single.html(修改,wrap comments in article-layout)docs/screenshots/post.png+post-dark.png(1440×1800)docs/screenshots/posts.png+posts-dark.png(1440×1800)README.md+README.zh.md(更新截图 alt text + Features 列表加 Artalk comments)
typo 教训:commit message 里
posts atl text应是posts alt text(alt = alternative text)。Amend 成新 hash,force-with-lease push。commit message 里的拼写也算文档——README alt text 直接引用。
关键设计决策(事后看值得记的)
1. 渐入动画双轨
加载即播放(.fade-up)vs. 进视口触发(data-reveal + IntersectionObserver)。
- 内容页用前者——文章一进来就该看见,不应该有"滑下来才发现"的延迟感。
- 列表页用后者——一屏只看到 2-3 张卡片,后面的卡片等滚到再淡入更自然。
如果只有一条轨道,要么文章页感觉延迟,要么列表页首屏过度。两条都要。
2. 主题切换用 localStorage 不用 URL query
URL 看起来更"可分享",但有坑:
- 复制带
?theme=dark的链接分享给朋友,朋友那边是 light 偏好,体验分裂。 - 用户在 dark 主题下点"复制链接",URL 带 query,复制给 dark 朋友正常、复制给 light 朋友异常。
- SSR / 爬虫看到带 query 的 URL 会以为有多个版本。
localStorage key 干净——只存用户偏好,URL 永远是 canonical。
3. 评论 config-driven
第一反应是写一个 partials/comments.html 然后在 single.html 里 {{ partial "comments.html" . }} 完事。但 Artalk 的 service / site 是用户站点相关的字段——主题里写死就是泄漏用户信息。
正确做法:主题只暴露参数,用户在 hugo.toml 里填:
[params.comments.artalk]
service = "我的站点"
server = "https://artalk.example.com"
site = "站点标识"
主题渲染时 {{ with site.Params.comments.artalk }}{{ ... }}{{ end }}。模板本身不出现任何 example.com / 用户名 / 站点名。
4. 截图 viewport 统一 1440×1800
不同尺寸截图混排会显得"不专业"。从 v0.1.3 开始定死 1440×1800,light + dark 双截图保持尺寸一致。Playwright 切主题用 localStorage.setItem('lumenveil-theme', 'light' | 'dark') + page.reload()——比 URL query 切换稳,等 2500–3000ms 让 Artalk.init + 评论 fetch 完成再截图。
5. Markdown 图片路径解析 4 层 fallback
Hugo 默认 markdown image 渲染原样输出 <img src="...">,不转换路径。主题里其实已经有 layouts/_default/_markup/render-image.html hook——包了 PhotoSwipe 灯箱——但只覆盖 page bundle 一种 case(.Page.Resources.GetMatch 找到 → .RelPermalink),找不到时 fallback 到原始 Destination 原样输出。
这就是为什么本篇改了三处图片路径都要手动加 /。每张图都得记——纯 footgun。
而且这是 silent failure:HTML 渲染没报错,浏览器从当前页 URL(如 /archives/2026/08/lumenveil-craft/)解析 images/foo.jpg → /archives/2026/08/lumenveil-craft/images/foo.jpg → 404,只有 DevTools 能看见。
修法:扩展 render-image.html fallback,按优先级解析——
- page bundle 资源(
.Page.Resources.GetMatch)→.RelPermalink - 外部 URL(
http:///https://///)→ 原样 - 绝对路径(
/images/foo.jpg)→ 原样 - 相对路径(
images/foo.jpg)→ 自动补前导/
核心 diff:
{{- $dest := .Destination -}}
{{- $img := "" -}}
{{- $src := $dest -}}
{{- with .Page.Resources -}}
{{- $img = .GetMatch (printf "%s" $dest) -}}
{{- end -}}
{{- if $img -}}
{{- $src = $img.RelPermalink -}}
{{- else -}}
{{- $isExternal := or (hasPrefix $dest "http://") (hasPrefix $dest "https://") (hasPrefix $dest "//") -}}
{{- if and (not $isExternal) (not (hasPrefix $dest "/")) -}}
{{- $src = printf "/%s" $dest -}}
{{- end -}}
{{- end -}}
验证:本篇封面图就用最自然的写法 images/lumenveil-craft-cover.jpg(front matter 和正文同款),重建后 <img src=/images/lumenveil-craft-cover.jpg> 自动前缀;PhotoSwipe 灯箱链接 <a href=/images/lumenveil-craft-cover.jpg> 也自动前缀。cover front matter 仍用 images/... 在列表页(post-card.html 走 relURL)也正常显示——因为 relURL 本来就会处理相对路径。
三条教训:
- 基础设施层的 bug 在基础设施层修——不要让每个内容创作者手动 workaround。
- “已经写了 render hook” ≠ “写对了 render hook”——只覆盖 page bundle 一种 case,static/ 走的是 fallback 分支,而 fallback 分支没改。检查已有代码时要看全部分支,不是看主路径。
- silent failure 最隐蔽——图片 404 但 HTML 没报错,只有 DevTools 能看见。
6. 阅读次数:嵌套 span 隔离数字 + busuanzi 第三方服务
post-meta 原本是日期 + 阅读时长 + 字数三项。加"阅读次数"思路很直接——找服务、塞数字。第一反应是接 Artalk PV(评论系统已部署,复用服务端),但 Artalk 2.x 没法给内联 span 灌计数(详见踩坑 #9),只能换第三方。
最终选了 busuanzi(ibruce.info)——CN Hugo 圈用得最广的第三方 PV 服务,async script 加载,跑得稳。trade-off 是依赖第三方服务、不能自托管——但"博客能看见阅读量"这个需求比"完全自托管"优先级更高。
集成方式:
<span class="page-views">
阅读
<span id="busuanzi_value_page_pv">0</span>
次
</span>
<script async src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>
busuanzi 加载完会直接 el.textContent = count 把数字塞进 #busuanzi_value_page_pv——和原先 localStorage 方案踩过的同一个 footgun。所以 HTML 结构必须把数字隔离到内层 span,“阅读” 和 " 次" 留作外层 span 的 sibling text;外层约定用 id="busuanzi_value_page_pv"——所有用 busuanzi 的 Hugo 主题都这样写,换自部署服务时改 id 即可,post-meta 结构不动。
为什么不接 Artalk PV / 自部署:
- Artalk widget
loadCountWidget({ pvEl, countEl })调通了但 counter 一直是 “0”——Artalk widget 是 “接管式” 渲染,不会去更新我们指定的 countEl 内联 span,详见踩坑 #9。 - Artalk 2.x REST PV API:试了 5+ 端点(
/api/v2/stats、/api/v2/page/pv等),全 404。2.x 没暴露 PV REST,只有 widget UI。 - 自建 Go 端点:可行,但要单独维护一个服务,跟"快速接入"目标不符。
换数据源的接口留好了——未来要切自部署(自建 Go / Artalk 评论 widget 等),只改 partial + 引入的 JS,post-meta HTML 结构不动。
三条教训:
- 嵌套 span 隔离数字是必备防御——busuanzi / Artalk widget / 自建端点都一样:第三方往 DOM 灌数字时大概率是
el.textContent = count或类似"整体替换"做法;要替换的部分必须从结构上隔离。 - 隔离方法:内层 span 包数字 + 外层 span 的 sibling text 放前后缀——第三方 widget 只动内层,外层 sibling 永远不会被覆盖。
- 第三方权衡:自托管 ≠ 自部署最优解。busuanzi 工作可靠、社区广、零运维——比起"为了自托管多维护一个 Go 服务"的成本,第三方服务的 trade-off 是值得的。前提:服务是行业标准(CN Hugo 圈都用)、运营稳定。
主题与外部系统的契约
这部分是给"想用 Lumenveil 建站的人"看的——主题作者必须公开的接口:
| 契约 | 值 / 格式 | 谁来消费 |
|---|---|---|
| localStorage key | lumenveil-theme = light | dark | layouts/_partials/head.html inline script |
| CSS 变量 | --accent-2、--ink-1..3、--line、--radius-md/lg/xl、--glass-bg、--font-sans/mono | assets/css/* + 主题使用者覆盖 |
| Artalk 三参数 | params.comments.artalk.{service,server,site} | partials/comments.html |
| 插入位置 | <article> 末尾 </article> 之前、post-nav 之后 | archives/single.html |
| 符号链接约定 | /var/www/<site>/themes/lumenveil → /<hugo-themes>/<theme-repo> | 站点侧,主题仓库不感知 |
| 重建命令 | cd /var/www/<site> && hugo --minify | 站点侧 |
| 截图规范 | 1440×1800 viewport,light + dark 双截图 | docs/screenshots/ + README |
主题使用者改了 CSS 变量就能换肤;改了 Artalk 三参数就能换评论;改了符号链接就能换部署路径。主题本身不需要 fork 任何东西。
主题侧的踩坑汇总(区别于站点踩坑)
| # | 问题 | 根因 | 修法 |
|---|---|---|---|
| 1 | 关于页没渐入 | 主题设计:fade-up/reveal 只挂首页/列表页 | 给 3 个内容页模板(page/single, archives/single, single)都加 .fade-up |
| 2 | welcome 文章页渐入没生效 | 只改了 single.html,文章页走 archives/single.html | 改主题要看 section 优先级,三个模板都要改 |
| 3 | Artalk 样式和主题不搭 | Artalk 默认白色圆角卡片,无 CSS 变量 | partial 内覆盖样式全栈走 lumenveil 变量 |
| 4 | Artalk 评论按钮偏大、风格突兀 | Artalk 默认 button 是实心蓝 | 走 .button--ghost 风格(透明 bg + cyan border + hover 填充) |
| 5 | Artalk 默认图标和主题 chevron 风格不一致 | 默认是粗实心 SVG | 隐藏 .atk-arrow-down-icon / .chevron / .widget-style 的 SVG |
| 6 | 评论容器和 article 卡片左右不对齐 | 评论块独立 div 不走 article-layout grid | 把 comments 包进 <div class="article-layout"> 走 grid |
| 7 | commit message typo “posts atl text” | alt text 写成 atl text | Amend 新 hash,force-with-lease push;commit message 也算文档 |
| 8 | 写新文章  图片 404,每张都得手动改成 /images/foo.jpg | render-image.html hook 已有但只覆盖 page bundle 一种 case;static/ 下图片走 fallback 分支,fallback 原样输出相对路径未补 / | 扩展 render-image.html fallback:相对路径自动补前导 /;解析优先级:page bundle 资源 → 外部 URL → 绝对路径 → 相对路径(自动前缀) |
| 9 | 试 Artalk 2.x loadCountWidget({ pvEl, countEl }),widget 调通但 counter.textContent 一直是 “0” | Artalk widget UI 是 “包装型” 渲染——把 pvEl 整个当容器塞自己的 DOM(chevron / “次访问” / 计数样式),不去更新我们指定的 countEl 内联 span;pvAdd: true 触发了后端 PV 计数,但前端不显示 | 要么接受 Artalk widget 自己的 UI(放弃自定义 “阅读 X 次” 格式),要么换 busuanzi 这类直接 el.textContent = count 的服务 |
当前状态(v0.1.3)+ 下一步
当前
- ✅ 起始版基础功能(v0.1.1)
- ✅ 全内容页渐入(v0.1.2)
- ✅ Artalk 评论集成 + 截图/README 同步(v0.1.3)
- ✅ 主题与外部系统契约清楚(localStorage / CSS 变量 / Artalk 三参数 / symlink / 重建命令)
下一步(v0.1.x 候选)
- 项目卡片分页加载
- 分类筛选
- 渐入动画曲线微调(fade-up 偏快,0.7s 改成 0.9s ease-out 更柔)
- home.png / home-dark.png / about.png / about-dark.png 截图补全(现在是 4 张,README 完整展示要 8 张)
- v0.2.0:考虑拆 glass 变量为
--glass-bg-light/--glass-bg-dark单独可控(目前 light/dark 同变量调透明度)
后记
写主题和写站点不一样。写站点是搭骨架——一旦稳了就动得少。写主题是养宠物——只要还在写文章,就在养它。Lumenveil 到 v0.1.3 还很年轻:4 个 partial、3 个内容页模板、~600 行 CSS 变量基础。够用,但能看出很多"先这样"的味道。
后面 v0.2 / v0.3 想做的是:把"先这样"变成"就该这样"。glass 变量拆 light/dark、shortcodes 整理、accessibility 走一遍(键盘导航 / 屏幕阅读器 / 颜色对比度)、OG image 自动生成。
主题不在大,在每次小改动都踩出教训。这篇记录是教训本身。
