织光成纱

把光织成纱——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.7s both
  • 主题切换: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.htmlarchives/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。

新增 partiallayouts/partials/comments.html,~314 行。

关键设计

  1. config-driven——三个参数:service(站点名)、server(API 域名)、site(Artalk 站点名)。主题使用者填自己站点的值,不硬编码 example.com。

  2. 样式全栈走 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 下用白色 + 浅灰边框,整体玻璃感延续主题。
  3. Send button 走 .button--ghost 风格——transparent bg + cyan border + cyan text,hover 填充。和主题其他 CTA 一致。

  4. 隐藏 Artalk 默认装饰.atk-arrow-down-icon / .chevron / .widget-style 的 SVG。换成本主题的 chevron 风格(细线段 + 1px ink)。

  5. .atk-header 走 article 的 .post-meta 字体(mono + uppercase + letter-spacing .07em)。“0 条评论” 这种 meta 看起来像页面 meta,不是组件 meta。

  6. 容器对齐.artalk 容器 width: 100%; max-width: 1060px(= .article-layout 820 + 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"--minify JS 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,按优先级解析——

  1. page bundle 资源.Page.Resources.GetMatch)→ .RelPermalink
  2. 外部 URLhttp:// / https:// / //)→ 原样
  3. 绝对路径/images/foo.jpg)→ 原样
  4. 相对路径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.htmlrelURL)也正常显示——因为 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 keylumenveil-theme = light | darklayouts/_partials/head.html inline script
CSS 变量--accent-2--ink-1..3--line--radius-md/lg/xl--glass-bg--font-sans/monoassets/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
2welcome 文章页渐入没生效只改了 single.html,文章页走 archives/single.html改主题要看 section 优先级,三个模板都要改
3Artalk 样式和主题不搭Artalk 默认白色圆角卡片,无 CSS 变量partial 内覆盖样式全栈走 lumenveil 变量
4Artalk 评论按钮偏大、风格突兀Artalk 默认 button 是实心蓝.button--ghost 风格(透明 bg + cyan border + hover 填充)
5Artalk 默认图标和主题 chevron 风格不一致默认是粗实心 SVG隐藏 .atk-arrow-down-icon / .chevron / .widget-style 的 SVG
6评论容器和 article 卡片左右不对齐评论块独立 div 不走 article-layout grid把 comments 包进 <div class="article-layout"> 走 grid
7commit message typo “posts atl text”alt text 写成 atl textAmend 新 hash,force-with-lease push;commit message 也算文档
8写新文章 ![alt](images/foo.jpg) 图片 404,每张都得手动改成 /images/foo.jpgrender-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 自动生成。

主题不在大,在每次小改动都踩出教训。这篇记录是教训本身。

这篇文章有帮助?
Search

搜索文章

输入关键词开始搜索。