Plugins

Extend DocuBook with hooks for sitemaps, analytics, custom transforms, and more

DocuBook's plugin system extends the build pipeline and dev server without forking internals. Plugins are optional - zero plugins = zero behavior change.

Configuration

Add a plugins array to docu.json:

json
{
  "plugins": [
    "@docubook/plugin-sitemap",
    ["@docubook/plugin-analytics", { "id": "G-XXXXXXX" }],
    "./plugins/reading-time"
  ]
}
FormatDescription
"string"Plugin name, npm package, or relative path
["string", { ... }]Plugin name + factory options

Resolution: relative path (starts with .) → project root, absolute → as-is, else → npm package.

Creating a Plugin

  1. Step 1: Project Setup

    Create a plugin file in your project directory, for example plugins/reading-time.ts:

    text
    project-root/
    ├── docs/
    ├── docu.json
    ├── plugins/
    │   ├── reading-time.ts
    │   └── sitemap.ts
    └── package.json
    

    A plugin can be stored as a local file in your plugins/ folder, or published as a separate package on npm under @docubook/plugin-* or docubook-plugin-*.

  2. Step 2: Basic Plugin Structure

    Every plugin must have a unique name and a setup(build) function:

    typescript
    // plugins/reading-time.ts
    import type { DocuBookPlugin } from "@docubook/flame";
    
    const plugin: DocuBookPlugin = {
      name: "reading-time",
      setup(build) {
        // Register hooks here
        build.onStart((config) => {
          console.log(`[${plugin.name}] Build started`);
        });
      },
    };
    
    export default plugin;
    
  3. Step 4: Using Factory Options

    If your plugin needs configuration via docu.json, use a factory function:

    typescript
    // plugins/analytics.ts
    import type { DocuBookPlugin } from "@docubook/flame";
    
    interface AnalyticsOptions {
      id: string;
      domain?: string;
    }
    
    export default function pluginAnalytics(
      opts?: AnalyticsOptions
    ): DocuBookPlugin {
      // Throw early if required option is missing
      if (!opts?.id) {
        throw new Error(
          "[plugin:analytics] `id` is required. " +
          'Example: ["@docubook/plugin-analytics", { id: "G-XXXXXXX" }]'
        );
      }
    
      return {
        name: "analytics",
        setup(build) {
          build.injectHead(() => {
            return `
    <script async src="https://www.googletagmanager.com/gtag/js?id=${opts.id}"></script>
    <script>
      window.dataLayer = window.dataLayer || [];
      function gtag(){ dataLayer.push(arguments); }
      gtag('js', new Date());
      gtag('config', '${opts.id}');
    </script>`;
          });
        },
      };
    }
    

    Then register it in docu.json:

    json
    {
      "plugins": [
        ["./plugins/analytics", { "id": "G-12345678" }]
      ]
    }
    
  4. Step 5: Managing Plugin State

    Plugins can maintain internal state using the setup() closure:

    typescript
    export default function pluginCounter(): DocuBookPlugin {
      // Internal state - persists across build/dev session
      let pageCount = 0;
      const startTime = Date.now();
    
      return {
        name: "counter",
        setup(build) {
          build.onEnd((_config, pages) => {
            pageCount = pages.length;
            const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
            console.log(`[counter] Built ${pageCount} pages in ${elapsed}s`);
          });
    
          build.onStart(() => {
            pageCount = 0;
          });
        },
      };
    }
    
  5. Step 6: Error Handling

    Don't throw raw errors - use clear messages with a [plugin:name] prefix:

    typescript
    export default function pluginValidator(): DocuBookPlugin {
      return {
        name: "validator",
        setup(build) {
          build.onStart((config) => {
            if (!config.meta?.baseURL) {
              throw new Error(
                "[plugin:validator] `meta.baseURL` is required in docu.json"
              );
            }
            // Validate URL
            try {
              new URL(config.meta.baseURL);
            } catch {
              throw new Error(
                "[plugin:validator] `meta.baseURL` is not a valid URL: " +
                config.meta.baseURL
              );
            }
          });
    
          build.onLoad({ filter: /\.mdx$/ }, ({ path, content }) => {
            if (!content.trim()) {
              // Just a warning, don't throw
              console.warn(`[plugin:validator] Empty file: ${path}`);
            }
          });
        },
      };
    }
    

    See Error Isolation for how errors behave in each hook.

  6. Step 7: Testing Plugins

    Use the available test helpers:

    typescript
    // plugins/__tests__/reading-time.test.ts
    import { describe, it, expect, beforeEach } from "vitest";
    import { BuildPluginBuilder } from "@docubook/flame/.docu/node/plugin-builder";
    import readingTimePlugin from "../reading-time";
    
    // Create a minimal config for testing
    function createTestConfig() {
      return {
        meta: { title: "Test", description: "", baseURL: "https://test.dev" },
        navbar: { logoText: "Test", menu: [] },
        footer: { social: [] },
        repo: { url: "", path: "", edit: false },
        routes: [],
      };
    }
    
    describe("reading-time plugin", () => {
      let builder: BuildPluginBuilder;
    
      beforeEach(() => {
        builder = new BuildPluginBuilder(createTestConfig() as any);
        readingTimePlugin.setup(builder);
      });
    
      it("adds readingTime to frontmatter", async () => {
        const result = await builder.runTransformFrontmatterChain(
          {},
          { slug: "test", filePath: "test.mdx", content: "a ".repeat(400) }
        );
        expect(result).toHaveProperty("readingTime");
        expect(result.readingTime).toMatch(/\d+ min read/);
      });
    
      it("does not modify title when content is empty", async () => {
        const result = await builder.runTransformFrontmatterChain(
          { title: "Test" },
          { slug: "test", filePath: "test.mdx", content: "" }
        );
        expect(result).toHaveProperty("title", "Test");
        expect(result).toHaveProperty("readingTime");
      });
    });
    

    Run the tests:

    bash
    npx vitest run plugins/__tests__/
    
  7. Step 8: Using Multiple Hooks

    A single plugin can register multiple hooks at once:

    typescript
    export default function pluginComplete(): DocuBookPlugin {
      return {
        name: "complete",
        setup(build) {
          build.onStart((config) => {
            console.log(`Building for ${config.meta.title}`);
          });
    
          build.onLoad({ filter: /\.md$/ }, ({ content }) => {
            // Convert .md to custom MDX format
            const enhanced = `import { CustomComponent } from "./components";\n\n${content}`;
            return { contents: enhanced, loader: "mdx" };
          });
    
          build.transformFrontmatter((fm) => ({
            ...fm,
            processed: true,
            generatedAt: new Date().toISOString(),
          }));
    
          build.remarkPlugins(() => [remarkCustomId]);
    
          build.injectHead(() => `<meta name="generator" content="docubook-flame">`);
    
          build.transformHtml((html) => html.replace(/\bTODO\b/g, ""));
    
          build.onEnd((_config, pages) => {
            console.log(`Done: ${pages.length} pages`);
          });
        },
      };
    }
    
  8. Step 9: Publishing a Plugin

    To publish a plugin to npm, choose a namespace@docubook/plugin-* for official plugins or docubook-plugin-* for community ones. The entry point must export a default DocuBookPlugin or factory function. Add @docubook/flame as a peer dependency and include a README with a docu.json configuration example.

    json
    // package.json for npm plugin
    {
      "name": "@docubook/plugin-sitemap",
      "version": "1.0.0",
      "type": "module",
      "main": "./dist/index.js",
      "types": "./dist/index.d.ts",
      "peerDependencies": {
        "@docubook/flame": "^1.0.0"
      },
      "files": ["dist"]
    }
    

    For the full implementation example, see the sitemap example below.

Error Isolation

DocuBook Flame applies catch & log + continue across all execution hooks to prevent a single plugin error from stopping the build:

HookError Behavior
onStartLog + continue to next callback
onEndLog + continue to next callback
onLoadLog + try next matching handler
transformFrontmatterLog + frontmatter unchanged
transformHtmlLog + HTML unchanged
handleRequestLog + continue to next handler
injectHead / injectBody / remark / rehypeThrow - developer error

In other words: your plugin can error without stopping anyone else's build. Errors are still visible in the console - so developers can debug without losing build output.

typescript
// Example: a failing plugin - error is logged, build continues
build.onLoad({ filter: /\.mdx$/ }, () => {
  throw new Error("Something went wrong");
});
// Subsequent plugins still run, build does not crash

Hook Reference

onStart(config)

Called once before the build. Use for config validation or resource initialization.

typescript
build.onStart((config) => {
  if (!config.meta.baseURL) throw new Error("baseURL is required");
});

onEnd(config, pages)

Called after all pages are built. Generate sitemaps, RSS feeds, or manifests.

typescript
build.onEnd((config, pages) => {
  // pages: { slug, title, filePath, outputPath }[]
  console.log(`Built ${pages.length} pages`);
});

onLoad(, callback)

Transform raw file content before MDX compilation. Bun's build.onLoad() convention.

typescript
build.onLoad({ filter: /\.md$/ }, ({ path, content }) => {
  return { contents: `<!-- auto-generated -->\n\n${content}` };
});

transformFrontmatter(fm, ctx)

Mutate frontmatter before MDX compilation. Return a new object or void.

typescript
build.transformFrontmatter((fm, ctx) => {
  const minutes = Math.max(1, Math.ceil((ctx.content ?? "").split(/\s+/).length / 200));
  return { ...fm, readingTime: `${minutes} min read` };
});

remarkPlugins() / rehypePlugins()

Add remark or rehype plugins to the MDX pipeline. Merged after the default set.

typescript
build.remarkPlugins(() => [require("remark-custom-heading-id")]);
build.rehypePlugins(() => [require("rehype-autolink-headings")]);

injectHead(ctx)

Return HTML strings to inject inside <head>. Results from all plugins are merged and deduplicated.

typescript
build.injectHead(() =>
  `<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script>`
);

injectBody(ctx)

Return HTML strings to inject before </body>.

typescript
build.injectBody(() =>
  `<script>console.log("DocuBook loaded");</script>`
);

transformHtml(html, ctx)

Transform the final HTML string per page - the last hook before writing to disk.

typescript
build.transformHtml((html, ctx) =>
  html.replace(/https?:\/\/old-domain\.com\//g, "/")
);

handleRequest(req, ctx)

Intercept incoming dev server requests. First plugin to return a Response short-circuits.

typescript
build.handleRequest((req, ctx) => {
  const url = new URL(req.url);
  if (url.pathname === "/api/status") {
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  }
});

Lifecycle

text
build()
├─ loadPlugins → plugin.setup(build)        ← hooks registered
├─ onStart(config)                          ← validate
├─ for each MDX file:
│   ├── onLoad({filter})                    ← transform raw content
│   ├── transformFrontmatter()              ← mutate frontmatter
│   ├── compileMdx() ← remark / rehype      ← MDX pipeline
│   ├── injectHead() / injectBody()         ← inject HTML
│   └── transformHtml()                     ← final transform
├─ onEnd(config, pages)                     ← sitemap, RSS, etc.
└─ write output

Execution is sequential (waterfall). Plugins run in docu.json order. handleRequest is first-wins. Errors fail-fast with the plugin name.

Example Plugins

typescript
// plugins/reading-time.ts
import type { DocuBookPlugin } from "@docubook/flame";

export default {
  name: "reading-time",
  setup(build) {
    build.transformFrontmatter((fm, ctx) => {
      const minutes = Math.ceil((ctx.content ?? "").split(/\s+/).length / 200);
      return { ...fm, readingTime: `${minutes} min read` };
    });
  },
} satisfies DocuBookPlugin;
typescript
// plugins/sitemap.ts
import { join } from "node:path";
import type { DocuBookPlugin } from "@docubook/flame";

export default function pluginSitemap(opts?: { hostname?: string }): DocuBookPlugin {
  return {
    name: "sitemap",
    setup(build) {
      build.onEnd(async (config, pages) => {
        const host = opts?.hostname || config.meta.baseURL;
        const urls = pages.map(
          (p) => `<url><loc>${host}/docs/${p.slug}</loc></url>`
        );
        const xml = `<?xml version="1.0"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>`;
        await Bun.write(join(".docu/dist", "sitemap.xml"), xml);
      });
    },
  };
}
typescript
// plugins/analytics.ts
import type { DocuBookPlugin } from "@docubook/flame";

export default function pluginAnalytics(opts: { id: string }): DocuBookPlugin {
  return {
    name: "analytics",
    setup(build) {
      build.injectHead(() =>
        `<script async src="https://www.googletagmanager.com/gtag/js?id=${opts.id}"></script>`
      );
      build.injectBody(() =>
        `<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${opts.id}');</script>`
      );
    },
  };
}

Security & Trust Model

Plugin hooks run with full system privileges - same as the user running bun dev.

Plugin SourceRisk LevelNotes
Official @docubook/plugin-*LowPublished and maintained by DocuBook team
Third-party npm packageHighArbitrary code execution at build/dev time. Vet before installing.
Local path (./plugins/...)MediumRequires write access to project; review code in PR

Path Resolution Security

text
./plugins/my-plugin.ts     ✅ Allowed (within project root)
../malicious.js            ❌ Blocked (path traversal guard)
/usr/lib/evil.mjs          ❌ Blocked (outside project root)
@docubook/plugin-sitemap   ✅ Allowed (npm package, bypasses guard)

The plugin loader rejects any specifier that resolves outside the project root. Absolute paths and .. traversal are blocked at load time.

Best Practices

  • Pin plugin versions in package.json - never use ranges for plugins
  • Review plugin source before adding to docu.json
  • Official plugins use the @docubook/plugin-* namespace - verify the package owner on npm
  • Run bun dev with least privilege - avoid running as root
  • CI/CD - validate docu.json against unexpected plugin entries

Plugins have the same trust model as devDependencies. Only install plugins from sources you trust, same as you would with any npm package.

TypeScript Types

typescript
import type {
  DocuBookPlugin,
  PluginBuilder,
  PageContext,
  PageMeta,
  PluginEntry,
} from "@docubook/flame";

All types are exported from the @docubook/flame package entry point.

Last updated Jul 4, 2026