@docubook/flame 1.2.0 → 1.3.0
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.
- package/.docu/components/Context.tsx +2 -1
- package/.docu/node/build.ts +166 -101
- package/.docu/node/html.ts +52 -3
- package/.docu/node/mdx.ts +21 -3
- package/.docu/node/plugin-builder.ts +465 -0
- package/.docu/node/plugin-loader.ts +119 -0
- package/.docu/node/plugin.ts +265 -0
- package/.docu/node/preview.ts +27 -16
- package/.docu/node/search-indexer.ts +2 -24
- package/.docu/node/security.ts +75 -4
- package/.docu/node/server-routes.ts +266 -0
- package/.docu/node/server.ts +53 -225
- package/.docu/node/types.ts +3 -0
- package/.docu/node/utils.ts +58 -1
- package/.docu/pages/docs/[[...slug]].tsx +3 -1
- package/README.md +48 -0
- package/docu.schema.json +28 -0
- package/package.json +5 -5
|
@@ -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
|
+
}
|