@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.
- package/.docu/components/Context.tsx +1 -1
- package/.docu/components/Search.tsx +6 -6
- package/.docu/components/Sidebar.tsx +37 -8
- package/.docu/components/Theme.tsx +2 -2
- package/.docu/components/Toc.tsx +51 -5
- package/.docu/components/Typography.tsx +1 -1
- package/.docu/components/home/Hero.tsx +4 -4
- package/.docu/components/registry.ts +1 -1
- package/.docu/lib/build.deno.js +3 -3
- package/.docu/lib/{build.impl-TUVHCOIP.js → build.impl-WR24HDUT.js} +3 -3
- package/.docu/lib/build.node.js +3 -3
- package/.docu/lib/{chunk-ICNXW4Z4.js → chunk-2EYRWRDP.js} +66 -26
- package/.docu/lib/{chunk-PFXUCD2U.js → chunk-AJV2GEDF.js} +5 -4
- package/.docu/lib/{chunk-XMIEMHTQ.js → chunk-KEWRVASF.js} +3 -3
- package/.docu/lib/{chunk-B6LGUADD.js → chunk-QPF4DPZQ.js} +294 -177
- package/.docu/lib/chunk-UISOJ4RW.js +114 -0
- package/.docu/lib/{chunk-QPO4DS5F.js → chunk-VPV7KP7K.js} +56 -9
- package/.docu/lib/{chunk-QL6TQV3S.js → chunk-X7KPVCDN.js} +6 -6
- 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/preview.deno.js +5 -3
- package/.docu/lib/preview.node.js +5 -3
- package/.docu/lib/server.deno.js +6 -4
- package/.docu/lib/server.node.js +6 -4
- package/.docu/node/build.impl.ts +72 -26
- package/.docu/node/build.ts +67 -17
- package/.docu/node/client.ts +64 -27
- package/.docu/node/deploy.shared.ts +5 -5
- package/.docu/node/deploy.ts +1 -1
- package/.docu/node/hydrate.node.ts +69 -4
- package/.docu/node/hydrate.ts +50 -1
- package/.docu/node/mdx-manifest.d.ts +5 -0
- package/.docu/node/mdx.ts +103 -17
- package/.docu/node/preview.deno.ts +1 -1
- package/.docu/node/preview.impl.ts +3 -3
- package/.docu/node/preview.node.ts +1 -1
- package/.docu/node/preview.ts +2 -2
- package/.docu/node/runtime/bun.ts +28 -0
- package/.docu/node/runtime/deno.ts +34 -0
- package/.docu/node/runtime/index.ts +4 -0
- package/.docu/node/runtime/node.ts +107 -0
- package/.docu/node/runtime/types.ts +19 -0
- package/.docu/node/search-indexer.ts +3 -2
- package/.docu/node/seo.ts +2 -2
- package/.docu/node/server-routes.ts +28 -13
- package/.docu/node/server.deno.ts +1 -1
- package/.docu/node/server.impl.ts +49 -3
- package/.docu/node/server.node.ts +1 -1
- package/.docu/node/server.ts +45 -1
- package/.docu/pages/docs/[[...slug]].tsx +13 -4
- package/.docu/pages/index.tsx +1 -1
- package/.docu/styles/globals.css +29 -20
- package/README.md +7 -48
- package/bin/cli.js +25 -7
- package/package.json +7 -6
- package/template/README.md +7 -48
package/.docu/node/build.ts
CHANGED
|
@@ -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 = (
|
|
117
|
-
const 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
|
|
128
|
-
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
|
-
|
|
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
|
|
package/.docu/node/client.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
29
|
-
//
|
|
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
|
-
|
|
56
|
+
"create"
|
|
41
57
|
);
|
|
42
58
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
|
174
|
+
`FROM ghcr.io/docubook/flame:${FLAME_MAJOR} AS builder
|
|
175
175
|
ENV NODE_ENV=production
|
|
176
176
|
WORKDIR /app
|
|
177
177
|
COPY . .
|
package/.docu/node/deploy.ts
CHANGED
|
@@ -49,7 +49,7 @@ async function runBuild() {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export const DOCKERFILE_BUN = `FROM ghcr.io/docubook/flame
|
|
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(
|
|
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
|
-
//
|
|
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("/
|
|
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 {
|
package/.docu/node/hydrate.ts
CHANGED
|
@@ -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(
|
|
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/
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
+
}
|