如何编写一个 Valaxy 主题

提示

Valaxy 与 Vite/Vue 的生态完全兼容,因此你在编写主题时,可以任意使用第三方的 Vite/Vue 插件。

Valaxy 主题无需预编译,直接发布源文件即可。

撰写中…

作为 Valaxy 作者,我可以很轻松的实现自己的主题。 但也因此,我可能很难了解真正主题开发者的需求。

因此,如果你有任何开发主题的相关问题, 可前往 QQ 频道「云乐坊」 或发起 Discussions 与我交流,我将会为您提供尽可能的帮助,并针对泛化的问题撰写文档。

对了,由于目前的主题并不多,主题作者可以在这里发现一些来自云游君私人的奖励。

主题示例

使用 AI 生成主题

填写主题名称与简短的设计说明,然后将生成的提示词复制到 AI 编程助手中。提示词会把任务限制在最小主题包内,要求助手根据当前安装的 Valaxy 版本核对 API,并提供实现与验证检查点。

你是一名 Valaxy 主题开发者。请在当前项目中实现一个最小可运行、可继续扩展的 Valaxy 主题包。

项目输入:
- 包名:valaxy-theme-[name]
- 视觉方向:[描述视觉方向、配色、字体与参考风格]
- 所需页面与功能:[列出所需布局、页面与功能]

如果以上任一方括号占位符仍未替换,请先提出简短、聚焦的问题,确认后再修改文件。

范围与依据:
- 只修改主题包,以及仓库约定中验证该主题所必需的现有示例;不要生成无关站点、插件或部署配置。
- 全程使用 pnpm,并遵守仓库内适用的 AGENTS.md 与现有代码风格。
- 优先以当前仓库的主题 starter、custom demo 或结构最接近的现有主题为基线,不要凭空重建约定。
- 编码前检查当前安装的 Valaxy 版本、导出、类型声明与主题开发文档。只能使用可在当前版本源码或类型中确认的 API;无法确认时先搜索或询问,不要虚构。

从以下最小结构开始,并按现有 starter 调整:

valaxy-theme-[name]/
├── package.json
├── index.ts
├── valaxy.config.ts
├── client/index.ts
├── components/
│   ├── layout.vue
│   └── ValaxyMain.vue
├── layouts/
│   ├── default.vue
│   ├── home.vue
│   └── post.vue
├── setup/main.ts
├── styles/index.scss
├── types/index.d.ts
└── README.md

仅在需求或当前 starter 确有需要时再增加 App.vue、node/、pages/、locales/、composables/、stores/ 或额外布局。

实现约定:
1. 在 valaxy.config.ts 中使用当前版本导出的 defineTheme() 声明带类型的默认 themeConfig;仅暴露主题实际实现的配置项。
2. 在 setup/main.ts 中使用 defineAppSetup() 加载主题样式;需要 Valaxy 公共样式时按当前文档的真实导入路径引入。
3. components/ValaxyMain.vue 接收 frontmatter: Post 与可选 data?: PageData,通过 ValaxyMd 渲染 Markdown,并保留主题扩展所需的插槽。
4. default、home、post 布局应可用;RouterView、Layout 与插槽的组合遵循当前 starter,不发明新的布局协议。
5. Vue 组件使用 Vue 3 Composition API、<script setup lang="ts"> 和语义化 HTML;交互支持键盘、可见焦点与合理的 aria 属性,并兼顾响应式布局。
6. 全局主题与 Markdown 样式放在 styles/,组件私有样式使用 scoped。优先复用现有 --va-* CSS 变量;同时检查浅色/深色对比度。动态 UnoCSS 类只有在确有需要时才加入 safelist。
7. package.json、index.ts、client/index.ts 与 types/ 的导出保持一致;只声明代码实际导入的依赖,并遵循当前 starter 的 peer/dev dependency 约定。
8. README 说明安装方式、站点 valaxy.config.ts 中的 theme 名称、theme.config.ts 配置示例、支持的布局和开发命令。

验证:
- 先查看 package.json 中真实存在的脚本,再使用 pnpm 运行最接近 pnpm lint、pnpm typecheck 和相关单测的命令;不要编造脚本。
- 让一个现有 Valaxy 示例站点选中该主题并执行其 pnpm build,确认 SSR/SSG 中没有直接访问仅浏览器可用的全局对象。
- 至少检查首页、文章页、默认页、Markdown 正文、窄屏,以及主题支持时的浅色/深色模式。
- 最后汇报改动文件、实际运行的命令与结果、未验证项和兼容性风险。请直接完成实现,不要只返回代码片段。

提示

为了获得更准确的结果,建议先安装 Valaxy Skills,并在已经包含 Valaxy 或主题 starter 的仓库内运行助手。发布主题前,请人工审查生成的代码与依赖变更。

创建主题模板

提示

如果你只想简单点,创建一个自己使用的博客主题而不发布,你可以直接在本地引用你的主题。

可参见 demo/custom

bash
# 使用 valaxy-theme-starter 模版
pnpm create valaxy
# choose Theme

在动手之前,我们先来了解一下一个 Valaxy 主题的基础结构,它与正常的用户目录结构也十分相似。

valaxy-theme-yun 为例:

尽管它们看起来很多,但是大部分都是可选的,你可以根据主题的需求按需编写。

  • App.vue: 主题的入口文件,用于挂载全局的主题组件
  • README.md: 主题的说明文档(毫无疑问,这是必不可少的 😛)
  • client:主题所暴露给用户的客户端辅助函数
    • index.ts: 主题的客户端辅助函数入口文件
  • components: 主题的组件
    • ValaxyMain.vue: 主题的文章渲染组件
    • YunSidebar.vue: 主题的侧边栏组件
    • YunSponsor.vue: 主题的赞助组件
    • YunWaline.vue: 第三方评论 Waline 适配组件
  • composables: 辅助的 Composition API
    • config.ts: 主题的配置文件
    • helper.ts: 主题的辅助函数
    • index.ts: 主题的 Composition API 入口文件
    • post.ts: 主题的文章相关的辅助函数
  • docs: 主题的文档(自由用你喜欢的结构组织并展示吧!)

    出于定制化与 DogFooding 的考虑,Valaxy 的文档采用自身制作,并制作了一个文档主题 valaxy-theme-press,如果你只是想要一个简单轻量的文档站点,Vitepress 是个不错的选择。(valaxy-theme-starter 在未来也许会内置该示例模版。)

    • en-US: 英文文档
    • zh-CN: 中文文档
  • features: 主题特色功能,一些不依赖于 Vue Composition API 的功能(区别于 composables
    • fireworks.ts: 烟花点击效果
  • layouts: 主题的布局(扩展更多布局)
    • default.vue: 默认布局
    • home.vue: 首页布局
    • layout.vue: 文章列表布局
    • post.vue: 文章布局(放置于 pages/posts/ 文件夹下的文章默认为 `post 布局)
    • tags.vue: 标签布局
  • locales: 主题的多语言支持
    • en.yml: 英文语言文件
    • zh-CN.yml: 中文语言文件
  • node_modules: 主题的依赖(请勿提交至仓库)
  • node: 主题的 Node 端逻辑
  • package.json: 主题的相关信息与依赖
  • pages: 主题的默认页面(扩展更多页面)
    • index.vue: 首页
    • page: 普通页
      • [page].vue: 文章列表页,动态路由,如 /page/2
  • setup: 主题的入口文件(可注册 Vue 插件等)
    • main.ts: 主入口文件 defineAppSetup
  • stores: 主题的状态管理
    • app.ts: 全局状态管理文件
  • styles: 主题的样式
    • index.ts: 主题的样式入口文件
  • tsconfig.json: 主题的 TypeScript 配置
  • types: 主题的类型声明
    • index.d.ts: 主题的类型声明入口文件
  • unocss.config.ts: 主题的 unocss 配置
  • utils: 主题的工具函数
  • valaxy.config.ts: 主题的配置文件

APIs

我们提供了一个扩展函数 extendMd,以供你快速扩展页面信息。

在主题的 valaxy.config.ts 中,你可以通过 extendMd 来访问每个 Markdown 页面的路由、frontmatter 数据、摘要和文件路径,并在构建时对其进行修改。

valaxy.config.ts
ts
import { defineTheme } from 'valaxy'

export default defineTheme({
  extendMd(ctx) {
    // ctx.route - EditableTreeNode,可修改路由 meta 信息
    // ctx.data  - 只读的 frontmatter 原始数据
    // ctx.content - 原始 Markdown 内容
    // ctx.excerpt - 摘要内容(如果存在)
    // ctx.path  - Markdown 文件的绝对路径

    // 示例:为所有页面添加自定义 meta
    ctx.route.addToMeta({
      frontmatter: {
        customField: 'hello from theme',
      },
    })
  },
})

你也可以直接扩展 vue-router/vite 插件中的 extendRoute

https://github.com/posva/unplugin-vue-router/issues/43#issuecomment-1433140464 (now part of vue-router)

valaxy.config.ts
ts
import { defineTheme } from 'valaxy'

export default defineTheme({
  router: {
    extendRoute(route) {
      // want to get component absolute paths?
      // const path = route.components.get('default')
      console.log(route)
    },
  },
  extendMd(ctx) {
    console.log(ctx.path)
  },
})
ts
import type { EditableTreeNode } from 'vue-router/unplugin'

// provided by valaxy, just as a tip
export interface ValaxyConfig {
  vue?: Parameters<typeof Vue>[0]
  components?: Parameters<typeof Components>[0]
  unocss?: UnoCSSConfig
  pages?: Parameters<typeof Pages>[0]
  extendMd?: (ctx: {
    route: EditableTreeNode
    data: Readonly<Record<string, any>>
    excerpt?: string
    path: string
  }) => void
}

提示

data 解析自 Markdown frontmatter,为原始数据(不可变),将会被合并至 route.meta.frontmatter 中。

Client

切换亮暗模式

以下变量被存储在全局状态中,你可以通过 useAppStore 获取。

  • isDark: 是否启用了暗黑模式
  • themeColor: 主题色(可跟随 isDark 变化)
  • toggleDark: 切换暗黑模式
  • toggleDarkWithTransition: 带有过渡效果的切换暗黑模式
components/YunToggleDark.vue
vue
<script lang="ts" setup>
import { useAppStore } from 'valaxy'

const appStore = useAppStore()
</script>

<template>
  <button class="yun-icon-btn" @click="app.toggleDarkWithTransition">
    <div i="ri-sun-line dark:ri-moon-line" />
  </button>
</template>

你可以通过 themeConfig.valaxyDarkOptions 来配置暗黑模式的相关选项。

Default Theme Config.valaxyDarkOptions
default-theme.ts
ts
import type { UseDarkOptions } from '@vueuse/core'

// eslint-disable-next-line ts/no-namespace
export namespace DefaultTheme {
  export interface Config {
    valaxyDarkOptions?: {
      /**
       * Options for `useDark`
       * disableTransition default is `true`
       * Its options are not computed, init when loaded.
       * @see https://vueuse.org/core/useDark
       * @url https://paco.me/writing/disable-theme-transitions
       *
       * @zh `useDark` 的选项
       * disableTransition 默认为 `true`,不会进行渐变过渡,这是 VueUse 的默认行为
       */
      useDarkOptions?: UseDarkOptions
      /**
       * Enable circle transition when toggling dark mode
       * Then use `toggleDarkWithTransition` instead of `toggleDark`
       * @zh 启用圆形过渡切换暗黑模式
       */
      circleTransition?: boolean

      /**
       * Theme color
       * @zh 主题色
       */
      themeColor?: {
        /**
         * Theme color for light mode
         * @zh 亮色主题色
         */
        light?: string
        /**
         * Theme color for dark mode
         * @zh 暗色主题色
         */
        dark?: string
      }
    }
    /**
     * Custom header levels of outline in the aside component.
     *
     * @default 2
     */
    outline?: number | [number, number] | 'deep' | false
  }
}

Node

Hooks

开始编写

App.vue

你的入口文件

譬如我想要为主题添加一个全局的 Loading 页面。

你可以从 valaxy 导入全局状态 useAppStore,记录 showLoading 来实现。

你也可以使用你自己的全局状态管理。参见 全局状态管理

valaxy-theme-yun/App.vue
vue
<script lang="ts" setup>
import { useHead } from '@unhead/vue'
import { useAppStore } from 'valaxy'
import { onMounted } from 'vue'

// ...

const app = useAppStore()
onMounted(() => {
  app.showLoading = false
})
</script>

<template>
  <!-- ... -->
  <!-- 添加 Loading 组件,components/YunLoading.vue -->
  <!-- https://github.com/YunYouJun/valaxy/blob/main/packages/valaxy-theme-yun/components/YunLoading.vue -->
  <Transition name="fade">
    <YunLoading v-if="app.showLoading" />
  </Transition>
</template>

提示

  • 你可以通过 ValaxyApp.vue 组件完全覆盖根组件,来达成你更深层次的定制化需求。(完全由你自定义,不再默认挂在 router-view 等默认处理。)

ValaxyMain

你需要自定义一个 ValaxyMain 组件来决定主题的文章渲染部分。

你可以从 ValaxyMainprops 中获取 frontmatterpageData

valaxy-theme-yun/components/ValaxyMain.vue
vue
<script lang="ts" setup>
import type { PageData, Post } from 'valaxy'

defineProps<{
  frontmatter: Post
  data?: PageData
}>()
</script>

<template>
  <main>
    <slot name="main-content">
      <ValaxyMd :frontmatter="frontmatter">
        <slot name="main-content-md" />
        <slot />
      </ValaxyMd>
    </slot>
  </main>
</template>

示例可参考 ValaxyMain.vue | valaxy-theme-yun

样式

引入默认样式

Valaxy 提供了一些默认样式,你需要在主题中自行引入。

例如,新建 valaxy-theme-yun/setup/main.ts:

setup/main.ts
ts
import { defineAppSetup, scrollTo } from 'valaxy'
import { nextTick } from 'vue'

// 引入 valaxy 公共样式
import 'valaxy/client/styles/common/index.scss'

// 你也可以按需引入
// common
import 'valaxy/client/styles/common/code.scss'
import 'valaxy/client/styles/common/hamburger.scss'
import 'valaxy/client/styles/common/transition.scss'
// Markdown Style
import 'valaxy/client/styles/common/markdown.scss'

export default defineAppSetup((ctx) => {
  const { router, isClient } = ctx
  if (!isClient)
    return

  router.afterEach((to, from) => {
    if (to.path !== from.path)
      return

    nextTick(() => {
      scrollTo(document.body, to.hash, {
        smooth: true,
      })
    })
  })
})

Markdown 样式

Markdown 样式是主题呈现文章样式的部分,需要由主题自定义。

你可以参考 valaxy-theme-press 自定义 Markdown 主题的方式,见 styles/markdown.scss

如果你想先使用常见的默认样式(后续再进行定制),你可以直接使用 star-markdown-css。 使用方式可参见 valaxy-theme-yun/styles

NProgress 进度条

内置了基础的 nprogress 样式,你可以通过覆盖 nprogress 的默认样式进行定制:

your-theme/styles/index.scss
scss
#nprogress {
  pointer-events: none;

  .bar {
    background: var(--va-c-primary);
    opacity: 0.75;
    position: fixed;
    z-index: 1024;
    top: 0;
    left: 0;
    width: 100%;
    height: 2px;
  }
}

功能

API

你还可以使用 Valaxy 内置的 API 以快速实现相关功能。

获取用户的 Valaxy Config

你可以通过内置的 useValaxyConfig 获取用户的 Valaxy 配置。

提示

这部分配置与用户的 valaxy.config.ts 中的配置相对应,但它仅在客户端使用,因此并不包含 Node 端相关配置(如 vite 等)。

composables/config.ts
ts
import { useSiteConfig, useValaxyConfig } from 'valaxy'
import { useThemeConfig } from 'valaxy-theme-custom'

const config = useValaxyConfig()
// site.config.ts or config.value.siteConfig
const siteConfig = useSiteConfig()
// theme.config.ts or config.value.themeConfig
const themeConfig = useThemeConfig()

提供 Typed useThemeConfig

你可以提供一个主题的 useThemeConfig 函数,以便自己/用户获得带有类型约束的配置。

composables/config.ts
ts
// custom your theme type
import type { YunTheme } from '../types'
import { useValaxyConfig } from 'valaxy'
/**
 * getThemeConfig
 */
export function useThemeConfig<ThemeConfig = YunTheme.Config>() {
  const config = useValaxyConfig<ThemeConfig>()
  return computed(() => config!.value.themeConfig)
}
components/Example.vue
vue
<script lang="ts" setup>
import { useThemeConfig } from 'valaxy-theme-custom'

const themeConfig = useThemeConfig()
</script>

获取文章列表

获取文章列表有两种方式。

  • usePostList: 获取文章列表(不推荐)
ts
import { usePostList } from 'valaxy'

const postList = usePostList()
  • useSiteStore: 获取全局站点信息(推荐)
ts
const site = useSiteStore()

// site.postList

以上两者之间的区别是,usePostList 是一个基础函数,每次调用都会获取所有文章并重新过滤一次,而 useSiteStore 则会先调用 usePostList 并将获取的文章列表缓存在全局的状态中,以供你后续调用。

(此外,useSiteStore 还实现了保存文章时(如标题)热更新列表信息的功能。)

valaxy/packages/valaxy-theme-yun/components/YunPostList.vue 是一个使用 useSiteStore 展示文章列表的示例。 分页功能可参考 valaxy-theme-yun/pages/page/[page].vuevalaxy-theme-yun/components/YunPostList.vue

获取文章分类与标签

在你获取文章列表后,site.postList 中的每篇文章都具有 categories(分类) 与 tags(标签) 属性。

你还可以通过 useCategoriesuseTags 获取所有分类、标签,其中便包含了与文章的对应关系。

ts
import { useCategories, useTags } from 'valaxy'

const categories = useCategories()
const tags = useTags()

useTags 中的 tags 为一个对象,其键为标签名,值为对应的文章列表。 useCategories 可传入参数 categoryuseCategories('aaa')) 以获取指定分类的文章列表。

获取 Front-matter

你可以通过 useFrontmatter 获取当前页面的 Front-matter。

譬如:

vue
<script lang="ts" setup>
import { useFrontmatter } from 'valaxy'

const fm = useFrontmatter()
</script>

<template>
  <h1>{{ fm.title }}</h1>
</template>

全局状态管理

你可以借助 Pinia (Valaxy 内置)建立自己的全局状态,并在随后使用它,

stores/app.ts
ts
import { acceptHMRUpdate, defineStore } from 'pinia'

// custom your theme name
export const useYunAppStore = defineStore('yun-app', () => {
  // global cache for yun

  return {}
})

if (import.meta.hot)
  import.meta.hot.accept(acceptHMRUpdate(useYunAppStore, import.meta.hot))
ts
// where you want to use
// components/YunExample.vue
import { useYunAppStore } from '../stores/app'

const yun = useYunAppStore()

上一篇/下一篇

文章底部通常存在切换上一篇/下一篇的导航。

你可以利用 siteStore.postList 自行实现,也可以使用 Valaxy 提供的 usePrevNext

可参见:valaxy-theme-yun/components/YunPrevNext.vue

ts
import { usePrevNext } from 'valaxy'

const [prev, next] = usePrevNext()
// prev/next type is PostFrontMatter
// prev.title prev.path

目录

如果你想要快速实现一个目录,Valaxy 提供了一个内置钩子函数 useOutline

你可以用它快速获取文章页的目录信息 headers 与对应点击事件 handleClick,如:

vue
<script setup lang="ts">
import { useOutline } from 'valaxy'

const { headers, handleClick } = useOutline()
</script>

<template>
  <nav aria-labelledby="doc-outline-aria-label">
    <span id="doc-outline-aria-label" class="visually-hidden">
      Table of Contents
    </span>

    <PressOutlineItem
      class="va-toc relative z-1 css-i18n-toc"
      :headers="headers"
      :on-click="handleClick"
      root
    />
  </nav>
</template>

更多可参见 PressOutline | valaxy-theme-press

引用静态资源

当主题需要内置一些静态资源(如:图片等),你可以通过相对引用的方式实现。(这在 scss 样式文件中也适用)

譬如 assetscomponents 处于同一目录下时:

bash
├── components
   └── ValaxyLogo.vue
└── assets
    └── images
        └── valaxy-logo.png
components/ValaxyLogo.vue
vue
<script lang="ts" setup>
import valaxyLogoPng from '../assets/images/valaxy-logo.png'
</script>

<template>
  <img max-w="50" m="auto" :src="valaxyLogoPng" alt="Valaxy Logo" z="1">
</template>

<style scoped>
.test-image {
  background-image: url('../assets/images/valaxy-logo.png');
}
</style>

Third Party Plugin

实现评论

作为博客,用户通常会有评论的需求。

而由于评论系统各不相同,如 Hexo 等主题开发者们通常需在主题侧重复实现多款评论系统。 这显然是繁琐的。

Valaxy 决定通过插件中心化地提供各类封装好的评论组件和辅助函数。

譬如主题开发者,可以借助 valaxy-addon-waline 来快速实现 Waline 评论系统的集成。 而用户则可以使用相同的配置穿梭漫游于不同的主题之间。

集成参见 valaxy-addon-waline

性能优化

添加依赖预构建 optimizeDeps

为了提高后续页面的加载性能,Vite 将那些具有许多内部模块的 ESM 依赖项转换为单个模块。 如果你的主题依赖了一些大型的 ESM 包,你可以通过添加 optimizeDeps 选项来预构建这些依赖项。

dayjs 已被默认预构建,您无需再次添加。 为什么用 dayjs 而不是 date-fns?

valaxy.config.ts
ts
import { defineTheme } from 'valaxy'

export default defineTheme({
  vite: {
    optimizeDeps: {
      include: ['lodash-es'],
    },
  }
})

在主题中使用插件配置

当你的主题集成了可选插件(如 Algolia 搜索、Waline 评论)时,可以使用 valaxy 提供的 useAddonConfig 读取插件选项,而无需对插件包产生硬依赖。

components/ThemeSearch.vue
vue
<script lang="ts" setup>
import type { AlgoliaSearchOptions } from '../types/algolia'
import { useAddonConfig } from 'valaxy'

const algolia = useAddonConfig<AlgoliaSearchOptions>('valaxy-addon-algolia')
// 插件未安装时 algolia.value 为 undefined
</script>

这避免了以往使用动态 import('valaxy-addon-xxx') + .then() / .catch() 的方式,后者容易出错且不具备响应式。

提醒特殊需求的用户安装第三方插件

如果您的主题适配了多个 addon,但用户并非都需要安装。 如评论插件:

  • valaxy-addon-waline
  • valaxy-addon-twikoo

当用户没有主动安装对应 addon 时(即 addon 不存在的情况),则会默认重定向至一个空函数。

因此,如果某个插件不是必须的,请在主题文档中提醒想要使用该功能的用户安装对应插件。


To Be Continued.

贡献者