@docubook/flame 1.2.1 → 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/search-indexer.ts +2 -24
- package/.docu/node/security.ts +44 -3
- package/.docu/node/server-routes.ts +266 -0
- package/.docu/node/server.ts +53 -226
- 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 +3 -3
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Pluggable } from "unified";
|
|
2
|
+
import type { DocuConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
// ─── Config Types ───────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plugin entry in `docu.json` config.
|
|
8
|
+
* - `string`: plugin package name or relative path (no options)
|
|
9
|
+
* - `[string, object]`: plugin package name + factory options
|
|
10
|
+
*
|
|
11
|
+
* @example "@docubook/plugin-sitemap"
|
|
12
|
+
* @example ["@docubook/plugin-search-algolia", { appId: "xxx" }]
|
|
13
|
+
*/
|
|
14
|
+
export type PluginEntry = string | [string, Record<string, unknown>];
|
|
15
|
+
|
|
16
|
+
// ─── Context Types ──────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Context passed to content-transform hooks for a single page.
|
|
20
|
+
*/
|
|
21
|
+
export interface PageContext {
|
|
22
|
+
/** Relative slug path: "getting-started/introduction" */
|
|
23
|
+
slug: string;
|
|
24
|
+
/** Absolute file path on disk */
|
|
25
|
+
filePath: string;
|
|
26
|
+
/** Parsed frontmatter metadata */
|
|
27
|
+
frontmatter: Record<string, unknown>;
|
|
28
|
+
/** Raw MDX/MD content (only available in `transformFrontmatter` when enabled) */
|
|
29
|
+
content?: string;
|
|
30
|
+
/** Resolved site configuration */
|
|
31
|
+
config: DocuConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Metadata for a single built page, aggregated and passed to `onEnd`.
|
|
36
|
+
*/
|
|
37
|
+
export interface PageMeta {
|
|
38
|
+
/** URL slug: "getting-started/introduction" */
|
|
39
|
+
slug: string;
|
|
40
|
+
/** Page title from frontmatter or filename */
|
|
41
|
+
title: string;
|
|
42
|
+
/** Absolute source file path */
|
|
43
|
+
filePath: string;
|
|
44
|
+
/** Relative output path under dist */
|
|
45
|
+
outputPath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Context passed to the dev server request handler.
|
|
50
|
+
*/
|
|
51
|
+
export interface DevServerContext {
|
|
52
|
+
/** Dev server port */
|
|
53
|
+
port: number;
|
|
54
|
+
/** Dev server hostname */
|
|
55
|
+
hostname: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── PluginBuilder Interface ────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builder object passed to each plugin's `setup()` function.
|
|
62
|
+
*
|
|
63
|
+
* Follows Bun's `PluginBuilder` convention:
|
|
64
|
+
* - Plugins register lifecycle callbacks through typed methods
|
|
65
|
+
* - Callbacks are executed sequentially in registration order
|
|
66
|
+
* - `config` provides read-only access to the resolved build config
|
|
67
|
+
*/
|
|
68
|
+
export interface PluginBuilder {
|
|
69
|
+
/** Resolved DocuBook configuration (read-only after setup phase). */
|
|
70
|
+
config: DocuConfig;
|
|
71
|
+
|
|
72
|
+
// ─── Build Lifecycle ───────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a callback to run once before the build starts.
|
|
76
|
+
* Use for: validating config, initializing resources, fetching remote data.
|
|
77
|
+
*
|
|
78
|
+
* @param callback - Receives the resolved config. May return a Promise.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* build.onStart((config) => {
|
|
82
|
+
* if (!config.meta.baseURL) throw new Error("baseURL required");
|
|
83
|
+
* });
|
|
84
|
+
*/
|
|
85
|
+
onStart(callback: (config: DocuConfig) => void | Promise<void>): void;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register a callback to run once after all pages are built.
|
|
89
|
+
* Use for: generating sitemaps, RSS feeds, manifests, post-build reports.
|
|
90
|
+
*
|
|
91
|
+
* @param callback - Receives config and aggregated page metadata. May return a Promise.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* build.onEnd((config, pages) => {
|
|
95
|
+
* const xml = generateSitemap(pages, config.meta.baseURL);
|
|
96
|
+
* await Bun.write(join(DIST_DIR, "sitemap.xml"), xml);
|
|
97
|
+
* });
|
|
98
|
+
*/
|
|
99
|
+
onEnd(callback: (config: DocuConfig, pages: PageMeta[]) => void | Promise<void>): void;
|
|
100
|
+
|
|
101
|
+
// ─── Content Transform ─────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register a callback to transform raw file content before MDX compilation.
|
|
105
|
+
* Similar to Bun's `build.onLoad()` — filtered by file path pattern.
|
|
106
|
+
*
|
|
107
|
+
* Use for: preprocessing MDX/MD, injecting frontmatter, code transformations.
|
|
108
|
+
*
|
|
109
|
+
* @param args.filter - RegExp matched against the file's relative path
|
|
110
|
+
* @param args.namespace - Optional namespace prefix (reserved for future use)
|
|
111
|
+
* @param callback - Receives file path and raw content. Return new contents or void.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* build.onLoad({ filter: /\.md$/ }, ({ path, content }) => {
|
|
115
|
+
* return { contents: `<!-- auto-processed -->\n${content}` };
|
|
116
|
+
* });
|
|
117
|
+
*/
|
|
118
|
+
onLoad(
|
|
119
|
+
args: { filter: RegExp; namespace?: string },
|
|
120
|
+
callback: (args: {
|
|
121
|
+
path: string;
|
|
122
|
+
content: string;
|
|
123
|
+
}) => { contents?: string; loader?: "js" | "ts" | "mdx" } | void
|
|
124
|
+
): void;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Register a callback to mutate frontmatter before MDX compilation.
|
|
128
|
+
* Callbacks are chained: the return value of one is passed as input to the next.
|
|
129
|
+
*
|
|
130
|
+
* Use for: injecting reading-time, validating fields, adding computed metadata.
|
|
131
|
+
*
|
|
132
|
+
* @param callback - Receives frontmatter object and page context. Return mutated frontmatter or void.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* build.transformFrontmatter((fm, ctx) => {
|
|
136
|
+
* const wordCount = ctx.content!.split(/\s+/).length;
|
|
137
|
+
* return { ...fm, readingTime: `${Math.ceil(wordCount / 200)} min read` };
|
|
138
|
+
* });
|
|
139
|
+
*/
|
|
140
|
+
transformFrontmatter(
|
|
141
|
+
callback: (
|
|
142
|
+
frontmatter: Record<string, unknown>,
|
|
143
|
+
context: Pick<PageContext, "slug" | "filePath" | "content">
|
|
144
|
+
) => Record<string, unknown> | void
|
|
145
|
+
): void;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Register a callback to transform the final HTML string per page.
|
|
149
|
+
* This is the **last** hook before the HTML is written to disk.
|
|
150
|
+
*
|
|
151
|
+
* Use for: post-processing, minification, link rewriting, custom injection.
|
|
152
|
+
*
|
|
153
|
+
* @param callback - Receives HTML string and full page context. Return modified HTML.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* build.transformHtml((html, ctx) => {
|
|
157
|
+
* return html.replace(/https?:\/\/old-domain\.com\//g, "/");
|
|
158
|
+
* });
|
|
159
|
+
*/
|
|
160
|
+
transformHtml(callback: (html: string, context: PageContext) => string | Promise<string>): void;
|
|
161
|
+
|
|
162
|
+
// ─── Head & Script Injection ───────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Register a callback that returns HTML strings to inject inside `<head>`.
|
|
166
|
+
* Results from all plugins are merged and deduplicated.
|
|
167
|
+
*
|
|
168
|
+
* Use for: analytics snippets, meta tags, stylesheet links.
|
|
169
|
+
*
|
|
170
|
+
* @param callback - Returns a single HTML string or an array. Called once per page.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* build.injectHead(() => {
|
|
174
|
+
* return `<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXX"></script>`;
|
|
175
|
+
* });
|
|
176
|
+
*/
|
|
177
|
+
injectHead(callback: (context: PageContext) => string | string[]): void;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Register a callback that returns HTML strings to inject before `</body>`.
|
|
181
|
+
* Results from all plugins are merged and deduplicated.
|
|
182
|
+
*
|
|
183
|
+
* Use for: chat widgets, live-script loaders, deferred scripts.
|
|
184
|
+
*
|
|
185
|
+
* @param callback - Returns a single HTML string or an array. Called once per page.
|
|
186
|
+
*/
|
|
187
|
+
injectBody(callback: (context: PageContext) => string | string[]): void;
|
|
188
|
+
|
|
189
|
+
// ─── MDX Pipeline Extension ────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register additional remark (Markdown) plugins for the MDX compilation pipeline.
|
|
193
|
+
* Plugins from all plugins are merged and applied **after** the default set.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* build.remarkPlugins(() => [require("remark-custom-heading-id")]);
|
|
197
|
+
*/
|
|
198
|
+
remarkPlugins(callback: () => Pluggable[]): void;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Register additional rehype (HTML) plugins for the MDX compilation pipeline.
|
|
202
|
+
* Plugins from all plugins are merged and applied **after** the default set.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* build.rehypePlugins(() => [require("rehype-autolink-headings")]);
|
|
206
|
+
*/
|
|
207
|
+
rehypePlugins(callback: () => Pluggable[]): void;
|
|
208
|
+
|
|
209
|
+
// ─── Dev Server ────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register a callback to intercept incoming requests during development.
|
|
213
|
+
* The **first** callback to return a `Response` short-circuits all subsequent handlers.
|
|
214
|
+
*
|
|
215
|
+
* Use for: custom API routes, mock data endpoints, redirects, request logging.
|
|
216
|
+
*
|
|
217
|
+
* @param callback - Receives the Request and dev server context. Return Response or void.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* build.handleRequest((req, ctx) => {
|
|
221
|
+
* if (new URL(req.url).pathname === "/api/status") {
|
|
222
|
+
* return new Response(JSON.stringify({ ok: true }), {
|
|
223
|
+
* headers: { "Content-Type": "application/json" },
|
|
224
|
+
* });
|
|
225
|
+
* }
|
|
226
|
+
* });
|
|
227
|
+
*/
|
|
228
|
+
handleRequest(
|
|
229
|
+
callback: (
|
|
230
|
+
req: Request,
|
|
231
|
+
context: DevServerContext
|
|
232
|
+
) => Response | void | Promise<Response | void>
|
|
233
|
+
): void;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Plugin Interface ────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A DocuBook plugin.
|
|
240
|
+
*
|
|
241
|
+
* Follows Bun's `BunPlugin` convention:
|
|
242
|
+
* - `name`: unique identifier for the plugin
|
|
243
|
+
* - `setup(build)`: called once to register lifecycle hooks via the provided `PluginBuilder`
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* const myPlugin: DocuBookPlugin = {
|
|
247
|
+
* name: "analytics",
|
|
248
|
+
* setup(build) {
|
|
249
|
+
* build.injectHead(() => `<script>...</script>`);
|
|
250
|
+
* build.onEnd(() => console.log("build done"));
|
|
251
|
+
* },
|
|
252
|
+
* };
|
|
253
|
+
*/
|
|
254
|
+
export interface DocuBookPlugin {
|
|
255
|
+
/** Unique plugin name. Used in error messages and logs. */
|
|
256
|
+
name: string;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Called once after all plugins are loaded and before the build begins.
|
|
260
|
+
* Register all lifecycle hooks via the `build` (PluginBuilder) parameter.
|
|
261
|
+
*
|
|
262
|
+
* @param build - The PluginBuilder instance for this build session.
|
|
263
|
+
*/
|
|
264
|
+
setup(build: PluginBuilder): void | Promise<void>;
|
|
265
|
+
}
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* content = first paragraph/list items after each heading
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { readFile, writeFile,
|
|
14
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
15
|
import { resolve, join } from "node:path";
|
|
16
16
|
import { extractFrontmatterWithContent } from "@docubook/core";
|
|
17
17
|
import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
|
|
18
|
+
import { scanMdxFiles } from "./utils";
|
|
18
19
|
|
|
19
20
|
const docuConfig = loadDocuConfig();
|
|
20
21
|
|
|
@@ -183,29 +184,6 @@ export function extractRecords(filePath: string, raw: string): SearchRecord[] {
|
|
|
183
184
|
return records;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
async function scanMdxFiles(dir: string, base = ""): Promise<{ path: string; absPath: string }[]> {
|
|
187
|
-
const files: { path: string; absPath: string }[] = [];
|
|
188
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
189
|
-
|
|
190
|
-
for (const entry of entries) {
|
|
191
|
-
const fullPath = join(dir, entry.name);
|
|
192
|
-
const relPath = base ? `${base}/${entry.name}` : entry.name;
|
|
193
|
-
|
|
194
|
-
if (entry.isDirectory()) {
|
|
195
|
-
if (entry.name === "assets" || entry.name.startsWith(".")) continue;
|
|
196
|
-
files.push(...(await scanMdxFiles(fullPath, relPath)));
|
|
197
|
-
} else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
|
|
198
|
-
if (entry.name === "index.mdx" && !base) continue;
|
|
199
|
-
let path = relPath.replace(/\.(mdx|md)$/, "");
|
|
200
|
-
if (/\/index$/.test(path)) {
|
|
201
|
-
path = path.replace(/\/index$/, "");
|
|
202
|
-
}
|
|
203
|
-
files.push({ path, absPath: fullPath });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return files;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
187
|
export async function generateSearchIndex(docsDir?: string, outputDir?: string): Promise<number> {
|
|
210
188
|
const docs = resolve(docsDir || DOCS_DIR);
|
|
211
189
|
const dist = resolve(outputDir || ASSETS_DIR);
|
package/.docu/node/security.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
|
|
3
4
|
export const SECURITY_HEADERS: Record<string, string> = {
|
|
4
5
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
@@ -32,18 +33,58 @@ export function isPathSafe(pathname: string, baseDir: string): boolean {
|
|
|
32
33
|
const decoded = decodeURIComponent(pathname);
|
|
33
34
|
const resolved = resolve(baseDir, decoded.slice(1));
|
|
34
35
|
const baseDirSlash = baseDir.endsWith("/") ? baseDir : baseDir + "/";
|
|
35
|
-
|
|
36
|
+
if (!(resolved === baseDir || resolved.startsWith(baseDirSlash))) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (resolved === baseDir) return true;
|
|
41
|
+
try {
|
|
42
|
+
const real = realpathSync(resolved);
|
|
43
|
+
const realBase = realpathSync(baseDir);
|
|
44
|
+
const realBaseDirSlash = realBase.endsWith("/") ? realBase : realBase + "/";
|
|
45
|
+
return real.startsWith(realBaseDirSlash);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const nodeErr = err as NodeJS.ErrnoException;
|
|
48
|
+
if (nodeErr.code === "ENOENT") {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
console.error(
|
|
52
|
+
`[security] isPathSafe error for pathname="${pathname}": ${nodeErr.message} (code=${nodeErr.code})`
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
export function isSlugSafe(slug: string, docsDir: string): boolean {
|
|
39
59
|
const resolved = resolve(docsDir, slug);
|
|
40
60
|
const docsDirSlash = docsDir.endsWith("/") ? docsDir : docsDir + "/";
|
|
41
|
-
|
|
61
|
+
if (!(resolved === docsDir || resolved.startsWith(docsDirSlash))) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (resolved === docsDir) return true;
|
|
66
|
+
try {
|
|
67
|
+
const real = realpathSync(resolved);
|
|
68
|
+
const realDocsDir = realpathSync(docsDir);
|
|
69
|
+
const realDocsDirSlash = realDocsDir.endsWith("/") ? realDocsDir : realDocsDir + "/";
|
|
70
|
+
return real.startsWith(realDocsDirSlash);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const nodeErr = err as NodeJS.ErrnoException;
|
|
73
|
+
if (nodeErr.code === "ENOENT") {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
console.error(
|
|
77
|
+
`[security] isSlugSafe error for slug="${slug}": ${nodeErr.message} (code=${nodeErr.code})`
|
|
78
|
+
);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
export function injectNonce(html: string, nonce: string): string {
|
|
45
84
|
return html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
|
|
46
|
-
if (/nonce\s*=/i.test(match))
|
|
85
|
+
if (/nonce\s*=/i.test(match)) {
|
|
86
|
+
return match.replace(/nonce="[^"]*"/i, `nonce="${nonce}"`);
|
|
87
|
+
}
|
|
47
88
|
return match.replace(/>$/, ` nonce="${nonce}">`);
|
|
48
89
|
});
|
|
49
90
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import React, { type ReactNode } from "react";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { compileMdx } from "./mdx";
|
|
7
|
+
import { getContentType } from "./utils";
|
|
8
|
+
import { DOCS_DIR, DIST_DIR, PROJECT_ROOT } from "./paths";
|
|
9
|
+
import { BuildPluginBuilder } from "./plugin-builder";
|
|
10
|
+
import type { PageContext } from "./plugin";
|
|
11
|
+
import type { DocuConfig, TocItem } from "./types";
|
|
12
|
+
import DocsPage from "../pages/docs/[[...slug]]";
|
|
13
|
+
import NotFoundPage from "../pages/404";
|
|
14
|
+
import IndexPage from "../pages/index";
|
|
15
|
+
import { DocsLayout } from "../components/DocsLayout";
|
|
16
|
+
import { generateNonce, isPathSafe, isSlugSafe, htmlResponse, SECURITY_HEADERS } from "./security";
|
|
17
|
+
import { htmlShell as createHtmlShell, hmrScript, errorHtml } from "./html";
|
|
18
|
+
|
|
19
|
+
export interface ServerState {
|
|
20
|
+
docuConfig: DocuConfig;
|
|
21
|
+
assetManifest: { css: string; js: string };
|
|
22
|
+
inlineThemeCss: string;
|
|
23
|
+
builder: BuildPluginBuilder | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createHtmlResponse(
|
|
27
|
+
title: string,
|
|
28
|
+
description: string,
|
|
29
|
+
body: string,
|
|
30
|
+
status: number,
|
|
31
|
+
state: ServerState
|
|
32
|
+
): Response {
|
|
33
|
+
const nonce = generateNonce();
|
|
34
|
+
const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
|
|
35
|
+
const html = createHtmlShell({
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
body,
|
|
39
|
+
favicon,
|
|
40
|
+
css: state.assetManifest.css,
|
|
41
|
+
js: state.assetManifest.js,
|
|
42
|
+
nonce,
|
|
43
|
+
extraScripts: hmrScript(nonce),
|
|
44
|
+
themeCss: state.inlineThemeCss,
|
|
45
|
+
});
|
|
46
|
+
return htmlResponse(html, nonce, status, process.env.NODE_ENV !== "production");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getDocsForSlug(
|
|
50
|
+
slug: string,
|
|
51
|
+
state: ServerState
|
|
52
|
+
): Promise<{
|
|
53
|
+
content: ReactNode;
|
|
54
|
+
compiledSource: string;
|
|
55
|
+
frontmatter: Record<string, unknown>;
|
|
56
|
+
tocs: TocItem[];
|
|
57
|
+
filePath: string;
|
|
58
|
+
resolvedContent: string;
|
|
59
|
+
} | null> {
|
|
60
|
+
if (!isSlugSafe(slug, DOCS_DIR)) return null;
|
|
61
|
+
|
|
62
|
+
const paths = [
|
|
63
|
+
join(DOCS_DIR, slug, "index.mdx"),
|
|
64
|
+
join(DOCS_DIR, `${slug}.mdx`),
|
|
65
|
+
join(DOCS_DIR, slug, "index.md"),
|
|
66
|
+
join(DOCS_DIR, `${slug}.md`),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
let filePath: string | null = null;
|
|
70
|
+
let raw: string | null = null;
|
|
71
|
+
for (const p of paths) {
|
|
72
|
+
if (!p.startsWith(DOCS_DIR)) continue;
|
|
73
|
+
try {
|
|
74
|
+
raw = await readFile(p, "utf-8");
|
|
75
|
+
filePath = p;
|
|
76
|
+
break;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!filePath || !raw) return null;
|
|
82
|
+
|
|
83
|
+
const relPath = filePath.replace(PROJECT_ROOT + "/", "");
|
|
84
|
+
|
|
85
|
+
let content = raw;
|
|
86
|
+
if (state.builder) {
|
|
87
|
+
const transformed = await state.builder.runOnLoad(relPath, content);
|
|
88
|
+
if (transformed?.contents) {
|
|
89
|
+
content = transformed.contents;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const remarkPlugins = state.builder?.collectRemarkPlugins();
|
|
94
|
+
const rehypePlugins = state.builder?.collectRehypePlugins();
|
|
95
|
+
const result = await compileMdx(content, relPath, undefined, remarkPlugins, rehypePlugins);
|
|
96
|
+
|
|
97
|
+
let frontmatter = result.frontmatter as Record<string, unknown>;
|
|
98
|
+
if (state.builder) {
|
|
99
|
+
frontmatter = await state.builder.runTransformFrontmatterChain(frontmatter, {
|
|
100
|
+
slug: slug || "/",
|
|
101
|
+
filePath: relPath,
|
|
102
|
+
content,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
content: result.content,
|
|
108
|
+
compiledSource: result.compiledSource,
|
|
109
|
+
frontmatter,
|
|
110
|
+
tocs: result.tocs,
|
|
111
|
+
filePath: relPath,
|
|
112
|
+
resolvedContent: content,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function renderDocsServerPage(
|
|
117
|
+
doc: NonNullable<Awaited<ReturnType<typeof getDocsForSlug>>>,
|
|
118
|
+
slug: string[],
|
|
119
|
+
pathname: string,
|
|
120
|
+
state: ServerState
|
|
121
|
+
): Promise<Response> {
|
|
122
|
+
const title = (doc.frontmatter.title as string) || slug.join("/") || "Docs";
|
|
123
|
+
const description = (doc.frontmatter.description as string) || "";
|
|
124
|
+
|
|
125
|
+
const page = React.createElement(
|
|
126
|
+
DocsLayout,
|
|
127
|
+
{ repoUrl: state.docuConfig.repo?.url, pathname },
|
|
128
|
+
React.createElement(DocsPage, {
|
|
129
|
+
slug,
|
|
130
|
+
title,
|
|
131
|
+
description,
|
|
132
|
+
date: doc.frontmatter.date as string | undefined,
|
|
133
|
+
content: doc.content,
|
|
134
|
+
tocs: doc.tocs,
|
|
135
|
+
filePath: doc.filePath,
|
|
136
|
+
repoUrl: state.docuConfig.repo?.url,
|
|
137
|
+
compiledSource: doc.compiledSource,
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const body = renderToString(page);
|
|
142
|
+
|
|
143
|
+
if (state.builder) {
|
|
144
|
+
const ctx: PageContext = {
|
|
145
|
+
slug: slug.join("/") || "/",
|
|
146
|
+
filePath: doc.filePath,
|
|
147
|
+
frontmatter: doc.frontmatter,
|
|
148
|
+
content: doc.resolvedContent,
|
|
149
|
+
config: state.docuConfig,
|
|
150
|
+
};
|
|
151
|
+
const headExtra = state.builder.collectHead(ctx);
|
|
152
|
+
const bodyExtra = state.builder.collectBody(ctx);
|
|
153
|
+
const nonce = generateNonce();
|
|
154
|
+
const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
|
|
155
|
+
let html = createHtmlShell({
|
|
156
|
+
title,
|
|
157
|
+
description,
|
|
158
|
+
body,
|
|
159
|
+
favicon,
|
|
160
|
+
css: state.assetManifest.css,
|
|
161
|
+
js: state.assetManifest.js,
|
|
162
|
+
nonce,
|
|
163
|
+
extraScripts: hmrScript(nonce),
|
|
164
|
+
themeCss: state.inlineThemeCss,
|
|
165
|
+
headExtra,
|
|
166
|
+
bodyExtra,
|
|
167
|
+
});
|
|
168
|
+
html = await state.builder.runTransformHtmlChain(html, ctx);
|
|
169
|
+
return htmlResponse(html, nonce, 200, process.env.NODE_ENV !== "production");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return createHtmlResponse(title, description, body, 200, state);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderPage(
|
|
176
|
+
Component: React.ComponentType<Record<string, unknown>>,
|
|
177
|
+
title: string,
|
|
178
|
+
description: string,
|
|
179
|
+
status: number,
|
|
180
|
+
state: ServerState,
|
|
181
|
+
props: Record<string, unknown> = {}
|
|
182
|
+
): Response {
|
|
183
|
+
const page = React.createElement(
|
|
184
|
+
DocsLayout,
|
|
185
|
+
{ repoUrl: state.docuConfig.repo?.url, pathname: "/docs" },
|
|
186
|
+
React.createElement(Component, props)
|
|
187
|
+
);
|
|
188
|
+
const body = renderToString(page);
|
|
189
|
+
return createHtmlResponse(title, description, body, status, state);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function handleDocsIndex(state: ServerState): Promise<Response> {
|
|
193
|
+
const doc = await getDocsForSlug("", state);
|
|
194
|
+
if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
|
|
195
|
+
return renderDocsServerPage(doc, [], "/docs", state);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function handleDocsRoute(slug: string[], state: ServerState): Promise<Response> {
|
|
199
|
+
const path = slug.join("/");
|
|
200
|
+
const doc = await getDocsForSlug(path, state);
|
|
201
|
+
if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
|
|
202
|
+
return renderDocsServerPage(doc, slug, `/docs/${path}`, state);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function handleIndex(state: ServerState): Response {
|
|
206
|
+
const page = React.createElement(IndexPage);
|
|
207
|
+
const body = renderToString(page);
|
|
208
|
+
return createHtmlResponse(
|
|
209
|
+
state.docuConfig.meta?.title || "DocuBook",
|
|
210
|
+
state.docuConfig.meta?.description || "",
|
|
211
|
+
body,
|
|
212
|
+
200,
|
|
213
|
+
state
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function handleNotFound(state: ServerState): Response {
|
|
218
|
+
return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function serveStatic(pathname: string): Response | null {
|
|
222
|
+
if (!isPathSafe(pathname, DIST_DIR)) return null;
|
|
223
|
+
const decoded = decodeURIComponent(pathname);
|
|
224
|
+
const assetPath = resolve(DIST_DIR, decoded.slice(1));
|
|
225
|
+
try {
|
|
226
|
+
const s = statSync(assetPath);
|
|
227
|
+
if (s.isFile()) {
|
|
228
|
+
return new Response(Bun.file(assetPath), {
|
|
229
|
+
headers: { "Content-Type": getContentType(pathname) },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (decoded.startsWith("/docs/assets/")) {
|
|
237
|
+
const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
|
|
238
|
+
const docsAssetsDir = resolve(DOCS_DIR, "assets");
|
|
239
|
+
const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
|
|
240
|
+
if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirSlash)) return null;
|
|
241
|
+
try {
|
|
242
|
+
const s = statSync(docsAsset);
|
|
243
|
+
if (s.isFile()) {
|
|
244
|
+
return new Response(Bun.file(docsAsset), {
|
|
245
|
+
headers: { "Content-Type": getContentType(pathname) },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function serverErrorResponse(error: unknown): Response {
|
|
256
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
257
|
+
const st = error instanceof Error ? error.stack : undefined;
|
|
258
|
+
return new Response(errorHtml(msg, st), {
|
|
259
|
+
status: 500,
|
|
260
|
+
headers: {
|
|
261
|
+
"Content-Type": "text/html",
|
|
262
|
+
...SECURITY_HEADERS,
|
|
263
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|