@docubook/flame 1.3.5 → 1.3.7

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.
@@ -220,7 +220,7 @@ async function build() {
220
220
 
221
221
  const hasPlugins = !!docuConfig.plugins?.length;
222
222
  const builder = hasPlugins ? new BuildPluginBuilder(docuConfig) : null;
223
- if (hasPlugins && builder) {
223
+ if (builder) {
224
224
  const plugins = await loadPlugins(docuConfig.plugins!);
225
225
  for (const plugin of plugins) {
226
226
  await plugin.setup(builder);
@@ -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;
@@ -231,8 +238,13 @@ export function handleNotFound(state: ServerState, depth = 0): Response {
231
238
  }
232
239
 
233
240
  export function serveStatic(pathname: string): Response | null {
241
+ let decoded: string;
242
+ try {
243
+ decoded = decodeURIComponent(pathname);
244
+ } catch {
245
+ return null;
246
+ }
234
247
  if (!isPathSafe(pathname, DIST_DIR)) return null;
235
- const decoded = decodeURIComponent(pathname);
236
248
  const assetPath = resolve(DIST_DIR, decoded.slice(1));
237
249
  try {
238
250
  const s = statSync(assetPath);
@@ -246,10 +258,11 @@ export function serveStatic(pathname: string): Response | null {
246
258
  }
247
259
 
248
260
  if (decoded.startsWith("/docs/assets/")) {
249
- const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
250
261
  const docsAssetsDir = resolve(DOCS_DIR, "assets");
251
- const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
252
- 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;
253
266
  try {
254
267
  const s = statSync(docsAsset);
255
268
  if (s.isFile()) {
@@ -15,11 +15,12 @@ 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 parsedPort = parseInt(process.env.PORT ?? "3000", 10);
23
+ const PORT = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : 3000;
23
24
 
24
25
  logger.buildStart();
25
26
 
@@ -42,7 +43,7 @@ logger.routes();
42
43
  // Plugin setup — all hooks active (onLoad, remark/rehype, frontmatter, head/body, html transform, handleRequest)
43
44
  const hasPlugins = !!docuConfig.plugins?.length;
44
45
  const builder = hasPlugins ? new BuildPluginBuilder(docuConfig) : null;
45
- if (hasPlugins && builder) {
46
+ if (builder) {
46
47
  const plugins = await loadPlugins(docuConfig.plugins!);
47
48
  for (const plugin of plugins) {
48
49
  await plugin.setup(builder);
@@ -103,28 +104,14 @@ const server = Bun.serve({
103
104
  const startTime = performance.now();
104
105
 
105
106
  if (builder) {
107
+ const serverPort = server.port ?? PORT;
108
+ const serverHostname = server.hostname ?? "localhost";
106
109
  const pluginResponse = await builder.runHandleRequest(req, {
107
- port: server.port!,
108
- hostname: server.hostname!,
110
+ port: serverPort,
111
+ hostname: serverHostname,
109
112
  });
110
113
  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
- });
114
+ const securedResponse = wrapPluginResponse(pluginResponse, true);
128
115
  logger.request(
129
116
  req.method,
130
117
  pathname,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
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/mdx-content": "^3.2.2",
56
57
  "@docubook/themes-colors": "^0.10.2",
57
- "@docubook/ui-react": "^0.1.4",
58
- "@docubook/mdx-content": "^3.2.2"
58
+ "@docubook/ui-react": "^0.1.4"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@sentry/bun": "^10.0.0"