@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.
- package/.docu/node/build.ts +2 -2
- package/.docu/node/plugin-builder.ts +13 -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 +31 -14
- package/.docu/node/server.ts +7 -21
- package/package.json +3 -3
package/.docu/node/build.ts
CHANGED
|
@@ -112,8 +112,8 @@ async function renderDocsPage(
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
const title = (frontmatter.title
|
|
116
|
-
const description =
|
|
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
|
-
|
|
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 →
|
|
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;
|
|
@@ -121,8 +128,12 @@ async function renderDocsServerPage(
|
|
|
121
128
|
pathname: string,
|
|
122
129
|
state: ServerState
|
|
123
130
|
): Promise<Response> {
|
|
124
|
-
const title =
|
|
125
|
-
|
|
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
|
|
248
|
-
|
|
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()) {
|
package/.docu/node/server.ts
CHANGED
|
@@ -15,11 +15,11 @@ 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 PORT = process.env.PORT ??
|
|
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:
|
|
108
|
-
hostname:
|
|
109
|
+
port: serverPort,
|
|
110
|
+
hostname: serverHostname,
|
|
109
111
|
});
|
|
110
112
|
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
|
-
});
|
|
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.
|
|
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/
|
|
56
|
+
"@docubook/mdx-content": "^3.2.2",
|
|
57
57
|
"@docubook/ui-react": "^0.1.4",
|
|
58
|
-
"@docubook/
|
|
58
|
+
"@docubook/themes-colors": "^0.10.2"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@sentry/bun": "^10.0.0"
|