插件开发 #
本篇说明如何编写一个 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,只需要注册真正用到的方法。
习惯上,插件导出一个工厂函数,让使用者可以传入选项:
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把插件加入站点:
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 接收真实的站点配置对象,可以直接修改。
api.config((config) => {
config.title ??= 'My Docs'
})需要知道语言、部署路径或源码目录的插件,通常在这里记下配置,留给后面的方法使用。
api.markdown() #
markdown() 在 Markdown 编译器创建时调用,handler 接收真实的 MarkdownExit 实例。
import { useMarkdownItPlugin } from 'kawapress'
import { notePlugin } from 'markdown-it-example-note'
api.markdown((markdown) => {
markdown.use(notePlugin)
})如果 markdown-it 插件的类型与 KawaPress 不完全匹配,请使用 useMarkdownItPlugin()。它保留插件的参数类型,并集中处理类型兼容:
api.markdown((markdown) => {
useMarkdownItPlugin(markdown, notePlugin, { className: 'acme-note' })
})让自己的组件出现在页面上 #
有些插件会输出自己的组件,比如图表或可交互的示例。用 addPageComponent() 告诉 KawaPress“这一页用到了某个组件”,它会把对应的导入写进当前页面的 SFC,Vue 就能在模板里认出这个标签:
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 接收当前页面的可修改数据:
api.pageData((pageData) => {
pageData.description ??= `${pageData.title} 的笔记`
})pageData 会被序列化给运行时使用,因此它只能包含 JSON 可表示的值:文字、数字、布尔值、null、数组和普通对象。Date、Map、函数和类实例都不能直接写入。例如,下面的写法会让构建失败:
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。
api.pageData((pageData, { file, sourcePath }) => {
pageData.frontmatter.source = sourcePath
})这两个值只在生成侧使用,不会发送到浏览器。需要在页面上显示时,像上面这样把需要的部分写进 pageData。
需要注意,在 pageData() 中修改的 title 只影响该页面自己的数据,不会进入用于导航的页面摘要:页面摘要描述的是作者在 Frontmatter 或第一个一级标题里写下的标题。
给类型加上你的字段 #
pageData() 写进去的字段,读的人也应该看到它的类型。KawaPress 把三个类型留给插件扩展:
| 类型 | 用在哪里 |
|---|---|
PageData | usePageData() 和 pageData() 拿到的页面数据 |
PageFrontmatter | 页面 Frontmatter。作者写的任意键仍然允许,类型是 unknown |
ThemeConfig | 站点和每种语言的 themeConfig |
在插件包里用 declare module 'kawapress' 加上你的字段:
// src/index.ts
declare module 'kawapress' {
interface PageData {
/** 最后一次提交的时间,毫秒。 */
lastUpdated?: number
}
interface PageFrontmatter {
/** 写 `false` 可以隐藏这一页的更新时间。 */
lastUpdated?: boolean
}
}站点 import 了你的插件之后,所有地方都能直接用:
const page = usePageData()
page.value?.lastUpdated // number | undefined,不需要写 as几个插件各自加的字段会合并到同一个类型里。没有安装的插件,它的字段就不会出现。
写的时候注意三点:
- 字段都写成可选。 站点可能没有安装你的插件,Frontmatter 也是作者手写的,值不一定存在。
- 类型只是编译期的帮助。
PageFrontmatter里的类型描述作者「应该」怎么写,不保证运行时一定如此。读取时仍然要判断,比如comments !== false。 - 只能放 JSON 可表示的值。 理由和上面的
pageData()一样。
api.pageArtifact() #
pageArtifact() 在一个页面完成 Markdown 编译和全部 pageData() 之后调用。handler 只能读取结果,不能修改页面的源文件、数据或路由。
const sourcePaths = new Map<string, string>()
api.pageArtifact((artifact) => {
sourcePaths.set(artifact.routePath, artifact.sourcePath)
})如果只需要构建结束时的全部页面,请优先使用 buildArtifacts()。
api.vite() #
vite() 在 Vite 配置完成基础组装后调用。handler 接收完整的 UserConfig,可以直接修改。
import { someVitePlugin } from 'some-vite-plugin'
api.vite((config) => {
config.plugins ??= []
config.plugins.push(someVitePlugin())
})KawaPress 会在创建 Vite 之前校验维持渲染所需的基本条件。插件不能通过修改 Vite 配置绕开这些条件。
声明运行侧用到的依赖 #
运行侧代码 import 了某个包,就要在 vite() 里用 useRuntimeDependencies() 声明它。开发服务器会先把这些包准备好,否则第一次访问用到它们的页面时,Vite 会边加载边发现新依赖,然后把整页刷新一次;服务端渲染也会把它们一起打包,不会另外加载一份 Vue。
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() 函数:
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():
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,按名字排序。
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() 读:
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() 和注册全局组件:
// 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 交给主题:
// 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.header | KawaPress(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:
@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()。
api.router((router) => {
router.beforeEach((to) => {
// 例如:草稿页只在本地开发时可访问
if (to.path === '/notes/draft' && import.meta.env.PROD) {
return false
}
})
})afterEach() 在跳转提交后执行,适合清理与上一页有关的状态:
api.router((router) => {
router.afterEach(() => {
closeDialog()
})
})Router 只覆盖文档站需要的能力(跳转、读取当前路由、导航前后钩子),没有 addRoute()、命名路由、动态参数与重定向。新增页面请添加 Markdown 文件;如果你需要页面清单,用 Generator Plugin 发布自己的虚拟模块。
运行侧的注意事项 #
Runtime Plugin 会为每个 Vue App 执行一次。服务端渲染时,每次渲染都会创建新的 App;浏览器中则在启动时创建一次。因此,不要在模块顶层保存只属于某次渲染的状态。
另外,Runtime Plugin 同时在服务端和浏览器中运行。访问 window、document 等浏览器对象前,请先阅读 SSR 兼容性。
完整的包结构 #
一个包含两个运行入口的插件,通常这样组织:
@acme/kawapress-plugin-notes
├─ package.json
└─ src
├─ index.ts 默认入口:Generator Plugin
├─ runtime-plugin.ts 运行入口:Runtime Plugin
└─ NotesBadge.vuepackage.json 需要公开两个入口,并只把源码交给 npm:
{
"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,避免站点中出现第二份核心包。- 包内包含许可证文件和说明文档。
调试与常见错误 #
插件报错时,错误信息会带上插件身份、执行面和能力名。例如:
[@acme/kawapress-plugin-notes / generator / pageData] Plugin execution failed.它表示 @acme/kawapress-plugin-notes 在生成侧的 pageData() 中失败。根据这三个部分,就能找到出错的注册位置。
另外,请注意下面的边界:
- 生成侧和运行侧的代码运行在不同阶段。不要让一侧依赖另一侧模块中的状态。
buildArtifacts()中不要使用eval或new Function绕过模块边界。需要加载模块时,请使用importModule()。- 0.1 不提供
addPage()。需要新的页面时,请先在 Markdown 源文件中创建它。


评论