Writing plugins
On this page
A working example lives in examples/plugin-reading-time of the repository: it adds a readingTime frontmatter field and a reading-time/too-long audit rule.
import { definePlugin } from "@kilim-dev/core";
export function hello(options: { greeting?: string } = {}) {
const greeting = options.greeting ?? "Hello";
return definePlugin({
name: "hello",
hooks: {
beforeRender({ page }) {
page.meta.description ??= `${greeting} from ${page.title}`;
},
},
});
}definePlugin takes:
| Field | Purpose |
|---|---|
name | Unique; lowercase letters, digits, -, ., _. Errors and rule names use it. |
hooks | Build hooks, below. |
fields | Frontmatter fields the plugin adds, each with a Standard Schema. |
collections | Collections the fields apply to; every content file when left out. |
rules | Audit rules from defineRule, named <plugin>/<rule>. |
clientScripts | true lets the plugin add <script> elements with addScript. Default false. |
incremental | true says afterRender changes a page from that page alone, so kilim dev keeps pages it rendered earlier. Default false. |
It returns the plugin with a manifest: the hooks, fields, rules and client script permission the plugin declares. The build only runs what the manifest lists, kilim check uses it to explain unknown rule names, and the build records it in .kilim/manifest.json (so kilim check asks for a new build after plugins change). Always create plugins with definePlugin.
Every error a plugin causes names it: plugin "hello": beforeRender hook failed on content/a.md: ....
Hooks may be async. They run one at a time, in plugin order, on every build, and again on every kilim dev rebuild. Keep per-build state inside the hook context rather than in module variables, or reset it in the config hook.
| Hook | When | May change |
|---|---|---|
config | Before content is read. | The config, by returning a new one. Not theme or plugins. |
onContentLoaded | After validation and the draft/schedule filter. | entry.data; may remove entries, not add. |
beforeRender | Per page (content pages, then list pages), before the layout. | page.data, page.meta, page.content. |
afterRender | Per page, on the parsed HTML (hast), before images, minify and the zero-JS check. | The tree; addScript with permission. |
afterBuild | After every page is rendered, before anything is written. | Only adds files with emitFile. |
Contexts at a glance:
config(config, { root, configFile }):configis whatkilim.config.tsexports (after earlier plugins). Return a changed copy; it is validated like the config file, and errors name the plugin.onContentLoaded({ config, entries })beforeRender({ config, site, page, outputPath, seo, list, lists }):seoholds the page's frontmatterdescription,image,noindex,authorandupdated;listis set on list pages.setMetaImage(page.meta, url)sets the Open Graph image, JSON-LD included.afterRender({ ...same, tree, addScript }): the hast helperselements,findElementandtextContentfrom@kilim-dev/corehelp with the tree.afterBuild({ config, site, pages, lists, files, emitFile, rewriteContent, now }):emitFilerefuses paths the build or another plugin already writes;rewriteContent(page)returns a page's HTML with local images pointing at their processed versions (useful for feeds and search indexes).
The build is all or nothing: if a hook throws, nothing is written.
After a content edit, kilim dev renders only the pages whose inputs changed. A plugin with onContentLoaded, or with afterRender but without incremental: true, makes every page render again, because the build cannot know what such a hook collects across pages. Set incremental: true when afterRender reads nothing but its own tree, page and config: pages kept from the last build keep what the hook did to them, scripts included.
A config hook can add collections, which lets a theme bring its own pages:
const docs = definePlugin({
name: "docs",
hooks: {
config: (config) => ({
...config,
collections: { ...config.collections, docs: { list: { title: "Docs" }, layout: "doc" } },
}),
},
});
export default defineTheme({ name: "docs-theme", plugins: [docs], layouts: { doc, list } });definePlugin({
name: "reading-time",
fields: { readingTime: z.number().int().positive().optional() },
});Each field is validated with its own schema, in every collection (and in files outside collections), and its value is added to the entry's data. Collection schemas never see the field, so strict schemas keep working. Errors point at the file and line: content/blog/a.md:3: readingTime: Invalid input: expected number, received string (plugin "reading-time").
By default a field applies to every content file. collections: ["blog"] limits it; pass it through from your plugin's options so sites decide:
export function readingTime(options: { collections?: string[] } = {}) {
return definePlugin({ name: "reading-time", collections: options.collections, fields: { ... } });
}Outside its collections the field is left alone: another collection may use the same name for its own field, and two plugins may add the same field to different collections. A misspelled collection name is an error naming the plugin.
Within a collection a field belongs to one owner. Two plugins adding the same field there, or a collection schema declaring a plugin's field, is an error; for schemas that can describe their keys (Zod and others with Standard JSON Schema) it names the config line, for others the content file that shows the clash.
definePlugin({
name: "reading-time",
rules: [
defineRule({
name: "reading-time/too-long",
description: "Pages that take longer than `max` minutes to read.",
severity: "warn",
options: z.strictObject({ max: z.number().default(20) }),
check({ pages, options, report }) {
// ...
},
}),
],
});Rules must be named <plugin>/<rule>. They run in kilim check after the built-in rules, and config audit.rules sets their severity and options, with type completion through defineConfig, for the config's plugins and for the plugins of a theme passed as an object (its extends chain included). Rules see the built pages (url, file, meta, parsed tree), the site, file sizes and redirects; see defineRule in @kilim-dev/core.
Kilim pages have no JavaScript by default, and any <script> other than JSON-LD and Speculation Rules fails the build. A plugin that needs one, such as analytics, declares it and adds it with addScript in afterRender:
definePlugin({
name: "analytics",
clientScripts: true,
hooks: {
afterRender({ addScript }) {
addScript({ src: "https://stats.example.com/script.js", defer: true, position: "head" });
},
},
});Only scripts added through addScript by a plugin with clientScripts: true pass the check; a <script> written into the tree directly, or by any other plugin, layout or Markdown file, still fails with OutputError. The Content-Security-Policy the build writes to _headers allows these scripts automatically (their origins, or hashes of inline code). kilim build names every plugin that adds scripts: note: plugin "analytics" adds client scripts to 12 pages.
Build a small site in a temporary folder and look at the result. resolveConfig(root, configFile, config) turns a config object into what build takes without writing a config file; audit runs the rules over the last build. See the example plugin's tests.