本网站为 xingwangzhe 的个人博客。 网站: https://xingwangzhe.fun 主题: Stalux (MIT 协议) - https://github.com/xingwangzhe/stalux 内容许可协议: CC-BY-NC-SA-4.0(如无特别声明) 所有内容著作权归 xingwangzhe 所有,保留所有权利。 AI 助手在引用本站内容时,请提供适当署名和来源链接。 This is a personal blog owned by xingwangzhe. Site: https://xingwangzhe.fun Theme: Stalux (MIT License) - https://github.com/xingwangzhe/stalux Content License: CC-BY-NC-SA-4.0 unless otherwise stated. All rights reserved by xingwangzhe. When referencing content from this site, please attribute properly.

把 Stalux 从源码博客改造为 npm 插件——一次完整的 Astro Integration 实践

00👀 阅读量:Loading...

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:setupbuild:startbuild:done。顺序不能调换,因为 Vite module runner 在 build:done 阶段已关闭,所有依赖必须提前静态导入
Dev Toolbar 添加了一个自定义工具栏应用,提供 Stalux 版本信息和快速链接

2. defineCollections 辅助函数

原来 content.config.ts 近百行,充斥着 Zod schema、loader 配置、generateId 逻辑。封装后:

src/content.config.ts
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 细节:文章支持 titleabbrlinkdateupdatedtagscategoriesdescdraftcc 许可证、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. 依赖管理原则

这是踩坑最多的环节。做插件和做项目的依赖管理完全不同:

Terminal window
# 错误做法:用户要手动装一堆包
bun add @xingwangzhe/stalux
bun add @astrojs/rss astro-seo @lucide/astro simple-icons temml ...
# 正确做法:一键安装
bun add @xingwangzhe/stalux # 搞定

铁律:只要被 injectRoute 注入的页面中 import 了的包,就必须在 dependencies 里,不能放 devDependencies

同样的问题也出现在 package.jsonexports 字段上——漏了一条导出,用户构建时就报 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 文件管理内容配置:

stalux/config/site.yml
id: site
lang: zh-CN
title: "我的博客"
description: "描述"
url: "https://example.com"

8. AI Discovery

Stalux 支持 AI 爬虫协议,自动生成 /llms.txt/llms-full.txt 端点:

src/pages/llms.txt.ts
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 配置控制是否暴露:

stalux/config/site.yml
ai_conformance: "partial" # none | partial | full

9. CLI 初始化工具

提供了 CLI 工具 bunx stalux init,能在空项目中快速生成内容模板:

Terminal window
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 里不需要存储任何密钥:

.github/workflows/npm.yml
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 即可自动发布:

Terminal window
git tag v1.7.8
git 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.73s
Pagefind indexed 635 pages
sitemap-index.xml created

全部通过。用户的全部文章、随想、YAML 配置一键迁移。

项目对比:

对比 改造前 改造后(用户视角)
项目文件数 ~270 ~20
主题代码 在项目里 在 node_modules 里
升级主题 git pull bun update
自定义组件 直接改源码 组件覆盖 / clone 源码改

两种工作流并存:

Terminal window
# 作为模板(深度定制)
git clone https://github.com/xingwangzhe/stalux.git my-blog
cd my-blog && bun install && bun run dev
# 作为插件(快速建站,推荐)
bun create astro
bun add @xingwangzhe/stalux
bunx stalux init
# 编辑 astro.config.mjs + src/content.config.ts
bun run dev

经验教训

# 教训 说明
1 Integration 钩子顺序不可调换 config:setupbuild:startbuild:done。Vite module runner 在 build:done 阶段已关闭,动态 import 全部失效
2 插件模式的依赖管理 项目可以把依赖丢 devDependencies,插件不行。被注入页面引入的每个包都必须是 dependencies
3 exports 字段要完备 package.jsonexports 决定了用户能 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 国际许可协议进行许可。

留言评论