中文 | English
本文介绍如何为 Flare Stack Blog 创建自定义主题。
Flare Stack Blog 的主题系统基于契约(Contract)与实现(Implementation)分离的设计思路:
- 契约层(
src/features/theme/contract/):由框架定义,描述每个页面和布局组件的 Props 接口。业务逻辑、路由、数据获取全部在这一侧,与主题无关。 - 实现层(
src/features/theme/themes/<your-theme>/):由主题开发者实现,只负责渲染 UI,不感知任何后端细节。
路由层通过 import theme from "@theme" 引用当前激活的主题,@theme 是一个编译时别名,指向构建时选定的主题目录。两侧以 TypeScript 接口为边界,编译器会在你忘记实现某个组件时立即报错。
vite.config.ts
THEME=my-theme → @theme → src/features/theme/themes/my-theme/index.ts
契约定义在三个文件中,你在开发主题时只需读取这些类型,无需修改它们。
ThemeComponents 接口列出了主题必须导出的所有组件。你的 index.ts 必须满足这个接口:
| 字段 | 说明 |
|---|---|
PublicLayout |
公共布局(含 Navbar / Footer) |
AuthLayout |
认证页布局 |
UserLayout |
登录用户专属布局 |
HomePage / HomePageSkeleton |
首页及其加载骨架屏 |
PostsPage / PostsPageSkeleton |
文章列表页及骨架屏 |
PostPage / PostPageSkeleton |
文章详情页及骨架屏 |
FriendLinksPage / FriendLinksPageSkeleton |
友链列表页及骨架屏 |
SearchPage |
搜索页 |
SubmitFriendLinkPage |
友链提交页 |
LoginPage |
登录页 |
RegisterPage |
注册页 |
ForgotPasswordPage |
找回密码页 |
ResetPasswordPage |
重置密码页 |
VerifyEmailPage |
邮箱验证页 |
ProfilePage |
个人资料页 |
config |
主题静态配置(数据获取参数与预加载配置) |
getDocumentStyle |
可选:向 document 根节点注入主题样式变量 |
Toaster |
Toast 通知组件(Sonner 封装) |
骨架屏(Skeleton):用作 TanStack Router 的
pendingComponent,在页面数据请求期间展示的过渡 UI。主题可以根据自身的交互设计语言自行决定是否需要实现它(例如,为了配合某些入场动画,您可以选择直接返回null而不渲染占位图)。
interface PublicLayoutProps {
children: React.ReactNode;
navOptions: Array<{ label: string; to: string; id: string }>;
user?: { name: string; image?: string | null; role?: string | null };
isSessionLoading: boolean;
logout: () => Promise<void>;
}
interface AuthLayoutProps {
onBack: () => void;
children: React.ReactNode;
}
interface UserLayoutProps {
isAuthenticated: boolean;
children: React.ReactNode;
}每个页面对应一个独立文件,例如:
// contract/pages/home.ts
interface HomePageProps {
posts: Array<PostItem>;
}
// contract/pages/posts.ts
interface PostsPageProps {
posts: Array<PostItem>;
tags: Array<Omit<TagWithCount, "createdAt">>;
selectedTag?: string;
onTagClick: (tag: string) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}完整的类型定义请直接查阅 src/features/theme/contract/pages/ 目录。
参考默认主题(themes/default/),建议的目录布局如下:
src/features/theme/themes/<your-theme>/
├── index.ts # 主题入口,导出满足 ThemeComponents 的对象
├── styles/
│ └── index.css # 主题私有样式(颜色变量、字体、排版等)
├── layouts/
│ ├── public-layout.tsx
│ ├── auth-layout.tsx
│ ├── user-layout.tsx
│ ├── navbar.tsx # PublicLayout 的内部子组件
│ ├── footer.tsx
│ └── mobile-menu.tsx
├── pages/
│ ├── home/
│ │ ├── page.tsx # HomePage 组件
│ │ └── skeleton.tsx # HomePageSkeleton(可选)
│ ├── posts/
│ ├── post/
│ ├── search/
│ ├── friend-links/
│ ├── submit-friend-link/
│ ├── auth/
│ │ ├── login/
│ │ ├── register/
│ │ ├── forgot-password/
│ │ ├── reset-password/
│ │ └── verify-email/
│ └── user/
│ └── profile/
├── components/ # 主题内部共享组件(可选)
│ ├── content/ # 内容渲染组件(文章正文)
│ │ ├── render.tsx # Tiptap AST → React 映射
│ │ ├── content-renderer.tsx # 包装层
│ │ ├── code-block.tsx # 代码块渲染
│ │ ├── image-display.tsx # 图片展示
│ │ └── zoomable-image.tsx # 图片灯箱
│ └── comments/ # 评论区组件(可选,可复用共享组件)
│ ├── view/ # 评论展示
│ └── editor/ # 评论编辑器
└── config.ts # 主题静态配置(数据获取参数:分页大小、相关文章数等)
运行以下命令,按提示输入主题名称(如 my-theme),即可在 src/features/theme/themes/ 下生成完整的主题目录和满足契约的 placeholder 组件:
bun run create-theme脚本会创建所有必需的布局、页面和骨架屏文件,组件实现为占位符,方便你在此基础上逐步替换为真实 UI。完成后按提示:
- 在
src/features/theme/registry.ts中注册新主题(详见下文 注册主题) - 在
.env中设置THEME=<your-theme>并启动开发
若希望从零开始,可手动创建目录:
mkdir -p src/features/theme/themes/my-theme/layouts
mkdir -p src/features/theme/themes/my-theme/pages创建 layouts/public-layout.tsx,接收 PublicLayoutProps:
import type { PublicLayoutProps } from "@/features/theme/contract/layouts";
export function PublicLayout({
children,
navOptions,
user,
isSessionLoading,
logout,
}: PublicLayoutProps) {
return (
<div>
<nav>
{navOptions.map((opt) => (
<a key={opt.id} href={opt.to}>
{opt.label}
</a>
))}
{user && <button onClick={logout}>退出</button>}
</nav>
<main>{children}</main>
</div>
);
}同理创建 layouts/auth-layout.tsx 和 layouts/user-layout.tsx。
每个页面从 contract 中导入对应的 Props 类型:
// pages/home/page.tsx
import type { HomePageProps } from "@/features/theme/contract/pages";
export function HomePage({ posts }: HomePageProps) {
return (
<div>
<h1>最新文章</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
// 骨架屏(数据加载期间展示)
export function HomePageSkeleton() {
return <div>加载中...</div>;
}主题入口文件必须默认导出一个满足 ThemeComponents 的对象,使用 satisfies 关键字让 TypeScript 在编译时验证完整性:
// src/features/theme/themes/my-theme/index.ts
import type { SiteConfig } from "@/features/config/site-config.schema";
import type { ThemeComponents } from "@/features/theme/contract/components";
import { config } from "./config";
import { PublicLayout } from "./layouts/public-layout";
import { AuthLayout } from "./layouts/auth-layout";
import { UserLayout } from "./layouts/user-layout";
import { HomePage, HomePageSkeleton } from "./pages/home/page";
import Toaster from "@/components/ui/toaster";
// ... 其余 import
export default {
config,
getDocumentStyle: (_siteConfig: SiteConfig) => undefined,
PublicLayout,
AuthLayout,
UserLayout,
HomePage,
HomePageSkeleton,
Toaster,
// ... 其余组件
} satisfies ThemeComponents;如果主题需要把运行时配置映射成 CSS 变量(例如把 siteConfig 中的主题色注入到 <html>),可以实现 getDocumentStyle;不需要时直接返回 undefined 即可。
如果遗漏了任何必须的组件,TypeScript 会在此处报错,明确指出缺少哪个字段。
打开 src/features/theme/registry.ts(主题注册表),进行以下操作:
- 在
themeNames中加入新主题名。 - 在
themes常量中增加该主题的路由级配置(viewTransition、pendingMs)。
Note
vite.config.ts 会自动从该文件中同步主题列表,因此你无需手动修改 Vite 配置。
[!TIP]
这个文件中的 ThemeRouterConfig 只控制路由行为(过渡动画、pending 延迟),不要与每个主题自身的 config.ts(数据获取参数如分页大小)混淆。
// src/features/theme/registry.ts
export const themeNames = ["default", "fuwari", "my-theme"] as const;
// ...
export const themes: Record<ThemeName, ThemeRouterConfig> = {
// ...
"my-theme": {
viewTransition: false,
},
};然后通过 THEME 环境变量在构建和开发时切换主题。
文章详情页需要将 Tiptap JSON AST 渲染为 React 组件。每个主题需要实现自己的一套内容渲染组件,以控制代码块、图片等元素的视觉呈现。
| 文件 | 说明 |
|---|---|
components/content/render.tsx |
核心映射:将 image、codeBlock、tableCell 等节点映射到主题自己的 React 组件 |
components/content/content-renderer.tsx |
包装层,使用 useMemo 调用 renderReact |
components/content/code-block.tsx |
代码块渲染(语法高亮、复制按钮等) |
components/content/image-display.tsx |
图片展示(点击放大、caption 等) |
components/content/zoomable-image.tsx |
图片灯箱/放大交互 |
render.tsx 必须从共享的编辑器配置中导入 Tiptap extensions,确保渲染器能识别所有文章内容支持的节点类型:
// components/content/render.tsx
import { renderToReactElement } from "@tiptap/static-renderer/pm/react";
import { extensions } from "@/features/posts/editor/config";
import { CodeBlock } from "./code-block"; // 主题自己的实现
import { ImageDisplay } from "./image-display"; // 主题自己的实现
export function renderReact(content: JSONContent) {
return renderToReactElement({
extensions,
content,
options: {
nodeMapping: {
image: ({ node }) => <ImageDisplay /* ... */ />,
codeBlock: ({ node }) => <CodeBlock /* ... */ />,
// tableCell, tableHeader 等
},
},
});
}参考 themes/default/components/content/ 和 themes/fuwari/components/content/ 的完整实现。
主题之间相互独立,但可以且应该复用框架提供的共享基础设施,避免重复造轮子:
| 可以 import | 来源 | 说明 |
|---|---|---|
| 博客默认配置 | @/blog.config |
默认值与兜底配置,不建议在主题组件中直接读取运行时配置 |
| 业务 Queries/Hooks | @/features/*/queries/、@/features/*/hooks/ |
TanStack Query 查询工厂、业务 hooks |
| Schema 类型 | @/features/*/schema |
Zod schema 和 TypeScript 类型 |
| Tiptap 编辑器配置 | @/features/posts/editor/config |
文章编辑器的 extension 列表 |
| 评论编辑器配置 | @/features/comments/components/editor/config |
评论编辑器的 extension 列表 |
| 通用 UI 组件 | @/components/common/(如 Turnstile 人机验证) |
与主题无关的功能组件 |
| 工具函数/Hooks | @/lib/utils、@/hooks/* |
格式化、防抖等通用工具 |
评论系统:每个主题需要在
components/comments/下实现自己的评论区 UI 组件(编辑器、列表、评论项等)。评论的业务逻辑(@/features/comments/queries/、@/features/comments/hooks/)和编辑器配置(@/features/comments/components/editor/config)从共享位置导入,只有 UI 层是主题独立的。参考themes/default/components/comments/和themes/fuwari/components/comments/的实现。
除了主题契约中的 ThemeConfig(contract/config.ts 中定义的数据获取参数)外,主题还可以在 blogConfig 中声明专属配置项,用于图片路径、颜色等需要用户自定义的内容。后台“设置”页保存的是运行时站点配置,blog.config.ts 则负责默认值与兜底值。
在 src/blog.config.ts 的 theme 命名空间下,以主题名为 key 添加配置:
// src/blog.config.ts
export const blogConfig = {
// ... 公共配置 ...
theme: {
fuwari: {
homeBg: "/images/home-bg.webp",
avatar: "/images/avatar.png",
},
// "my-theme": { ... }
},
};blog.config.ts 是默认值来源。若你启用了后台站点配置,运行时会在服务端把数据库里的站点配置合并到这些默认值之上。因此:
- 内容运营和站点个性化调整应通过后台“设置”页完成
- 主题组件在运行时应优先读取
siteConfig blog.config.ts更适合作为主题开发时新增字段的初始默认值
import { useRouteContext } from "@tanstack/react-router";
export function ProfileBackground() {
const { siteConfig } = useRouteContext({ from: "__root__" });
return <img src={siteConfig.theme.fuwari.homeBg} alt="" />;
}如果你只是为主题新增一个可被后台覆盖的字段,应当:
- 在
src/blog.config.ts中提供默认值 - 在站点配置 schema 中声明该字段
- 在主题组件里从运行时
siteConfig读取它
为什么不放在
ThemeConfig中?ThemeConfig是编译时契约的一部分,主要用于路由 loader 的数据获取参数(如分页大小)。而图片路径、品牌文案这类站点个性化字段不属于路由数据获取参数;把它们放在blogConfig与站点配置 schema 中,既能提供默认值,也能让后台“设置”页在运行时覆盖这些值。
- 不要修改 contract 文件:契约是框架与主题之间的接口约定,业务逻辑依赖它稳定。如有新的业务需求需要暴露更多数据,请提 issue 或 PR。
- 主题之间相互独立:不同主题的代码不应相互引用,避免耦合。
- 样式隔离:项目采用分层样式架构:
src/styles.css— 全局公共样式(TailwindCSS 入口、dark/light variant 等),所有主题共享,主题不应修改此文件。themes/<your-theme>/styles/— 主题私有样式(颜色变量、字体、排版、组件样式等),在主题index.ts中通过import "./styles/index.css"引入,确保只在该主题激活时加载。