@docubook/flame 2.0.3 → 2.1.1
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 +6 -5
- package/.docu/components/DocsLayout.tsx +7 -4
- package/.docu/components/Menu.tsx +10 -9
- package/.docu/components/Navbar.tsx +24 -22
- package/.docu/components/Pagination.tsx +3 -2
- package/.docu/components/Search.tsx +6 -1
- package/.docu/components/Sidebar.tsx +22 -21
- package/.docu/components/Sublink.tsx +3 -3
- package/.docu/lib/build.deno.js +1 -1
- package/.docu/lib/build.impl-DxI2YYqL.js +2 -0
- package/.docu/lib/{build.impl-BSt6Vikt.js → build.impl-oTVkE6D9.js} +60 -31
- package/.docu/lib/build.impl-oTVkE6D9.js.map +1 -0
- package/.docu/lib/build.node.js +1 -1
- package/.docu/lib/clean.js +1 -1
- package/.docu/lib/deploy.deno.js +1 -1
- package/.docu/lib/deploy.node.js +1 -1
- package/.docu/lib/{deploy.shared-D6S_FE7d.js → deploy.shared-p8UHVvAw.js} +27 -8
- package/.docu/lib/deploy.shared-p8UHVvAw.js.map +1 -0
- package/.docu/lib/{html.shared-CsEFmy_Y.js → html.shared-RPCM1y5Q.js} +264 -73
- package/.docu/lib/html.shared-RPCM1y5Q.js.map +1 -0
- package/.docu/lib/{logger-DFKQUy8M.js → logger-NdMEl8u7.js} +3 -3
- package/.docu/lib/{logger-DFKQUy8M.js.map → logger-NdMEl8u7.js.map} +1 -1
- package/.docu/lib/paths-CHXABfXa.js +332 -0
- package/.docu/lib/paths-CHXABfXa.js.map +1 -0
- package/.docu/lib/preview.deno.js +1 -1
- package/.docu/lib/{preview.impl-BfESMUM7.js → preview.impl-CLHIPgry.js} +4 -4
- package/.docu/lib/preview.impl-CLHIPgry.js.map +1 -0
- package/.docu/lib/preview.node.js +1 -1
- package/.docu/lib/{utils-B_CoyKie.js → server-utils-BYWB7A6T.js} +3 -21
- package/.docu/lib/server-utils-BYWB7A6T.js.map +1 -0
- package/.docu/lib/server.deno.js +1 -1
- package/.docu/lib/{server.impl-CZ4eMe5L.js → server.impl-C4t3Xqb5.js} +24 -19
- package/.docu/lib/server.impl-C4t3Xqb5.js.map +1 -0
- package/.docu/lib/server.node.js +1 -1
- package/.docu/node/base-path.ts +306 -0
- package/.docu/node/build.impl.ts +50 -17
- package/.docu/node/build.ts +50 -17
- package/.docu/node/cache-key.ts +92 -4
- package/.docu/node/client-routes.ts +16 -1
- package/.docu/node/deploy.shared.ts +51 -15
- package/.docu/node/deploy.ts +7 -3
- package/.docu/node/html.shared.ts +33 -3
- package/.docu/node/html.ts +18 -3
- package/.docu/node/hydrate.node.ts +6 -0
- package/.docu/node/hydrate.ts +16 -1
- package/.docu/node/mdx.ts +102 -23
- package/.docu/node/paths.ts +115 -0
- package/.docu/node/preview.impl.ts +1 -1
- package/.docu/node/preview.ts +1 -1
- package/.docu/node/search-indexer.ts +20 -3
- package/.docu/node/seo.ts +41 -7
- package/.docu/node/server-routes.ts +34 -11
- package/.docu/node/server-utils.ts +152 -0
- package/.docu/node/server.impl.ts +22 -12
- package/.docu/node/server.ts +23 -27
- package/.docu/node/types.ts +13 -0
- package/.docu/node/utils.ts +56 -149
- package/.docu/pages/404.tsx +2 -1
- package/.docu/pages/index.tsx +6 -13
- package/bin/cli.js +17 -21
- package/docu.schema.json +10 -1
- package/package.json +5 -5
- package/template/docu.json +1 -0
- package/.docu/lib/build.impl-BSt6Vikt.js.map +0 -1
- package/.docu/lib/build.impl-xNDEh8DJ.js +0 -2
- package/.docu/lib/deploy.shared-D6S_FE7d.js.map +0 -1
- package/.docu/lib/html.shared-CsEFmy_Y.js.map +0 -1
- package/.docu/lib/paths-Cbz-dj4s.js +0 -72
- package/.docu/lib/paths-Cbz-dj4s.js.map +0 -1
- package/.docu/lib/preview.impl-BfESMUM7.js.map +0 -1
- package/.docu/lib/server.impl-CZ4eMe5L.js.map +0 -1
- package/.docu/lib/utils-B_CoyKie.js.map +0 -1
|
@@ -4,8 +4,9 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
4
4
|
import React, { type ReactNode } from "react";
|
|
5
5
|
import { renderToString } from "react-dom/server";
|
|
6
6
|
import { compileMdx, frontmatterField } from "./mdx";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { getContentType } from "./server-utils";
|
|
8
|
+
import { defaultFavicon } from "./paths";
|
|
9
|
+
import { DOCS_DIR, DIST_DIR, PAGES_DIR, PROJECT_ROOT, servedBasePath, docsDepth } from "./paths";
|
|
9
10
|
import { BuildPluginBuilder } from "./plugin-builder";
|
|
10
11
|
import type { PageContext, PageType } from "./plugin";
|
|
11
12
|
import type { AssetEntry, AssetManifest, DocuConfig, TocItem } from "./types";
|
|
@@ -34,7 +35,7 @@ async function createHtmlResponse(
|
|
|
34
35
|
depth = 0
|
|
35
36
|
): Promise<Response> {
|
|
36
37
|
const nonce = generateNonce();
|
|
37
|
-
const favicon = state.docuConfig.meta?.favicon ||
|
|
38
|
+
const favicon = state.docuConfig.meta?.favicon || defaultFavicon();
|
|
38
39
|
const assets: AssetEntry = state.assetManifest[pageType];
|
|
39
40
|
const context: PageContext = {
|
|
40
41
|
...page,
|
|
@@ -55,6 +56,10 @@ async function createHtmlResponse(
|
|
|
55
56
|
headExtra: state.builder?.collectHead(context),
|
|
56
57
|
bodyExtra: state.builder?.collectBody(context),
|
|
57
58
|
depth,
|
|
59
|
+
basePath: servedBasePath(),
|
|
60
|
+
// Dev serves the project from the origin root, so the host's deployment
|
|
61
|
+
// path never applies here — only the static build writes it into HTML.
|
|
62
|
+
deployPath: "",
|
|
58
63
|
// 404 pages can be requested at arbitrary depths (e.g. a noLink section
|
|
59
64
|
// path typed in the address bar) — relative asset paths would resolve
|
|
60
65
|
// against the wrong directory and break CSS/JS.
|
|
@@ -185,8 +190,8 @@ async function renderDocsServerPage(
|
|
|
185
190
|
|
|
186
191
|
const body = renderToString(page);
|
|
187
192
|
|
|
188
|
-
//
|
|
189
|
-
const depth = slug.
|
|
193
|
+
// Same relative climb as the static build — see docsDepth.
|
|
194
|
+
const depth = docsDepth(slug.join("/"), servedBasePath());
|
|
190
195
|
|
|
191
196
|
return createHtmlResponse(
|
|
192
197
|
title,
|
|
@@ -233,16 +238,33 @@ async function renderPage(
|
|
|
233
238
|
|
|
234
239
|
export async function handleDocsIndex(state: ServerState): Promise<Response> {
|
|
235
240
|
const doc = await getDocsForSlug("", state);
|
|
236
|
-
if (!doc)
|
|
237
|
-
|
|
241
|
+
if (!doc)
|
|
242
|
+
return renderPage(
|
|
243
|
+
NotFoundPage,
|
|
244
|
+
"404 - Not Found",
|
|
245
|
+
"",
|
|
246
|
+
404,
|
|
247
|
+
state,
|
|
248
|
+
{},
|
|
249
|
+
docsDepth("", servedBasePath())
|
|
250
|
+
);
|
|
251
|
+
return renderDocsServerPage(doc, [], servedBasePath() || "/", state);
|
|
238
252
|
}
|
|
239
253
|
|
|
240
254
|
export async function handleDocsRoute(slug: string[], state: ServerState): Promise<Response> {
|
|
241
255
|
const path = slug.join("/");
|
|
242
256
|
const doc = await getDocsForSlug(path, state);
|
|
243
257
|
if (!doc)
|
|
244
|
-
return renderPage(
|
|
245
|
-
|
|
258
|
+
return renderPage(
|
|
259
|
+
NotFoundPage,
|
|
260
|
+
"404 - Not Found",
|
|
261
|
+
"",
|
|
262
|
+
404,
|
|
263
|
+
state,
|
|
264
|
+
{},
|
|
265
|
+
docsDepth(path, servedBasePath())
|
|
266
|
+
);
|
|
267
|
+
return renderDocsServerPage(doc, slug, `${servedBasePath()}/${path}`, state);
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
export async function handleIndex(state: ServerState): Promise<Response> {
|
|
@@ -287,9 +309,10 @@ export function serveStatic(pathname: string): Response | null {
|
|
|
287
309
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
288
310
|
}
|
|
289
311
|
|
|
290
|
-
|
|
312
|
+
const docsAssetsPrefix = `${servedBasePath()}/assets/`;
|
|
313
|
+
if (decoded.startsWith(docsAssetsPrefix)) {
|
|
291
314
|
const docsAssetsDir = resolve(DOCS_DIR, "assets");
|
|
292
|
-
const requestedRelative = decoded.slice(
|
|
315
|
+
const requestedRelative = decoded.slice(docsAssetsPrefix.length);
|
|
293
316
|
const docsAsset = resolve(docsAssetsDir, requestedRelative);
|
|
294
317
|
const docsAssetsDirWithSep = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
|
|
295
318
|
if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirWithSep)) return null;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-only helpers that touch the filesystem, git, or `node:path`.
|
|
7
|
+
*
|
|
8
|
+
* Kept OUT of `./utils` (which is bundled into the browser): Bun's bundler
|
|
9
|
+
* injects a full `node:path` polyfill into browser builds for any module that
|
|
10
|
+
* imports it, even when the calls are never reached at runtime. Everything in
|
|
11
|
+
* this file is imported exclusively by server-side modules (build, dev server,
|
|
12
|
+
* search indexer) and their tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ScannedMdxFile {
|
|
16
|
+
path: string;
|
|
17
|
+
absPath: string;
|
|
18
|
+
mtime: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scan a directory recursively for MDX/MD files.
|
|
23
|
+
* Skips "assets" directories, hidden directories (dot-prefixed), and root-level
|
|
24
|
+
* index.mdx/index.md (the docs root renders separately in build).
|
|
25
|
+
* Shared between build.ts and search-indexer.ts.
|
|
26
|
+
*/
|
|
27
|
+
export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
|
|
28
|
+
const files: ScannedMdxFile[] = [];
|
|
29
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const fullPath = join(dir, entry.name);
|
|
33
|
+
const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
|
|
34
|
+
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (entry.name === "assets" || entry.name.startsWith(".")) continue;
|
|
37
|
+
files.push(...(await scanMdxFiles(fullPath, relativePath)));
|
|
38
|
+
} else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
|
|
39
|
+
// Root index renders separately (either extension) — scanning it would
|
|
40
|
+
// produce a duplicate "index" page colliding with dist/docs/index.html.
|
|
41
|
+
if (!baseDir && (entry.name === "index.mdx" || entry.name === "index.md")) continue;
|
|
42
|
+
const stats = await stat(fullPath);
|
|
43
|
+
let path = relativePath.replace(/\.(mdx|md)$/, "");
|
|
44
|
+
|
|
45
|
+
if (/\/index$/.test(path)) {
|
|
46
|
+
path = path.replace(/\/index$/, "");
|
|
47
|
+
}
|
|
48
|
+
files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the docs root index source — docs/index.mdx preferred, docs/index.md
|
|
57
|
+
* as fallback (mirrors the dev server's getDocsForSlug extension handling).
|
|
58
|
+
*/
|
|
59
|
+
export function resolveDocsIndexSource(docsDir: string): string | undefined {
|
|
60
|
+
for (const ext of [".mdx", ".md"]) {
|
|
61
|
+
const candidate = join(docsDir, `index${ext}`);
|
|
62
|
+
if (existsSync(candidate)) return candidate;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get git last modified date for a file */
|
|
68
|
+
export async function getGitLastModified(filePath: string): Promise<string | null> {
|
|
69
|
+
try {
|
|
70
|
+
const cleanPath = filePath.replace(/^\//, "");
|
|
71
|
+
if (
|
|
72
|
+
!cleanPath ||
|
|
73
|
+
!/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
|
|
74
|
+
/(^|\/)\.\.($|\/)/.test(cleanPath)
|
|
75
|
+
)
|
|
76
|
+
return null;
|
|
77
|
+
const proc = Bun.spawn(["git", "log", "-1", "--format=%cI", "--", cleanPath], {
|
|
78
|
+
stderr: "ignore",
|
|
79
|
+
});
|
|
80
|
+
const text = await new Response(proc.stdout).text();
|
|
81
|
+
const date = text.trim();
|
|
82
|
+
return date || null;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error("Failed to get git last modified for", filePath, err);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Batch git last modified dates for multiple files in a single spawn */
|
|
90
|
+
export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {
|
|
91
|
+
const result = new Map<string, string>();
|
|
92
|
+
if (filePaths.length === 0) return result;
|
|
93
|
+
|
|
94
|
+
// Filter and validate paths — same guard as getGitLastModified
|
|
95
|
+
const safePaths: string[] = [];
|
|
96
|
+
for (const fp of filePaths) {
|
|
97
|
+
const cleanPath = fp.replace(/^\//, "");
|
|
98
|
+
if (
|
|
99
|
+
!cleanPath ||
|
|
100
|
+
!/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
|
|
101
|
+
/(^|\/)\.\.($|\/)/.test(cleanPath)
|
|
102
|
+
) {
|
|
103
|
+
console.warn(`[server-utils] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
safePaths.push(cleanPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (safePaths.length === 0) return result;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const proc = Bun.spawn(
|
|
113
|
+
["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...safePaths],
|
|
114
|
+
{ stderr: "ignore" }
|
|
115
|
+
);
|
|
116
|
+
const text = await new Response(proc.stdout).text();
|
|
117
|
+
let currentDate = "";
|
|
118
|
+
|
|
119
|
+
for (const line of text.split("\n")) {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed) continue;
|
|
122
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
|
|
123
|
+
currentDate = trimmed;
|
|
124
|
+
} else if (currentDate && !result.has(trimmed)) {
|
|
125
|
+
result.set(trimmed, currentDate);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error("Failed to get git last modified batch for", filePaths, err);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const MIME_TYPES: Record<string, string> = {
|
|
136
|
+
html: "text/html",
|
|
137
|
+
css: "text/css",
|
|
138
|
+
js: "application/javascript",
|
|
139
|
+
json: "application/json",
|
|
140
|
+
png: "image/png",
|
|
141
|
+
jpg: "image/jpeg",
|
|
142
|
+
jpeg: "image/jpeg",
|
|
143
|
+
svg: "image/svg+xml",
|
|
144
|
+
ico: "image/x-icon",
|
|
145
|
+
woff: "font/woff",
|
|
146
|
+
woff2: "font/woff2",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function getContentType(pathname: string): string {
|
|
150
|
+
const ext = pathname.split(".").pop()?.toLowerCase();
|
|
151
|
+
return MIME_TYPES[ext || ""] || "application/octet-stream";
|
|
152
|
+
}
|
|
@@ -9,7 +9,7 @@ import { watch, existsSync } from "node:fs";
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import type { RuntimeAdapter, ServerHandle } from "./runtime";
|
|
12
|
-
import { DOCS_DIR, loadDocuConfig } from "./paths";
|
|
12
|
+
import { DOCS_DIR, loadDocuConfig, resolveBasePath, assertValidBasePath } from "./paths";
|
|
13
13
|
import { loadPlugins } from "./plugin-loader";
|
|
14
14
|
import { BuildPluginBuilder } from "./plugin-builder";
|
|
15
15
|
import { buildClientBundle, computeInlineThemeCss } from "./hydrate.node";
|
|
@@ -26,10 +26,15 @@ import {
|
|
|
26
26
|
type ServerState,
|
|
27
27
|
} from "./server-routes";
|
|
28
28
|
import { wrapPluginResponse } from "./security";
|
|
29
|
-
import { stripDocsHtmlSuffix } from "./utils";
|
|
29
|
+
import { matchDocsSlug, stripDocsHtmlSuffix } from "./utils";
|
|
30
30
|
|
|
31
31
|
export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle> {
|
|
32
32
|
const docuConfig = loadDocuConfig();
|
|
33
|
+
const resolvedBasePath = resolveBasePath(docuConfig);
|
|
34
|
+
|
|
35
|
+
// Report normalized prefixes (and stop on a value that cannot be honored)
|
|
36
|
+
// before the server announces a site that would be built somewhere else.
|
|
37
|
+
for (const warning of assertValidBasePath()) logger.warn(warning.message);
|
|
33
38
|
|
|
34
39
|
const parsedPort = parseInt(process.env.PORT ?? "3000", 10);
|
|
35
40
|
const PORT =
|
|
@@ -149,7 +154,7 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
|
|
|
149
154
|
const url = new URL(req.url);
|
|
150
155
|
// Generated links carry `.html` (matching the static build output);
|
|
151
156
|
// route them to the same handler as their extensionless form.
|
|
152
|
-
const pathname = stripDocsHtmlSuffix(url.pathname);
|
|
157
|
+
const pathname = stripDocsHtmlSuffix(url.pathname, resolvedBasePath);
|
|
153
158
|
const startTime = performance.now();
|
|
154
159
|
|
|
155
160
|
if (builder) {
|
|
@@ -197,18 +202,23 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
|
|
|
197
202
|
|
|
198
203
|
let response: Response;
|
|
199
204
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
205
|
+
// The landing page owns `/` in every deployment. At the deployment root
|
|
206
|
+
// (`meta.basePath: ""`) docs would otherwise claim it, but the build
|
|
207
|
+
// skips the docs index there — so dev must not serve it either.
|
|
208
|
+
// Static assets were already handled above, so a root deployment may
|
|
209
|
+
// still match every other path as a docs route.
|
|
202
210
|
if (pathname === "/") {
|
|
203
211
|
response = await handleIndex(state);
|
|
204
|
-
} else if (pathname === "/docs" || pathname === "/docs/") {
|
|
205
|
-
response = await handleDocsIndex(state);
|
|
206
|
-
} else if (pathname.startsWith("/docs/")) {
|
|
207
|
-
const slug = pathname.slice("/docs/".length).split("/").filter(Boolean);
|
|
208
|
-
response =
|
|
209
|
-
slug.length === 0 ? await handleDocsIndex(state) : await handleDocsRoute(slug, state);
|
|
210
212
|
} else {
|
|
211
|
-
|
|
213
|
+
const docsSlug = matchDocsSlug(pathname, resolvedBasePath);
|
|
214
|
+
if (docsSlug) {
|
|
215
|
+
response =
|
|
216
|
+
docsSlug.length === 0
|
|
217
|
+
? await handleDocsIndex(state)
|
|
218
|
+
: await handleDocsRoute(docsSlug, state);
|
|
219
|
+
} else {
|
|
220
|
+
response = await handleNotFound(state);
|
|
221
|
+
}
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
logger.request(
|
package/.docu/node/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { watch, existsSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { DOCS_DIR,
|
|
4
|
+
import { DOCS_DIR, loadDocuConfig, resolveBasePath, assertValidBasePath } from "./paths";
|
|
5
5
|
import { loadPlugins } from "./plugin-loader";
|
|
6
6
|
import { BuildPluginBuilder } from "./plugin-builder";
|
|
7
7
|
import { buildClientBundle, computeInlineThemeCss } from "./hydrate";
|
|
@@ -18,9 +18,14 @@ import {
|
|
|
18
18
|
type ServerState,
|
|
19
19
|
} from "./server-routes";
|
|
20
20
|
import { wrapPluginResponse } from "./security";
|
|
21
|
-
import { stripDocsHtmlSuffix } from "./utils";
|
|
21
|
+
import { matchDocsSlug, stripDocsHtmlSuffix } from "./utils";
|
|
22
22
|
|
|
23
23
|
const docuConfig = loadDocuConfig();
|
|
24
|
+
const resolvedBasePath = resolveBasePath(docuConfig);
|
|
25
|
+
|
|
26
|
+
// Report normalized prefixes (and stop on a value that cannot be honored)
|
|
27
|
+
// before the server announces a site that would be built somewhere else.
|
|
28
|
+
for (const warning of assertValidBasePath()) logger.warn(warning.message);
|
|
24
29
|
|
|
25
30
|
const parsedPort = parseInt(process.env.PORT ?? "3000", 10);
|
|
26
31
|
const PORT =
|
|
@@ -64,16 +69,6 @@ const state: ServerState = {
|
|
|
64
69
|
builder,
|
|
65
70
|
};
|
|
66
71
|
|
|
67
|
-
let router: InstanceType<typeof Bun.FileSystemRouter> | null = null;
|
|
68
|
-
try {
|
|
69
|
-
router = new Bun.FileSystemRouter({
|
|
70
|
-
style: "nextjs",
|
|
71
|
-
dir: PAGES_DIR,
|
|
72
|
-
});
|
|
73
|
-
} catch (e) {
|
|
74
|
-
logger.warn(`FileSystemRouter failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
72
|
const hmrClients = new Set<ReadableStreamDefaultController>();
|
|
78
73
|
|
|
79
74
|
const require = createRequire(import.meta.url);
|
|
@@ -151,7 +146,7 @@ const server = Bun.serve({
|
|
|
151
146
|
const url = new URL(req.url);
|
|
152
147
|
// Generated links carry `.html` (matching the static build output);
|
|
153
148
|
// route them to the same handler as their extensionless form.
|
|
154
|
-
const pathname = stripDocsHtmlSuffix(url.pathname);
|
|
149
|
+
const pathname = stripDocsHtmlSuffix(url.pathname, resolvedBasePath);
|
|
155
150
|
const startTime = performance.now();
|
|
156
151
|
|
|
157
152
|
if (builder) {
|
|
@@ -199,24 +194,25 @@ const server = Bun.serve({
|
|
|
199
194
|
if (staticRes) return staticRes;
|
|
200
195
|
}
|
|
201
196
|
|
|
202
|
-
const match = router?.match(pathname);
|
|
203
197
|
let response: Response;
|
|
204
198
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (slug.length === 0) {
|
|
212
|
-
response = await handleDocsIndex(state);
|
|
213
|
-
} else {
|
|
214
|
-
response = await handleDocsRoute(slug, state);
|
|
215
|
-
}
|
|
216
|
-
} else if (routeName === "/") {
|
|
199
|
+
// The landing page owns `/` in every deployment. At the deployment root
|
|
200
|
+
// (`meta.basePath: ""`) docs would otherwise claim it, but the build
|
|
201
|
+
// skips the docs index there — so dev must not serve it either.
|
|
202
|
+
// Static assets were already handled above, so a root deployment may
|
|
203
|
+
// still match every other path as a docs route.
|
|
204
|
+
if (pathname === "/") {
|
|
217
205
|
response = await handleIndex(state);
|
|
218
206
|
} else {
|
|
219
|
-
|
|
207
|
+
const docsSlug = matchDocsSlug(pathname, resolvedBasePath);
|
|
208
|
+
if (docsSlug) {
|
|
209
|
+
response =
|
|
210
|
+
docsSlug.length === 0
|
|
211
|
+
? await handleDocsIndex(state)
|
|
212
|
+
: await handleDocsRoute(docsSlug, state);
|
|
213
|
+
} else {
|
|
214
|
+
response = await handleNotFound(state);
|
|
215
|
+
}
|
|
220
216
|
}
|
|
221
217
|
|
|
222
218
|
logger.request(
|
package/.docu/node/types.ts
CHANGED
|
@@ -18,7 +18,15 @@ export interface DocuRoute {
|
|
|
18
18
|
export interface DocuMeta {
|
|
19
19
|
title: string;
|
|
20
20
|
description: string;
|
|
21
|
+
/** Origin + deployment root, e.g. `https://docubook.pro` or `https://user.github.io`. */
|
|
21
22
|
baseURL: string;
|
|
23
|
+
/**
|
|
24
|
+
* URL prefix the docs site is served under, e.g. "/docs" (default) or
|
|
25
|
+
* "/repo" for a GitHub Pages project site. An empty string serves the site
|
|
26
|
+
* from the deployment root. Set it independently of `baseURL`: the prefix is
|
|
27
|
+
* appended to `baseURL` when building canonical/OG URLs.
|
|
28
|
+
*/
|
|
29
|
+
basePath?: string;
|
|
22
30
|
favicon?: string;
|
|
23
31
|
/** Default OG image path (e.g. /docs/assets/images/og.png). Used when page frontmatter has no image. */
|
|
24
32
|
ogImage?: string;
|
|
@@ -114,6 +122,11 @@ export interface BuildCacheEntry {
|
|
|
114
122
|
export interface BuildCacheMeta extends BuildCacheEntry {
|
|
115
123
|
version: number;
|
|
116
124
|
runtime: string;
|
|
125
|
+
/**
|
|
126
|
+
* Fingerprint of the framework's rendering sources. Optional so a cache file
|
|
127
|
+
* written before this field existed is treated as a mismatch and discarded.
|
|
128
|
+
*/
|
|
129
|
+
render?: string;
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
export interface BuildCache {
|
package/.docu/node/utils.ts
CHANGED
|
@@ -1,175 +1,82 @@
|
|
|
1
1
|
export { cn, parseDate, formatDate, formatDate2 } from "@docubook/core";
|
|
2
|
+
export {
|
|
3
|
+
withBasePath,
|
|
4
|
+
assetHref,
|
|
5
|
+
applyBasePath,
|
|
6
|
+
rebaseContentPath,
|
|
7
|
+
stripDocsHtmlSuffix,
|
|
8
|
+
matchDocsSlug,
|
|
9
|
+
} from "./base-path";
|
|
2
10
|
|
|
3
|
-
import {
|
|
4
|
-
import { readdir, stat } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
|
|
7
|
-
export interface ScannedMdxFile {
|
|
8
|
-
path: string;
|
|
9
|
-
absPath: string;
|
|
10
|
-
mtime: number;
|
|
11
|
-
}
|
|
11
|
+
import { DEFAULT_BASE_PATH, isDocsPath, rebaseContentPath } from "./base-path";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Client-safe utilities shared by SSR and the browser bundle.
|
|
15
|
+
*
|
|
16
|
+
* This module MUST NOT import Node built-ins (`node:fs`, `node:path`, ...):
|
|
17
|
+
* Bun's bundler injects a `node:path` polyfill into browser builds for any
|
|
18
|
+
* bundled module that imports it. Filesystem/git helpers live in
|
|
19
|
+
* `./server-utils` (server-only).
|
|
18
20
|
*/
|
|
19
|
-
export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
|
|
20
|
-
const files: ScannedMdxFile[] = [];
|
|
21
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
-
|
|
23
|
-
for (const entry of entries) {
|
|
24
|
-
const fullPath = join(dir, entry.name);
|
|
25
|
-
const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
|
|
26
|
-
|
|
27
|
-
if (entry.isDirectory()) {
|
|
28
|
-
if (entry.name === "assets" || entry.name.startsWith(".")) continue;
|
|
29
|
-
files.push(...(await scanMdxFiles(fullPath, relativePath)));
|
|
30
|
-
} else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
|
|
31
|
-
// Root index renders separately (either extension) — scanning it would
|
|
32
|
-
// produce a duplicate "index" page colliding with dist/docs/index.html.
|
|
33
|
-
if (!baseDir && (entry.name === "index.mdx" || entry.name === "index.md")) continue;
|
|
34
|
-
const stats = await stat(fullPath);
|
|
35
|
-
let path = relativePath.replace(/\.(mdx|md)$/, "");
|
|
36
|
-
|
|
37
|
-
if (/\/index$/.test(path)) {
|
|
38
|
-
path = path.replace(/\/index$/, "");
|
|
39
|
-
}
|
|
40
|
-
files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return files;
|
|
45
|
-
}
|
|
46
21
|
|
|
47
22
|
export function isExternalUrl(url: string): boolean {
|
|
48
23
|
return /^(https?:\/\/|\/\/)/.test(url);
|
|
49
24
|
}
|
|
50
25
|
|
|
51
|
-
/**
|
|
52
|
-
* Resolve the docs root index source — docs/index.mdx preferred, docs/index.md
|
|
53
|
-
* as fallback (mirrors the dev server's getDocsForSlug extension handling).
|
|
54
|
-
*/
|
|
55
|
-
export function resolveDocsIndexSource(docsDir: string): string | undefined {
|
|
56
|
-
for (const ext of [".mdx", ".md"]) {
|
|
57
|
-
const candidate = join(docsDir, `index${ext}`);
|
|
58
|
-
if (existsSync(candidate)) return candidate;
|
|
59
|
-
}
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Default favicon — resolves in both build output (`docs/assets/` is copied
|
|
64
|
-
* to `dist/docs/assets/`) and dev (served from `docs/assets/` via fallback). */
|
|
65
|
-
export const DEFAULT_FAVICON = "/docs/assets/images/favicon.ico";
|
|
66
|
-
|
|
67
26
|
/** Suffix an internal docs link with `.html` to match the flat static build output. */
|
|
68
27
|
export function docsHtmlHref(path: string): string {
|
|
69
28
|
return `${path}.html`;
|
|
70
29
|
}
|
|
71
30
|
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Resolve an authored nav/menu link for the current deployment.
|
|
33
|
+
*
|
|
34
|
+
* Docs pages ship as flat `.html` files, so `/docs/guide/routing` has to request
|
|
35
|
+
* `/docs/guide/routing.html`, while the docs root itself stays a directory index
|
|
36
|
+
* and keeps no suffix. Authored `/docs/...` paths are re-based onto the
|
|
37
|
+
* configured prefix, so one `docu.json` keeps working at a custom subpath
|
|
38
|
+
* (`/repo`) or at a root deployment (`""`). External URLs, hash targets and
|
|
39
|
+
* paths that already name a file pass through untouched.
|
|
40
|
+
*/
|
|
41
|
+
export function docsNavHref(link: string, resolvedBasePath: string = DEFAULT_BASE_PATH): string {
|
|
42
|
+
if (isExternalUrl(link) || !link.startsWith("/")) return link;
|
|
80
43
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error("Failed to parse URL", url, err);
|
|
86
|
-
return url;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
44
|
+
const cut = link.search(/[?#]/);
|
|
45
|
+
const suffix = cut === -1 ? "" : link.slice(cut);
|
|
46
|
+
const pathname = (cut === -1 ? link : link.slice(0, cut)).replace(/\/+$/, "") || "/";
|
|
89
47
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const cleanPath = filePath.replace(/^\//, "");
|
|
94
|
-
if (
|
|
95
|
-
!cleanPath ||
|
|
96
|
-
!/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
|
|
97
|
-
/(^|\/)\.\.($|\/)/.test(cleanPath)
|
|
98
|
-
)
|
|
99
|
-
return null;
|
|
100
|
-
const proc = Bun.spawn(["git", "log", "-1", "--format=%cI", "--", cleanPath], {
|
|
101
|
-
stderr: "ignore",
|
|
102
|
-
});
|
|
103
|
-
const text = await new Response(proc.stdout).text();
|
|
104
|
-
const date = text.trim();
|
|
105
|
-
return date || null;
|
|
106
|
-
} catch (err) {
|
|
107
|
-
console.error("Failed to get git last modified for", filePath, err);
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
48
|
+
// Sibling routes on the same origin are not docs pages — leave them verbatim.
|
|
49
|
+
if (!isDocsPath(pathname, resolvedBasePath)) return link;
|
|
111
50
|
|
|
112
|
-
|
|
113
|
-
export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {
|
|
114
|
-
const result = new Map<string, string>();
|
|
115
|
-
if (filePaths.length === 0) return result;
|
|
51
|
+
const rebased = rebaseContentPath(pathname, resolvedBasePath);
|
|
116
52
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
!cleanPath ||
|
|
123
|
-
!/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
|
|
124
|
-
/(^|\/)\.\.($|\/)/.test(cleanPath)
|
|
125
|
-
) {
|
|
126
|
-
console.warn(`[utils] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
safePaths.push(cleanPath);
|
|
53
|
+
// The docs root is a directory index, and anything with an extension is
|
|
54
|
+
// already a file — neither takes the `.html` suffix.
|
|
55
|
+
if (rebased === "/" || rebased === resolvedBasePath || /\.[a-z0-9]+$/i.test(rebased)) {
|
|
56
|
+
return `${rebased}${suffix}`;
|
|
130
57
|
}
|
|
58
|
+
return `${rebased}.html${suffix}`;
|
|
59
|
+
}
|
|
131
60
|
|
|
132
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Does `pathname` refer to a link returned by {@link docsNavHref}?
|
|
63
|
+
*
|
|
64
|
+
* Nav links reach the browser in two forms: the generated `.html` URL and the
|
|
65
|
+
* extensionless route a visitor may have typed (the dev server maps both), so
|
|
66
|
+
* active states have to accept the pair.
|
|
67
|
+
*/
|
|
68
|
+
export function docsNavActive(pathname: string, href: string): boolean {
|
|
69
|
+
const extensionless = href.endsWith(".html") ? href.slice(0, -".html".length) : href;
|
|
70
|
+
return (
|
|
71
|
+
pathname === href || pathname === extensionless || pathname.startsWith(`${extensionless}/`)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
133
74
|
|
|
75
|
+
export function getPath(url: string): string {
|
|
134
76
|
try {
|
|
135
|
-
|
|
136
|
-
["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...safePaths],
|
|
137
|
-
{ stderr: "ignore" }
|
|
138
|
-
);
|
|
139
|
-
const text = await new Response(proc.stdout).text();
|
|
140
|
-
let currentDate = "";
|
|
141
|
-
|
|
142
|
-
for (const line of text.split("\n")) {
|
|
143
|
-
const trimmed = line.trim();
|
|
144
|
-
if (!trimmed) continue;
|
|
145
|
-
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
|
|
146
|
-
currentDate = trimmed;
|
|
147
|
-
} else if (currentDate && !result.has(trimmed)) {
|
|
148
|
-
result.set(trimmed, currentDate);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
77
|
+
return new URL(url).pathname;
|
|
151
78
|
} catch (err) {
|
|
152
|
-
console.error("Failed to
|
|
79
|
+
console.error("Failed to parse URL", url, err);
|
|
80
|
+
return url;
|
|
153
81
|
}
|
|
154
|
-
|
|
155
|
-
return result;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const MIME_TYPES: Record<string, string> = {
|
|
159
|
-
html: "text/html",
|
|
160
|
-
css: "text/css",
|
|
161
|
-
js: "application/javascript",
|
|
162
|
-
json: "application/json",
|
|
163
|
-
png: "image/png",
|
|
164
|
-
jpg: "image/jpeg",
|
|
165
|
-
jpeg: "image/jpeg",
|
|
166
|
-
svg: "image/svg+xml",
|
|
167
|
-
ico: "image/x-icon",
|
|
168
|
-
woff: "font/woff",
|
|
169
|
-
woff2: "font/woff2",
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
export function getContentType(pathname: string): string {
|
|
173
|
-
const ext = pathname.split(".").pop()?.toLowerCase();
|
|
174
|
-
return MIME_TYPES[ext || ""] || "application/octet-stream";
|
|
175
82
|
}
|