OINK 实施日记:从复制外壳到统一主题

记录 OINK 实现预览背后的技术决策、迁移方法、安全边界、测试与文档工作。

OINK 始于一个令人不安的事实:多个生产文档站之所以看起来相互关联,是因为它们确实源于同一套实现;但公共实现却以复制文件的形式散落在各处。呈现效果足够一致,维护模型却并非如此。

这篇日记记录项目如何从重复站点覆盖项走向一款直接演化的统一主题。它关注决策与证据,而不是逐条复述提交历史。

锁定产品契约

第一项真正有价值的工作是做减法。选择实现方式之前,我们先写清产品必须是什么:

  • 从 Docsy 直接演化而来的独立主题;
  • 唯一标准外壳,而不是可切换皮肤;
  • Hugo Extended 是消费端唯一构建依赖;
  • 所有主题自带浏览器资源默认本地优先;
  • 多语言行为从 Hugo 推导,而不是从 PGSTY 域名推导;
  • 可复用组件进入主题,业务语义留在站点;
  • 保留 Docsy 历史、许可证与可追踪的上游关系。

这排除了一个看似诱人、实际代价高昂的捷径:增加 params.oink.enabled 并保留旧外壳。模式开关会让每次布局调整、无障碍修复与测试都支持两套产品。直接演化则让目标设计成为唯一设计。

替换页面外壳

文档、博客与 API 参考布局围绕一组小型共享 partial 重新构建。新的外壳包括:

  • 全局导航与响应式次级导航;
  • 可调整宽度、可折叠的侧栏;
  • 本地搜索与快捷链接;
  • 语言与颜色模式控件;
  • 面包屑、目录(TOC)、页面元数据与反馈;
  • 一致的页脚与打印布局。

真正困难的不是画出导航栏,而是在删除复制的 baseof.html 时保留 Docsy 既有扩展点。范围明确的 hook 仍然存在;复制整个站点外壳不再是正常的定制路径。

移除消费端工具链

原有依赖链假设 Bootstrap 与 Font Awesome 来自 npm,部分路径还会调用 PostCSS。OINK 把必需源码与编译产物移入主题,并让 SCSS 留在 Hugo 自身的 Asset Pipeline 中。

测试不只检查 hugo 是否成功。fixture 中的陷阱会在消费端构建尝试运行 Node.js、npm、PostCSS 或 Autoprefixer,或模板调用 resources.GetRemote 时立即失败。LTR 与 RTL 页面遵守同一约束。

这里的区分非常重要:仓库仍使用 Node 运行维护者测试工具。“仅依赖 Hugo”描述的是消费站点取得完整主题后所需的构建环境,并不是禁止主题仓库使用开发工具。

纳管浏览器运行时

下一层工作覆盖浏览器原本可能从远端获取的全部依赖:Bootstrap、Font Awesome、字体、jQuery、Lunr、Mermaid、KaTeX、Markmap、Swagger UI、Redoc、Asciinema、ECharts、Infographic 及其辅助库。

theme/VENDOR.json 为每项选定产物记录来源、固定版本、许可证路径、校验值与更新流程。许可证与 vendor 内容相邻存放。清单会与真实文件一起验证,而不是停留在愿望列表。

PlantUML 与 Diagrams.net 迫使我们做出一项重要区分:它们依赖服务,而不只是 JavaScript 库。OINK 拒绝虚构公共端点;启用功能却没有配置服务时,构建会失败。

构建多语言内核

旧的语言行为散落在导航逻辑与站点专用假设中。新的内核从 Hugo 已配置语言、.Translations.AllTranslations 出发。

呈现方式刻意保持稳定:只有一种语言时隐藏选择器;两种或更多语言统一使用同一个图标按钮。点击会按配置权重切换,短暂悬停或键盘聚焦则打开完整语言菜单。

缺少译文时回退到目标语言首页。语言名称使用该语言的自称。同一组对象还会驱动 lang、书写方向、canonical、 hreflang 与 Open Graph locale 元数据,因此可见选择器不会与 SEO 输出漂移。

测试会让 RTL 语言作为当前语言覆盖每种状态,而不只验证 starter 的两种 LTR 语言。原生链接与 disclosure 控件让键盘行为保持可预期。

提炼通用组件

Asciinema、ECharts、Infographic、文档轮播、折叠块、标签页、卡片与参数渲染,已经在 PGSTY 站点证明了价值。接下来的工作,是把复制品转化为产品 API:

  • 统一参数名称与默认值;
  • 根据页面身份与短代码序号生成唯一 ID;
  • 每页只加载一次运行时,未使用页面完全省略;
  • 保持子路径 URL 正确;
  • 支持多个内容完全相同的实例;
  • 提供打印、深色模式、移动端、键盘与减少动态效果行为;
  • 为导入内容保留兼容别名。

产品矩阵等业务控件没有迁入主题。是否复用不能只看有多少仓库包含同一个副本;通用组件必须拥有稳定、与业务无关的契约。

设计安全的 ECharts

ECharts 暴露了最尖锐的迁移边界。部分现有页面会在图表块中放置任意 JavaScript 函数。完全保留会让可执行内容成为默认 API;立即禁止又会破坏已经验证的生产页面。

最终方案把两种模式分开:

  1. 新内容提供 JSON 或 YAML,由 Hugo 解析并安全序列化;
  2. 默认拒绝 JavaScript;
  3. 经过审查的旧实例可以设置 unsafe=true
  4. 站点级 params.content.echarts_unsafe 只作为迁移桥梁。

测试包含 </script> 边界载荷、内容相同的重复图表、非法 CSS 长度,以及明确的接受与拒绝场景。

创建 starter 与归档

如果最小示例能完整演示契约,契约就更容易获得信任。starter 包含双语首页、文档、博客与组件页面,以及本地搜索、深色模式、图表、API 文档和新增组件;它没有 package.json,也没有站点工作流。

离线打包器会组合 theme/starter/、许可证、上游记录与迁移指南,并排除生成结果与依赖缓存。它会写出配套 SHA-256 文件,并拒绝覆盖已有产物。

验收测试把 starter 与主题复制到临时目录,清空缓存,阻断 HTTP/HTTPS 与 Go 代理,使用 Hugo 构建,再检查 HTML 与 CSS 中是否出现第三方子资源。

演练四站迁移

SILO、PGSTY、SOW 与 Pigsty 提供了现实检验。演练工具会复制各工作区而不是修改源目录,只删除已经分类的公共覆盖项,应用本地主题 replacement,禁止网络与前端工具,再运行生产构建。

最近一次演练分别从 SILO、PGSTY、SOW 删除 20 个公共覆盖项,从 Pigsty 删除 24 个。Pigsty 保留三个业务矩阵短代码,并使用显式的旧 ECharts 迁移桥梁。所有临时副本均成功构建,分别生成 1,095、16、128 与 2,473 个 HTML 文件。

这些数字证明的是记录提交上的迁移演练,不代表任何生产仓库已经改变,也不代表任何托管站点已经部署。

把样例站变成 OINK 文档

继承而来的 docsy.dev 站点是很有价值的回归语料库,但它只描述 Docsy。文档阶段完成了四项工作:

  1. 把英文设为首要语言、简体中文设为第二语言;后续外壳评审又从演示站点移除了法文;
  2. 为每份核心文档与博客源文件创建并置的 .zh.md 译文;
  3. 在每个中文标题中显式保留英文标题 ID;
  4. 增加 OINK 产品指南、项目公告与本实施日记。

翻译之前,我们先建立术语与排版指南。随后使用检查器验证源文件与译文配对、标题数量、中文显式 ID,以及渲染后的中英文标题 ID 是否完全相同。

Docsy 历史发布文章保持忠实翻译,其中的 npm 时代说明属于历史语境;OINK 架构与迁移指南则明确说明当前仅依赖 Hugo 的产品契约。

测试如何改变设计

多项测试不仅验证实现,也反过来改变了设计:

  • 子路径 fixture 迫使每个本地组件 URL 都经过 Hugo URL 处理;
  • 重复实例测试用“页面加序号”ID 替代内容哈希;
  • 离线浏览器检查暴露了隐含运行时请求;
  • RTL 语言矩阵避免选择器只适用于 starter 的两种 LTR 语言;
  • ECharts unsafe 拒绝测试把安全边界变成可执行契约;
  • 迁移演练保留了直接清空 layouts/ 时会被误删的站点专用 partial。

最有力的测试套件约束的是产品边界,而不只是当前 HTML 快照。

后续工作

目前仍有两个发布关卡有意保持开放。公开品牌、仓库、模块与软件包身份,以及首个版本需要批准。随后还要让真实 Cloudflare Pages 项目从源分支构建,并通过托管验证。

生产迁移应逐站进行,使用专用分支、预览部署、视觉回归与回滚产物。四站临时演练是这项工作的基础,不能替代正式迁移。

经验总结

  • 移动文件前先写清产品边界。
  • 本地优先承诺必须同时具有构建阶段与浏览器阶段证据。
  • 配置应表达用户选择,而不是内部实现分支。
  • 翻译质量不仅是正文,还包括稳定链接、代码保真、排版与渲染结构。
  • 复用应消除维护副本,而不能吞并业务语义。
  • “构建”“打包”“公开发布”“部署”与“迁移”是不同声明,需要不同证据。

最终成果没有重写那样戏剧化,却更加实用:一款能够作为完整产品被理解、构建、测试、翻译与迁移的统一主题。