@docubook/flame 1.7.2 → 2.0.0-alpha.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.
Files changed (57) hide show
  1. package/.docu/components/Context.tsx +1 -1
  2. package/.docu/components/Search.tsx +6 -6
  3. package/.docu/components/Sidebar.tsx +37 -8
  4. package/.docu/components/Theme.tsx +2 -2
  5. package/.docu/components/Toc.tsx +51 -5
  6. package/.docu/components/Typography.tsx +1 -1
  7. package/.docu/components/home/Hero.tsx +4 -4
  8. package/.docu/components/registry.ts +1 -1
  9. package/.docu/lib/build.deno.js +3 -3
  10. package/.docu/lib/{build.impl-TSIF3F7O.js → build.impl-WR24HDUT.js} +3 -3
  11. package/.docu/lib/build.node.js +3 -3
  12. package/.docu/lib/{chunk-HRO7ONJQ.js → chunk-2EYRWRDP.js} +66 -26
  13. package/.docu/lib/{chunk-PJIJEPNR.js → chunk-AJV2GEDF.js} +5 -4
  14. package/.docu/lib/{chunk-KMDGSD57.js → chunk-KEWRVASF.js} +3 -3
  15. package/.docu/lib/{chunk-B6LGUADD.js → chunk-QPF4DPZQ.js} +294 -177
  16. package/.docu/lib/chunk-UISOJ4RW.js +114 -0
  17. package/.docu/lib/{chunk-DITXUPUV.js → chunk-VPV7KP7K.js} +56 -9
  18. package/.docu/lib/{chunk-SPHVBXRR.js → chunk-X7KPVCDN.js} +6 -6
  19. package/.docu/lib/clean.js +1 -1
  20. package/.docu/lib/deploy.deno.js +1 -1
  21. package/.docu/lib/deploy.node.js +1 -1
  22. package/.docu/lib/preview.deno.js +5 -3
  23. package/.docu/lib/preview.node.js +5 -3
  24. package/.docu/lib/server.deno.js +6 -4
  25. package/.docu/lib/server.node.js +6 -4
  26. package/.docu/node/build.impl.ts +72 -26
  27. package/.docu/node/build.ts +67 -17
  28. package/.docu/node/client.ts +64 -27
  29. package/.docu/node/deploy.shared.ts +5 -5
  30. package/.docu/node/deploy.ts +1 -1
  31. package/.docu/node/hydrate.node.ts +69 -4
  32. package/.docu/node/hydrate.ts +50 -1
  33. package/.docu/node/mdx-manifest.d.ts +5 -0
  34. package/.docu/node/mdx.ts +103 -17
  35. package/.docu/node/preview.deno.ts +1 -1
  36. package/.docu/node/preview.impl.ts +3 -3
  37. package/.docu/node/preview.node.ts +1 -1
  38. package/.docu/node/preview.ts +2 -2
  39. package/.docu/node/runtime/bun.ts +28 -0
  40. package/.docu/node/runtime/deno.ts +34 -0
  41. package/.docu/node/runtime/index.ts +4 -0
  42. package/.docu/node/runtime/node.ts +107 -0
  43. package/.docu/node/runtime/types.ts +19 -0
  44. package/.docu/node/search-indexer.ts +3 -2
  45. package/.docu/node/seo.ts +2 -2
  46. package/.docu/node/server-routes.ts +28 -13
  47. package/.docu/node/server.deno.ts +1 -1
  48. package/.docu/node/server.impl.ts +49 -3
  49. package/.docu/node/server.node.ts +1 -1
  50. package/.docu/node/server.ts +45 -1
  51. package/.docu/pages/docs/[[...slug]].tsx +13 -4
  52. package/.docu/pages/index.tsx +1 -1
  53. package/.docu/styles/globals.css +29 -20
  54. package/README.md +7 -48
  55. package/bin/cli.js +25 -7
  56. package/package.json +7 -6
  57. package/template/README.md +7 -48
@@ -6,7 +6,7 @@
6
6
  import { existsSync, statSync, readFileSync } from "node:fs";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { resolve } from "node:path";
9
- import type { RuntimeAdapter, ServerHandle } from "@docubook/runt";
9
+ import type { RuntimeAdapter, ServerHandle } from "./runtime";
10
10
  import { logger } from "./logger";
11
11
  import { DIST_DIR } from "./paths";
12
12
  import { getContentType } from "./utils";
@@ -63,7 +63,7 @@ export async function runPreview(adapter: RuntimeAdapter): Promise<ServerHandle
63
63
  headers: {
64
64
  "Content-Type": "text/html",
65
65
  ...SECURITY_HEADERS,
66
- "Content-Security-Policy": cspHeader(nonce, true),
66
+ "Content-Security-Policy": cspHeader(nonce),
67
67
  },
68
68
  });
69
69
  }
@@ -81,7 +81,7 @@ export async function runPreview(adapter: RuntimeAdapter): Promise<ServerHandle
81
81
  headers: {
82
82
  "Content-Type": "text/html",
83
83
  ...SECURITY_HEADERS,
84
- "Content-Security-Policy": cspHeader(nonce, true),
84
+ "Content-Security-Policy": cspHeader(nonce),
85
85
  },
86
86
  });
87
87
  }
@@ -1,4 +1,4 @@
1
- import { nodeAdapter } from "@docubook/runt";
1
+ import { nodeAdapter } from "./runtime";
2
2
  import { runPreview } from "./preview.impl";
3
3
 
4
4
  await runPreview(nodeAdapter);
@@ -57,7 +57,7 @@ const server = Bun.serve({
57
57
  headers: {
58
58
  "Content-Type": "text/html",
59
59
  ...SECURITY_HEADERS,
60
- "Content-Security-Policy": cspHeader(nonce, true),
60
+ "Content-Security-Policy": cspHeader(nonce),
61
61
  },
62
62
  });
63
63
  }
@@ -75,7 +75,7 @@ const server = Bun.serve({
75
75
  headers: {
76
76
  "Content-Type": "text/html",
77
77
  ...SECURITY_HEADERS,
78
- "Content-Security-Policy": cspHeader(nonce, true),
78
+ "Content-Security-Policy": cspHeader(nonce),
79
79
  },
80
80
  });
81
81
  }
@@ -0,0 +1,28 @@
1
+ import type { FetchHandler, RuntimeAdapter, ServerOptions } from "./types";
2
+
3
+ /** Minimal ambient view of the Bun global — only what the adapter touches. */
4
+ declare const Bun: {
5
+ serve(options: { port: number; hostname?: string; idleTimeout?: number; fetch: FetchHandler }): {
6
+ port?: number;
7
+ hostname?: string;
8
+ stop(closeActiveConnections?: boolean): void | Promise<void>;
9
+ };
10
+ };
11
+
12
+ export const bunAdapter: RuntimeAdapter = {
13
+ name: "bun",
14
+
15
+ serve(fetch: FetchHandler, options: ServerOptions) {
16
+ const server = Bun.serve({
17
+ port: options.port,
18
+ hostname: options.hostname,
19
+ idleTimeout: options.idleTimeout,
20
+ fetch,
21
+ });
22
+ return {
23
+ port: server.port ?? options.port,
24
+ hostname: server.hostname ?? options.hostname ?? "localhost",
25
+ stop: () => server.stop(),
26
+ };
27
+ },
28
+ };
@@ -0,0 +1,34 @@
1
+ import type { FetchHandler, RuntimeAdapter, ServerOptions } from "./types";
2
+
3
+ /** Minimal ambient view of the Deno global — only what the adapter touches. */
4
+ declare const Deno: {
5
+ serve(
6
+ options: { port?: number; hostname?: string; onListen?: (addr: unknown) => void },
7
+ handler: (req: Request) => Response | Promise<Response>
8
+ ): {
9
+ addr: { port: number; hostname: string };
10
+ shutdown(): Promise<void>;
11
+ };
12
+ };
13
+
14
+ export const denoAdapter: RuntimeAdapter = {
15
+ name: "deno",
16
+
17
+ serve(fetch: FetchHandler, options: ServerOptions) {
18
+ const server = Deno.serve(
19
+ {
20
+ port: options.port,
21
+ hostname: options.hostname,
22
+ onListen: () => {
23
+ // Suppress Deno's default "Listening on ..." log; callers print their own.
24
+ },
25
+ },
26
+ fetch
27
+ );
28
+ return {
29
+ port: server.addr.port,
30
+ hostname: server.addr.hostname,
31
+ stop: () => server.shutdown(),
32
+ };
33
+ },
34
+ };
@@ -0,0 +1,4 @@
1
+ export type { FetchHandler, RuntimeAdapter, ServerHandle, ServerOptions } from "./types";
2
+ export { bunAdapter } from "./bun";
3
+ export { denoAdapter } from "./deno";
4
+ export { nodeAdapter } from "./node";
@@ -0,0 +1,107 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { FetchHandler, RuntimeAdapter, ServerHandle, ServerOptions } from "./types";
3
+
4
+ function toWebRequest(req: IncomingMessage, port: number, hostname: string): Request {
5
+ const host = req.headers.host ?? `${hostname}:${port}`;
6
+ const url = `http://${host}${req.url ?? "/"}`;
7
+ const headers = new Headers();
8
+ for (const [key, value] of Object.entries(req.headers)) {
9
+ if (value === undefined) continue;
10
+ if (Array.isArray(value)) {
11
+ for (const v of value) headers.append(key, v);
12
+ } else {
13
+ headers.set(key, value);
14
+ }
15
+ }
16
+ const method = req.method ?? "GET";
17
+ const hasBody = method !== "GET" && method !== "HEAD";
18
+ return new Request(url, {
19
+ method,
20
+ headers,
21
+ // IncomingMessage is an async iterable of Buffer chunks; Request accepts
22
+ // an async iterable body when half-duplex is declared.
23
+ body: hasBody ? (req as unknown as BodyInit) : undefined,
24
+ // @ts-expect-error -- required by undici for streaming request bodies
25
+ duplex: hasBody ? "half" : undefined,
26
+ });
27
+ }
28
+
29
+ async function writeResponse(response: Response, res: ServerResponse): Promise<void> {
30
+ const headers: Record<string, string | string[]> = {};
31
+ const setCookie = response.headers.getSetCookie?.() ?? [];
32
+ response.headers.forEach((value, key) => {
33
+ if (key === "set-cookie") return;
34
+ headers[key] = value;
35
+ });
36
+ if (setCookie.length > 0) headers["set-cookie"] = setCookie;
37
+
38
+ // Flush headers immediately so streaming responses (SSE) reach the client
39
+ // before the first body chunk.
40
+ res.writeHead(response.status, headers);
41
+
42
+ if (!response.body) {
43
+ res.end();
44
+ return;
45
+ }
46
+
47
+ const reader = response.body.getReader();
48
+ try {
49
+ for (;;) {
50
+ const { done, value } = await reader.read();
51
+ if (done) break;
52
+ // Incremental piping — never buffer the full body (SSE streams forever).
53
+ const ok = res.write(value);
54
+ if (!ok) await new Promise<void>((resolve) => res.once("drain", resolve));
55
+ }
56
+ res.end();
57
+ } catch {
58
+ // Client disconnected mid-stream; cancel the source.
59
+ await reader.cancel().catch(() => {});
60
+ res.destroy();
61
+ }
62
+ }
63
+
64
+ export const nodeAdapter: RuntimeAdapter = {
65
+ name: "node",
66
+
67
+ serve(fetch: FetchHandler, options: ServerOptions): Promise<ServerHandle> {
68
+ const hostname = options.hostname ?? "localhost";
69
+
70
+ const server = createServer((req, res) => {
71
+ Promise.resolve()
72
+ .then(() => fetch(toWebRequest(req, options.port, hostname)))
73
+ .then((response) => writeResponse(response, res))
74
+ .catch((err) => {
75
+ console.error(err);
76
+ if (!res.headersSent) {
77
+ res.writeHead(500, { "Content-Type": "text/plain" });
78
+ }
79
+ res.end("Internal Server Error");
80
+ });
81
+ });
82
+
83
+ if (options.idleTimeout !== undefined) {
84
+ server.timeout = options.idleTimeout * 1000;
85
+ // keepAliveTimeout must not undercut idleTimeout or Node closes
86
+ // long-lived SSE connections early.
87
+ server.keepAliveTimeout = options.idleTimeout * 1000;
88
+ }
89
+
90
+ return new Promise((resolve, reject) => {
91
+ server.once("error", reject);
92
+ server.listen(options.port, () => {
93
+ const address = server.address();
94
+ const port = typeof address === "object" && address ? address.port : options.port;
95
+ resolve({
96
+ port,
97
+ hostname,
98
+ stop: () =>
99
+ new Promise<void>((res2, rej2) => {
100
+ server.closeAllConnections?.();
101
+ server.close((err) => (err ? rej2(err) : res2()));
102
+ }),
103
+ });
104
+ });
105
+ });
106
+ },
107
+ };
@@ -0,0 +1,19 @@
1
+ export interface ServerOptions {
2
+ port: number;
3
+ hostname?: string;
4
+ /** Seconds a connection may stay idle before being closed. */
5
+ idleTimeout?: number;
6
+ }
7
+
8
+ export interface ServerHandle {
9
+ port: number;
10
+ hostname: string;
11
+ stop(): void | Promise<void>;
12
+ }
13
+
14
+ export type FetchHandler = (req: Request) => Response | Promise<Response>;
15
+
16
+ export interface RuntimeAdapter {
17
+ name: string;
18
+ serve(fetch: FetchHandler, options: ServerOptions): ServerHandle | Promise<ServerHandle>;
19
+ }
@@ -14,6 +14,7 @@
14
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
+ import { frontmatterField } from "./mdx";
17
18
  import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
18
19
  import { scanMdxFiles, docsHtmlHref } from "./utils";
19
20
 
@@ -74,7 +75,7 @@ export function extractRecords(filePath: string, raw: string): SearchRecord[] {
74
75
  const records: SearchRecord[] = [];
75
76
  const url = docsHtmlHref(`/docs/${filePath}`);
76
77
  const lvl0 = getSectionTitle(filePath);
77
- const lvl1 = frontmatter.title || null;
78
+ const lvl1 = frontmatterField(frontmatter, "title") || null;
78
79
 
79
80
  const hierarchy = {
80
81
  lvl0,
@@ -90,7 +91,7 @@ export function extractRecords(filePath: string, raw: string): SearchRecord[] {
90
91
  records.push({
91
92
  url,
92
93
  hierarchy: { ...hierarchy },
93
- content: frontmatter.description || null,
94
+ content: frontmatterField(frontmatter, "description") || null,
94
95
  type: "lvl1",
95
96
  });
96
97
  }
package/.docu/node/seo.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DocuConfig } from "./types";
2
+ import { frontmatterField } from "./mdx";
2
3
 
3
4
  export interface SeoMeta {
4
5
  /** Absolute canonical URL */
@@ -27,8 +28,7 @@ export function buildSeoMeta(
27
28
  };
28
29
 
29
30
  // Per-page image from frontmatter, fallback to global default from config
30
- const image =
31
- (typeof frontmatter.image === "string" && frontmatter.image) || config.meta?.ogImage;
31
+ const image = frontmatterField(frontmatter, "image") || config.meta?.ogImage;
32
32
  if (image) {
33
33
  // Resolve using URL constructor — handles absolute, root-relative, and relative paths
34
34
  try {
@@ -1,9 +1,9 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import React, { type ReactNode } from "react";
5
5
  import { renderToString } from "react-dom/server";
6
- import { compileMdx } from "./mdx";
6
+ import { compileMdx, frontmatterField } from "./mdx";
7
7
  import { getContentType } from "./utils";
8
8
  import { DOCS_DIR, DIST_DIR, PROJECT_ROOT } from "./paths";
9
9
  import { BuildPluginBuilder } from "./plugin-builder";
@@ -45,10 +45,20 @@ function createHtmlResponse(
45
45
  themeCss: state.inlineThemeCss,
46
46
  depth,
47
47
  });
48
- /** unsafe-eval required by mdx-remote hydration — see build.impl.ts */
48
+ /** Dev-only: serves compiledSource → MDXRemote eval path (no CSP in production). */
49
49
  return htmlResponse(html, nonce, status, true);
50
50
  }
51
51
 
52
+ // Dev-only memo: compileMdx per request is ~70ms; repeat navigations with
53
+ // an unchanged file (same mtime) reuse the compiled result. The dev server
54
+ // has no other per-page cache — without this every navigation re-parses MDX.
55
+ // ponytail: unbounded Map is fine — docs sites have a handful of pages; cap
56
+ // with an LRU only if a project exceeds thousands of routes.
57
+ const docsCache = new Map<
58
+ string,
59
+ { mtimeMs: number; doc: NonNullable<Awaited<ReturnType<typeof getDocsForSlug>>> }
60
+ >();
61
+
52
62
  async function getDocsForSlug(
53
63
  slug: string,
54
64
  state: ServerState
@@ -92,6 +102,12 @@ async function getDocsForSlug(
92
102
 
93
103
  const relPath = filePath.replace(PROJECT_ROOT + "/", "");
94
104
 
105
+ const mtimeMs = (await stat(filePath)).mtimeMs;
106
+ const cached = docsCache.get(relPath);
107
+ if (cached && cached.mtimeMs === mtimeMs) {
108
+ return cached.doc;
109
+ }
110
+
95
111
  let content = raw;
96
112
  if (state.builder) {
97
113
  const transformed = await state.builder.runOnLoad(relPath, content);
@@ -113,7 +129,7 @@ async function getDocsForSlug(
113
129
  });
114
130
  }
115
131
 
116
- return {
132
+ const doc = {
117
133
  content: result.content,
118
134
  compiledSource: result.compiledSource,
119
135
  frontmatter,
@@ -121,6 +137,8 @@ async function getDocsForSlug(
121
137
  filePath: relPath,
122
138
  resolvedContent: content,
123
139
  };
140
+ docsCache.set(relPath, { mtimeMs, doc });
141
+ return doc;
124
142
  }
125
143
 
126
144
  async function renderDocsServerPage(
@@ -129,12 +147,8 @@ async function renderDocsServerPage(
129
147
  pathname: string,
130
148
  state: ServerState
131
149
  ): Promise<Response> {
132
- const title =
133
- (typeof doc.frontmatter.title === "string" ? doc.frontmatter.title : "") ||
134
- slug.join("/") ||
135
- "Docs";
136
- const description =
137
- typeof doc.frontmatter.description === "string" ? doc.frontmatter.description : "";
150
+ const title = frontmatterField(doc.frontmatter, "title") || slug.join("/") || "Docs";
151
+ const description = frontmatterField(doc.frontmatter, "description");
138
152
 
139
153
  const page = React.createElement(
140
154
  DocsLayout,
@@ -143,8 +157,9 @@ async function renderDocsServerPage(
143
157
  slug,
144
158
  title,
145
159
  description,
146
- date: doc.frontmatter.date as string | undefined,
147
- content: doc.content,
160
+ date: frontmatterField(doc.frontmatter, "date") || undefined,
161
+ // Same root-relative SSR as build — see build.ts.
162
+ content: renderToString(doc.content),
148
163
  tocs: doc.tocs,
149
164
  filePath: doc.filePath,
150
165
  repoUrl: state.docuConfig.repo?.url,
@@ -184,7 +199,7 @@ async function renderDocsServerPage(
184
199
  depth,
185
200
  });
186
201
  html = await state.builder.runTransformHtmlChain(html, ctx);
187
- /** unsafe-eval required by mdx-remote hydration — see build.impl.ts */
202
+ /** Dev-only: serves compiledSource → MDXRemote eval path (no CSP in production). */
188
203
  return htmlResponse(html, nonce, 200, true);
189
204
  }
190
205
 
@@ -1,4 +1,4 @@
1
- import { denoAdapter } from "@docubook/runt";
1
+ import { denoAdapter } from "./runtime";
2
2
  import { runServer } from "./server.impl";
3
3
 
4
4
  await runServer(denoAdapter);
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Runtime-neutral dev server — mirror of `server.ts` (Bun-only, protected)
3
- * driven by a `RuntimeAdapter` from `@docubook/runt` instead of `Bun.serve`,
3
+ * driven by a `RuntimeAdapter` (./runtime) instead of `Bun.serve`,
4
4
  * with manual route matching instead of `Bun.FileSystemRouter`. The page set
5
5
  * is static (`/`, `/docs/[[...slug]]`, `/404`), so a router is unnecessary.
6
6
  */
7
7
 
8
- import { watch } from "node:fs";
9
- import type { RuntimeAdapter, ServerHandle } from "@docubook/runt";
8
+ import { watch, existsSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { createRequire } from "node:module";
11
+ import type { RuntimeAdapter, ServerHandle } from "./runtime";
10
12
  import { DOCS_DIR, loadDocuConfig } from "./paths";
11
13
  import { loadPlugins } from "./plugin-loader";
12
14
  import { BuildPluginBuilder } from "./plugin-builder";
@@ -72,6 +74,18 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
72
74
 
73
75
  const hmrClients = new Set<ReadableStreamDefaultController>();
74
76
 
77
+ const require = createRequire(import.meta.url);
78
+ /** Locate the installed @docubook/markdown source dir (workspace link), if any. */
79
+ function resolveMdxContentSrc(): string | null {
80
+ try {
81
+ const pkgDir = dirname(require.resolve("@docubook/markdown/package.json"));
82
+ const src = join(pkgDir, "src");
83
+ return existsSync(src) ? src : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
75
89
  let hmrTimeout: ReturnType<typeof setTimeout> | null = null;
76
90
  const watcher = watch(DOCS_DIR, { recursive: true }, (_event, filename) => {
77
91
  if (!filename || (!filename.endsWith(".mdx") && !filename.endsWith(".md"))) return;
@@ -87,12 +101,44 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
87
101
  }, 300);
88
102
  });
89
103
 
104
+ // Rebuild the client bundle when component sources change (e.g. the
105
+ // @docubook/markdown workspace package) so interactive islands pick up
106
+ // edits, then reload connected clients. MDX edits are handled by the watcher
107
+ // above — no rebuild needed, content recompiles per request.
108
+ let srcHmrTimeout: ReturnType<typeof setTimeout> | null = null;
109
+ const mdxContentSrc = resolveMdxContentSrc();
110
+ const srcWatcher = mdxContentSrc
111
+ ? watch(mdxContentSrc, { recursive: true }, (_event, filename) => {
112
+ if (!filename || !/\.(ts|tsx|js|jsx|css)$/.test(filename)) return;
113
+ if (srcHmrTimeout) clearTimeout(srcHmrTimeout);
114
+ srcHmrTimeout = setTimeout(async () => {
115
+ try {
116
+ state.assetManifest = await buildClientBundle();
117
+ logger.warn("[hmr] client bundle rebuilt (component change)");
118
+ } catch (e) {
119
+ logger.warn(
120
+ `[hmr] bundle rebuild failed: ${e instanceof Error ? e.message : String(e)}`
121
+ );
122
+ }
123
+ for (const client of [...hmrClients]) {
124
+ try {
125
+ client.enqueue(new TextEncoder().encode("data: reload\n\n"));
126
+ } catch {
127
+ hmrClients.delete(client);
128
+ }
129
+ }
130
+ }, 300);
131
+ })
132
+ : null;
133
+
90
134
  process.on("SIGINT", () => {
91
135
  watcher.close();
136
+ srcWatcher?.close();
92
137
  process.exit(0);
93
138
  });
94
139
  process.on("SIGTERM", () => {
95
140
  watcher.close();
141
+ srcWatcher?.close();
96
142
  process.exit(0);
97
143
  });
98
144
 
@@ -1,4 +1,4 @@
1
- import { nodeAdapter } from "@docubook/runt";
1
+ import { nodeAdapter } from "./runtime";
2
2
  import { runServer } from "./server.impl";
3
3
 
4
4
  await runServer(nodeAdapter);
@@ -1,4 +1,6 @@
1
- import { watch } from "node:fs";
1
+ import { watch, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createRequire } from "node:module";
2
4
  import { DOCS_DIR, PAGES_DIR, loadDocuConfig } from "./paths";
3
5
  import { loadPlugins } from "./plugin-loader";
4
6
  import { BuildPluginBuilder } from "./plugin-builder";
@@ -73,6 +75,18 @@ try {
73
75
 
74
76
  const hmrClients = new Set<ReadableStreamDefaultController>();
75
77
 
78
+ const require = createRequire(import.meta.url);
79
+ /** Locate the installed @docubook/markdown source dir (workspace link), if any. */
80
+ function resolveMdxContentSrc(): string | null {
81
+ try {
82
+ const pkgDir = dirname(require.resolve("@docubook/markdown/package.json"));
83
+ const src = join(pkgDir, "src");
84
+ return existsSync(src) ? src : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
76
90
  let hmrTimeout: ReturnType<typeof setTimeout> | null = null;
77
91
  const watcher = watch(DOCS_DIR, { recursive: true }, (_event, filename) => {
78
92
  if (!filename || (!filename.endsWith(".mdx") && !filename.endsWith(".md"))) return;
@@ -88,12 +102,42 @@ const watcher = watch(DOCS_DIR, { recursive: true }, (_event, filename) => {
88
102
  }, 300);
89
103
  });
90
104
 
105
+ // Rebuild the client bundle when component sources change (e.g. the
106
+ // @docubook/markdown workspace package) so interactive islands pick up
107
+ // edits, then reload connected clients. MDX edits are handled by the watcher
108
+ // above — no rebuild needed, content recompiles per request.
109
+ let srcHmrTimeout: ReturnType<typeof setTimeout> | null = null;
110
+ const mdxContentSrc = resolveMdxContentSrc();
111
+ const srcWatcher = mdxContentSrc
112
+ ? watch(mdxContentSrc, { recursive: true }, (_event, filename) => {
113
+ if (!filename || !/\.(ts|tsx|js|jsx|css)$/.test(filename)) return;
114
+ if (srcHmrTimeout) clearTimeout(srcHmrTimeout);
115
+ srcHmrTimeout = setTimeout(async () => {
116
+ try {
117
+ state.assetManifest = await buildClientBundle();
118
+ logger.warn("[hmr] client bundle rebuilt (component change)");
119
+ } catch (e) {
120
+ logger.warn(`[hmr] bundle rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
121
+ }
122
+ for (const client of [...hmrClients]) {
123
+ try {
124
+ client.enqueue(new TextEncoder().encode("data: reload\n\n"));
125
+ } catch {
126
+ hmrClients.delete(client);
127
+ }
128
+ }
129
+ }, 300);
130
+ })
131
+ : null;
132
+
91
133
  process.on("SIGINT", () => {
92
134
  watcher.close();
135
+ srcWatcher?.close();
93
136
  process.exit(0);
94
137
  });
95
138
  process.on("SIGTERM", () => {
96
139
  watcher.close();
140
+ srcWatcher?.close();
97
141
  process.exit(0);
98
142
  });
99
143
 
@@ -1,4 +1,3 @@
1
- import type { ReactNode } from "react";
2
1
  import { ChevronLeft, ChevronRight } from "lucide-react";
3
2
  import DocsBreadcrumb from "../../components/Breadcrumb";
4
3
  import Pagination from "../../components/Pagination";
@@ -14,10 +13,15 @@ interface DocsPageProps {
14
13
  title: string;
15
14
  description?: string;
16
15
  date?: string;
17
- content: ReactNode;
16
+ /** SSR'd MDX content HTML — rendered as its own root so client hydration
17
+ * (separate island root) matches useId-based ids. */
18
+ content: string;
18
19
  tocs: TocItem[];
19
20
  filePath: string;
20
21
  repoUrl?: string;
22
+ /** Build-time slug keying into the bundled mdxModules manifest (client.ts). */
23
+ mdxSlug?: string;
24
+ /** Dev-only pre-compiled source for the legacy MDXRemote eval path. */
21
25
  compiledSource?: string;
22
26
  }
23
27
 
@@ -30,6 +34,7 @@ export default function DocsPage({
30
34
  tocs,
31
35
  filePath,
32
36
  repoUrl,
37
+ mdxSlug,
33
38
  compiledSource,
34
39
  }: DocsPageProps) {
35
40
  const pathname = slug.join("/");
@@ -39,7 +44,7 @@ export default function DocsPage({
39
44
  <div className="flex w-full flex-1 px-0 pb-4 lg:h-[calc(100vh-4rem)] lg:px-8 lg:pb-8">
40
45
  <div
41
46
  id="scroll-container"
42
- className="bg-base-100 border-base-300 max-lg:scroll-p-54 relative flex w-full flex-col items-start rounded-b-3xl border shadow-md lg:h-full lg:flex-row lg:overflow-y-auto lg:rounded-xl"
47
+ className="bg-base-100 border-base-300 relative flex w-full flex-col items-start rounded-b-3xl border shadow-md max-lg:scroll-p-54 lg:h-full lg:flex-row lg:overflow-y-auto lg:rounded-xl"
43
48
  >
44
49
  {/* Mobile bar - island */}
45
50
  <div
@@ -60,7 +65,11 @@ export default function DocsPage({
60
65
  {description && (
61
66
  <p className="text-muted-foreground -mt-4 text-[16.5px]">{description}</p>
62
67
  )}
63
- <div id="mdx-content-island">{content}</div>
68
+ <div
69
+ id="mdx-content-island"
70
+ data-mdx-slug={mdxSlug}
71
+ dangerouslySetInnerHTML={{ __html: content }}
72
+ />
64
73
  {compiledSource && (
65
74
  <script
66
75
  id="mdx-compiled-source"
@@ -61,7 +61,7 @@ export default function IndexPage() {
61
61
 
62
62
  return (
63
63
  <div className="bg-base-100 relative isolate min-h-screen overflow-hidden">
64
- <div className="absolute right-4 top-4 z-10" id="theme-island" />
64
+ <div className="absolute top-4 right-4 z-10" id="theme-island" />
65
65
 
66
66
  {/* Background gradient blobs */}
67
67
  <div