import type { Pluggable } from "unified"; import type { BuildEndContext, DevServerContext, DocuConfig, PageContext, PageMeta, PluginBuilder, } from "./plugin"; type Awaitable = T | Promise; interface OnLoadHandler { filter: RegExp; namespace?: string; fn: (args: { path: string; content: string; }) => Awaitable<{ contents?: string; loader?: "js" | "ts" | "mdx" } | void>; } export class BuildPluginBuilder implements PluginBuilder { readonly config: DocuConfig; private _handleRequest: Array< (req: Request, context: DevServerContext) => Awaitable > = []; private _injectBody: Array<(context: PageContext) => string | string[]> = []; private _injectHead: Array<(context: PageContext) => string | string[]> = []; private _onEnd: Array< (config: DocuConfig, pages: PageMeta[], context: BuildEndContext) => Awaitable > = []; private _onLoad: OnLoadHandler[] = []; private _onStart: Array<(config: DocuConfig) => Awaitable> = []; private _rehypePlugins: Array<() => Pluggable[]> = []; private _remarkPlugins: Array<() => Pluggable[]> = []; private _transformFrontmatter: Array< ( frontmatter: Record, context: Pick ) => Awaitable | void> > = []; private _transformHtml: Array<(html: string, context: PageContext) => Awaitable> = []; constructor(config: DocuConfig) { this.config = config; } /** * Collect and deduplicate all `` injection snippets from registered plugins. * Each callback is executed in registration order; plugin errors are wrapped * with a descriptive message. * * @param context - Current page context passed to each injectBody callback. * @returns Deduplicated array of HTML strings to inject before ``. * @throws Error if any injectBody callback throws — wraps original error as cause. */ collectBody(context: PageContext): string[] { const items: string[] = []; for (const cb of this._injectBody) { try { const result = cb(context); if (result) { this.collectItems(items, result, "injectBody"); } } catch (err) { throw new Error( `[plugin] injectBody callback failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err } ); } } return [...new Set(items)]; } /** * Collect and deduplicate all `` injection snippets from registered plugins. * Each callback is executed in registration order; plugin errors are wrapped * with a descriptive message. * * @param context - Current page context passed to each injectHead callback. * @returns Deduplicated array of HTML strings to inject before ``. * @throws Error if any injectHead callback throws — wraps original error as cause. */ collectHead(context: PageContext): string[] { const items: string[] = []; for (const cb of this._injectHead) { try { const result = cb(context); if (result) { this.collectItems(items, result, "injectHead"); } } catch (err) { throw new Error( `[plugin] injectHead callback failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err } ); } } return [...new Set(items)]; } /** * Collect all rehype plugin arrays from registered rehypePlugins callbacks. * Results from all plugins are flattened into a single array. * * @returns Flattened array of rehype plugin instances applied after default set. * @throws Error if any rehypePlugins callback throws — wraps original error as cause. */ collectRehypePlugins(): Pluggable[] { const plugins: Pluggable[] = []; for (const cb of this._rehypePlugins) { try { plugins.push(...cb()); } catch (err) { throw new Error( `[plugin] rehypePlugins callback failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err } ); } } return plugins; } /** * Collect all remark plugin arrays from registered remarkPlugins callbacks. * Results from all plugins are flattened into a single array. * * @returns Flattened array of remark plugin instances applied after default set. * @throws Error if any remarkPlugins callback throws — wraps original error as cause. */ collectRemarkPlugins(): Pluggable[] { const plugins: Pluggable[] = []; for (const cb of this._remarkPlugins) { try { plugins.push(...cb()); } catch (err) { throw new Error( `[plugin] remarkPlugins callback failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err } ); } } return plugins; } /** * Register a callback to intercept incoming requests during development. * The **first** callback to return a `Response` short-circuits all subsequent handlers. * Errors inside callbacks are caught and logged — execution continues to next handler. * * @param callback - Receives the Request and dev server context. Return Response or void. * * @example * build.handleRequest((req, ctx) => { * if (new URL(req.url).pathname === "/api/status") { * return new Response(JSON.stringify({ ok: true }), { * headers: { "Content-Type": "application/json" }, * }); * } * }); */ handleRequest( callback: (req: Request, context: DevServerContext) => Awaitable ): void { this._handleRequest.push(callback); } /** * Register a callback that returns HTML strings to inject before ``. * Results from all plugins are merged, deduplicated, and served via `collectBody()`. * * @param callback - Returns a single HTML string or an array. Called once per page. * * @example * build.injectBody(() => `
`); */ injectBody(callback: (context: PageContext) => string | string[]): void { this._injectBody.push(callback); } /** * Register a callback that returns HTML strings to inject inside ``. * Results from all plugins are merged, deduplicated, and served via `collectHead()`. * * @param callback - Returns a single HTML string or an array. Called once per page. * * @example * build.injectHead(() => ``); */ injectHead(callback: (context: PageContext) => string | string[]): void { this._injectHead.push(callback); } /** * Register a callback to run once after all pages are built. * Receives the resolved config, aggregated docs metadata, and final build metadata. * Callback errors are logged by `runOnEnd()` and do not stop later callbacks. * * @param callback - Receives config, page metadata, and emitted assets. May return a Promise. * * @example * build.onEnd(async (config, pages, { outDir }) => { * const xml = generateSitemap(pages, config.meta.baseURL); * const out = join(outDir, "sitemap.xml"); * // Bun.write on Bun for speed, writeFile on Node/Deno * await (typeof Bun !== "undefined" * ? Bun.write(out, xml) * : writeFile(out, xml)); * }); */ onEnd( callback: (config: DocuConfig, pages: PageMeta[], context: BuildEndContext) => Awaitable ): void { this._onEnd.push(callback); } /** * Register a callback to transform raw file content before MDX compilation. * Filtered by regex against the file's relative path — only the **first** matching * handler's result is used. * Callback errors are logged by `runOnLoad()` before trying later matching handlers. * * @param args.filter - RegExp matched against the file's relative path. * @param args.namespace - Optional namespace prefix (reserved for future use). * @param callback - Receives file path and raw content. Return new contents or void. * * @example * build.onLoad({ filter: /\.md$/ }, ({ path, content }) => { * return { contents: `\n${content}`, loader: "mdx" }; * }); */ onLoad( args: { filter: RegExp; namespace?: string }, callback: (args: { path: string; content: string; }) => Awaitable<{ contents?: string; loader?: "js" | "ts" | "mdx" } | void> ): void { this._onLoad.push({ ...args, fn: callback }); } /** * Register a callback to run once before the build starts. * Receives the resolved DocuConfig for validation or resource initialization. * Callback errors are logged by `runOnStart()` and do not stop later callbacks. * * @param callback - Receives the resolved config. May return a Promise. * * @example * build.onStart((config) => { * if (!config.meta.baseURL) throw new Error("baseURL required"); * }); */ onStart(callback: (config: DocuConfig) => Awaitable): void { this._onStart.push(callback); } /** * Register additional rehype (HTML) plugins for the MDX compilation pipeline. * Results from all plugins are merged and applied **after** the default set. * * @param callback - Returns an array of rehype plugins. * * @example * build.rehypePlugins(() => [require("rehype-autolink-headings")]); */ rehypePlugins(callback: () => Pluggable[]): void { this._rehypePlugins.push(callback); } /** * Register additional remark (Markdown) plugins for the MDX compilation pipeline. * Results from all plugins are merged and applied **after** the default set. * * @param callback - Returns an array of remark plugins. * * @example * build.remarkPlugins(() => [require("remark-custom-heading-id")]); */ remarkPlugins(callback: () => Pluggable[]): void { this._remarkPlugins.push(callback); } /** * Execute all registered handleRequest callbacks sequentially. * Stops and returns the **first** `Response` returned by any callback. * Errors inside individual callbacks are caught and logged — execution * continues to the next callback without throwing. * * @param req - The incoming HTTP Request. * @param context - Dev server context (port, hostname). * @returns A Response if a callback intercepted the request, or null if none did. */ async runHandleRequest(req: Request, context: DevServerContext): Promise { for (let i = 0; i < this._handleRequest.length; i++) { try { const result = await this._handleRequest[i](req, context); if (result instanceof Response) { return result; } } catch (err) { console.error( `[plugin] handleRequest callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}` ); } } return null; } /** * Execute all registered onEnd callbacks sequentially with the resolved * config and aggregated page metadata. * Errors inside individual callbacks are caught and logged — execution * continues to the next callback without throwing. * * @param pages - Array of metadata for every built docs page. * @param context - Final output directory and page-specific asset manifest. */ async runOnEnd(pages: PageMeta[], context: BuildEndContext): Promise { for (let i = 0; i < this._onEnd.length; i++) { try { await this._onEnd[i](this.config, pages, context); } catch (err) { console.error( `[plugin] onEnd callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}` ); } } } /** * Execute registered onLoad handlers in registration order against a file. * Only the **first** handler whose `filter` regex matches the path and returns * a result is applied. If a matching handler throws, the error is logged and * subsequent handlers are tried. * * @param path - Relative path of the file being loaded. * @param content - Raw file content. * @returns Transformed content if a matching handler returned it, or null. */ async runOnLoad( path: string, content: string ): Promise<{ contents?: string; loader?: "js" | "ts" | "mdx" } | null> { for (const handler of this._onLoad) { if (handler.filter.test(path)) { try { const result = await handler.fn({ path, content }); if (result) return result; } catch (err) { console.error( `[plugin] onLoad handler for filter ${handler.filter} error: ${err instanceof Error ? err.message : String(err)}` ); } } } return null; } /** * Execute all registered onStart callbacks sequentially. * Each callback receives the resolved DocuConfig. * Errors inside individual callbacks are caught and logged — execution * continues to the next callback without throwing. */ async runOnStart(): Promise { for (let i = 0; i < this._onStart.length; i++) { try { await this._onStart[i](this.config); } catch (err) { console.error( `[plugin] onStart callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}` ); } } } /** * Execute the transformFrontmatter chain in waterfall pattern. * Each callback receives the **previous** callback's return value (or the * original frontmatter for the first). Callbacks that return `undefined` or * `null` pass the current value through unchanged. * Callbacks that return a non-object (string, number, array) are skipped * with a console warning — only plain objects are accepted. * Errors inside individual callbacks are caught and logged — the current * frontmatter passes through unchanged for that step. * * @param frontmatter - Initial frontmatter object parsed from MDX. * @param context - Page context with slug, filePath, and raw content. * @returns The final transformed frontmatter object. */ async runTransformFrontmatterChain( frontmatter: Record, context: Pick ): Promise> { let result = frontmatter; for (let i = 0; i < this._transformFrontmatter.length; i++) { try { const next = await this._transformFrontmatter[i](result, context); if (next !== undefined && next !== null) { if (typeof next === "object" && !Array.isArray(next)) { result = next; } else { console.warn( `[plugin] transformFrontmatter callback #${i + 1} returned invalid type (expected a plain object), skipping` ); } } } catch (err) { console.error( `[plugin] transformFrontmatter callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}` ); } } return result; } /** * Execute the transformHtml chain in pipeline pattern. * Each callback receives the **previous** callback's return value (or the * original HTML for the first). Every callback **must** return a string. * Callbacks returning a non-string (e.g. `undefined`) are skipped with a * warning — the current HTML passes through unchanged for that step. * Errors inside individual callbacks are caught and logged — the current * HTML passes through unchanged for that step. * * @param html - The initial HTML string. * @param context - Full page context (slug, filePath, frontmatter, content, config). * @returns The final transformed HTML string. */ async runTransformHtmlChain(html: string, context: PageContext): Promise { let result = html; for (let i = 0; i < this._transformHtml.length; i++) { try { const next = await this._transformHtml[i](result, context); if (typeof next === "string") { result = next; } else { console.warn( `[plugin] transformHtml callback #${i + 1} returned invalid type (expected a string), keeping previous HTML` ); } } catch (err) { console.error( `[plugin] transformHtml callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}` ); } } return result; } /** * Register a callback to mutate frontmatter before MDX compilation. * Callbacks are chained in a waterfall: the return value of one is passed * as input to the next. Return `undefined` to pass through unchanged. * * **Note:** Only plain objects are accepted as return values. Returning * a string, number, or array will be silently skipped with a warning. * Plugin authors should validate their return values before returning. * * @param callback - Receives frontmatter object and page context. * * @example * build.transformFrontmatter((fm, ctx) => { * const wordCount = ctx.content!.split(/\s+/).length; * return { ...fm, readingTime: `${Math.ceil(wordCount / 200)} min read` }; * }); */ transformFrontmatter( callback: ( frontmatter: Record, context: Pick ) => Awaitable | void> ): void { this._transformFrontmatter.push(callback); } /** * Register a callback to transform the final HTML string per page. * This is the **last** hook before the HTML is written to disk. * Callbacks are chained in a pipeline: each receives the previous callback's output. * * @param callback - Receives HTML string and full page context. Must return HTML. * * @example * build.transformHtml((html, ctx) => { * return html.replace(/https?:\/\/old-domain\.com\//g, "/"); * }); */ transformHtml(callback: (html: string, context: PageContext) => Awaitable): void { this._transformHtml.push(callback); } /** * Collect items from a callback result, filtering only valid strings. * Non-string items and unexpected types are logged as warnings. */ private collectItems(items: string[], result: string | string[], hookName: string): void { if (Array.isArray(result)) { for (const item of result) { if (typeof item === "string") { items.push(item); } else { console.warn( `[plugin] ${hookName} callback returned non-string item (got ${typeof item}), skipping` ); } } } else if (typeof result === "string") { items.push(result); } else { console.warn( `[plugin] ${hookName} callback returned unexpected type (got ${typeof result}), expected string or string[], skipping` ); } } }