Skip to content
Packages

@docvia/plugins

The plugin runtime and config layer: plugin ordering, lifecycle hook execution, and defineConfig / loadConfig.

@docvia/plugins is the plugin runtime and configuration layer of docvia. It does two jobs. First, it validates and orders the configured plugins, then runs their lifecycle hooks during a build, wrapping any failure in a docviaError that names the offending plugin. Second, it provides the configuration helpers: defineConfig for authoring a config and loadConfig for loading one from disk.

The package depends on @docvia/ir (for the plugin and config contracts and the error class) and jiti (to import TypeScript config files at runtime without a separate build step).

Installation

pnpm add @docvia/plugins

Requires Node.js >=20.0.0. ESM only.

Exports

@docvia/plugins exposes a single entry point.

SubpathModuleContents
../dist/index.mjsresolvePlugins, the PluginRunner class, defineConfig, and loadConfig.
import {
  resolvePlugins,
  PluginRunner,
  defineConfig,
  loadConfig,
} from "@docvia/plugins";

Plugin resolution

resolvePlugins

function resolvePlugins(
  plugins: readonly docviaPlugin[],
): readonly docviaPlugin[]

Validates a plugin list and returns it sorted into execution order.

Validation. Every plugin is checked, and a docviaError with code PLUGIN_ERROR is thrown if any of the following holds:

ConditionError message
A plugin has no namePlugin missing name
A plugin has no versionPlugin "<name>" missing version
Two plugins share a nameDuplicate plugin: "<name>"

Ordering. Valid plugins are sorted by phase first, then by priority:

  1. Phase. pre plugins run before normal, which run before post. A plugin with no phase is treated as normal.
  2. Priority. Within a phase, plugins are sorted by ascending priority; lower runs first. A plugin with no priority is treated as 100.

The input array is not mutated; a sorted copy is returned.

The plugin runner

PluginRunner

class PluginRunner {
  constructor(plugins: readonly docviaPlugin[]);
  runBeforeParse(file: FileEntry): Promise<FileEntry>;
  runAfterParse(ast: unknown, file: FileEntry): Promise<unknown>;
  runBeforeTransform(ast: unknown, meta: FrontmatterData): Promise<unknown>;
  runAfterTransform(doc: IRDocument): Promise<IRDocument>;
  runBeforeRender(doc: IRDocument): Promise<IRDocument>;
  getPluginCacheKeys(): string[];
}

The PluginRunner drives the plugin lifecycle for a build. The constructor passes the plugin list through resolvePlugins, so a runner always holds validated, correctly ordered plugins.

Each run* method corresponds to one lifecycle hook. For a given hook, the runner iterates the resolved plugins in order; for each plugin that implements that hook, it feeds the output of the previous plugin into the next. The hooks form a transformation chain, so every plugin gets the chance to inspect or rewrite the value as it flows through.

MethodHookInput → output
runBeforeParsebeforeParseFileEntry → FileEntry
runAfterParseafterParseparsed AST → AST
runBeforeTransformbeforeTransformAST (with FrontmatterData) → AST
runAfterTransformafterTransformIRDocument → IRDocument
runBeforeRenderbeforeRenderIRDocument → IRDocument

Error wrapping. Every hook call is wrapped. If a hook throws something that is already a docviaError, it propagates unchanged. Anything else is re-wrapped as a docviaError with code PLUGIN_ERROR and a message of the form Plugin "<name>@<version>" failed in <hook>: <reason>, with the original error attached as cause. For the file-bound hooks (beforeParse, afterParse) the file path is attached too.

getPluginCacheKeys

getPluginCacheKeys(): string[]

Returns one cache key per plugin, in resolved order. If a plugin implements cacheKey(), its return value is used; otherwise the key defaults to <name>@<version>. The compiler folds these keys into the build cache so changing a plugin (or its cacheKey() output) invalidates affected entries.

Configuration helpers

defineConfig

function defineConfig(config: Partial<docviaConfig>): docviaConfig

Takes a partial config and returns a fully resolved docviaConfig, filling in every default. This is the function users call in their docvia.config.ts.

FieldDefault
sourceDir"docs"
outDir".docvia"
plugins[]
rendererundefined (passed through)
componentsundefined (passed through)
collectionsundefined (passed through)
frontmatterundefined (passed through)
markdown.remarkPlugins[]
syntax.highlighter"shiki"
syntax.theme"github-dark"
syntax.langs["javascript", "typescript", "bash", "json", "css", "html", "svelte"]
theme.name"default"
theme.options{}

loadConfig

function loadConfig(configPath: string): Promise<docviaConfig>

Loads a config file from disk and returns a resolved docviaConfig. The steps:

  1. Resolves configPath to an absolute path.
  2. Imports the module with jiti, so a TypeScript config file runs directly with no separate compile step. The jiti instance is created with moduleCache: false and fsCache: false so config changes are always picked up.
  3. Unwraps a default export if present (the typical export default defineConfig({...}) form), otherwise uses the module itself.
  4. Passes the result through defineConfig to apply defaults.

Failure modes, both of which throw a docviaError with code CONFIG_ERROR:

ConditionBehavior
The import itself failsError message Failed to load config: <path> plus the underlying message; the original error is attached as cause.
The module does not export an objectError message noting the actual type and suggesting export default defineConfig({...}).

Writing a plugin

A plugin is a plain object satisfying the docviaPlugin interface from @docvia/ir. Only name and version are required; implement just the hooks you need.

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

export function readingTimePlugin(): docviaPlugin {
  return {
    name: "reading-time",
    version: "1.0.0",
    phase: "post",
    priority: 50,
    cacheKey() {
      // Bump this string to invalidate cached pages when the plugin changes.
      return "reading-time@1.0.0";
    },
    afterTransform(doc) {
      const words = JSON.stringify(doc.children).split(/\s+/).length;
      return {
        ...doc,
        frontmatter: {
          ...doc.frontmatter,
          readingMinutes: Math.ceil(words / 200),
        },
      };
    },
  };
}

Usage example

Authoring a config and running plugins through a PluginRunner:

import { defineConfig, loadConfig, PluginRunner } from "@docvia/plugins";
import { readingTimePlugin } from "./plugins/reading-time";

// Author a config inline...
export default defineConfig({
  sourceDir: "docs",
  plugins: [readingTimePlugin()],
  syntax: { highlighter: "shiki", theme: "github-dark", langs: ["ts", "bash"] },
});

// ...or load one from disk.
const config = await loadConfig("./docvia.config.ts");

// Drive the plugin lifecycle (this is what @docvia/compiler does internally).
const runner = new PluginRunner(config.plugins);
const cacheKeys = runner.getPluginCacheKeys();
const file = await runner.runBeforeParse({
  path: "/abs/docs/intro.md",
  relativePath: "intro.md",
  content: rawSource,
  hash: fileHash,
});