插件开发 #

本篇说明如何编写一个 KawaPress 插件。阅读前,请先了解 插件体系 中 Generator Plugin、Runtime Plugin 与 Preset 的分工。

下面的示例都围绕一个虚构的包 @acme/kawapress-plugin-notes。它的各个部分分别演示一种 API,最后组合成一个完整的包。

从需求找到扩展位置 #

编写扩展前,先问“这项工作发生在哪里”:

想做的事扩展位置
修改站点配置Generator Plugin 的 config()
扩展 Markdown 语法Generator Plugin 的 markdown()
修改每个页面的数据Generator Plugin 的 pageData()
观察每个页面的构建结果Generator Plugin 的 pageArtifact()
添加或调整 Vite 能力Generator Plugin 的 vite()
构建结束后生成静态文件Generator Plugin 的 buildArtifacts()
安装 Vue 插件或全局组件Runtime Plugin 的 vueApp()
把界面放进主题预留的位置Runtime Plugin 的 addToOutlet()
添加导航守卫或运行时路由行为Runtime Plugin 的 router()
提供一套可以直接开始的完整体验Preset

一个能力可以使用表中的多个位置,但应该作为一个插件包交付。

创建 Generator Plugin #

Generator Plugin 使用 definePlugin() 创建。name 是插件的稳定身份,应与 npm 包名一致。setup() 接收 api,只需要注册真正用到的方法。

习惯上,插件导出一个工厂函数,让使用者可以传入选项:

TypeScript
import { definePlugin } from 'kawapress'

export interface NotesPluginOptions {
  /** 描述的前缀,例如 `笔记:`。 */
  prefix?: string
}

export function notesPlugin(options: NotesPluginOptions = {}) {
  const prefix = options.prefix ?? ''

  return definePlugin({
    name: '@acme/kawapress-plugin-notes',
    setup(api) {
      api.pageData((pageData) => {
        pageData.description ??= `${prefix}${pageData.title}`
      })
    },
  })
}

export default notesPlugin

把插件加入站点:

TypeScript
import notesPlugin from '@acme/kawapress-plugin-notes'
import { defineConfig } from 'kawapress'

export default defineConfig({
  plugins: [
    notesPlugin({ prefix: '笔记:' }),
  ],
})

工厂函数负责校验并整理选项。setup() 中只使用整理好的值,不再重复读取原始选项。

生成侧 API #

六个生成侧方法都在 setup(api) 中注册。handler 可以是同步函数,也可以是异步函数。同一类 handler 按插件在 plugins 中的顺序串行执行。

api.config() #

config() 在站点配置归一化后调用。handler 接收真实的站点配置对象,可以直接修改。

TypeScript
api.config((config) => {
  config.title ??= 'My Docs'
})

需要知道语言、部署路径或源码目录的插件,通常在这里记下配置,留给后面的方法使用。

api.markdown() #

markdown() 在 Markdown 编译器创建时调用,handler 接收真实的 MarkdownExit 实例。

TypeScript
import { useMarkdownItPlugin } from 'kawapress'
import { notePlugin } from 'markdown-it-example-note'

api.markdown((markdown) => {
  markdown.use(notePlugin)
})

如果 markdown-it 插件的类型与 KawaPress 不完全匹配,请使用 useMarkdownItPlugin()。它保留插件的参数类型,并集中处理类型兼容:

TypeScript
api.markdown((markdown) => {
  useMarkdownItPlugin(markdown, notePlugin, { className: 'acme-note' })
})

让自己的组件出现在页面上 #

有些插件会输出自己的组件,比如图表或可交互的示例。用 addPageComponent() 告诉 KawaPress“这一页用到了某个组件”,它会把对应的导入写进当前页面的 SFC,Vue 就能在模板里认出这个标签:

TypeScript
import { addPageComponent } from 'kawapress'

api.markdown((markdown) => {
  const renderFence = markdown.renderer.rules.fence
  markdown.renderer.rules.fence = (tokens, index, options, env, renderer) => {
    if (tokens[index].info.trim() !== 'acme-chart') {
      return renderFence(tokens, index, options, env, renderer)
    }
    addPageComponent(env, { name: 'AcmeChart', from: '@acme/chart' })
    return '<AcmeChart />'
  }
})

env 是渲染这一页时使用的那份环境对象。规则、围栏渲染器和 Shiki 高亮回调都能拿到它,把它们收到的 env 直接传进来就行。name 是模板里使用的标签名,也是生成的导入绑定名;from 是默认导出该组件的模块,可以写包名,也可以写绝对路径。

写 Markdown 的人不需要声明任何东西。组件只在真正用到的页面上加载,没写这个围栏的页面不会为它多下载一个字节。页面自己写的 <script setup> 会被保留,导入追加在同一个块里;页面只有普通 <script> 或者完全没有脚本时,KawaPress 会补一个 lang 相同的 <script setup>。

api.pageData() #

pageData() 在每个页面的 pageData 生成之后调用。handler 接收当前页面的可修改数据:

TypeScript
api.pageData((pageData) => {
  pageData.description ??= `${pageData.title} 的笔记`
})

pageData 会被序列化给运行时使用,因此它只能包含 JSON 可表示的值:文字、数字、布尔值、null、数组和普通对象。Date、Map、函数和类实例都不能直接写入。例如,下面的写法会让构建失败:

TypeScript
api.pageData((pageData) => {
  pageData.frontmatter.reviewedAt = new Date('2026-10-08')
})

请改用字符串,例如 '2026-10-08',再在需要时自行解析。

handler 的第二个参数告诉你当前页面来自哪个文件:file 是 Markdown 源文件的绝对路径,sourcePath 是相对 srcDir 的路径,例如 /guide/intro.md。需要读取文件信息(比如 git 提交时间)时使用它,不要从路由反推文件名,因为 /guide 既可能来自 guide.md,也可能来自 guide/index.md。

TypeScript
api.pageData((pageData, { file, sourcePath }) => {
  pageData.frontmatter.source = sourcePath
})

这两个值只在生成侧使用,不会发送到浏览器。需要在页面上显示时,像上面这样把需要的部分写进 pageData。

需要注意,在 pageData() 中修改的 title 只影响该页面自己的数据,不会进入用于导航的页面摘要:页面摘要描述的是作者在 Frontmatter 或第一个一级标题里写下的标题。

给类型加上你的字段 #

pageData() 写进去的字段,读的人也应该看到它的类型。KawaPress 把三个类型留给插件扩展:

类型用在哪里
PageDatausePageData() 和 pageData() 拿到的页面数据
PageFrontmatter页面 Frontmatter。作者写的任意键仍然允许,类型是 unknown
ThemeConfig站点和每种语言的 themeConfig

在插件包里用 declare module 'kawapress' 加上你的字段:

TypeScript
// src/index.ts
declare module 'kawapress' {
  interface PageData {
    /** 最后一次提交的时间,毫秒。 */
    lastUpdated?: number
  }

  interface PageFrontmatter {
    /** 写 `false` 可以隐藏这一页的更新时间。 */
    lastUpdated?: boolean
  }
}

站点 import 了你的插件之后,所有地方都能直接用:

TypeScript
const page = usePageData()
page.value?.lastUpdated // number | undefined,不需要写 as

几个插件各自加的字段会合并到同一个类型里。没有安装的插件,它的字段就不会出现。

写的时候注意三点:

  • 字段都写成可选。 站点可能没有安装你的插件,Frontmatter 也是作者手写的,值不一定存在。
  • 类型只是编译期的帮助。 PageFrontmatter 里的类型描述作者「应该」怎么写,不保证运行时一定如此。读取时仍然要判断,比如 comments !== false。
  • 只能放 JSON 可表示的值。 理由和上面的 pageData() 一样。

api.pageArtifact() #

pageArtifact() 在一个页面完成 Markdown 编译和全部 pageData() 之后调用。handler 只能读取结果,不能修改页面的源文件、数据或路由。

TypeScript
const sourcePaths = new Map<string, string>()

api.pageArtifact((artifact) => {
  sourcePaths.set(artifact.routePath, artifact.sourcePath)
})

如果只需要构建结束时的全部页面,请优先使用 buildArtifacts()。

api.vite() #

vite() 在 Vite 配置完成基础组装后调用。handler 接收完整的 UserConfig,可以直接修改。

TypeScript
import { someVitePlugin } from 'some-vite-plugin'

api.vite((config) => {
  config.plugins ??= []
  config.plugins.push(someVitePlugin())
})

KawaPress 会在创建 Vite 之前校验维持渲染所需的基本条件。插件不能通过修改 Vite 配置绕开这些条件。

声明运行侧用到的依赖 #

运行侧代码 import 了某个包,就要在 vite() 里用 useRuntimeDependencies() 声明它。开发服务器会先把这些包准备好,否则第一次访问用到它们的页面时,Vite 会边加载边发现新依赖,然后把整页刷新一次;服务端渲染也会把它们一起打包,不会另外加载一份 Vue。

TypeScript
import { useRuntimeDependencies } from 'kawapress/generator'

api.vite((config) => {
  useRuntimeDependencies(config, '@your-scope/plugin-name', ['some-browser-lib'])
})

第二个参数是真正依赖这个包的包名,通常就是你自己的插件包。站点一般只安装 kawapress 或某个 Preset,装不到插件内部的依赖,所以这个函数会顺着 package.json 里声明的依赖关系,算出从站点到这个包的路径,再按 Vite 的 owner > dependency 写法声明。只在开发时生效,不影响构建产物。

api.buildArtifacts() #

buildArtifacts() 只在 kawapress build 中执行。它发生在全部 Vite 构建完成之后、HTML 预渲染之前。handler 接收全部页面和一个 emitFile() 函数:

TypeScript
api.buildArtifacts(async ({ pages, emitFile }) => {
  const routes = pages.map(page => page.routePath)
  await emitFile('notes/routes.json', `${JSON.stringify(routes, null, 2)}\n`)
})

emitFile() 只接受输出目录内的相对路径。绝对路径、包含 .. 的路径,以及与 public/ 或其他插件产物同名的文件,都会直接报错。pages 按源文件路径排序,结果稳定。

按语言给出不同输出 #

多语言站点的插件往往要给不同语言不同的文案或文件。判断一个路由属于哪种语言时,别自己拆路径,用 resolveLocaleIndex():

TypeScript
import { resolveLocaleIndex, useMarkdownItPlugin } from 'kawapress'

const LABELS: Record<string, string> = {
  root: 'Note',
  zh: '说明',
}

let localeKeys = ['root']

api.config((config) => {
  localeKeys = Object.keys(config.locales ?? { root: {} })
})

api.markdown((markdown) => {
  useMarkdownItPlugin(markdown, notePlugin, {
    // '/zh/guide' 得到 'zh','/guide' 得到 'root'
    title: (env: { path?: string }) =>
      LABELS[resolveLocaleIndex(env.path ?? '/', localeKeys)] ?? LABELS.root,
  })
})

它的规则与站点本身一致:root 负责其他语言前缀之外的所有路径,前缀最长者优先。/en 只认 /en 和 /en/...,所以 /english 不会落到英文站;/zh-hant 长于 zh,同时配置两者时也能选对。查询串和 hash 会被忽略,/zh/guide?tab=api 依然是 zh。

这个函数是纯函数,运行侧同样可以用,从 kawapress/client 导入即可。

列出全部页面、读取页面标题 #

想自己做导航、索引或搜索数据,先要知道站点有哪些页面。kawapress/generator 的 listMarkdownPages() 使用的规则和 KawaPress 自己完全一样:跳过以 . 开头的目录以及 dist、node_modules,排除 publicDir,按名字排序。

TypeScript
import { resolve } from 'node:path'
import { listMarkdownPages } from 'kawapress/generator'

let srcDir = '.'

api.config((config) => {
  srcDir = config.srcDir ?? '.'
})

api.vite((config) => {
  config.plugins ??= []
  config.plugins.push({
    name: 'your-plugin:pages',
    async configResolved(resolved) {
      // sourceRoot 是页面所在的目录;publicDir 里放的是原样复制的资源,不算页面
      const pages = await listMarkdownPages({
        sourceRoot: resolve(resolved.root, srcDir),
        publicDir: resolved.publicDir,
      })
      // 每项形如
      // { file: '/site/src/guide/intro.md', sourcePath: '/guide/intro.md', routePath: '/guide/intro' }
      console.log(pages.map(page => page.routePath))
    },
  })
})

每个页面的标题和 layout 用 createPageSummaryReader() 读:

TypeScript
import { readFile } from 'node:fs/promises'
import { createPageSummaryReader } from 'kawapress/generator'

const reader = createPageSummaryReader()
const source = await readFile(page.file, 'utf8')
const { title, layout } = reader.read(source, page.file)

它只解析 Markdown,不会渲染页面:pageData() handler 不会执行,Markdown 插件、Shiki、Twoslash、MathJax 都不参与,所以列出整站也不需要先把页面编译一遍。标题的规则和页面本身一致:Frontmatter 里写了 title 就用它,否则取第一个一级标题的纯文本(格式标记去掉,公式按源码原样保留);layout 就是 Frontmatter 里写的那个字符串,没写就是 undefined。

同一个文件、同样的内容会直接用上次的结果,反复读整站很便宜。主题自动生成侧边栏时就是这样读标题的。

开发服务器把改动的文件报成绝对路径,判断它是不是页面用 isMarkdownPage(),换回路由路径用 markdownFileToRoutePath()。

运行侧 API #

Runtime Plugin 在网站运行时工作,它的代码会进入站点的 Vite 模块图,所以可以静态导入 .vue、CSS 和普通 Vue 插件。

它与 Generator Plugin 放在同一个包中,并通过 ./runtime-plugin 入口公开。它的 name 同样应与包名一致。

api.vueApp() #

vueApp() 接收真实的 Vue App 实例。它适合调用 app.use() 和注册全局组件:

TypeScript
// src/runtime-plugin.ts
import { defineRuntimePlugin } from 'kawapress'
import NotesBadge from './NotesBadge.vue'

export default defineRuntimePlugin({
  name: '@acme/kawapress-plugin-notes',
  setup(api) {
    api.vueApp((app) => {
      app.component('NotesBadge', NotesBadge)
    })
  },
})

NotesBadge.vue 是普通的 Vue 组件。注册后,页面中就可以按 NotesBadge 这个名称使用它。

api.addToOutlet() #

addToOutlet() 把一个组件放进主题提供的某个固定位置。导航栏按钮、复制按钮、评论区这类界面,属于页面框架的某个位置,而不属于 Markdown 正文。这类界面应该通过 Outlet 交给主题:

TypeScript
// src/runtime-plugin.ts
import { defineRuntimePlugin } from 'kawapress'
import CopyPageButton from './CopyPageButton.vue'

export default defineRuntimePlugin({
  name: '@acme/kawapress-plugin-notes',
  setup(api) {
    api.addToOutlet('doc.header', {
      id: 'copy-page',
      component: CopyPageButton,
    })
  },
})

主题只需在合适的位置写 <KawaOutlet name="doc.header" />,不需要知道这个插件的存在。

首版有四个 Outlet:

Outlet渲染方适合放什么
header.actions主题搜索按钮、仓库链接等顶栏操作
doc.headerKawaPress(Markdown 首个 H1 之后)复制页面、页面工具
doc.footer主题最后更新时间、编辑此页链接
doc.after主题评论区、反馈表单

规则很简单:

  • id 在同一个插件内必须唯一。完整标识是 <插件名>:<id>,插件名由 KawaPress 自动补上,插件无法伪造。
  • 渲染顺序先按插件在 plugins 数组中的顺序,再按同一个插件内调用 addToOutlet() 的先后顺序。不提供数字权重或相对位置设置。
  • 名字写错、id 为空、同一个插件里 id 重复,都会直接报错,错误信息会带插件名。
  • 全站都没有页面渲染你使用的 Outlet 时,kawapress build 会按插件名给出警告。看到警告时,先确认主题是否支持这个 Outlet。

如果插件既会显示自己的界面,又希望主题可以完全接管,可以另外导出一个组合式函数(例如 useCopyPage()),主题就能用自己的组件套用同一套逻辑,然后在 Slot 中过滤掉默认组件。

插件样式包进 kawa.plugin 层 #

插件自带的默认样式请全部包在 @layer kawa.plugin { ... } 里,并且只读取 --kawa-* Token:

CSS
@layer kawa.plugin {
  .acme-notes-badge {
    color: var(--kawa-c-text-2);
    border: 1px solid var(--kawa-c-border);
    border-radius: var(--kawa-radius);
  }
}

这样做有三个好处:主题样式不需要借助 !important 就能盖过插件样式;插件不写 fallback 颜色,所以任何主题下都能直接使用;更换主题时插件代码完全不用改。

不要在插件里读取某个主题的私有变量(例如 --nagi-*),也不要自己再定义一套通用颜色变量。真正只属于插件自己的数值(例如搜索弹窗宽度)可以保留为插件私有变量。

插件样式也不要限定在某个主题的正文容器里(例如 .nagi-doc .kawa-container)。选择器请直接基于插件自己的 .kawa-* class 和 ARIA、原生属性,这样换一个主题也照样生效。

api.router() #

router() 接收 KawaPress 的站内 Router,名字与用法和 Vue Router 相同的部分保持相同。它适合添加导航守卫:beforeEach() 返回 false 就取消这次跳转;插件想换一个地址时,自己调用 router.push()。

TypeScript
api.router((router) => {
  router.beforeEach((to) => {
    // 例如:草稿页只在本地开发时可访问
    if (to.path === '/notes/draft' && import.meta.env.PROD) {
      return false
    }
  })
})

afterEach() 在跳转提交后执行,适合清理与上一页有关的状态:

TypeScript
api.router((router) => {
  router.afterEach(() => {
    closeDialog()
  })
})

Router 只覆盖文档站需要的能力(跳转、读取当前路由、导航前后钩子),没有 addRoute()、命名路由、动态参数与重定向。新增页面请添加 Markdown 文件;如果你需要页面清单,用 Generator Plugin 发布自己的虚拟模块。

运行侧的注意事项 #

Runtime Plugin 会为每个 Vue App 执行一次。服务端渲染时,每次渲染都会创建新的 App;浏览器中则在启动时创建一次。因此,不要在模块顶层保存只属于某次渲染的状态。

另外,Runtime Plugin 同时在服务端和浏览器中运行。访问 window、document 等浏览器对象前,请先阅读 SSR 兼容性。

完整的包结构 #

一个包含两个运行入口的插件,通常这样组织:

Text
@acme/kawapress-plugin-notes
├─ package.json
└─ src
   ├─ index.ts           默认入口:Generator Plugin
   ├─ runtime-plugin.ts  运行入口:Runtime Plugin
   └─ NotesBadge.vue

package.json 需要公开两个入口,并只把源码交给 npm:

JSON
{
  "name": "@acme/kawapress-plugin-notes",
  "type": "module",
  "version": "0.1.0",
  "license": "MIT",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./runtime-plugin": {
      "types": "./src/runtime-plugin.ts",
      "import": "./src/runtime-plugin.ts",
      "default": "./src/runtime-plugin.ts"
    }
  },
  "files": [
    "src/**/!(*.test.*)"
  ],
  "peerDependencies": {
    "kawapress": "^0.0.1"
  }
}

如果插件只参与构建,就删除 ./runtime-plugin 入口和 runtime-plugin.ts。

发布前检查 #

发布前,请逐项确认:

  • package.json 的 name 与 Generator Plugin 的 name 完全一致。
  • 只有存在 Runtime Plugin 时才公开 ./runtime-plugin。
  • files 只包含发布需要的源码,并排除测试文件。
  • kawapress 声明为 peerDependencies,避免站点中出现第二份核心包。
  • 包内包含许可证文件和说明文档。

调试与常见错误 #

插件报错时,错误信息会带上插件身份、执行面和能力名。例如:

Text
[@acme/kawapress-plugin-notes / generator / pageData] Plugin execution failed.

它表示 @acme/kawapress-plugin-notes 在生成侧的 pageData() 中失败。根据这三个部分,就能找到出错的注册位置。

另外,请注意下面的边界:

  • 生成侧和运行侧的代码运行在不同阶段。不要让一侧依赖另一侧模块中的状态。
  • buildArtifacts() 中不要使用 eval 或 new Function 绕过模块边界。需要加载模块时,请使用 importModule()。
  • 0.1 不提供 addPage()。需要新的页面时,请先在 Markdown 源文件中创建它。

评论