Skip to content

Latest commit

 

History

History
402 lines (315 loc) · 17 KB

File metadata and controls

402 lines (315 loc) · 17 KB

中文 | 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

主题契约

契约定义在三个文件中,你在开发主题时只需读取这些类型,无需修改它们。

contract/components.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 而不渲染占位图)。

contract/layouts.ts — 布局 Props

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/ — 各页面 Props

每个页面对应一个独立文件,例如:

// 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                 # 主题静态配置(数据获取参数:分页大小、相关文章数等)

Step-by-step:创建第一个主题

快速开始:使用脚手架脚本

运行以下命令,按提示输入主题名称(如 my-theme),即可在 src/features/theme/themes/ 下生成完整的主题目录和满足契约的 placeholder 组件:

bun run create-theme

脚本会创建所有必需的布局、页面和骨架屏文件,组件实现为占位符,方便你在此基础上逐步替换为真实 UI。完成后按提示:

  1. src/features/theme/registry.ts 中注册新主题(详见下文 注册主题
  2. .env 中设置 THEME=<your-theme> 并启动开发

手动创建:Step 1 — 创建主题目录

若希望从零开始,可手动创建目录:

mkdir -p src/features/theme/themes/my-theme/layouts
mkdir -p src/features/theme/themes/my-theme/pages

Step 2:实现布局组件

创建 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.tsxlayouts/user-layout.tsx

Step 3:实现页面组件

每个页面从 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>;
}

Step 4:创建 index.ts

主题入口文件必须默认导出一个满足 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 会在此处报错,明确指出缺少哪个字段。

Step 5:注册主题并启动

打开 src/features/theme/registry.ts(主题注册表),进行以下操作:

  1. themeNames 中加入新主题名。
  2. themes 常量中增加该主题的路由级配置(viewTransitionpendingMs)。

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 核心映射:将 imagecodeBlocktableCell 等节点映射到主题自己的 React 组件
components/content/content-renderer.tsx 包装层,使用 useMemo 调用 renderReact
components/content/code-block.tsx 代码块渲染(语法高亮、复制按钮等)
components/content/image-display.tsx 图片展示(点击放大、caption 等)
components/content/zoomable-image.tsx 图片灯箱/放大交互

关键:共享 Tiptap 扩展

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/ 的实现。

主题专属配置

除了主题契约中的 ThemeConfigcontract/config.ts 中定义的数据获取参数)外,主题还可以在 blogConfig 中声明专属配置项,用于图片路径、颜色等需要用户自定义的内容。后台“设置”页保存的是运行时站点配置,blog.config.ts 则负责默认值与兜底值。

约定

src/blog.config.tstheme 命名空间下,以主题名为 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="" />;
}

如果你只是为主题新增一个可被后台覆盖的字段,应当:

  1. src/blog.config.ts 中提供默认值
  2. 在站点配置 schema 中声明该字段
  3. 在主题组件里从运行时 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" 引入,确保只在该主题激活时加载。