把 Shirone 从源码仓库装进 npm: 一次双模式 Astro Integration 改造
今天是中秋节, 祝大家中Cia快llo~(∠・ω< )⌒★
Shirone 早期是一个只能通过 Git Clone 使用的 Astro 主题. 用户想使用它, 需要把整个仓库拉下来, 再自行安装依赖, 修改配置和维护内容.
这种模式适合主题作者本人, 却很难直接交给普通用户. 每建一个博客, 都要 fork 完整仓库. 每次升级主题, 都要面对自己的修改与上游更新之间的冲突. 对的催了这么久的更新终于来了
截至 2026 年 9 月 25 日, Shirone 的 main 分支已有 989 个提交和 897 个受 Git 跟踪的文件. 它同时包含页面, 组件, 样式, 内容示例, 构建脚本和测试. 继续把所有能力都塞在整仓复制的交付方式里, 维护成本只会继续上升.
这次改造的目标很明确: 保留源码模式的定制能力, 同时增加一个可以正常安装, 升级和初始化的 npm 包模式.
改造目标
改造后的 Shirone 计划并行支持两种模式:
| 模式 | 获取方式 | 适用场景 |
|---|---|---|
| 源码模板 | Git Clone 主题仓库 | 主题开发, 深度定制, 直接修改内部实现 |
| npm 包 | 执行 npx shirones init | 快速建站, 常规升级, 通过覆盖层扩展主题 |
两种模式共享同一份主题源码, 但需要明确解决几个问题:
- 包内页面如何进入用户项目的 Astro 路由系统.
- 用户配置, 组件和布局如何覆盖主题内部实现.
- 主题文件与用户文件分别从哪里读取.
- pnpm 严格依赖布局下, 哪些依赖必须暴露给用户项目.
- 源码仓与 npm 包如何共用同一套配置入口.
- npm tarball 如何构建和验证.
核心思路是将主题自身封装成 Astro Integration. 用户项目只需调用 Integration, 内容和配置则放在约定目录中.
历史起点: 它确实只能跑源码
旧版 Shirone 已经使用了 Swup, Svelte, MDX, Sitemap 等第三方 integration. 真正缺失的是 Shirone 自己那层 src/integration/.
在 2026 年 8 月 28 日以前, 主题仓的运行方式仍然完全依赖源码布局:
- 根目录的
astro.config.mjs负责注册所有 integration. tsconfig.json中的别名全部指向仓库自身的src.- 内容集合直接读取
src/content. - 页面由 Astro 扫描仓库中的
src/pages. - 字体, Markdown processor 和 Vite 插件都由根配置手工组织.
这时虽然已经有 package.json, 但它主要用于执行 dev, build 和 preview, 尚无 files, exports, bin, peerDependencies 等发布契约.
源码模式本身没有稳定性问题. 问题出现在这些文件被移动到 node_modules 之后.
架构概览
改造后的职责被拆成两个仓库.
那么至于为什么要拆成两个呢?一开始是打算用单独的密钥去发包, 但有希并没有给我LyraVoid的权限, 所以我用的自建仓库发包,
虽然后面摆烂直接用oicd了, 另一个原因就是shirones起初还很完善, 我协作习惯pr更改而不是直接提交, Shirone因为策略, 所以每次shirones的改动提了pr后都要找黎明点个review,其实就是嫌麻烦
主题仓: Shirone
主题仓继续维护所有主题实现:
src/pages: 主题路由.src/components,src/layouts: 组件和布局.src/config,src/data: 默认配置与数据.src/content: 示例内容与集合定义.src/integration: 源码模式和 npm 模式共用的运行入口.docs/npm-package-mode.md,docs/packaging-contract.md: 双模式约束.tests: 主题站点, 配置所有权和集合声明测试.
包仓: shirones
包仓不保存主题源码, 只负责转换和发布:
- 从指定 ref 获取 Shirone.
- 生成用户初始化模板.
- 打包 Astro Integration 与主题源码.
- 生成发布包清单, manifest 和构建信息.
- 使用真实 tarball 安装并验证.
- 验证配置, 数据, 组件和布局覆盖.
- 通过 GitHub Actions 手动发布 npm 包.
两个仓库拥有完全独立的 Git 历史, 没有共同可达对象. 包仓也没有做持久镜像, 每次构建都从明确的上游 ref 重新获取源码.
这种分工让主题仓继续作为唯一实现来源, 同时允许发布流程独立演进.
关键技术改造
1. 用 Integration 统一运行入口
第一版主题 Integration 在 2026 年 8 月 28 日加入主题仓. 它一次增加了 9 个文件, 负责以下工作:
- 在
astro:config:setup阶段注册路由, 字体, Markdown 和 Vite 配置. - 扫描主题页面, 并在包模式下调用
injectRoute. - 建立配置, 数据, 组件和布局的覆盖映射.
- 在 Node 构建阶段加载用户配置.
- 管理字体声明, 子集化与缓存.
- 在构建结束后生成 Pagefind 索引.
- 提供
init,info和升级相关 CLI 能力.
这一步让包模式拥有了自己的生命周期, 目录解析和用户扩展规则.
2. 明确主题文件与用户文件的归属
包模式中, 主题代码位于 node_modules/shirones, 用户项目根目录则是另一个文件系统边界.
Integration 因此对配置, 数据, 组件和布局建立了固定映射:
| 主题内部位置 | 用户覆盖位置 |
|---|---|
src/config | shirones/config |
src/data | shirones/config/data |
src/components | 用户项目的 src/components |
src/layouts | 用户项目的 src/layouts |
用户只需镜像包内目录结构, 即可覆盖单个文件, 无需 fork 整个主题.
Vite overlay 只处理主题别名, 以及 importer 确实位于主题包内部的相对导入. 这个范围经过一次重要修正.
早期 resolver 会拦截几乎所有 specifier, 导致 shirones/collections 也被错误接管, 内容集合类型生成失败. 后来将处理范围收窄后, 路由和公开导出才恢复正常.
3. Node 阶段也需要加载用户配置
部分配置会在 Vite 启动前被 Astro 读取, 例如站点 URL, 字体声明和 Expressive Code 主题.
这些配置属于 TypeScript, 又需要根据当前运行模式决定从哪里加载. 因此 Integration 在 Node 阶段使用 esbuild 对用户配置进行单独打包, 并复用与 Vite overlay 相同的覆盖规则.
这一层解决了一个现实问题: 同一个配置文件既可能被页面直接导入, 也可能在 Integration 初始化时被 Node 读取. 两边必须得到一致结果.
4. 路由注入与 Astro starter 冲突
主题页面在源码模式下可以直接被 Astro 扫描, 进入 npm 包后则需要 Integration 注入.
用户项目又可能来自 create-astro, 其中已经存在首页, Layout, 组件和配置文件. Astro 对本地文件路由的优先级高于注入路由, 结果可能是 Integration 已经注册主题页面, 用户仍然看到 starter 首页.
CLI 因此增加了 starter 清理和备份流程:
- 普通
init以报告漂移为主. init --update只补回缺失文件.init --force先将旧内容移动到.shirones-backup/, 再替换模板.- 用户本地路由继续拥有最高优先级.
5. pnpm 严格依赖改变了解析规则
源码项目中, 依赖都位于项目根, Vite 和 Node 通常可以直接解析.
发布包使用 pnpm 严格布局后, 主题内部可见的依赖不一定能从用户项目根访问. 这造成了几类问题:
- 包内 bare import 无法从用户根解析.
sharp的动态导入可能触发版本错配.- Iconify 数据集通过
require.resolve查找, 必须真实存在于用户项目. - Svelte 子路径需要由用户根可见的 peer 依赖提供.
最终处理方式包括:
- 将关键运行时依赖提升为 peer dependency.
- 从上游依赖范围派生 peer 版本.
- 对失败的包内导入使用 fallback resolver.
- 将
sharp及其原生包保持 external, 避免错误复用. - 在
init时安装这些 peer 依赖.
6. CommonJS 在构建期仍需要 Node 全局
Shirone 最终产物是静态 HTML, 但 Astro 会在构建阶段通过 Node 执行页面和组件.
Stylus 等 CommonJS 依赖被 Vite 打包进 SSR chunk 后, 会失去原本的 __dirname, __filename 和 require. Integration 后续增加了 SSR Node shim, 让这些模块在两种模式下都能找到真实文件位置.
这也是源码模式很难提前暴露的问题: 主题仓直接运行源码时, 模块通常保持原始形态. 发布包才会经历完整的 bundling 和 SSR 转换.
7. 模板转换成为独立流水线
shirones没有保存主题源码, 从第一天开始就负责把上游源码转换成可安装的包.
构建分为几个阶段:
- 浅克隆指定上游 ref.
- 复制到临时
workspace. - 生成
init使用的dist/template. - 打包 Integration, 主题源码, 类型和 manifest.
- 执行
npm pack. - 在临时项目中安装真实 tarball.
- 运行
init, Astro build, dev smoke 和覆盖测试. - 生成构建信息并发布.
由于源码和用户目录布局不同, 流水线还需要重写配置, 数据与示例文章中的相对路径.
src/user 曾经因为目录白名单遗漏而没有进入包内. 后来流水线改成排除列表, 新顶层 src 目录可以自动进入发布包.
8. 两套配置最终合并为一个入口
包化早期, 主题仓根配置和 Integration 各维护一套完整配置. Sitemap, Swup, Iconify 等选项都曾只修改其中一边.
包仓先加入配置 parity 检查, 后来将规则升级为 config ownership:
- 源码仓的
astro.config.mjs只负责调用shirones(). - 选项值由
src/config/integrationsConfig.ts提供. - Vite, 路由, 字体, Markdown 和 integration wiring 全部归 Integration 管理.
2026 年 9 月 15 日, 主题仓根配置正式缩成一次 Integration 调用. 源码模式和包模式从此共用同一个入口.
9. 模式识别也需要处理 symlink
pnpm link 和 workspace symlink 会将模块解析到真实路径. 旧实现使用多组布尔条件判断模式, 真实路径可能同时不满足插件模式和源码模式.
结果包括:
- 使用包模式目录默认值.
- 不注入主题路由.
- 不给出
init提示. - 页面静默变成空站点.
后来将判断收敛为单一的 isThemeRepo 状态, 模式边界才变得稳定.
10. 发布版本与主题版本解耦
早期流水线直接继承主题仓的 package.json 版本. 主题版本描述源码树, npm 版本描述一次发布, 两者继续绑定时, 忘记 bump 就会在发布最后一步失败.
版本选择后来移到包仓:
- 工作流显式输入优先.
- 未输入时基于 npm 最新稳定版本做 patch bump.
- 已存在版本直接终止发布.
- prerelease 不参与稳定版本基线选择.
发布产物还会记录上游 SHA, 流水线 commit, Node 和 pnpm 版本, 便于发布后追溯.
踩坑
| 问题 | 表面现象 | 根因 | 处理方式 |
|---|---|---|---|
process.cwd() 指向用户项目 | 主题文件 ENOENT | 把用户目录当成主题目录 | 按文件所有权拆分路径解析 |
| pnpm 严格依赖失败 | 包内 import, MissingSharp | 用户根无法访问包内依赖 | peer dependency 与 fallback resolver |
| Overlay 拦截公开导出 | shirones/collections 失效 | resolver 接管范围过宽 | 仅处理主题别名和包内相对导入 |
| Starter 遮蔽首页 | 主题已安装, 仍显示欢迎页 | 本地文件路由优先 | 清理并备份 starter 文件 |
| SSR 缺少 Node 全局 | Stylus 找不到资源目录 | CommonJS 被内联进 ESM chunk | 增加 SSR Node shim |
| 两套配置漂移 | sitemap, Swup 等行为不同 | source/package wiring 重复 | config ownership 检查 |
| pnpm 构建脚本被忽略 | esbuild, sharp 安装失败 | pnpm 10/11 配置位置不同 | 同时维护两代 build approval |
| link 模式空站点 | 无路由, 无报错 | symlink realpath 改变判断 | 收敛为 isThemeRepo |
collection base 指向 src | 构建成功但集合为空 | 模板仍使用源码目录 | 从 manifest 生成用户路径 |
最终
改造后的主题仓根配置只保留 Integration 调用, Integration 根据运行模式解析目录, 路由, 配置和覆盖关系.
包仓则负责把指定上游 ref 转换成 npm tarball, 并在临时项目中验证真实安装生命周期.
用户可以获得两种工作流:
| 需求 | 推荐模式 |
|---|---|
| 修改主题内部实现 | Git Clone 源码模式 |
| 快速创建个人博客 | npm 包模式 |
| 覆盖单个组件或配置 | npm 包模式 |
| 跟随主题版本升级 | npm 包模式 |
| 参与主题开发和测试 | 源码模式 |
两种模式共享同一份主题源码, 同一套路由发现和配置 ownership. 目录布局可以不同, 运行契约保持一致.
虽然没人给我发中秋快乐, 还是祝大家中秋过的开心, 我们修复了月亮不圆的Bug, 仅限今晚哦
