@docubook/flame 1.7.1 → 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-TUVHCOIP.js → build.impl-WR24HDUT.js} +3 -3
  11. package/.docu/lib/build.node.js +3 -3
  12. package/.docu/lib/{chunk-ICNXW4Z4.js → chunk-2EYRWRDP.js} +66 -26
  13. package/.docu/lib/{chunk-PFXUCD2U.js → chunk-AJV2GEDF.js} +5 -4
  14. package/.docu/lib/{chunk-XMIEMHTQ.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-QPO4DS5F.js → chunk-VPV7KP7K.js} +56 -9
  18. package/.docu/lib/{chunk-QL6TQV3S.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
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { join, dirname } from "node:path";
5
5
  import React from "react";
6
6
  import { renderToString } from "react-dom/server";
7
- import { compileMdx, getGitLastModifiedBatch } from "./mdx";
7
+ import { compileMdx, compileMdxModule, frontmatterField, getGitLastModifiedBatch } from "./mdx";
8
8
  import {
9
9
  DOCS_DIR,
10
10
  DIST_DIR,
@@ -113,8 +113,8 @@ async function renderDocsPage(
113
113
  });
114
114
  }
115
115
 
116
- const title = (typeof frontmatter.title === "string" ? frontmatter.title : "") || slug || "Docs";
117
- const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
116
+ const title = frontmatterField(frontmatter, "title") || slug || "Docs";
117
+ const description = frontmatterField(frontmatter, "description");
118
118
  const slugParts = slug ? slug.split("/") : [];
119
119
 
120
120
  const page = React.createElement(
@@ -124,12 +124,15 @@ async function renderDocsPage(
124
124
  slug: slugParts,
125
125
  title,
126
126
  description,
127
- date: (frontmatter.date as string) || undefined,
128
- content: result.content,
127
+ date: frontmatterField(frontmatter, "date") || undefined,
128
+ // Render MDX content as its own root: client hydrates the island as a
129
+ // separate root, so SSR must be root-relative too or useId-based ids
130
+ // (mdx-compiler components) mismatch during hydration.
131
+ content: renderToString(result.content),
129
132
  tocs: result.tocs,
130
133
  filePath,
131
134
  repoUrl: docuConfig.repo?.url,
132
- compiledSource: result.compiledSource,
135
+ mdxSlug: slug,
133
136
  })
134
137
  );
135
138
 
@@ -203,9 +206,66 @@ async function build() {
203
206
  let built = 0;
204
207
  let skipped = 0;
205
208
 
209
+ const pluginsConfig = docuConfig.plugins ?? [];
210
+ const builder = pluginsConfig.length > 0 ? new BuildPluginBuilder(docuConfig) : null;
211
+ if (builder) {
212
+ const plugins = await loadPlugins(pluginsConfig);
213
+ for (const plugin of plugins) {
214
+ await plugin.setup(builder);
215
+ }
216
+ await builder.runOnStart();
217
+ }
218
+
219
+ // Pre-compile every page's MDX to an ESM module (program format) so the
220
+ // client bundle can hydrate the content island statically — no new Function.
221
+ // Mirrors the page loop's transform + plugin chain so SSR and client trees
222
+ // match. Runs for all files regardless of cache; the bundle is shared by
223
+ // every page, so a content change invalidates the page cache anyway.
224
+ const mdxSources: Record<string, string> = {};
225
+ const prePassTasks = mdxFiles.map(async (file) => {
226
+ let raw: string;
227
+ try {
228
+ raw = await readFile(file.absPath, "utf-8");
229
+ } catch {
230
+ return;
231
+ }
232
+ let content = raw;
233
+ if (builder) {
234
+ const relPath = file.absPath.replace(PROJECT_ROOT + "/", "");
235
+ const transformed = await builder.runOnLoad(relPath, content);
236
+ if (transformed?.contents) content = transformed.contents;
237
+ }
238
+ const remarkPlugins = builder?.collectRemarkPlugins();
239
+ const rehypePlugins = builder?.collectRehypePlugins();
240
+ mdxSources[file.path] = await compileMdxModule(content, remarkPlugins, rehypePlugins);
241
+ });
242
+ await Promise.all(prePassTasks);
243
+
244
+ // The docs root (index.mdx) renders with slug "" — mirror that key so the
245
+ // index page hydrates too. Its render has its own try/catch; skip on error.
246
+ const indexMdxPath = join(DOCS_DIR, "index.mdx");
247
+ if (existsSync(indexMdxPath)) {
248
+ try {
249
+ const indexRaw = await readFile(indexMdxPath, "utf-8");
250
+ let indexContent = indexRaw;
251
+ if (builder) {
252
+ const relPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
253
+ const transformed = await builder.runOnLoad(relPath, indexContent);
254
+ if (transformed?.contents) indexContent = transformed.contents;
255
+ }
256
+ mdxSources[""] = await compileMdxModule(
257
+ indexContent,
258
+ builder?.collectRemarkPlugins(),
259
+ builder?.collectRehypePlugins()
260
+ );
261
+ } catch {
262
+ // ignore — the index render reports its own error
263
+ }
264
+ }
265
+
206
266
  logger.bundleStart();
207
267
  let t = performance.now();
208
- assetManifest = await buildClientBundle();
268
+ assetManifest = await buildClientBundle(mdxSources);
209
269
  logger.bundleDone(Math.round(performance.now() - t));
210
270
 
211
271
  inlineThemeCss = computeInlineThemeCss();
@@ -221,16 +281,6 @@ async function build() {
221
281
  };
222
282
  }
223
283
 
224
- const pluginsConfig = docuConfig.plugins ?? [];
225
- const builder = pluginsConfig.length > 0 ? new BuildPluginBuilder(docuConfig) : null;
226
- if (builder) {
227
- const plugins = await loadPlugins(pluginsConfig);
228
- for (const plugin of plugins) {
229
- await plugin.setup(builder);
230
- }
231
- await builder.runOnStart();
232
- }
233
-
234
284
  logger.spinner.start("Building pages...");
235
285
  t = performance.now();
236
286
 
@@ -1,22 +1,37 @@
1
1
  import { createRoot, hydrateRoot } from "react-dom/client";
2
2
  import React from "react";
3
+ import { MDXProvider } from "@mdx-js/react";
3
4
  import { MDXRemote } from "@docubook/core";
4
- import { createMdxComponents } from "@docubook/mdx-content";
5
+ import { createMdxComponents } from "@docubook/markdown";
6
+ import { mdxModules } from "./mdx-manifest";
5
7
  import Sidebar, { MobileBar } from "../components/Sidebar";
6
8
  import Toc from "../components/Toc";
7
9
  import { ThemeToggle } from "../components/Theme";
8
10
  import { safeParseTocs } from "./parse-tocs";
9
11
  import type { TocItem } from "./types";
10
12
 
13
+ /**
14
+ * Island mount mode — deliberate trade-off per island API:
15
+ *
16
+ * - `hydrate`: SSR HTML exists and the client renders the identical tree →
17
+ * attach React in place (no flash, SSR content preserved).
18
+ * - `create`: client-only render — the client tree deliberately differs from
19
+ * SSR (or SSR output is absent) → full render, discards SSR markup.
20
+ * - `auto`: hydrate when the SSR container has children, else create.
21
+ */
22
+ type MountMode = "auto" | "hydrate" | "create";
23
+
11
24
  function mountIsland(
12
25
  id: string,
13
- render: (el: HTMLElement) => React.ReactElement,
14
- forceCreate = false
26
+ render: (el: HTMLElement) => React.ReactElement | null,
27
+ mode: MountMode = "auto"
15
28
  ) {
16
29
  const el = document.getElementById(id);
17
30
  if (!el) return;
18
31
  const node = render(el);
19
- if (!forceCreate && el.childElementCount > 0) {
32
+ if (node === null) return; // island stays as-is (SSR HTML preserved)
33
+ const hydrate = mode === "hydrate" || (mode === "auto" && el.childElementCount > 0);
34
+ if (hydrate) {
20
35
  hydrateRoot(el, node);
21
36
  } else {
22
37
  el.innerHTML = "";
@@ -25,8 +40,9 @@ function mountIsland(
25
40
  }
26
41
 
27
42
  function mountIslands() {
28
- // forceCreate: SSR sidebar renders <Menu> only; client renders full <Sidebar>
29
- // (DesktopSidebar + MobileBar) — structure mismatch forces full createRoot.
43
+ // SSR renders <Menu> only; client renders full <Sidebar> (DesktopSidebar +
44
+ // MobileBar) — structural mismatch makes hydration impossible, so always
45
+ // createRoot and discard the SSR <Menu> markup.
30
46
  mountIsland(
31
47
  "sidebar-island",
32
48
  (el) => {
@@ -37,11 +53,11 @@ function mountIslands() {
37
53
  repoUrl: el.dataset.repo || "",
38
54
  });
39
55
  },
40
- true
56
+ "create"
41
57
  );
42
58
 
43
- // mobile-bar-island SSR div is empty (data attributes only),
44
- // so hydrateRoot child check falls through to createRoot automatically.
59
+ // SSR div is empty (data attributes only) — childElementCount is 0, so
60
+ // auto falls through to createRoot.
45
61
  mountIsland("mobile-bar-island", (el) => {
46
62
  const tocs: TocItem[] = safeParseTocs(el.dataset.tocs);
47
63
  return React.createElement(MobileBar, {
@@ -58,24 +74,45 @@ function mountIslands() {
58
74
 
59
75
  mountIsland("theme-island", () => React.createElement(ThemeToggle));
60
76
 
61
- hydrateMdxContent();
62
- }
63
-
64
- function hydrateMdxContent() {
65
- const island = document.getElementById("mdx-content-island");
66
- const sourceEl = document.getElementById("mdx-compiled-source");
67
- if (!island || !sourceEl) return;
68
-
69
- try {
70
- const compiledSource = JSON.parse(sourceEl.textContent || "");
71
- const components = createMdxComponents();
72
- hydrateRoot(
73
- island,
74
- React.createElement(MDXRemote, { compiledSource, scope: {}, frontmatter: {}, components })
75
- );
76
- } catch (e) {
77
- console.error("[mdx-hydrate]", e);
78
- }
77
+ // MDX content: SSR renders the full content HTML; the client rebuilds the
78
+ // identical tree. Two sources, same tree shape:
79
+ // - static build: per-slug compiled ESM module bundled via ./mdx-manifest
80
+ // (no new Function) — module and SSR output come from the same plugin
81
+ // chain, so hydration matches;
82
+ // - dev: legacy per-page compiledSource script → MDXRemote eval.
83
+ // Hydrate when SSR markup exists, create only when the container is empty.
84
+ mountIsland(
85
+ "mdx-content-island",
86
+ (el) => {
87
+ const sourceEl = document.getElementById("mdx-compiled-source");
88
+ if (sourceEl) {
89
+ try {
90
+ const compiledSource = JSON.parse(sourceEl.textContent || "");
91
+ return React.createElement(MDXRemote, {
92
+ compiledSource,
93
+ scope: {},
94
+ frontmatter: {},
95
+ components: createMdxComponents(),
96
+ });
97
+ } catch (e) {
98
+ console.error("[mdx-hydrate]", e);
99
+ return null;
100
+ }
101
+ }
102
+ const slug = el.dataset.mdxSlug;
103
+ // The docs root (index.mdx) renders with an empty slug — `mdxSlug != null`
104
+ // keeps "" addressable (its module is stored under key ""), while a
105
+ // missing marker stays undefined and skips hydration.
106
+ const mod = slug != null ? mdxModules[slug] : undefined;
107
+ if (!mod) return null;
108
+ return React.createElement(
109
+ MDXProvider,
110
+ { components: createMdxComponents() },
111
+ React.createElement(mod.default, null)
112
+ );
113
+ },
114
+ "auto"
115
+ );
79
116
  }
80
117
 
81
118
  if (document.readyState === "loading") {
@@ -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 . .
@@ -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);