@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.
- package/.docu/node/build.ts +1 -1
- package/.docu/node/plugin-loader.ts +9 -2
- package/.docu/node/plugin.ts +2 -2
- package/.docu/node/security.ts +34 -0
- package/.docu/node/server-routes.ts +25 -12
- package/.docu/node/server.ts +9 -22
- package/package.json +3 -3
package/.docu/node/build.ts
CHANGED
|
@@ -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 (
|
|
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 →
|
|
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
|
|
package/.docu/node/plugin.ts
CHANGED
|
@@ -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
|
|
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
|
|
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")]);
|
package/.docu/node/security.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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(
|
|
77
|
-
filePath =
|
|
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
|
|
252
|
-
|
|
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()) {
|
package/.docu/node/server.ts
CHANGED
|
@@ -15,11 +15,12 @@ import {
|
|
|
15
15
|
serverErrorResponse,
|
|
16
16
|
type ServerState,
|
|
17
17
|
} from "./server-routes";
|
|
18
|
-
import {
|
|
18
|
+
import { wrapPluginResponse } from "./security";
|
|
19
19
|
|
|
20
20
|
const docuConfig = loadDocuConfig();
|
|
21
21
|
|
|
22
|
-
const
|
|
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 (
|
|
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:
|
|
108
|
-
hostname:
|
|
110
|
+
port: serverPort,
|
|
111
|
+
hostname: serverHostname,
|
|
109
112
|
});
|
|
110
113
|
if (pluginResponse) {
|
|
111
|
-
|
|
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.
|
|
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"
|