@docubook/flame 1.7.2 → 2.0.0-alpha.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.
Files changed (67) hide show
  1. package/.docu/components/Context.tsx +1 -1
  2. package/.docu/components/Pagination.tsx +9 -1
  3. package/.docu/components/Search.tsx +6 -6
  4. package/.docu/components/Sidebar.tsx +37 -8
  5. package/.docu/components/Theme.tsx +2 -2
  6. package/.docu/components/Toc.tsx +52 -6
  7. package/.docu/components/Typography.tsx +1 -1
  8. package/.docu/components/home/Hero.tsx +4 -4
  9. package/.docu/components/registry.ts +1 -1
  10. package/.docu/lib/build.deno.js +3 -3
  11. package/.docu/lib/{build.impl-TSIF3F7O.js → build.impl-VXB4KL4D.js} +3 -3
  12. package/.docu/lib/build.node.js +3 -3
  13. package/.docu/lib/{chunk-SPHVBXRR.js → chunk-7PRQ3RQB.js} +6 -6
  14. package/.docu/lib/{chunk-DITXUPUV.js → chunk-A6FIEG3H.js} +61 -10
  15. package/.docu/lib/{chunk-PJIJEPNR.js → chunk-JRERMREW.js} +5 -4
  16. package/.docu/lib/{chunk-KMDGSD57.js → chunk-LZDEWK25.js} +3 -3
  17. package/.docu/lib/{chunk-HRO7ONJQ.js → chunk-MQWWCO6O.js} +70 -27
  18. package/.docu/lib/{chunk-B6LGUADD.js → chunk-TRT6WQZG.js} +346 -183
  19. package/.docu/lib/chunk-UISOJ4RW.js +114 -0
  20. package/.docu/lib/clean.js +1 -1
  21. package/.docu/lib/deploy.deno.js +1 -1
  22. package/.docu/lib/deploy.node.js +1 -1
  23. package/.docu/lib/preview.deno.js +5 -3
  24. package/.docu/lib/preview.node.js +5 -3
  25. package/.docu/lib/server.deno.js +6 -4
  26. package/.docu/lib/server.node.js +6 -4
  27. package/.docu/node/build.impl.ts +75 -26
  28. package/.docu/node/build.ts +70 -17
  29. package/.docu/node/client.ts +64 -27
  30. package/.docu/node/deploy.shared.ts +5 -5
  31. package/.docu/node/deploy.ts +1 -1
  32. package/.docu/node/html.shared.ts +7 -2
  33. package/.docu/node/html.ts +4 -2
  34. package/.docu/node/hydrate.node.ts +69 -4
  35. package/.docu/node/hydrate.ts +50 -1
  36. package/.docu/node/mdx-manifest.d.ts +5 -0
  37. package/.docu/node/mdx.ts +103 -17
  38. package/.docu/node/preview.deno.ts +1 -1
  39. package/.docu/node/preview.impl.ts +3 -3
  40. package/.docu/node/preview.node.ts +1 -1
  41. package/.docu/node/preview.ts +2 -2
  42. package/.docu/node/route.ts +57 -1
  43. package/.docu/node/runtime/bun.ts +28 -0
  44. package/.docu/node/runtime/deno.ts +34 -0
  45. package/.docu/node/runtime/index.ts +4 -0
  46. package/.docu/node/runtime/node.ts +107 -0
  47. package/.docu/node/runtime/types.ts +19 -0
  48. package/.docu/node/search-indexer.ts +3 -2
  49. package/.docu/node/seo.ts +2 -2
  50. package/.docu/node/server-routes.ts +32 -13
  51. package/.docu/node/server.deno.ts +1 -1
  52. package/.docu/node/server.impl.ts +49 -3
  53. package/.docu/node/server.node.ts +1 -1
  54. package/.docu/node/server.ts +45 -1
  55. package/.docu/pages/docs/[[...slug]].tsx +13 -4
  56. package/.docu/pages/index.tsx +1 -1
  57. package/.docu/styles/globals.css +38 -20
  58. package/README.md +7 -48
  59. package/bin/cli.js +25 -7
  60. package/package.json +7 -6
  61. package/template/README.md +7 -48
  62. package/template/docs/getting-started/configuration.mdx +16 -4
  63. package/template/docs/getting-started/overview.mdx +34 -42
  64. package/template/docs/guide/components.mdx +46 -228
  65. package/template/docs/guide/routing.mdx +8 -6
  66. package/template/docs/index.mdx +9 -8
  67. package/template/docu.json +4 -4
@@ -35,7 +35,7 @@ export const NGINX_CONF = `server {
35
35
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
36
36
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
37
37
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
38
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
38
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
39
39
 
40
40
  location /assets/ {
41
41
  expires 1y;
@@ -45,7 +45,7 @@ export const NGINX_CONF = `server {
45
45
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
46
46
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
47
47
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
48
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
48
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
49
49
  }
50
50
 
51
51
  location /docs/assets/ {
@@ -56,7 +56,7 @@ export const NGINX_CONF = `server {
56
56
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
57
57
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
58
58
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
59
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
59
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
60
60
  }
61
61
 
62
62
  location / {
@@ -135,7 +135,7 @@ export const HEADERS_FILE = `/*
135
135
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
136
136
  Referrer-Policy: strict-origin-when-cross-origin
137
137
  Permissions-Policy: camera=(), microphone=(), geolocation=()
138
- Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'
138
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'
139
139
 
140
140
  /assets/*
141
141
  Cache-Control: public, max-age=31536000, immutable
@@ -171,7 +171,7 @@ async function writeDockerFiles() {
171
171
  if (!existsSync(join(dockerDir, "Dockerfile"))) {
172
172
  await writeFile(
173
173
  join(dockerDir, "Dockerfile"),
174
- `FROM ghcr.io/docubook/flame-builder:${FLAME_MAJOR} AS builder
174
+ `FROM ghcr.io/docubook/flame:${FLAME_MAJOR} AS builder
175
175
  ENV NODE_ENV=production
176
176
  WORKDIR /app
177
177
  COPY . .
@@ -49,7 +49,7 @@ async function runBuild() {
49
49
  }
50
50
  }
51
51
 
52
- export const DOCKERFILE_BUN = `FROM ghcr.io/docubook/flame-builder:${FLAME_MAJOR} AS builder
52
+ export const DOCKERFILE_BUN = `FROM ghcr.io/docubook/flame:${FLAME_MAJOR} AS builder
53
53
  ENV NODE_ENV=production
54
54
  WORKDIR /app
55
55
  COPY . .
@@ -34,6 +34,9 @@ export interface HtmlShellOptions {
34
34
  bodyExtra?: string[];
35
35
  /** SEO meta tags derived from config + frontmatter */
36
36
  seo?: SeoMeta;
37
+ /** Root-absolute asset URLs (`/assets/...`). Required for pages served at
38
+ * arbitrary paths (404 fallback) — relative depth is wrong there. */
39
+ absoluteAssets?: boolean;
37
40
  }
38
41
 
39
42
  export function htmlShell(opts: HtmlShellOptions): string {
@@ -51,14 +54,16 @@ export function htmlShell(opts: HtmlShellOptions): string {
51
54
  depth = 0,
52
55
  headExtra,
53
56
  bodyExtra,
57
+ absoluteAssets = false,
54
58
  } = opts;
55
59
  const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
56
60
  const themeStyle = themeCss ? `\n <style${nonceAttr}>${escapeHtml(themeCss)}</style>` : "";
57
61
  const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
58
62
  const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
59
63
  const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
60
- const assetPrefix = depthPrefix + "assets/";
61
- const resolvePath = (path: string) => (path.startsWith("/") ? depthPrefix + path.slice(1) : path);
64
+ const assetPrefix = absoluteAssets ? "/assets/" : depthPrefix + "assets/";
65
+ const resolvePath = (path: string) =>
66
+ absoluteAssets ? path : path.startsWith("/") ? depthPrefix + path.slice(1) : path;
62
67
 
63
68
  // Build SEO meta tags (OG, Twitter, canonical)
64
69
  let seoTags = "";
@@ -16,14 +16,16 @@ export function htmlShell(opts: HtmlShellOptions): string {
16
16
  depth = 0,
17
17
  headExtra,
18
18
  bodyExtra,
19
+ absoluteAssets = false,
19
20
  } = opts;
20
21
  const nonceAttr = nonce ? ` nonce="${Bun.escapeHTML(nonce)}"` : "";
21
22
  const themeStyle = themeCss ? `\n <style${nonceAttr}>${Bun.escapeHTML(themeCss)}</style>` : "";
22
23
  const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
23
24
  const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
24
25
  const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
25
- const assetPrefix = depthPrefix + "assets/";
26
- const resolvePath = (path: string) => (path.startsWith("/") ? depthPrefix + path.slice(1) : path);
26
+ const assetPrefix = absoluteAssets ? "/assets/" : depthPrefix + "assets/";
27
+ const resolvePath = (path: string) =>
28
+ absoluteAssets ? path : path.startsWith("/") ? depthPrefix + path.slice(1) : path;
27
29
 
28
30
  // Build SEO meta tags (OG, Twitter, canonical)
29
31
  let seoTags = "";
@@ -187,7 +187,6 @@ function collectAllLucideIcons(): string[] {
187
187
  join(FRAMEWORK_ROOT, "..", "mdx-content", "dist"),
188
188
  join(FRAMEWORK_ROOT, "..", "ui-react", "dist"),
189
189
  join(FRAMEWORK_ROOT, "..", "core", "dist"),
190
- join(FRAMEWORK_ROOT, "..", "runt", "dist"),
191
190
  join(FRAMEWORK_ROOT, "..", "themes-colors", "dist"),
192
191
  ];
193
192
  for (const d of depDirs) scanDirLucideIcons(resolve(d), icons);
@@ -195,7 +194,10 @@ function collectAllLucideIcons(): string[] {
195
194
  }
196
195
 
197
196
  /** Build the client JS bundle and Tailwind CSS. */
198
- export async function buildClientBundle(): Promise<{ js: string; css: string }> {
197
+ export async function buildClientBundle(
198
+ /** slug → compiled MDX ESM module source (program format) for static hydration. */
199
+ mdxSources: Record<string, string> = {}
200
+ ): Promise<{ js: string; css: string }> {
199
201
  await mkdir(ASSETS_DIR, { recursive: true });
200
202
  const twKey = tailwindCacheKey();
201
203
  await cleanOldBundles(new Set([`client-${twKey}.css`]));
@@ -243,11 +245,11 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
243
245
  if (args.namespace === "lucide-virt") {
244
246
  return { path: getLucideRealEntry(), namespace: "file" };
245
247
  }
246
- // mdx-content Icon.tsx uses namespace import for arbitrary
248
+ // markdown Icon.tsx uses namespace import for arbitrary
247
249
  // user-provided icon names in MDX — keep full barrel there.
248
250
  if (args.importer) {
249
251
  const normalized = normalizeImporterPath(args.importer);
250
- if (normalized.includes("/mdx-content/dist/")) {
252
+ if (normalized.includes("/markdown/dist/")) {
251
253
  return { path: getLucideRealEntry(), namespace: "file" };
252
254
  }
253
255
  }
@@ -294,6 +296,69 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
294
296
  });
295
297
  },
296
298
  },
299
+ {
300
+ // Serves per-page compiled MDX (program format) as real modules so
301
+ // the client hydrates the content island without `new Function`.
302
+ // client.ts imports `{ mdxModules } from "./mdx-manifest"`.
303
+ name: "mdx-hydrate",
304
+ setup(build) {
305
+ // Compiled MDX (program format) imports the JSX runtime and the
306
+ // MDX provider. Virtual modules have no real directory, so esbuild
307
+ // cannot resolve bare specifiers from them — map them to the
308
+ // installed real paths explicitly (same pattern as lucide-optimize).
309
+ const require = createRequire(import.meta.url);
310
+ const mdxModuleDeps: Record<string, string> = {
311
+ "react/jsx-runtime": require.resolve("react/jsx-runtime"),
312
+ "react/jsx-dev-runtime": require.resolve("react/jsx-dev-runtime"),
313
+ "@mdx-js/react": require.resolve("@mdx-js/react"),
314
+ };
315
+ build.onResolve(
316
+ { filter: /^(react\/jsx-runtime|react\/jsx-dev-runtime|@mdx-js\/react)$/ },
317
+ (args) => {
318
+ if (args.namespace !== "mdx-module") return;
319
+ return { path: mdxModuleDeps[args.path], namespace: "file" };
320
+ }
321
+ );
322
+ build.onResolve({ filter: /^mdx-module:/ }, (args) => ({
323
+ path: args.path,
324
+ namespace: "mdx-module",
325
+ }));
326
+ build.onLoad({ filter: /.*/, namespace: "mdx-module" }, (args) => {
327
+ const slug = args.path.slice("mdx-module:".length);
328
+ const contents = mdxSources[slug];
329
+ if (contents == null) {
330
+ return {
331
+ errors: [{ text: `unknown mdx module: ${slug}` }],
332
+ contents: "",
333
+ loader: "js",
334
+ };
335
+ }
336
+ return { contents, loader: "js" };
337
+ });
338
+ build.onResolve({ filter: /mdx-manifest$/ }, (args) => ({
339
+ path: args.path,
340
+ namespace: "mdx-manifest",
341
+ }));
342
+ build.onLoad({ filter: /.*/, namespace: "mdx-manifest" }, () => {
343
+ // Sort keys: the prePass fills mdxSources via Promise.all, so
344
+ // insertion order = resolution order (non-deterministic across
345
+ // processes). Stable key order keeps the bundle hash stable so
346
+ // the build cache (`assetsChanged`) actually hits.
347
+ const slugs = Object.keys(mdxSources).sort();
348
+ const imports = slugs
349
+ .map((slug, i) => {
350
+ const key = slug.replace(/["\\]/g, "");
351
+ return `import * as _mdx${i} from "mdx-module:${key}";`;
352
+ })
353
+ .join("\n");
354
+ const map = slugs.map((slug, i) => `${JSON.stringify(slug)}: _mdx${i}`).join(", ");
355
+ return {
356
+ contents: `${imports}\nexport const mdxModules = { ${map} };\n`,
357
+ loader: "js",
358
+ };
359
+ });
360
+ },
361
+ },
297
362
  ],
298
363
  });
299
364
  } finally {
@@ -125,7 +125,10 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
125
125
  return { file: cssFile, content: cssContent };
126
126
  }
127
127
 
128
- export async function buildClientBundle(): Promise<{ js: string; css: string }> {
128
+ export async function buildClientBundle(
129
+ /** slug → compiled MDX ESM module source (program format) for static hydration. */
130
+ mdxSources: Record<string, string> = {}
131
+ ): Promise<{ js: string; css: string }> {
129
132
  await mkdir(ASSETS_DIR, { recursive: true });
130
133
  const twKey = twCacheKey();
131
134
  await cleanOldBundles(new Set([`client-${twKey}.css`]));
@@ -166,6 +169,52 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
166
169
  });
167
170
  },
168
171
  },
172
+ {
173
+ // Serves per-page compiled MDX (program format) as real modules so
174
+ // the client hydrates the content island without `new Function`.
175
+ // client.ts imports `{ mdxModules } from "./mdx-manifest"`.
176
+ name: "mdx-hydrate",
177
+ setup(build) {
178
+ build.onResolve({ filter: /^mdx-module:/ }, (args) => ({
179
+ path: args.path,
180
+ namespace: "mdx-module",
181
+ }));
182
+ build.onLoad({ filter: /.*/, namespace: "mdx-module" }, (args) => {
183
+ const slug = args.path.slice("mdx-module:".length);
184
+ const contents = mdxSources[slug];
185
+ if (contents == null) {
186
+ return {
187
+ errors: [{ text: `unknown mdx module: ${slug}` }],
188
+ contents: "",
189
+ loader: "js",
190
+ };
191
+ }
192
+ return { contents, loader: "js" };
193
+ });
194
+ build.onResolve({ filter: /mdx-manifest$/ }, (args) => ({
195
+ path: args.path,
196
+ namespace: "mdx-manifest",
197
+ }));
198
+ build.onLoad({ filter: /.*/, namespace: "mdx-manifest" }, () => {
199
+ // Sort keys: the prePass fills mdxSources via Promise.all, so
200
+ // insertion order = resolution order (non-deterministic across
201
+ // processes). Stable key order keeps the bundle hash stable so
202
+ // the build cache (`assetsChanged`) actually hits.
203
+ const slugs = Object.keys(mdxSources).sort();
204
+ const imports = slugs
205
+ .map((slug, i) => {
206
+ const key = slug.replace(/["\\]/g, "");
207
+ return `import * as _mdx${i} from "mdx-module:${key}";`;
208
+ })
209
+ .join("\n");
210
+ const map = slugs.map((slug, i) => `${JSON.stringify(slug)}: _mdx${i}`).join(", ");
211
+ return {
212
+ contents: `${imports}\nexport const mdxModules = { ${map} };\n`,
213
+ loader: "js",
214
+ };
215
+ });
216
+ },
217
+ },
169
218
  ],
170
219
  });
171
220
 
@@ -0,0 +1,5 @@
1
+ // Virtual module served by the `mdx-hydrate` esbuild plugin (hydrate.node.ts).
2
+ // Maps doc slug → compiled MDX module. Generated at bundle time; never on disk.
3
+ declare module "./mdx-manifest" {
4
+ export const mdxModules: Record<string, { default: React.ComponentType }>;
5
+ }
package/.docu/node/mdx.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import type { Pluggable } from "unified";
3
+ import { z, type ZodType } from "zod";
3
4
  import {
4
5
  serialize,
5
6
  extractTocsFromRawMdx,
@@ -8,7 +9,7 @@ import {
8
9
  createDefaultRemarkPlugins,
9
10
  MDXRemote,
10
11
  } from "@docubook/core";
11
- import { createMdxComponents } from "@docubook/mdx-content";
12
+ import { createMdxComponents } from "@docubook/markdown";
12
13
  import { getGitLastModified, getGitLastModifiedBatch, getFilesystemMtime } from "./git";
13
14
 
14
15
  /**
@@ -106,10 +107,39 @@ export { getGitLastModifiedBatch };
106
107
  export interface MdxResult {
107
108
  content: React.ReactElement;
108
109
  compiledSource: string;
109
- frontmatter: { title?: string; description?: string; date?: string };
110
+ frontmatter: Frontmatter;
110
111
  tocs: ReturnType<typeof extractTocsFromRawMdx>;
111
112
  }
112
113
 
114
+ /**
115
+ * DocuBook frontmatter contract — single source of truth for frontmatter
116
+ * fields. Add new properties here; types and validation derive from it.
117
+ * YAML coerces unquoted values, so string fields use `z.coerce.*`.
118
+ */
119
+ export const frontmatterSchema = z.object({
120
+ title: z.coerce.string().optional(),
121
+ description: z.coerce.string().optional(),
122
+ image: z.coerce.string().optional(),
123
+ date: z.coerce.string().optional(),
124
+ });
125
+
126
+ export type Frontmatter = z.infer<typeof frontmatterSchema>;
127
+
128
+ /**
129
+ * Read a string field from frontmatter after the plugin transform chain
130
+ * (which widens the type to `Record<string, unknown>`). Returns "" when
131
+ * missing or not a string.
132
+ */
133
+ export function frontmatterField(frontmatter: Record<string, unknown>, key: string): string {
134
+ return typeof frontmatter[key] === "string" ? (frontmatter[key] as string) : "";
135
+ }
136
+
137
+ /**
138
+ * Zod schema validating frontmatter after extraction.
139
+ * Must satisfy the frontmatter contract (defaults to `frontmatterSchema`).
140
+ */
141
+ export type FrontmatterSchema = ZodType<Frontmatter>;
142
+
113
143
  /**
114
144
  * Compile MDX/MD content into a React element and compiled source.
115
145
  *
@@ -118,20 +148,26 @@ export interface MdxResult {
118
148
  * @param gitDates - Optional pre-fetched git last-modified map
119
149
  * @param remarkPlugins - Additional remark plugins (merged after defaults, optional)
120
150
  * @param rehypePlugins - Additional rehype plugins (merged after defaults, optional)
151
+ * @param frontmatterSchema - Custom schema overriding the default contract
121
152
  */
122
- export async function compileMdx(
153
+ /**
154
+ * Shared compile core used by `compileMdx` (SSR) and `compileMdxModule`
155
+ * (static hydration). Strips frontmatter, merges the doc plugin chain
156
+ * (defaults + .html link fixes + user plugins) and runs `serialize()`.
157
+ */
158
+ async function serializeWithDocPlugins(
123
159
  rawMdx: string,
124
- filePath: string,
125
- gitDates?: Map<string, string>,
126
- remarkPlugins?: Pluggable[],
127
- rehypePlugins?: Pluggable[]
128
- ): Promise<MdxResult> {
129
- const tocs = extractTocsFromRawMdx(rawMdx);
130
- const { frontmatter, strippedContent } = extractFrontmatterWithContent<{
131
- title?: string;
132
- description?: string;
133
- date?: string;
134
- }>(rawMdx);
160
+ opts: {
161
+ outputFormat?: "function-body" | "program";
162
+ remarkPlugins?: Pluggable[];
163
+ rehypePlugins?: Pluggable[];
164
+ frontmatterSchema?: FrontmatterSchema;
165
+ } = {}
166
+ ) {
167
+ const { strippedContent } = extractFrontmatterWithContent<Frontmatter>(
168
+ rawMdx,
169
+ opts.frontmatterSchema
170
+ );
135
171
 
136
172
  const defaultRemark = createDefaultRemarkPlugins();
137
173
  const defaultRehype = createDefaultRehypePlugins();
@@ -139,15 +175,46 @@ export async function compileMdx(
139
175
  // remarkMdxJsxDocsHtmlLinks must run before user plugins so custom remark
140
176
  // transforms see already-fixed hrefs. rehypeDocsHtmlLinks handles plain
141
177
  // markdown [text](path) → <a> elements in the HAST phase.
142
- const finalRemark = [...defaultRemark, remarkMdxJsxDocsHtmlLinks, ...(remarkPlugins ?? [])];
143
- const finalRehype = [...defaultRehype, rehypeDocsHtmlLinks, ...(rehypePlugins ?? [])];
178
+ const finalRemark = [...defaultRemark, remarkMdxJsxDocsHtmlLinks, ...(opts.remarkPlugins ?? [])];
179
+ const finalRehype = [...defaultRehype, rehypeDocsHtmlLinks, ...(opts.rehypePlugins ?? [])];
144
180
 
145
- const serialized = await serialize(strippedContent, {
181
+ // v2 contract: plain markdown + directives only — authored JSX tags are
182
+ // not parsed (dropped, content kept as text).
183
+ return serialize(strippedContent, {
184
+ outputFormat: opts.outputFormat,
185
+ format: "md",
146
186
  mdxOptions: {
147
187
  rehypePlugins: finalRehype,
148
188
  remarkPlugins: finalRemark,
149
189
  },
150
190
  });
191
+ }
192
+
193
+ /**
194
+ * Compile MDX/MD content into a React element and compiled source.
195
+ *
196
+ * @param rawMdx - Raw MDX/MD file content
197
+ * @param filePath - Relative file path for git date lookup
198
+ * @param gitDates - Optional pre-fetched git last-modified map
199
+ * @param remarkPlugins - Additional remark plugins (merged after defaults, optional)
200
+ * @param rehypePlugins - Additional rehype plugins (merged after defaults, optional)
201
+ * @param frontmatterSchema - Custom schema overriding the default contract
202
+ */
203
+ export async function compileMdx(
204
+ rawMdx: string,
205
+ filePath: string,
206
+ gitDates?: Map<string, string>,
207
+ remarkPlugins?: Pluggable[],
208
+ rehypePlugins?: Pluggable[],
209
+ frontmatterSchema?: FrontmatterSchema
210
+ ): Promise<MdxResult> {
211
+ const tocs = extractTocsFromRawMdx(rawMdx);
212
+ const { frontmatter } = extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema);
213
+ const serialized = await serializeWithDocPlugins(rawMdx, {
214
+ remarkPlugins,
215
+ rehypePlugins,
216
+ frontmatterSchema,
217
+ });
151
218
 
152
219
  const components = createMdxComponents();
153
220
  const content = React.createElement(MDXRemote, {
@@ -171,3 +238,22 @@ export async function compileMdx(
171
238
  tocs,
172
239
  };
173
240
  }
241
+
242
+ /**
243
+ * Compile MDX to a real ESM module source (program format) for static
244
+ * client-side hydration — the browser imports and executes it via the bundler
245
+ * instead of `new Function(compiledSource)`. Uses the same plugin chain as
246
+ * `compileMdx` so the hydrated tree matches the SSR output.
247
+ */
248
+ export async function compileMdxModule(
249
+ rawMdx: string,
250
+ remarkPlugins?: Pluggable[],
251
+ rehypePlugins?: Pluggable[]
252
+ ): Promise<string> {
253
+ const serialized = await serializeWithDocPlugins(rawMdx, {
254
+ outputFormat: "program",
255
+ remarkPlugins,
256
+ rehypePlugins,
257
+ });
258
+ return serialized.compiledSource;
259
+ }
@@ -1,4 +1,4 @@
1
- import { denoAdapter } from "@docubook/runt";
1
+ import { denoAdapter } from "./runtime";
2
2
  import { runPreview } from "./preview.impl";
3
3
 
4
4
  await runPreview(denoAdapter);
@@ -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
  }
@@ -1,6 +1,11 @@
1
1
  import { loadDocuConfig } from "./paths";
2
2
  import type { DocuRoute } from "./types";
3
3
  import { resolveRoutes } from "./fs-scanner";
4
+ import { DOCS_DIR } from "./paths";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { extractFrontmatter } from "@docubook/core";
8
+ import type { Frontmatter } from "./mdx";
4
9
 
5
10
  const docuConfig = loadDocuConfig();
6
11
  export const routes: DocuRoute[] = resolveRoutes(docuConfig.routes);
@@ -41,8 +46,53 @@ export function getRouteMap(): Map<string, string> {
41
46
  return map;
42
47
  }
43
48
 
49
+ /** Build-time cache of href → frontmatter description (docs content is static). */
50
+ const descriptionCache = new Map<string, string>();
51
+
52
+ function readDescription(href: string): string {
53
+ const cached = descriptionCache.get(href);
54
+ if (cached !== undefined) return cached;
55
+
56
+ let description = "";
57
+ const rel = href.replace(/^\/|$/g, "");
58
+ for (const ext of [".mdx", ".md"]) {
59
+ for (const file of [join(DOCS_DIR, `${rel}${ext}`), join(DOCS_DIR, `${rel}/index${ext}`)]) {
60
+ try {
61
+ const fm = extractFrontmatter<Frontmatter>(readFileSync(file, "utf-8"));
62
+ description = typeof fm.description === "string" ? fm.description : "";
63
+ if (description) break;
64
+ } catch {
65
+ // not this file — try the next candidate
66
+ }
67
+ }
68
+ if (description) break;
69
+ }
70
+ descriptionCache.set(href, description);
71
+ return description;
72
+ }
73
+
44
74
  export function getPreviousNext(pathname: string) {
45
75
  const normalizedPath = pathname.replace(/^\/|$/g, "");
76
+
77
+ // Docs index (/docs — DocsPage renders with pathname "" from slug []):
78
+ // next-only navigation into the first docs page — never read the route
79
+ // backward from the index, so prev stays null even if a page sits before
80
+ // it in the route list.
81
+ if (normalizedPath === "docs" || normalizedPath === "") {
82
+ const paths = flattenRoutes();
83
+ const routeMap = getRouteMap();
84
+ const first = paths[0];
85
+ if (!first) return { prev: null, next: null };
86
+ return {
87
+ prev: null,
88
+ next: {
89
+ href: first,
90
+ title: routeMap.get(first) || "",
91
+ description: readDescription(first),
92
+ },
93
+ };
94
+ }
95
+
46
96
  const paths = flattenRoutes();
47
97
 
48
98
  const index = paths.findIndex((href) => href === `/${normalizedPath}` || href === normalizedPath);
@@ -57,7 +107,13 @@ export function getPreviousNext(pathname: string) {
57
107
 
58
108
  return {
59
109
  prev: prevHref ? { href: prevHref, title: routeMap.get(prevHref) || "" } : null,
60
- next: nextHref ? { href: nextHref, title: routeMap.get(nextHref) || "" } : null,
110
+ next: nextHref
111
+ ? {
112
+ href: nextHref,
113
+ title: routeMap.get(nextHref) || "",
114
+ description: readDescription(nextHref),
115
+ }
116
+ : null,
61
117
  };
62
118
  }
63
119
 
@@ -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";