---
title: 把 Stalux 从源码博客改造为 npm 插件——一次完整的 Astro Integration 实践
date: "2026-07-27 22:00:00"
updated: "2026-07-30 22:00:00"
desc: Stalux 博客主题从传统 Git Clone 源码项目，改造为 Astro Integration + npm 包的双模式架构。详细记录了 Integration 封装、字体裁剪、组件覆盖系统、Sätteri 插件生态、OIDC CI/CD 等完整实践。全程 AI 辅助开发。
abbrlink: stalux-dual-mode-refactor
tags:
    - stalux
    - astro
    - 重构
    - npm
    - 博客主题
categories:
    - 技术
cc: CC-BY-NC-SA-4.0
---

> **AI 辅助声明**：本文档的撰写和代码实现在多个阶段使用了 AI 编程助手（Claude、ZCode）进行代码生成、重构建议和文档润色。所有 AI 生成的内容均经过人工审核和测试验证。

Stalux 是我一直在维护的 Astro 博客主题，截至本文写作时已有 466 次提交、277 个文件（不含 node_modules）。它最初就是一个普通的源码项目——你 clone 下来 `bun install && bun run dev` 就能跑。但这样意味着没法直接复用：每次开新博客要么 fork 整个仓库，要么手动复制文件，改主题还得在 fork 里改。

我一直在想能不能让它像 Starlight 那样，装个包就能用，又保留源码可修改的灵活性。

这篇文章记录了完整的改造过程和踩坑经验。

<!--more-->

## 改造目标

两个模式并行：

```text
模式 A：源码模板 → git clone 下来随便改，适合深度定制
模式 B：npm 插件 → bun add @xingwangzhe/stalux 即装即用，适合快速建站
```

核心思路：把主题代码封装成一个 **Astro Integration**，通过 `injectRoute` 把所有页面注入到用户项目里，用户只需要在 `stalux/posts/` 下写 Markdown。

## 架构概览

改造后的 Stalux 采用分层架构：

```text
@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
```

### 用户视角的项目结构

用户安装后看到的项目极简：

```text
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()` 函数：

```typescript
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 逻辑。封装后：

```typescript
// 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 细节：文章支持 `title`、`abbrlink`、`date`、`updated`、`tags`、`categories`、`desc`、`draft`、`cc` 许可证、`cover` 封面图等字段。tags 和 categories 兼容单值字符串和数组两种格式。

### 3. 组件覆盖系统

借鉴 Starlight 的 override 模式，实现了完整的组件覆盖机制：

```typescript
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）在构建时动态裁剪。这是提升页面性能的关键。

**裁剪策略**：

```text
完整的 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. 依赖管理原则

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

```bash
# 错误做法：用户要手动装一堆包
bun add @xingwangzhe/stalux
bun 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 文件管理内容配置：

```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` 端点：

```typescript
// 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 配置控制是否暴露：

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

### 9. CLI 初始化工具

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

```bash
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 里不需要存储任何密钥：

```yaml
# .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 即可自动发布：

```bash
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`，配置内容后构建：

```text
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 源码改 |

两种工作流并存：

```bash
# 作为模板（深度定制）
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: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](https://github.com/xingwangzhe/stalux)*
*本文 AI 辅助声明：撰写过程中使用 Claude、ZCode 进行代码分析和文档组织，所有技术内容均经过实践验证。*


---

**作者：**xingwangzhe

**本文链接：**[https://xingwangzhe.fun/posts/stalux-dual-mode-refactor/](https://xingwangzhe.fun/posts/stalux-dual-mode-refactor/)

本文采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。