Skip to content
Guide

Writing plugins

The five pipeline hook points and how to author a docvia plugin.

A docvia plugin is a plain object that taps one or more points in the compile pipeline. Plugins are how the OpenAPI integration, Mermaid diagrams, custom transforms, and content generation are built, all without forking the compiler.

The plugin shape

A plugin implements the docviaPlugin interface from @docvia/ir:

FieldTypeDescription
namestringUnique plugin name. Required.
versionstringPlugin version. Required.
phase"pre" | "normal" | "post"Execution phase. Default "normal".
prioritynumberTie-break within a phase. Default 100; lower runs first.
cacheKey()() => stringContributes to the build cache key.
beforeParsehookSee below.
afterParsehookSee below.
beforeTransformhookSee below.
afterTransformhookSee below.
beforeRenderhookSee below.

name and version are mandatory. resolvePlugins throws a PLUGIN_ERROR for a plugin that omits either, or for a duplicated name.

The five hook points

The pipeline runs each file through these stages in order. A hook may be synchronous or return a Promise; whatever it returns is threaded into the next plugin and then the next stage.

flowchart LR
  RAW["Raw file"] --> BP{{beforeParse}}
  BP --> AST["Markdown AST"]
  AST --> AP{{afterParse}}
  AP --> BT{{beforeTransform}}
  BT --> IR["IRDocument"]
  IR --> AT{{afterTransform}}
  AT --> BR{{beforeRender}}
  BR --> OUT["Rendered module"]
Where each hook sits in the pipeline
HookSignatureRuns
beforeParse(file: FileEntry) => FileEntryOn the raw file, before Markdown parsing.
afterParse(ast, file: FileEntry) => astOn the parsed Markdown AST.
beforeTransform(ast, meta: FrontmatterData) => astOn the AST, before IR conversion.
afterTransform(doc: IRDocument) => IRDocumentOn the IR document.
beforeRender(doc: IRDocument) => IRDocumentOn the IR, just before rendering.

Use beforeParse to rewrite raw text, afterParse to manipulate Markdown nodes (this is where @docvia/plugin-openapi does its work), and afterTransform / beforeRender to act on the framework-agnostic IR. Two first-party plugins work on the IR: @docvia/plugin-shiki bakes highlighted HTML onto code-block nodes, and @docvia/plugin-mermaid rewrites diagram fences into component nodes.

Execution order

Plugins are sorted by phase, in the order pre, normal, post, and within a phase by ascending priority. A failure inside any hook is wrapped in a docviaError with code PLUGIN_ERROR, naming the plugin and the hook, so a broken plugin never produces a mystery stack trace.

flowchart LR
  subgraph pre ["phase: pre"]
    M["@docvia/plugin-mermaid<br/>claims ```mermaid fences"]
  end
  subgraph normal ["phase: normal"]
    N["your plugins"]
  end
  subgraph post ["phase: post"]
    S["@docvia/plugin-shiki<br/>highlights what is left"]
  end
  M --> N --> S
Ordering two plugins that both touch code blocks

A minimal plugin

import type { docviaPlugin } from "@docvia/ir";

export function upperTitles(): docviaPlugin {
  return {
    name: "upper-titles",
    version: "1.0.0",
    phase: "post",
    afterTransform: (doc) => ({
      ...doc,
      frontmatter: {
        ...doc.frontmatter,
        title: doc.frontmatter.title.toUpperCase(),
      },
    }),
  };
}

Register it in your config:

import { defineConfig } from "@docvia/cli";
import { upperTitles } from "./plugins/upper-titles";

export default defineConfig({
  plugins: [upperTitles()],
  // ...
});

Rendering custom content

A plugin that wants to draw something the Markdown grammar has no syntax for can rewrite a node into a component node. The renderer then resolves that name against the ComponentRegistry your app passes in, which keeps the drawing library out of the compiler and out of the server bundle. @docvia/plugin-mermaid is the reference implementation:

if (node.type === "code-block" && node.props.lang === "mermaid") {
  return {
    type: "component",
    id: node.id,
    props: {
      name: "Mermaid",
      hydrate: "none",
      attributes: { code: String(node.props.value ?? "").trim() },
    },
    children: [],
  };
}

Caching

If your plugin depends on an external input, such as a spec file, an environment variable, or a remote source, implement cacheKey(). Its return value is folded into the build cache key, so when the input changes every affected page is rebuilt. When you omit it, docvia falls back to name@version. See Incremental builds for how the cache key drives rebuilds.