@docubook/flame 1.3.4 → 1.3.6

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.
@@ -112,8 +112,8 @@ async function renderDocsPage(
112
112
  });
113
113
  }
114
114
 
115
- const title = (frontmatter.title as string) || slug || "Docs";
116
- const description = (frontmatter.description as string) || "";
115
+ const title = (typeof frontmatter.title === "string" ? frontmatter.title : "") || slug || "Docs";
116
+ const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
117
117
  const slugParts = slug ? slug.split("/") : [];
118
118
 
119
119
  const page = React.createElement(
@@ -365,6 +365,8 @@ export class BuildPluginBuilder implements PluginBuilder {
365
365
  * Each callback receives the **previous** callback's return value (or the
366
366
  * original frontmatter for the first). Callbacks that return `undefined` or
367
367
  * `null` pass the current value through unchanged.
368
+ * Callbacks that return a non-object (string, number, array) are skipped
369
+ * with a console warning — only plain objects are accepted.
368
370
  * Errors inside individual callbacks are caught and logged — the current
369
371
  * frontmatter passes through unchanged for that step.
370
372
  *
@@ -381,7 +383,13 @@ export class BuildPluginBuilder implements PluginBuilder {
381
383
  try {
382
384
  const next = await this._transformFrontmatter[i](result, context);
383
385
  if (next !== undefined && next !== null) {
384
- result = next;
386
+ if (typeof next === "object" && !Array.isArray(next)) {
387
+ result = next;
388
+ } else {
389
+ console.warn(
390
+ `[plugin] transformFrontmatter callback #${i + 1} returned invalid type (expected a plain object), skipping`
391
+ );
392
+ }
385
393
  }
386
394
  } catch (err) {
387
395
  console.error(
@@ -422,6 +430,10 @@ export class BuildPluginBuilder implements PluginBuilder {
422
430
  * Callbacks are chained in a waterfall: the return value of one is passed
423
431
  * as input to the next. Return `undefined` to pass through unchanged.
424
432
  *
433
+ * **Note:** Only plain objects are accepted as return values. Returning
434
+ * a string, number, or array will be silently skipped with a warning.
435
+ * Plugin authors should validate their return values before returning.
436
+ *
425
437
  * @param callback - Receives frontmatter object and page context.
426
438
  *
427
439
  * @example
@@ -2,13 +2,15 @@ import { resolve } from "node:path";
2
2
  import { PROJECT_ROOT } from "./paths";
3
3
  import type { DocuBookPlugin, PluginEntry } from "./plugin";
4
4
 
5
+ const NPM_PACKAGE_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
6
+
5
7
  /**
6
8
  * Resolve a plugin specifier to an absolute path or npm package name.
7
9
  *
8
10
  * Resolution rules:
9
11
  * 1. Relative path (starts with `.`) → resolve from project root, guard traversal
10
12
  * 2. Absolute path (starts with `/`) → guard traversal
11
- * 3. Anything else → treat as npm package name (handled by Bun's import)
13
+ * 3. Anything else → validate as npm package name, then pass to Bun's import
12
14
  *
13
15
  * Path traversal protection:
14
16
  * - All file-system paths (relative & absolute) must resolve within PROJECT_ROOT.
@@ -25,7 +27,12 @@ export function resolveSpecifier(specifier: string): string {
25
27
  // Absolute path → use as-is
26
28
  resolved = specifier;
27
29
  } else {
28
- // npm package name → handled by Bun's import
30
+ // npm package name → validate format, then handled by Bun's import
31
+ if (!NPM_PACKAGE_RE.test(specifier)) {
32
+ throw new Error(
33
+ `[plugin-loader] Invalid plugin specifier "${specifier}": must be a valid npm package name, relative path, or absolute path`
34
+ );
35
+ }
29
36
  return specifier;
30
37
  }
31
38
 
@@ -198,7 +198,7 @@ export interface PluginBuilder {
198
198
 
199
199
  /**
200
200
  * Register additional remark (Markdown) plugins for the MDX compilation pipeline.
201
- * Plugins from all plugins are merged and applied **after** the default set.
201
+ * Plugins from all registered callbacks are merged and applied **after** the default set.
202
202
  *
203
203
  * @example
204
204
  * build.remarkPlugins(() => [require("remark-custom-heading-id")]);
@@ -207,7 +207,7 @@ export interface PluginBuilder {
207
207
 
208
208
  /**
209
209
  * Register additional rehype (HTML) plugins for the MDX compilation pipeline.
210
- * Plugins from all plugins are merged and applied **after** the default set.
210
+ * Plugins from all registered callbacks are merged and applied **after** the default set.
211
211
  *
212
212
  * @example
213
213
  * build.rehypePlugins(() => [require("rehype-autolink-headings")]);
@@ -89,6 +89,40 @@ export function injectNonce(html: string, nonce: string): string {
89
89
  });
90
90
  }
91
91
 
92
+ export interface PluginResponseLike {
93
+ status: number;
94
+ statusText?: string;
95
+ headers: Headers;
96
+ body: BodyInit | null;
97
+ }
98
+
99
+ /**
100
+ * Wrap a plugin response with security headers.
101
+ * - Fills in SECURITY_HEADERS defaults where plugin hasn't set a value
102
+ * - Adds Content-Security-Policy for HTML responses (with optional unsafe-eval)
103
+ * - Preserves plugin body, status, statusText unchanged
104
+ */
105
+ export function wrapPluginResponse(
106
+ pluginResponse: PluginResponseLike,
107
+ allowEval = false
108
+ ): Response {
109
+ const securedHeaders = new Headers(pluginResponse.headers);
110
+ for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
111
+ if (!securedHeaders.has(key)) {
112
+ securedHeaders.set(key, value);
113
+ }
114
+ }
115
+ const contentType = securedHeaders.get("Content-Type") || "";
116
+ if (contentType.includes("text/html") && !securedHeaders.has("Content-Security-Policy")) {
117
+ securedHeaders.set("Content-Security-Policy", cspHeader(generateNonce(), allowEval));
118
+ }
119
+ return new Response(pluginResponse.body, {
120
+ status: pluginResponse.status,
121
+ statusText: pluginResponse.statusText,
122
+ headers: securedHeaders,
123
+ });
124
+ }
125
+
92
126
  export function htmlResponse(
93
127
  html: string,
94
128
  nonce: string,
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { resolve, join } from "node:path";
2
+ import { resolve } from "node:path";
3
3
  import { statSync } from "node:fs";
4
4
  import React, { type ReactNode } from "react";
5
5
  import { renderToString } from "react-dom/server";
@@ -62,19 +62,26 @@ async function getDocsForSlug(
62
62
  if (!isSlugSafe(slug, DOCS_DIR)) return null;
63
63
 
64
64
  const paths = [
65
- join(DOCS_DIR, slug, "index.mdx"),
66
- join(DOCS_DIR, `${slug}.mdx`),
67
- join(DOCS_DIR, slug, "index.md"),
68
- join(DOCS_DIR, `${slug}.md`),
65
+ resolve(DOCS_DIR, slug, "index.mdx"),
66
+ resolve(DOCS_DIR, `${slug}.mdx`),
67
+ resolve(DOCS_DIR, slug, "index.md"),
68
+ resolve(DOCS_DIR, `${slug}.md`),
69
69
  ];
70
70
 
71
+ const resolvedDocsDir = resolve(DOCS_DIR);
71
72
  let filePath: string | null = null;
72
73
  let raw: string | null = null;
73
74
  for (const p of paths) {
74
- if (!p.startsWith(DOCS_DIR)) continue;
75
+ const resolvedCandidate = resolve(p);
76
+ if (
77
+ resolvedCandidate !== resolvedDocsDir &&
78
+ !resolvedCandidate.startsWith(resolvedDocsDir + "/")
79
+ ) {
80
+ continue;
81
+ }
75
82
  try {
76
- raw = await readFile(p, "utf-8");
77
- filePath = p;
83
+ raw = await readFile(resolvedCandidate, "utf-8");
84
+ filePath = resolvedCandidate;
78
85
  break;
79
86
  } catch (err) {
80
87
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
@@ -121,8 +128,12 @@ async function renderDocsServerPage(
121
128
  pathname: string,
122
129
  state: ServerState
123
130
  ): Promise<Response> {
124
- const title = (doc.frontmatter.title as string) || slug.join("/") || "Docs";
125
- const description = (doc.frontmatter.description as string) || "";
131
+ const title =
132
+ (typeof doc.frontmatter.title === "string" ? doc.frontmatter.title : "") ||
133
+ slug.join("/") ||
134
+ "Docs";
135
+ const description =
136
+ typeof doc.frontmatter.description === "string" ? doc.frontmatter.description : "";
126
137
 
127
138
  const page = React.createElement(
128
139
  DocsLayout,
@@ -227,8 +238,13 @@ export function handleNotFound(state: ServerState, depth = 0): Response {
227
238
  }
228
239
 
229
240
  export function serveStatic(pathname: string): Response | null {
241
+ let decoded: string;
242
+ try {
243
+ decoded = decodeURIComponent(pathname);
244
+ } catch {
245
+ return null;
246
+ }
230
247
  if (!isPathSafe(pathname, DIST_DIR)) return null;
231
- const decoded = decodeURIComponent(pathname);
232
248
  const assetPath = resolve(DIST_DIR, decoded.slice(1));
233
249
  try {
234
250
  const s = statSync(assetPath);
@@ -242,10 +258,11 @@ export function serveStatic(pathname: string): Response | null {
242
258
  }
243
259
 
244
260
  if (decoded.startsWith("/docs/assets/")) {
245
- const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
246
261
  const docsAssetsDir = resolve(DOCS_DIR, "assets");
247
- const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
248
- if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirSlash)) return null;
262
+ const requestedRelative = decoded.slice("/docs/assets/".length);
263
+ const docsAsset = resolve(docsAssetsDir, requestedRelative);
264
+ const docsAssetsDirWithSep = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
265
+ if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirWithSep)) return null;
249
266
  try {
250
267
  const s = statSync(docsAsset);
251
268
  if (s.isFile()) {
@@ -15,11 +15,11 @@ import {
15
15
  serverErrorResponse,
16
16
  type ServerState,
17
17
  } from "./server-routes";
18
- import { SECURITY_HEADERS, cspHeader, generateNonce } from "./security";
18
+ import { wrapPluginResponse } from "./security";
19
19
 
20
20
  const docuConfig = loadDocuConfig();
21
21
 
22
- const PORT = process.env.PORT ?? "3000";
22
+ const PORT = Number(process.env.PORT ?? 3000);
23
23
 
24
24
  logger.buildStart();
25
25
 
@@ -103,28 +103,14 @@ const server = Bun.serve({
103
103
  const startTime = performance.now();
104
104
 
105
105
  if (builder) {
106
+ const serverPort = server.port ?? PORT;
107
+ const serverHostname = server.hostname ?? "localhost";
106
108
  const pluginResponse = await builder.runHandleRequest(req, {
107
- port: server.port!,
108
- hostname: server.hostname!,
109
+ port: serverPort,
110
+ hostname: serverHostname,
109
111
  });
110
112
  if (pluginResponse) {
111
- // Wrap plugin response with security headers.
112
- // Plugin's own headers take precedence over defaults.
113
- const securedHeaders = new Headers(pluginResponse.headers);
114
- for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
115
- if (!securedHeaders.has(key)) {
116
- securedHeaders.set(key, value);
117
- }
118
- }
119
- const contentType = securedHeaders.get("Content-Type") || "";
120
- if (contentType.includes("text/html") && !securedHeaders.has("Content-Security-Policy")) {
121
- securedHeaders.set("Content-Security-Policy", cspHeader(generateNonce(), true));
122
- }
123
- const securedResponse = new Response(pluginResponse.body, {
124
- status: pluginResponse.status,
125
- statusText: pluginResponse.statusText,
126
- headers: securedHeaders,
127
- });
113
+ const securedResponse = wrapPluginResponse(pluginResponse, true);
128
114
  logger.request(
129
115
  req.method,
130
116
  pathname,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,9 +53,9 @@
53
53
  "react-dom": "^19.2.7",
54
54
  "unified": "^11.0.0",
55
55
  "@docubook/core": "^1.7.2",
56
- "@docubook/themes-colors": "^0.10.2",
56
+ "@docubook/mdx-content": "^3.2.2",
57
57
  "@docubook/ui-react": "^0.1.4",
58
- "@docubook/mdx-content": "^3.2.2"
58
+ "@docubook/themes-colors": "^0.10.2"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@sentry/bun": "^10.0.0"