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:
{
"plugins": [
"@docubook/plugin-sitemap",
["@docubook/plugin-analytics", { "id": "G-XXXXXXX" }],
"./plugins/reading-time"
]
}
| Format | Description |
|---|
"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:
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:
import type { DocuBookPlugin } from "@docubook/flame";
const plugin: DocuBookPlugin = {
name: "reading-time",
setup(build) {
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:
import type { DocuBookPlugin } from "@docubook/flame";
interface AnalyticsOptions {
id: string;
domain?: string;
}
export default function pluginAnalytics(
opts?: AnalyticsOptions
): DocuBookPlugin {
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:
{
"plugins": [
["./plugins/analytics", { "id": "G-12345678" }]
]
}
- 4
Step 5: Managing Plugin State
Plugins can maintain internal state using the setup() closure:
export default function pluginCounter(): DocuBookPlugin {
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:
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"
);
}
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()) {
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:
import { describe, it, expect, beforeEach } from "vitest";
import { BuildPluginBuilder } from "@docubook/flame/.docu/node/plugin-builder";
import readingTimePlugin from "../reading-time";
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:
npx vitest run plugins/__tests__/
- 7
Step 8: Using Multiple Hooks
A single plugin can register multiple hooks at once:
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 }) => {
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.
{
"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:
| Hook | Error Behavior |
|---|
onStart | Log + continue to next callback |
onEnd | Log + continue to next callback |
onLoad | Log + try next matching handler |
transformFrontmatter | Log + frontmatter unchanged |
transformHtml | Log + HTML unchanged |
handleRequest | Log + continue to next handler |
injectHead / injectBody / remark / rehype | Throw - 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.
build.onLoad({ filter: /\.mdx$/ }, () => {
throw new Error("Something went wrong");
});
Hook Reference
onStart(config)
Called once before the build. Use for config validation or resource initialization.
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.
build.onEnd((config, pages) => {
console.log(`Built ${pages.length} pages`);
});
onLoad(, callback)
Transform raw file content before MDX compilation. Bun's build.onLoad() convention.
build.onLoad({ filter: /\.md$/ }, ({ path, content }) => {
return { contents: `<!-- auto-generated -->\n\n${content}` };
});
Mutate frontmatter before MDX compilation. Return a new object or void.
build.transformFrontmatter((fm, ctx) => {
const minutes = Math.max(1, Math.ceil((ctx.content ?? "").split(/\s+/).length / 200));
return { ...fm, readingTime: `${minutes} min read` };
});
Add remark or rehype plugins to the MDX pipeline. Merged after the default set.
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.
build.injectHead(() =>
`<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script>`
);
injectBody(ctx)
Return HTML strings to inject before </body>.
build.injectBody(() =>
`<script>console.log("DocuBook loaded");</script>`
);
Transform the final HTML string per page - the last hook before writing to disk.
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.
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
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
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;
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);
});
},
};
}
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 Source | Risk Level | Notes |
|---|
Official @docubook/plugin-* | Low | Published and maintained by DocuBook team |
| Third-party npm package | High | Arbitrary code execution at build/dev time. Vet before installing. |
Local path (./plugins/...) | Medium | Requires write access to project; review code in PR |
Path Resolution Security
./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
import type {
DocuBookPlugin,
PluginBuilder,
PageContext,
PageMeta,
PluginEntry,
} from "@docubook/flame";
All types are exported from the @docubook/flame package entry point.