@docubook/flame 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,465 @@
1
+ import type { Pluggable } from "unified";
2
+ import type { DocuConfig, PageContext, PageMeta, DevServerContext, PluginBuilder } from "./plugin";
3
+
4
+ type Awaitable<T> = T | Promise<T>;
5
+
6
+ interface OnLoadHandler {
7
+ filter: RegExp;
8
+ namespace?: string;
9
+ fn: (args: {
10
+ path: string;
11
+ content: string;
12
+ }) => Awaitable<{ contents?: string; loader?: "js" | "ts" | "mdx" } | void>;
13
+ }
14
+
15
+ export class BuildPluginBuilder implements PluginBuilder {
16
+ readonly config: DocuConfig;
17
+
18
+ private _handleRequest: Array<
19
+ (req: Request, context: DevServerContext) => Awaitable<Response | void>
20
+ > = [];
21
+ private _injectBody: Array<(context: PageContext) => string | string[]> = [];
22
+ private _injectHead: Array<(context: PageContext) => string | string[]> = [];
23
+ private _onEnd: Array<(config: DocuConfig, pages: PageMeta[]) => Awaitable<void>> = [];
24
+ private _onLoad: OnLoadHandler[] = [];
25
+ private _onStart: Array<(config: DocuConfig) => Awaitable<void>> = [];
26
+ private _rehypePlugins: Array<() => Pluggable[]> = [];
27
+ private _remarkPlugins: Array<() => Pluggable[]> = [];
28
+ private _transformFrontmatter: Array<
29
+ (
30
+ frontmatter: Record<string, unknown>,
31
+ context: Pick<PageContext, "slug" | "filePath" | "content">
32
+ ) => Awaitable<Record<string, unknown> | void>
33
+ > = [];
34
+ private _transformHtml: Array<(html: string, context: PageContext) => Awaitable<string>> = [];
35
+
36
+ constructor(config: DocuConfig) {
37
+ this.config = config;
38
+ }
39
+
40
+ /**
41
+ * Collect and deduplicate all `<body>` injection snippets from registered plugins.
42
+ * Each callback is executed in registration order; plugin errors are wrapped
43
+ * with a descriptive message.
44
+ *
45
+ * @param context - Current page context passed to each injectBody callback.
46
+ * @returns Deduplicated array of HTML strings to inject before `</body>`.
47
+ * @throws Error if any injectBody callback throws — wraps original error as cause.
48
+ */
49
+ collectBody(context: PageContext): string[] {
50
+ const items: string[] = [];
51
+ for (const cb of this._injectBody) {
52
+ try {
53
+ const result = cb(context);
54
+ if (result) {
55
+ if (Array.isArray(result)) {
56
+ items.push(...result);
57
+ } else {
58
+ items.push(result);
59
+ }
60
+ }
61
+ } catch (err) {
62
+ throw new Error(
63
+ `[plugin] injectBody callback failed: ${err instanceof Error ? err.message : String(err)}`,
64
+ { cause: err }
65
+ );
66
+ }
67
+ }
68
+ return [...new Set(items)];
69
+ }
70
+
71
+ /**
72
+ * Collect and deduplicate all `<head>` injection snippets from registered plugins.
73
+ * Each callback is executed in registration order; plugin errors are wrapped
74
+ * with a descriptive message.
75
+ *
76
+ * @param context - Current page context passed to each injectHead callback.
77
+ * @returns Deduplicated array of HTML strings to inject before `</head>`.
78
+ * @throws Error if any injectHead callback throws — wraps original error as cause.
79
+ */
80
+ collectHead(context: PageContext): string[] {
81
+ const items: string[] = [];
82
+ for (const cb of this._injectHead) {
83
+ try {
84
+ const result = cb(context);
85
+ if (result) {
86
+ if (Array.isArray(result)) {
87
+ items.push(...result);
88
+ } else {
89
+ items.push(result);
90
+ }
91
+ }
92
+ } catch (err) {
93
+ throw new Error(
94
+ `[plugin] injectHead callback failed: ${err instanceof Error ? err.message : String(err)}`,
95
+ { cause: err }
96
+ );
97
+ }
98
+ }
99
+ return [...new Set(items)];
100
+ }
101
+
102
+ /**
103
+ * Collect all rehype plugin arrays from registered rehypePlugins callbacks.
104
+ * Results from all plugins are flattened into a single array.
105
+ *
106
+ * @returns Flattened array of rehype plugin instances applied after default set.
107
+ * @throws Error if any rehypePlugins callback throws — wraps original error as cause.
108
+ */
109
+ collectRehypePlugins(): Pluggable[] {
110
+ const plugins: Pluggable[] = [];
111
+ for (const cb of this._rehypePlugins) {
112
+ try {
113
+ plugins.push(...cb());
114
+ } catch (err) {
115
+ throw new Error(
116
+ `[plugin] rehypePlugins callback failed: ${err instanceof Error ? err.message : String(err)}`,
117
+ { cause: err }
118
+ );
119
+ }
120
+ }
121
+ return plugins;
122
+ }
123
+
124
+ /**
125
+ * Collect all remark plugin arrays from registered remarkPlugins callbacks.
126
+ * Results from all plugins are flattened into a single array.
127
+ *
128
+ * @returns Flattened array of remark plugin instances applied after default set.
129
+ * @throws Error if any remarkPlugins callback throws — wraps original error as cause.
130
+ */
131
+ collectRemarkPlugins(): Pluggable[] {
132
+ const plugins: Pluggable[] = [];
133
+ for (const cb of this._remarkPlugins) {
134
+ try {
135
+ plugins.push(...cb());
136
+ } catch (err) {
137
+ throw new Error(
138
+ `[plugin] remarkPlugins callback failed: ${err instanceof Error ? err.message : String(err)}`,
139
+ { cause: err }
140
+ );
141
+ }
142
+ }
143
+ return plugins;
144
+ }
145
+
146
+ /**
147
+ * Register a callback to intercept incoming requests during development.
148
+ * The **first** callback to return a `Response` short-circuits all subsequent handlers.
149
+ * Errors inside callbacks are caught and logged — execution continues to next handler.
150
+ *
151
+ * @param callback - Receives the Request and dev server context. Return Response or void.
152
+ *
153
+ * @example
154
+ * build.handleRequest((req, ctx) => {
155
+ * if (new URL(req.url).pathname === "/api/status") {
156
+ * return new Response(JSON.stringify({ ok: true }), {
157
+ * headers: { "Content-Type": "application/json" },
158
+ * });
159
+ * }
160
+ * });
161
+ */
162
+ handleRequest(
163
+ callback: (req: Request, context: DevServerContext) => Awaitable<Response | void>
164
+ ): void {
165
+ this._handleRequest.push(callback);
166
+ }
167
+
168
+ /**
169
+ * Register a callback that returns HTML strings to inject before `</body>`.
170
+ * Results from all plugins are merged, deduplicated, and served via `collectBody()`.
171
+ *
172
+ * @param callback - Returns a single HTML string or an array. Called once per page.
173
+ *
174
+ * @example
175
+ * build.injectBody(() => `<div id="chat-widget"></div>`);
176
+ */
177
+ injectBody(callback: (context: PageContext) => string | string[]): void {
178
+ this._injectBody.push(callback);
179
+ }
180
+
181
+ /**
182
+ * Register a callback that returns HTML strings to inject inside `<head>`.
183
+ * Results from all plugins are merged, deduplicated, and served via `collectHead()`.
184
+ *
185
+ * @param callback - Returns a single HTML string or an array. Called once per page.
186
+ *
187
+ * @example
188
+ * build.injectHead(() => `<script async src="https://cdn.example.com/analytics.js"></script>`);
189
+ */
190
+ injectHead(callback: (context: PageContext) => string | string[]): void {
191
+ this._injectHead.push(callback);
192
+ }
193
+
194
+ /**
195
+ * Register a callback to run once after all pages are built.
196
+ * Receives the resolved config and aggregated page metadata.
197
+ * Errors thrown by the callback propagate to the caller via `runOnEnd()`.
198
+ *
199
+ * @param callback - Receives config and page metadata array. May return a Promise.
200
+ *
201
+ * @example
202
+ * build.onEnd((config, pages) => {
203
+ * const xml = generateSitemap(pages, config.meta.baseURL);
204
+ * await Bun.write(".docu/dist/sitemap.xml", xml);
205
+ * });
206
+ */
207
+ onEnd(callback: (config: DocuConfig, pages: PageMeta[]) => Awaitable<void>): void {
208
+ this._onEnd.push(callback);
209
+ }
210
+
211
+ /**
212
+ * Register a callback to transform raw file content before MDX compilation.
213
+ * Filtered by regex against the file's relative path — only the **first** matching
214
+ * handler's result is used.
215
+ * Errors thrown by the callback propagate to the caller via `runOnLoad()`.
216
+ *
217
+ * @param args.filter - RegExp matched against the file's relative path.
218
+ * @param args.namespace - Optional namespace prefix (reserved for future use).
219
+ * @param callback - Receives file path and raw content. Return new contents or void.
220
+ *
221
+ * @example
222
+ * build.onLoad({ filter: /\.md$/ }, ({ path, content }) => {
223
+ * return { contents: `<!-- preprocessed -->\n${content}`, loader: "mdx" };
224
+ * });
225
+ */
226
+ onLoad(
227
+ args: { filter: RegExp; namespace?: string },
228
+ callback: (args: {
229
+ path: string;
230
+ content: string;
231
+ }) => Awaitable<{ contents?: string; loader?: "js" | "ts" | "mdx" } | void>
232
+ ): void {
233
+ this._onLoad.push({ ...args, fn: callback });
234
+ }
235
+
236
+ /**
237
+ * Register a callback to run once before the build starts.
238
+ * Receives the resolved DocuConfig for validation or resource initialization.
239
+ * Errors thrown by the callback propagate to the caller via `runOnStart()`.
240
+ *
241
+ * @param callback - Receives the resolved config. May return a Promise.
242
+ *
243
+ * @example
244
+ * build.onStart((config) => {
245
+ * if (!config.meta.baseURL) throw new Error("baseURL required");
246
+ * });
247
+ */
248
+ onStart(callback: (config: DocuConfig) => Awaitable<void>): void {
249
+ this._onStart.push(callback);
250
+ }
251
+
252
+ /**
253
+ * Register additional rehype (HTML) plugins for the MDX compilation pipeline.
254
+ * Results from all plugins are merged and applied **after** the default set.
255
+ *
256
+ * @param callback - Returns an array of rehype plugins.
257
+ *
258
+ * @example
259
+ * build.rehypePlugins(() => [require("rehype-autolink-headings")]);
260
+ */
261
+ rehypePlugins(callback: () => Pluggable[]): void {
262
+ this._rehypePlugins.push(callback);
263
+ }
264
+
265
+ /**
266
+ * Register additional remark (Markdown) plugins for the MDX compilation pipeline.
267
+ * Results from all plugins are merged and applied **after** the default set.
268
+ *
269
+ * @param callback - Returns an array of remark plugins.
270
+ *
271
+ * @example
272
+ * build.remarkPlugins(() => [require("remark-custom-heading-id")]);
273
+ */
274
+ remarkPlugins(callback: () => Pluggable[]): void {
275
+ this._remarkPlugins.push(callback);
276
+ }
277
+
278
+ /**
279
+ * Execute all registered handleRequest callbacks sequentially.
280
+ * Stops and returns the **first** `Response` returned by any callback.
281
+ * Errors inside individual callbacks are caught and logged — execution
282
+ * continues to the next callback without throwing.
283
+ *
284
+ * @param req - The incoming HTTP Request.
285
+ * @param context - Dev server context (port, hostname).
286
+ * @returns A Response if a callback intercepted the request, or null if none did.
287
+ */
288
+ async runHandleRequest(req: Request, context: DevServerContext): Promise<Response | null> {
289
+ for (let i = 0; i < this._handleRequest.length; i++) {
290
+ try {
291
+ const result = await this._handleRequest[i](req, context);
292
+ if (result instanceof Response) {
293
+ return result;
294
+ }
295
+ } catch (err) {
296
+ console.error(
297
+ `[plugin] handleRequest callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
298
+ );
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Execute all registered onEnd callbacks sequentially with the resolved
306
+ * config and aggregated page metadata.
307
+ * Errors inside individual callbacks are caught and logged — execution
308
+ * continues to the next callback without throwing.
309
+ *
310
+ * @param pages - Array of metadata for every built page.
311
+ */
312
+ async runOnEnd(pages: PageMeta[]): Promise<void> {
313
+ for (let i = 0; i < this._onEnd.length; i++) {
314
+ try {
315
+ await this._onEnd[i](this.config, pages);
316
+ } catch (err) {
317
+ console.error(
318
+ `[plugin] onEnd callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Execute registered onLoad handlers in registration order against a file.
326
+ * Only the **first** handler whose `filter` regex matches the path and returns
327
+ * a result is applied. If a matching handler throws, the error is logged and
328
+ * subsequent handlers are tried.
329
+ *
330
+ * @param path - Relative path of the file being loaded.
331
+ * @param content - Raw file content.
332
+ * @returns Transformed content if a matching handler returned it, or null.
333
+ */
334
+ async runOnLoad(
335
+ path: string,
336
+ content: string
337
+ ): Promise<{ contents?: string; loader?: "js" | "ts" | "mdx" } | null> {
338
+ for (const handler of this._onLoad) {
339
+ if (handler.filter.test(path)) {
340
+ try {
341
+ const result = await handler.fn({ path, content });
342
+ if (result) return result;
343
+ } catch (err) {
344
+ console.error(
345
+ `[plugin] onLoad handler for filter ${handler.filter} error: ${err instanceof Error ? err.message : String(err)}`
346
+ );
347
+ }
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * Execute all registered onStart callbacks sequentially.
355
+ * Each callback receives the resolved DocuConfig.
356
+ * Errors inside individual callbacks are caught and logged — execution
357
+ * continues to the next callback without throwing.
358
+ */
359
+ async runOnStart(): Promise<void> {
360
+ for (let i = 0; i < this._onStart.length; i++) {
361
+ try {
362
+ await this._onStart[i](this.config);
363
+ } catch (err) {
364
+ console.error(
365
+ `[plugin] onStart callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
366
+ );
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Execute the transformFrontmatter chain in waterfall pattern.
373
+ * Each callback receives the **previous** callback's return value (or the
374
+ * original frontmatter for the first). Callbacks that return `undefined` or
375
+ * `null` pass the current value through unchanged.
376
+ * Errors inside individual callbacks are caught and logged — the current
377
+ * frontmatter passes through unchanged for that step.
378
+ *
379
+ * @param frontmatter - Initial frontmatter object parsed from MDX.
380
+ * @param context - Page context with slug, filePath, and raw content.
381
+ * @returns The final transformed frontmatter object.
382
+ */
383
+ async runTransformFrontmatterChain(
384
+ frontmatter: Record<string, unknown>,
385
+ context: Pick<PageContext, "slug" | "filePath" | "content">
386
+ ): Promise<Record<string, unknown>> {
387
+ let result = frontmatter;
388
+ for (let i = 0; i < this._transformFrontmatter.length; i++) {
389
+ try {
390
+ const next = await this._transformFrontmatter[i](result, context);
391
+ if (next !== undefined && next !== null) {
392
+ result = next;
393
+ }
394
+ } catch (err) {
395
+ console.error(
396
+ `[plugin] transformFrontmatter callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
397
+ );
398
+ }
399
+ }
400
+ return result;
401
+ }
402
+
403
+ /**
404
+ * Execute the transformHtml chain in pipeline pattern.
405
+ * Each callback receives the **previous** callback's return value (or the
406
+ * original HTML for the first). Every callback **must** return a string.
407
+ * Errors inside individual callbacks are caught and logged — the current
408
+ * HTML passes through unchanged for that step.
409
+ *
410
+ * @param html - The initial HTML string.
411
+ * @param context - Full page context (slug, filePath, frontmatter, content, config).
412
+ * @returns The final transformed HTML string.
413
+ */
414
+ async runTransformHtmlChain(html: string, context: PageContext): Promise<string> {
415
+ let result = html;
416
+ for (let i = 0; i < this._transformHtml.length; i++) {
417
+ try {
418
+ result = await this._transformHtml[i](result, context);
419
+ } catch (err) {
420
+ console.error(
421
+ `[plugin] transformHtml callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
422
+ );
423
+ }
424
+ }
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * Register a callback to mutate frontmatter before MDX compilation.
430
+ * Callbacks are chained in a waterfall: the return value of one is passed
431
+ * as input to the next. Return `undefined` to pass through unchanged.
432
+ *
433
+ * @param callback - Receives frontmatter object and page context.
434
+ *
435
+ * @example
436
+ * build.transformFrontmatter((fm, ctx) => {
437
+ * const wordCount = ctx.content!.split(/\s+/).length;
438
+ * return { ...fm, readingTime: `${Math.ceil(wordCount / 200)} min read` };
439
+ * });
440
+ */
441
+ transformFrontmatter(
442
+ callback: (
443
+ frontmatter: Record<string, unknown>,
444
+ context: Pick<PageContext, "slug" | "filePath" | "content">
445
+ ) => Awaitable<Record<string, unknown> | void>
446
+ ): void {
447
+ this._transformFrontmatter.push(callback);
448
+ }
449
+
450
+ /**
451
+ * Register a callback to transform the final HTML string per page.
452
+ * This is the **last** hook before the HTML is written to disk.
453
+ * Callbacks are chained in a pipeline: each receives the previous callback's output.
454
+ *
455
+ * @param callback - Receives HTML string and full page context. Must return HTML.
456
+ *
457
+ * @example
458
+ * build.transformHtml((html, ctx) => {
459
+ * return html.replace(/https?:\/\/old-domain\.com\//g, "/");
460
+ * });
461
+ */
462
+ transformHtml(callback: (html: string, context: PageContext) => Awaitable<string>): void {
463
+ this._transformHtml.push(callback);
464
+ }
465
+ }
@@ -0,0 +1,119 @@
1
+ import { resolve } from "node:path";
2
+ import { PROJECT_ROOT } from "./paths";
3
+ import type { DocuBookPlugin, PluginEntry } from "./plugin";
4
+
5
+ /**
6
+ * Resolve a plugin specifier to an absolute path or npm package name.
7
+ *
8
+ * Resolution rules:
9
+ * 1. Relative path (starts with `.`) → resolve from project root, guard traversal
10
+ * 2. Absolute path (starts with `/`) → guard traversal
11
+ * 3. Anything else → treat as npm package name (handled by Bun's import)
12
+ *
13
+ * Path traversal protection:
14
+ * - All file-system paths (relative & absolute) must resolve within PROJECT_ROOT.
15
+ * - This prevents `../../sensitive-file` or `/etc/passwd` from being imported.
16
+ */
17
+ /** @internal Exported for testing only. */
18
+ export function resolveSpecifier(specifier: string): string {
19
+ let resolved: string;
20
+
21
+ if (specifier.startsWith(".")) {
22
+ // Relative path → resolve from project root
23
+ resolved = resolve(PROJECT_ROOT, specifier);
24
+ } else if (specifier.startsWith("/")) {
25
+ // Absolute path → use as-is
26
+ resolved = specifier;
27
+ } else {
28
+ // npm package name → handled by Bun's import
29
+ return specifier;
30
+ }
31
+
32
+ // Path traversal guard: resolved path must stay within PROJECT_ROOT
33
+ const root = PROJECT_ROOT.endsWith("/") ? PROJECT_ROOT : PROJECT_ROOT + "/";
34
+ if (!resolved.startsWith(root)) {
35
+ throw new Error(
36
+ `[plugin-loader] Path traversal blocked: "${specifier}" resolves outside project root`
37
+ );
38
+ }
39
+
40
+ return resolved;
41
+ }
42
+
43
+ /**
44
+ * Load and instantiate all plugins from a config entries array.
45
+ *
46
+ * @param entries - Array of plugin entries from `docu.json`.
47
+ * Each entry is either:
48
+ * - a `string` (plugin specifier, no options)
49
+ * - a `[string, object]` tuple (plugin specifier + factory options)
50
+ * @returns Array of resolved `DocuBookPlugin` instances.
51
+ *
52
+ * @throws If a plugin specifier cannot be imported.
53
+ * @throws If a plugin's default export lacks a `name` property.
54
+ *
55
+ * @example
56
+ * const plugins = await loadPlugins([
57
+ * "@docubook/plugin-sitemap",
58
+ * ["@docubook/plugin-analytics", { id: "G-XXXXXXX" }],
59
+ * "./plugins/local-reading-time",
60
+ * ]);
61
+ */
62
+ export async function loadPlugins(entries: PluginEntry[] = []): Promise<DocuBookPlugin[]> {
63
+ const plugins: DocuBookPlugin[] = [];
64
+
65
+ for (const entry of entries) {
66
+ const [specifier, options] = Array.isArray(entry) ? entry : [entry, undefined];
67
+ const resolved = resolveSpecifier(specifier);
68
+
69
+ let mod: Record<string, unknown>;
70
+ try {
71
+ mod = await import(resolved);
72
+ } catch (err) {
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ throw new Error(`[plugin-loader] Failed to import plugin "${specifier}": ${message}`, {
75
+ cause: err,
76
+ });
77
+ }
78
+
79
+ const exported = mod.default as unknown;
80
+
81
+ let plugin: DocuBookPlugin;
82
+
83
+ if (typeof exported === "function") {
84
+ // Factory pattern: exported function receives options, returns DocuBookPlugin
85
+ try {
86
+ plugin = (exported as (opts?: Record<string, unknown>) => DocuBookPlugin)(options);
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ throw new Error(
90
+ `[plugin-loader] Plugin factory "${specifier}" threw during initialization: ${message}`,
91
+ { cause: err }
92
+ );
93
+ }
94
+ } else if (exported && typeof exported === "object") {
95
+ // Simple pattern: exported object is (or duck-types as) DocuBookPlugin
96
+ plugin = exported as DocuBookPlugin;
97
+ } else {
98
+ throw new Error(
99
+ `[plugin-loader] Plugin "${specifier}" must export a default function or object. Got: ${typeof exported}`
100
+ );
101
+ }
102
+
103
+ if (!plugin.name || typeof plugin.name !== "string") {
104
+ throw new Error(
105
+ `[plugin-loader] Plugin "${specifier}" must have a valid 'name' property (string). Got: ${typeof plugin.name}`
106
+ );
107
+ }
108
+
109
+ if (typeof plugin.setup !== "function") {
110
+ throw new Error(
111
+ `[plugin-loader] Plugin "${specifier}" (name: "${plugin.name}") must have a 'setup(build)' function.`
112
+ );
113
+ }
114
+
115
+ plugins.push(plugin);
116
+ }
117
+
118
+ return plugins;
119
+ }