把 Stalux 从源码博客改造为 npm 插件——一次完整的 Astro Integration 实践
AI 辅助声明:本文档的撰写和代码实现在多个阶段使用了 AI 编程助手(Claude、ZCode)进行代码生成、重构建议和文档润色。所有 AI 生成的内容均经过人工审核和测试验证。
Stalux 是我一直在维护的 Astro 博客主题,截至本文写作时已有 466 次提交、277 个文件(不含 node_modules)。它最初就是一个普通的源码项目——你 clone 下来 bun install && bun run dev 就能跑。但这样意味着没法直接复用:每次开新博客要么 fork 整个仓库,要么手动复制文件,改主题还得在 fork 里改。
我一直在想能不能让它像 Starlight 那样,装个包就能用,又保留源码可修改的灵活性。
这篇文章记录了完整的改造过程和踩坑经验。
改造目标
两个模式并行:
模式 A:源码模板 → git clone 下来随便改,适合深度定制模式 B:npm 插件 → bun add @xingwangzhe/stalux 即装即用,适合快速建站核心思路:把主题代码封装成一个 Astro Integration,通过 injectRoute 把所有页面注入到用户项目里,用户只需要在 stalux/posts/ 下写 Markdown。
架构概览
改造后的 Stalux 采用分层架构:
@xingwangzhe/stalux (npm 包)├── Astro Integration (src/index.ts) ← 核心入口├── Layouts (src/layouts/) ← Stalux.astro / PostLayout.astro├── Components (src/components/stalux/) ← 30+ 可覆盖组件├── Pages (src/pages/) ← 26 个路由├── Font Subsetting System ← 构建时字体裁剪├── Plugin Ecosystem ← Sätteri 插件系列│ ├── satteri-mermaid (Rust napi-rs) ← Mermaid 图渲染│ ├── satteri-photoswipe (JS) ← 图片灯箱│ └── satteri-template (JS) ← 插件脚手架└── CLI (scripts/cli.mjs) ← bunx stalux init用户视角的项目结构
用户安装后看到的项目极简:
myblog/├── astro.config.mjs ← 只有几行配置├── src/content.config.ts ← 三行 defineCollections├── stalux/│ ├── config/ ← YAML 配置文件│ ├── posts/ ← Markdown 文章│ ├── about/ ← 关于页面│ └── words/ ← 随想片段└── public/ ← 静态资源对比改造前的 ~270 文件,用户只需要关注 ~20 个文件。
关键技术实现
1. Integration 入口封装
核心在 src/index.ts,暴露一个 stalux() 函数:
export function stalux(options: StaluxOptions = {}): AstroIntegration { const isPluginMode = import.meta.url.includes("node_modules");
return { name: "stalux", hooks: { "astro:config:setup": async ({ injectRoute, injectScript, updateConfig, addDevToolbarApp, logger }) => { // 1. 注入 Vite 别名 + 组件覆盖插件 updateConfig({ vite: { resolve: { alias: getViteAliases(srcDir) }, plugins: [staluxComponentsAlias(opt.components)], }, });
// 2. 仅插件模式:注入所有页面路由 if (isPluginMode) { const routes = getRoutes(new URL(".", import.meta.url)); for (const route of routes) injectRoute(route); }
// 3. 字体裁剪(开发/构建均执行) await runFontSubsetting(projectRoot, logger, true); },
"astro:build:start": async ({ logger }) => { // 构建时:生成全部路由字体子集 await runFontSubsetting(projectRoot, logger); },
"astro:server:setup": async ({ server, logger }) => { // 开发模式:按需字体切片中间件 const { createDevFontMiddleware } = await import("./internal/font-subset-dev.ts"); server.middlewares.use(createDevFontMiddleware(projectRoot, logger)); },
"astro:build:done": async ({ dir, logger }) => { // 构建后:Pagefind 搜索索引 if (opt.pagefind) { const { index } = await pagefindCreateIndex(); await index.addDirectory({ path: outDir }); await index.writeFiles({ outputPath: path.join(outDir, "pagefind") }); } }, }, };}几点设计决策:
| 决策 | 说明 |
|---|---|
isPluginMode 探测 |
通过 import.meta.url 判断是否在 node_modules 中运行。插件模式需要 injectRoute,源码模式直接使用文件路由,两种模式共享同一份代码 |
| 钩子生命周期 | config:setup → build:start → build:done。顺序不能调换,因为 Vite module runner 在 build:done 阶段已关闭,所有依赖必须提前静态导入 |
| Dev Toolbar | 添加了一个自定义工具栏应用,提供 Stalux 版本信息和快速链接 |
2. defineCollections 辅助函数
原来 content.config.ts 近百行,充斥着 Zod schema、loader 配置、generateId 逻辑。封装后:
import { defineCollections } from "./schemas/collections";export const collections = defineCollections({ contentDir: "stalux" });三行搞定。内部自动注册 posts、config、about、words 四个集合:
| 集合 | 内容 | loader |
|---|---|---|
posts |
博客文章 (.md/.mdx) |
glob + abbrlink 做 ID |
config |
站点配置 (.yml) |
glob + id 做 ID |
about |
关于页面 (.md/.mdx) |
glob |
words |
随想片段 (.md) |
glob + deferRender |
Schema 细节:文章支持 title、abbrlink、date、updated、tags、categories、desc、draft、cc 许可证、cover 封面图等字段。tags 和 categories 兼容单值字符串和数组两种格式。
3. 组件覆盖系统
借鉴 Starlight 的 override 模式,实现了完整的组件覆盖机制:
stalux({ components: { Navs: "./src/components/CustomNavs.astro", Footer: "./src/components/CustomFooter.astro", PostToc: "./src/components/CustomToc.astro", }})实现原理:通过 Vite 插件 staluxComponentsAlias 在构建时动态解析组件路径。src/internal/override.ts 中定义了 30+ 个可覆盖组件的清单,涵盖:
| 分类 | 组件 |
|---|---|
| 布局层 | Head、Footer、Navs、ListPage |
| 通用 | Author、Date、Search、TextType、VerCount |
| 文章 | PostContent、PostLeft、PostRight、PostNavigation、PostCC、PostToc、PostAuthor、PostCard、PostList、PostTaxonomy、PhotoSwipeStyle、RandomPost |
| 归档 | ArchivesList |
| 分类 | CategoriesCard、CategoriesList |
| 标签 | TagHeader、TagsCloud |
| 随想 | WordCard、WordList |
| 友链 | LinkCard、LinkList |
| 页脚 | FooterBadges、FooterBeian、FooterCopyright、FooterStats、FooterThemeInfo |
| 分析 | Clarity、Google、Umami |
每个组件都可以被用户完全替换,无需 fork 整个主题。
4. 字体裁剪系统
Stalux 使用了一个 25MB 的完整中文 TTF 字体(LXGW WenKai),通过 subset-font(Harfbuzz WASM)在构建时动态裁剪。这是提升页面性能的关键。
裁剪策略:
完整的 TTF 字体:25 MB ──→ 仅用于裁剪源,不加载到页面 ↓common 子集 (UI 文本): ~174 KB ├── 每页都加载每路由专属子集: 0.5-1 KB └── 仅当前页加载扫描范围(scanContent 函数):
| 范围 | 说明 |
|---|---|
| 文章内容 | stalux/posts/*.md,每篇文章生成独立子集 |
| 标签/分类聚合页 | 合并所有匹配文章的字符集 |
| 归档页 | 所有文章标题和日期 |
| 配置文件 | stalux/config/*.yml 中的站点标题、导航文字 |
| 关于/随想 | about 和 words 目录 |
去重优化:deduplicate 函数将字符集完全相同的路由合并,多个页面共享同一个字体文件。
开发模式:通过 font-subset-dev.ts 中的 Express 中间件实现按需生成——访问页面的瞬间才生成对应的字体子集,避免开发时全量生成。
5. Sätteri 插件生态
Stalux 周边发展出了一个小型插件生态——Sätteri 系列包,全部发布在 @xingwangzhe scope 下:
| 包 | 类型 | 功能 |
|---|---|---|
satteri-mermaid |
Rust napi-rs 原生 | Mermaid 图 → 静态 SVG 渲染(23 种图表类型) |
satteri-photoswipe |
JS | 图片灯箱增强(纯 HAST 插件,零客户端 JS 开销) |
satteri-template |
JS | 新插件脚手架模板 |
satteri-mermaid 是最特别的一个——它使用 napi-rs(而不是 WASM)将 Rust 端的 mermaid-rs-renderer 编译为原生 .node 二进制。对比传统方案:
| 方案 | 性能 | 客户端依赖 | 构建产物体积 |
|---|---|---|---|
| 浏览器端 Mermaid | 慢(运行时解析) | ~200KB JS | 小 |
| WASM (mermaidjs/wasm) | 中等 | 无 | 中等 (~8MB) |
| napi-rs 原生 (本站) | 最快 | 无 | 各平台 ~5MB |
构建流程:bun run build:napi(Rust 编译)→ vite build(JS 层)→ tsc(类型声明)。CI 中为四个平台(linux-x64、linux-arm64、darwin-arm64、win32-x64)并行构建原生二进制,最终打包为 npm 发布。
6. 依赖管理原则
这是踩坑最多的环节。做插件和做项目的依赖管理完全不同:
# 错误做法:用户要手动装一堆包bun add @xingwangzhe/staluxbun add @astrojs/rss astro-seo @lucide/astro simple-icons temml ...
# 正确做法:一键安装bun add @xingwangzhe/stalux # 搞定铁律:只要被 injectRoute 注入的页面中 import 了的包,就必须在 dependencies 里,不能放 devDependencies。
同样的问题也出现在 package.json 的 exports 字段上——漏了一条导出,用户构建时就报 is not a function,排查成本很高。
7. 配置系统
Stalux 的配置层次如下:
StaluxOptions (用户传入 astro.config.mjs)├── contentDir → 内容根目录(默认 "stalux")├── contentPaths → 子目录路径覆盖├── components → 组件覆盖映射├── analytics → Google / Clarity / Umami 配置├── pagefind → 构建后自动索引(默认 true)├── devToolbar → Dev Toolbar 应用(默认 true)└── site → 站点 URL用户通过 YAML 文件管理内容配置:
id: sitelang: zh-CNtitle: "我的博客"description: "描述"url: "https://example.com"8. AI Discovery
Stalux 支持 AI 爬虫协议,自动生成 /llms.txt 和 /llms-full.txt 端点:
export const GET: APIRoute = async (context) => { const config = await loadConfig(); const text = await renderLlmsTxt(config, site); return new Response(text, { headers: { "Content-Type": "text/plain" } });};用户可以通过 YAML 配置控制是否暴露:
ai_conformance: "partial" # none | partial | full9. CLI 初始化工具
提供了 CLI 工具 bunx stalux init,能在空项目中快速生成内容模板:
bunx stalux init # 当前目录生成bunx stalux init my-blog # 子目录生成自动生成 stalux/config/ 下的 YAML 模板(site、author、navs、links、comment、footer)和示例文章。
10. OIDC CI/CD
npm 的 Trusted Publisher(OIDC)配置好以后,CI 里不需要存储任何密钥:
on: push: tags: "v*"
jobs: publish: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - run: bun install - uses: actions/setup-node@v7 with: registry-url: https://registry.npmjs.org - run: npm publish --provenance --access public推送 tag 即可自动发布:
git tag v1.7.8git push origin v1.7.8# → GitHub Actions 构建 → npm publish → GitHub Release 一条龙同时还支持 workflow_dispatch 手动触发,可指定版本号和 dry-run 模式。
最终效果
验证环节:在一个 bun create astro 空项目中,安装 @xingwangzhe/stalux,配置内容后构建:
bun run build
633 page(s) built in 15.73sPagefind indexed 635 pagessitemap-index.xml created全部通过。用户的全部文章、随想、YAML 配置一键迁移。
项目对比:
| 对比 | 改造前 | 改造后(用户视角) |
|---|---|---|
| 项目文件数 | ~270 | ~20 |
| 主题代码 | 在项目里 | 在 node_modules 里 |
| 升级主题 | git pull | bun update |
| 自定义组件 | 直接改源码 | 组件覆盖 / clone 源码改 |
两种工作流并存:
# 作为模板(深度定制)git clone https://github.com/xingwangzhe/stalux.git my-blogcd my-blog && bun install && bun run dev
# 作为插件(快速建站,推荐)bun create astrobun add @xingwangzhe/staluxbunx stalux init# 编辑 astro.config.mjs + src/content.config.tsbun run dev经验教训
| # | 教训 | 说明 |
|---|---|---|
| 1 | Integration 钩子顺序不可调换 | config:setup → build:start → build:done。Vite module runner 在 build:done 阶段已关闭,动态 import 全部失效 |
| 2 | 插件模式的依赖管理 | 项目可以把依赖丢 devDependencies,插件不行。被注入页面引入的每个包都必须是 dependencies |
| 3 | exports 字段要完备 |
package.json 的 exports 决定了用户能 import 什么。每改一次就要用测试项目验证一遍 |
| 4 | 模板与主包同步 | bunx stalux init 生成的配置模板必须与当前版本的 Stalux 配置结构一致,很容易漏 |
| 5 | OIDC 比 token 香 | 配好 Trusted Publisher 后零密钥管理,npm 包上还会显示 “Published by GitHub Actions” 的 provenance 证书 |
| 6 | napi-rs vs WASM 的选择 | 对于需要高性能且面向多平台的场景,napi-rs 原生二进制构建更复杂(每平台单独构建),但运行时性能最佳。WASM 部署简单但体积和性能介于中间 |
| 7 | 字体裁剪的边界情况 | 标签/分类页面的字符集需要聚合所有匹配文章的字符,而不能只看自身模板的字符。归档页也一样——需要合并所有文章标题和日期 |
| 8 | 组件覆盖的局限 | 用户覆盖组件后,如果主题更新了该组件的接口(props),用户的覆盖组件可能不兼容。目前通过 TypeScript 类型约束来缓解,但没有运行时检测 |
Stalux 源码:github.com/xingwangzhe/stalux 本文 AI 辅助声明:撰写过程中使用 Claude、ZCode 进行代码分析和文档组织,所有技术内容均经过实践验证。
把 Stalux 从源码博客改造为 npm 插件——一次完整的 Astro Integration 实践
作者:xingwangzhe
本文链接:https://example.com/posts/stalux-dual-mode-refactor/
本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
留言评论