Writing a plugin
Package a tag/hook registration as an installable plugin — from an empty folder to something `kiri install` can find.
If you've registered a tag or a hook in a project's own prepros.includes file (see Writing pages), you already know most of this — a plugin is exactly that, moved into its own npm package so it can be versioned, published, and reused across projects instead of copy-pasted. kiri loads it, calls one function, and the rest is the same tag/hook registry.
This page builds one from scratch: a <badge> tag that renders a small colored pill, {% badge %}-style but packaged. It's small on purpose — the three official plugins do the same thing at real scale, and are worth reading alongside this.
-
The package shape
A plugin is an ordinary npm package with one extra block in its
package.json:{ "name": "kirigami-plugin-badge", "version": "0.1.0", "type": "module", "main": "index.js", "kirigami": { "type": "plugin", "minVersion": "1.5.0", "optionsSchema": "./options.schema.json" }, "dependencies": { "@kirigami/sdk": "^0.2.0" } }kirionly accepts three naming shapes for thenamefield — this is how it tells a plugin apart from an unrelated dependency inplugins::Convention Example @kirigami/plugin-*reserved for official plugins <scope>/kirigami-plugin-*@acme/kirigami-plugin-badgekirigami-plugin-*kirigami-plugin-badge(used here)kirigami.minVersiongates the loader against akiritoo old for whatever hooks you use;optionsSchemais optional but worth having from the start — see step 5. -
Register on load
mainexports a default function.kiricalls it once, at the top of everybuild/export/watch, with theoptions:this project gave it inkirigami.yaml:// index.js import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { on, HOOKS } from '@kirigami/sdk'; const dir = path.dirname(fileURLToPath(import.meta.url)); export default function register(options = {}) { on(HOOKS.PREPROS_PHP, () => path.join(dir, 'php', 'badge.php')); }on(hook, fn)is@kirigami/sdk's whole API surface, shared by every task and every plugin in the process.PREPROS_PHPtellskiri"mount this absolute PHP path andinclude_onceit before any page renders" — the seam that lets a plugin callPREPROS::registerTag()from real PHP, the same call a project's own_lib/functions.phpwould make. -
The tag itself
Ordinary
@kirigami/php-preprosPHP — nothing plugin-specific about this file at all:// php/badge.php PREPROS::registerTag('badge', function ($tag, $attrs, $body) { $text = trim($body) ?: ($attrs['text'] ?? ''); if ($text === '') return ''; $tone = $attrs['tone'] ?? 'accent'; return '<span class="pg-badge pg-badge--' . htmlspecialchars($tone, ENT_QUOTES) . '">' . htmlspecialchars($text, ENT_QUOTES) . '</span>'; });<badge>New</badge>and<badge text="Beta" tone="accent">both work —STR::replaceTags()(whatregisterTag()runs on under the hood) accepts a tag with a body, self-closing, or bare. Full signature and the rest of thePREPROSAPI —mount(),exportFile(),fstat(),$config— is in the PHP class library. -
Ship default styles
The same
on()call, a different hook —SASS_AFTERappends a file to everysasstask, after the project's own entry (so the project's tokens are already defined when it compiles):on(HOOKS.SASS_AFTER, () => path.join(dir, 'badge.scss'));// badge.scss .pg-badge { display: inline-block; padding: .15em .6em; border-radius: 3px; font-size: .78em; &--accent { background: var(--accent-soft); color: var(--accent); } &--muted { background: var(--surface-2); color: var(--ink-muted); } }Reading
var(--accent)instead of a hard-coded color is what lets this render correctly in any project's own palette, light or dark, with nothing to configure —@kirigami/canva'sconfis what defines those tokens; see plugin-embed's play button for a slightly fancier version of the same idea.ESBUILD_BEFORE/ESBUILD_AFTERwork identically for client-side JS — seeplugin-embed'ssrc/embed.jsfor a real one, andPREPROS_HTMLif what you need is to transform each page's rendered HTML rather than contribute a tag (plugin-highlight's whole job runs on that hook). -
Make it configurable
register(options)receives whatever this project wrote under this plugin'soptions:inkirigami.yaml— but that value only exists on the JS side. There's no built-in channel from a JS-side option into the PHP filePREPROS_PHPmounts; if a tag's PHP-side behavior needs to vary, either hard-code the default in the PHP file itself (simplest — every official plugin here does exactly that for anything PHP-side), or gate a whole hook on an option, which is a normal JS-side decision:export default function register(options = {}) { const opts = { style: true, ...options }; on(HOOKS.PREPROS_PHP, () => path.join(dir, 'php', 'badge.php')); if (opts.style) on(HOOKS.SASS_AFTER, () => path.join(dir, 'badge.scss')); }kirivalidatesoptions:againstoptionsSchemabefore callingregister()— point it at a small JSON Schema:// options.schema.json { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "additionalProperties": false, "properties": { "style": { "type": "boolean", "default": true } } }This is also what makes
plugins[].optionsautocomplete in an editor oncekirigami.schema.json$refs it — see how the three official plugins wire that in their own repo if you're publishing under@kirigami/; a third-party plugin's options are still validated at build time either way. -
Try it
plugins: - name: "kirigami-plugin-badge" active: true options: style: trueFor local development before publishing, point at the folder with a
file:dependency andnpm install— exactly what every plugin on this site was verified with before it shipped.NOTE
If a hook you registered seems to silently never fire while developing this way, check for a second copy of
@kirigami/sdk— e.g. annpm installrun standalone inside the plugin's own folder, which can leave it with its ownnode_modules/@kirigami/sdkinstead of sharing the consuming project's one. The hook registry is a single module-levelMap; two copies of the module means two disconnected registries, andon()in one is invisible torun()in the other — no error either side, it just never fires. A realnpm installof a published plugin doesn't hit this: npm dedupes@kirigami/sdkto one copy in the consuming project'snode_modulesin the normal case.
Hook reference
Every hook below is @kirigami/sdk's on(HOOKS.<NAME>, fn). A hook either collects (every listener's return value is gathered into a list — undefined/null is skipped, an array is flattened in) or pipes (each listener receives the previous one's output and can transform it).
| Hook | Kind | Receives | Returns |
|---|---|---|---|
SASS_BEFORE / SASS_AFTER |
collect | { __root, task, exportPath, config } |
a `.scss` path, or an array of them |
SASS_FUNCTIONS |
collect | same as above | { 'my-fn($x)': (args) => SassValue } |
ESBUILD_BEFORE / ESBUILD_AFTER |
collect | { __root, task, exportPath, config } |
a `.js`/`.ts` path, or an array — bundled as a side-effect import |
ESBUILD_PLUGINS |
collect | same as above | an esbuild plugin object, or an array |
PREPROS_PHP |
collect | { __root, config } |
an absolute `.php` path, or an array — mounted + include_once'd once, before any page renders |
PREPROS_HTML |
pipe | (html, { file, abs, exportPath, config }) |
the transformed HTML string (or nothing, to leave it untouched) |
On a signature collision with a native Sass function, the native one always wins. Multiple listeners on the same hook all run, in registration order — a project's own prepros.includes file and three plugins can all hook PREPROS_HTML without stepping on each other.
Publishing
- Package name matches one of the three conventions in step 1 — anything else,
kiriwon't load it fromplugins:at all. kirigami.minVersionset to whateverkiriversion you actually tested against.kirigami.optionsSchemapresent, even a trivial one — free build-time validation and editor completion for anyone using the plugin.npm publish.kiri install your-plugin-namethen resolves, installs, and prints theplugins:block for anyone to paste in — nothing else to wire up on your end.
plugin-highlight, plugin-extlink and plugin-embed are real, MIT-licensed packages built exactly this way — worth reading end to end once this page's toy example makes sense.