@clidey/dory 0.45.0 → 0.47.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/package.json +3 -3
- package/scripts/render-routes.mjs +15 -6
- package/src/components/routes.tsx +39 -91
- package/src/components/search.tsx +1 -0
- package/src/components/store.ts +64 -88
- package/src/entry-server.tsx +161 -71
- package/src/main.tsx +18 -4
- package/src/mdx/api-playground.tsx +2 -0
- package/src/mdx/latex.tsx +5 -0
- package/src/mdx/mdx.tsx +19 -11
- package/src/mdx/websocket-playground.tsx +2 -0
- package/src/plugins/frontmatter-dev-server.ts +37 -0
- package/src/plugins/frontmatter-generator.ts +46 -1
- package/src/plugins/sanitize.ts +1 -1
- package/src/ui/layout.tsx +9 -8
- package/vite.config.ssr.ts +2 -1
- package/vite.config.ts +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clidey/dory",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.47.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A CLI tool for building and previewing documentation sites",
|
|
7
7
|
"keywords": [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"dev": "vite",
|
|
39
|
-
"build": "npm run build:cli && tsc -b
|
|
39
|
+
"build": "npm run build:cli && tsc -b",
|
|
40
40
|
"build:cli": "tsc -p tsconfig.bin.json",
|
|
41
41
|
"build:docs": "vite build && vite build --config vite.config.ssr.ts && node scripts/render-routes.mjs",
|
|
42
42
|
"build:embed": "npm run build:embed:loader && npm run build:embed:widget",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@algolia/autocomplete-core": "1.19.5",
|
|
51
|
-
"@clidey/ux": "0.
|
|
51
|
+
"@clidey/ux": "0.40.0",
|
|
52
52
|
"@fortawesome/fontawesome-svg-core": "7.2.0",
|
|
53
53
|
"@fortawesome/free-brands-svg-icons": "7.2.0",
|
|
54
54
|
"@fortawesome/free-regular-svg-icons": "7.2.0",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Post-build SSR rendering script.
|
|
3
3
|
* Reads the SSR bundle from dist-ssr/, renders each route to HTML,
|
|
4
4
|
* and injects the content into the prerendered HTML files in dist/.
|
|
5
|
+
* Also inlines frontmatter JSON so the client can hydrate without fetching.
|
|
5
6
|
*/
|
|
6
7
|
import { readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
|
|
7
8
|
import { resolve, dirname } from 'path';
|
|
@@ -31,14 +32,22 @@ const frontmatter = JSON.parse(readFileSync(frontmatterPath, 'utf-8'));
|
|
|
31
32
|
let count = 0;
|
|
32
33
|
let failed = 0;
|
|
33
34
|
|
|
34
|
-
function injectSSR(htmlPath, ssrHtml) {
|
|
35
|
+
function injectSSR(htmlPath, ssrHtml, routePath) {
|
|
35
36
|
let html = readFileSync(htmlPath, 'utf-8');
|
|
36
37
|
|
|
38
|
+
// Inject SSR-rendered content into #app
|
|
37
39
|
html = html.replace(
|
|
38
40
|
'<div id="app"></div>',
|
|
39
41
|
`<div id="app">${ssrHtml}</div>`
|
|
40
42
|
);
|
|
41
43
|
|
|
44
|
+
// Blocking theme script — sets dark class before first paint to prevent flash.
|
|
45
|
+
// Inline frontmatter JSON so the client can read it synchronously.
|
|
46
|
+
html = html.replace(
|
|
47
|
+
'</head>',
|
|
48
|
+
`<script>(function(){try{var t=localStorage.getItem("@clidey/dory/theme");if(t==="dark"||(t==="system"||!t)&&window.matchMedia("(prefers-color-scheme:dark)").matches)document.documentElement.classList.add("dark")}catch(e){}})();window.__DORY_FRONTMATTER__=${JSON.stringify(frontmatter)};window.__DORY_ROUTE__=${JSON.stringify(routePath)};</script>\n</head>`
|
|
49
|
+
);
|
|
50
|
+
|
|
42
51
|
html = html.replace(
|
|
43
52
|
'<noscript>You need to enable JavaScript to run this app.</noscript>',
|
|
44
53
|
'<noscript>JavaScript enhances this page with interactive features.</noscript>'
|
|
@@ -47,7 +56,7 @@ function injectSSR(htmlPath, ssrHtml) {
|
|
|
47
56
|
writeFileSync(htmlPath, html, 'utf-8');
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
// Render each route
|
|
59
|
+
// Render each route — pass frontmatter so SSR can build navigation titles
|
|
51
60
|
for (const page of frontmatter) {
|
|
52
61
|
const routePath = page.path;
|
|
53
62
|
if (!routePath || routePath === '/') continue;
|
|
@@ -56,10 +65,10 @@ for (const page of frontmatter) {
|
|
|
56
65
|
if (!existsSync(htmlPath)) continue;
|
|
57
66
|
|
|
58
67
|
try {
|
|
59
|
-
const ssrHtml = await render(routePath);
|
|
68
|
+
const ssrHtml = await render(routePath, frontmatter);
|
|
60
69
|
if (!ssrHtml) continue;
|
|
61
70
|
|
|
62
|
-
injectSSR(htmlPath, ssrHtml);
|
|
71
|
+
injectSSR(htmlPath, ssrHtml, routePath);
|
|
63
72
|
count++;
|
|
64
73
|
} catch (error) {
|
|
65
74
|
console.warn(`⚠️ SSR failed for ${routePath}: ${error.message}`);
|
|
@@ -73,9 +82,9 @@ try {
|
|
|
73
82
|
if (existsSync(rootHtmlPath) && frontmatter.length > 0) {
|
|
74
83
|
const firstRoute = frontmatter[0].path;
|
|
75
84
|
if (firstRoute) {
|
|
76
|
-
const ssrHtml = await render(firstRoute);
|
|
85
|
+
const ssrHtml = await render(firstRoute, frontmatter);
|
|
77
86
|
if (ssrHtml) {
|
|
78
|
-
injectSSR(rootHtmlPath, ssrHtml);
|
|
87
|
+
injectSSR(rootHtmlPath, ssrHtml, firstRoute);
|
|
79
88
|
count++;
|
|
80
89
|
}
|
|
81
90
|
}
|
|
@@ -1,115 +1,63 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useMemo } from 'preact/hooks';
|
|
2
2
|
import { Route, Router, useLocation } from 'wouter-preact';
|
|
3
3
|
import { usePathname } from './hooks';
|
|
4
4
|
import { ALL_NAVIGATION, ALL_PAGES, pathFromFilename } from './store';
|
|
5
|
-
import { Loading } from './loading';
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
export default function Routes() {
|
|
9
|
-
const [routes, setRoutes] = useState<any[]>([]);
|
|
10
8
|
const pathname = usePathname();
|
|
11
9
|
const [, navigate] = useLocation();
|
|
12
|
-
const [loading, setLoading] = useState(true);
|
|
13
10
|
|
|
11
|
+
// Redirect root to the first page so the URL reflects the actual content
|
|
14
12
|
useEffect(() => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const href = link.getAttribute('href');
|
|
21
|
-
if (!href) return;
|
|
22
|
-
|
|
23
|
-
const loaderEntry = Object.entries(ALL_PAGES).find(
|
|
24
|
-
([path]) => path === href
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
if (!loaderEntry) return;
|
|
28
|
-
|
|
29
|
-
const [path, loader] = loaderEntry;
|
|
30
|
-
|
|
31
|
-
const handleMouseEnter = () => {
|
|
32
|
-
if (!preloaded.has(path)) {
|
|
33
|
-
loader();
|
|
34
|
-
preloaded.add(path);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
link.addEventListener('mouseenter', handleMouseEnter);
|
|
39
|
-
listeners.push(() => link.removeEventListener('mouseenter', handleMouseEnter));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
return () => {
|
|
43
|
-
listeners.forEach(unsub => unsub());
|
|
44
|
-
};
|
|
45
|
-
}, []);
|
|
13
|
+
if (pathname === '/') {
|
|
14
|
+
const firstPage = ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href;
|
|
15
|
+
if (firstPage) navigate(firstPage, { replace: true });
|
|
16
|
+
}
|
|
17
|
+
}, [pathname]);
|
|
46
18
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
19
|
+
const route = useMemo(() => {
|
|
20
|
+
let targetPath = pathname;
|
|
21
|
+
if (pathname === '/') {
|
|
22
|
+
const ssrRoute = typeof window !== 'undefined' && (window as any).__DORY_ROUTE__;
|
|
23
|
+
targetPath = ssrRoute || ALL_NAVIGATION[0].groups[0].pages[0].href;
|
|
24
|
+
}
|
|
53
25
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const module = await loader();
|
|
57
|
-
const routePath = pathname === '/' ? '/' : pathFromFilename(path);
|
|
58
|
-
return {
|
|
59
|
-
path: routePath,
|
|
60
|
-
component: module.default,
|
|
61
|
-
};
|
|
62
|
-
})
|
|
63
|
-
);
|
|
64
|
-
setRoutes(entries);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
console.error('Failed to load route:', error);
|
|
67
|
-
setRoutes([]);
|
|
68
|
-
} finally {
|
|
69
|
-
setLoading(false);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
26
|
+
const entry = Object.entries(ALL_PAGES).find(([path]) => path === targetPath);
|
|
27
|
+
if (!entry) return null;
|
|
72
28
|
|
|
73
|
-
|
|
29
|
+
const [path, mod] = entry;
|
|
30
|
+
return {
|
|
31
|
+
path: pathname === '/' ? '/' : pathFromFilename(path),
|
|
32
|
+
component: (mod as any).default,
|
|
33
|
+
};
|
|
74
34
|
}, [pathname]);
|
|
75
35
|
|
|
76
|
-
|
|
36
|
+
// 404
|
|
37
|
+
if (!route) {
|
|
77
38
|
const page = ALL_NAVIGATION.find((tab) => tab.groups.find((group) => group.pages.find((page) => page.href === pathname)));
|
|
78
39
|
if (!page && pathname !== '/') {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
</div>
|
|
95
|
-
)
|
|
96
|
-
}]);
|
|
97
|
-
setLoading(false);
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col items-center justify-center min-h-[50vh] p-8">
|
|
42
|
+
<h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
|
|
43
|
+
<h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
|
|
44
|
+
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">
|
|
45
|
+
The page <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm">{pathname}</code> doesn't exist.
|
|
46
|
+
</p>
|
|
47
|
+
<button
|
|
48
|
+
onClick={() => navigate(ALL_NAVIGATION[0].groups[0].pages[0].href)}
|
|
49
|
+
className="px-4 py-2 text-sm font-medium rounded-md bg-[var(--brand-primary)] text-white hover:opacity-90 transition-opacity"
|
|
50
|
+
>
|
|
51
|
+
Go to Home
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
98
55
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
103
|
-
}, [pathname]);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (loading) return <div className="h-[25vh] flex grow"><Loading /></div>;
|
|
56
|
+
}
|
|
107
57
|
|
|
108
58
|
return (
|
|
109
59
|
<Router>
|
|
110
|
-
{
|
|
111
|
-
<Route key={path} path={path} component={Component} />
|
|
112
|
-
))}
|
|
60
|
+
{route && <Route key={route.path} path={route.path} component={route.component} />}
|
|
113
61
|
</Router>
|
|
114
62
|
);
|
|
115
63
|
}
|
package/src/components/store.ts
CHANGED
|
@@ -6,9 +6,12 @@ import { addToSearchIndex } from './search-index';
|
|
|
6
6
|
|
|
7
7
|
const config = docsConfig as DoryConfig;
|
|
8
8
|
|
|
9
|
+
// Eager-load all page modules so they're available synchronously.
|
|
10
|
+
// This eliminates the async loading spinner in Routes and enables
|
|
11
|
+
// proper hydrate() — the first render matches the SSR DOM.
|
|
9
12
|
export const ALL_PAGES = Object.fromEntries(
|
|
10
|
-
Object.entries(import.meta.glob<{ default: ComponentType; }>('../../docs/**/*.mdx'))
|
|
11
|
-
.map(([path,
|
|
13
|
+
Object.entries(import.meta.glob<{ default: ComponentType; }>('../../docs/**/*.mdx', { eager: true }))
|
|
14
|
+
.map(([path, mod]) => [pathFromFilename(path), mod])
|
|
12
15
|
);
|
|
13
16
|
export const ALL_OPENAPI = Object.fromEntries(
|
|
14
17
|
Object.entries(import.meta.glob<{ default: string }>('../../docs/**/openapi.json', { query: 'raw', eager: true }))
|
|
@@ -59,30 +62,26 @@ export function pathFromFilename(filename: string): string {
|
|
|
59
62
|
.toLowerCase();
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
function parseFrontMatter(content: string) {
|
|
63
|
-
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
64
|
-
if (!frontMatterMatch) return { data: {}, content };
|
|
65
|
-
|
|
66
|
-
const frontMatter = frontMatterMatch[1];
|
|
67
|
-
const data: Record<string, any> = {};
|
|
68
|
-
|
|
69
|
-
frontMatter.split('\n').forEach(line => {
|
|
70
|
-
const [key, ...valueParts] = line.split(':');
|
|
71
|
-
if (key && valueParts.length) {
|
|
72
|
-
const value = valueParts.join(':').trim();
|
|
73
|
-
data[key.trim()] = value.replace(/^["']|["']$/g, ''); // Remove quotes if present
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
return { data, content: content.slice(frontMatterMatch[0].length) };
|
|
78
|
-
}
|
|
79
65
|
|
|
80
66
|
export let completeFrontMatter: Record<string, any>[] = [];
|
|
67
|
+
export function isFrontmatterReady() { return preloadedFrontMatter !== null; }
|
|
81
68
|
let preloadedFrontMatter: Record<string, any>[] | null = null;
|
|
82
69
|
|
|
83
|
-
//
|
|
70
|
+
// Check for inlined frontmatter from SSR (available synchronously, no fetch needed)
|
|
71
|
+
if (typeof window !== 'undefined' && (window as any).__DORY_FRONTMATTER__) {
|
|
72
|
+
preloadedFrontMatter = (window as any).__DORY_FRONTMATTER__;
|
|
73
|
+
completeFrontMatter = [...preloadedFrontMatter!];
|
|
74
|
+
for (const fm of preloadedFrontMatter!) {
|
|
75
|
+
updateNavigationTitle(fm.path, fm.title);
|
|
76
|
+
}
|
|
77
|
+
delete (window as any).__DORY_FRONTMATTER__;
|
|
78
|
+
// Build search index in the background after the page has fully rendered
|
|
79
|
+
setTimeout(() => addPreloadedContentToSearch(), 2000);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Preload frontmatter from JSON file (fallback when no inline data)
|
|
84
83
|
export async function preloadFrontmatter() {
|
|
85
|
-
if (preloadedFrontMatter) return; // Already loaded
|
|
84
|
+
if (preloadedFrontMatter) return; // Already loaded (inline or previous fetch)
|
|
86
85
|
|
|
87
86
|
try {
|
|
88
87
|
const response = await fetch('/frontmatter.json');
|
|
@@ -106,33 +105,23 @@ export async function preloadFrontmatter() {
|
|
|
106
105
|
}
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
// Add preloaded content to search index (runs in background, never blocks rendering)
|
|
108
|
+
// Add preloaded content to search index (runs in background, never blocks rendering).
|
|
109
|
+
// Fetches pre-built search-content.json generated at build time — no ?raw glob needed.
|
|
110
110
|
async function addPreloadedContentToSearch() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const [, loader] = fileEntry;
|
|
126
|
-
const rawContent = (await loader()).default;
|
|
127
|
-
const { content } = parseFrontMatter(rawContent);
|
|
128
|
-
|
|
129
|
-
await addToSearchIndex(fm.path, { title: fm.title }, content);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
console.warn(`Failed to index ${fm.path}:`, error);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
})
|
|
135
|
-
);
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch('/search-content.json');
|
|
113
|
+
if (!response.ok) return;
|
|
114
|
+
const searchContent: Array<{ path: string; title: string; content: string }> = await response.json();
|
|
115
|
+
|
|
116
|
+
for (const entry of searchContent) {
|
|
117
|
+
try {
|
|
118
|
+
await addToSearchIndex(entry.path, { title: entry.title }, entry.content);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.warn(`Failed to index ${entry.path}:`, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn('Failed to load search content:', error);
|
|
136
125
|
}
|
|
137
126
|
}
|
|
138
127
|
|
|
@@ -146,53 +135,40 @@ export async function loadMDXFrontMatterForPath(pathname: string) {
|
|
|
146
135
|
// Check if already loaded in completeFrontMatter
|
|
147
136
|
if (completeFrontMatter.find(fm => fm.path === pathname)) return;
|
|
148
137
|
|
|
149
|
-
// Fallback
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const frontmatterItem = { ...data, path: filename };
|
|
165
|
-
completeFrontMatter = [...completeFrontMatter, frontmatterItem];
|
|
138
|
+
// Fallback: fetch frontmatter.json for this page's metadata
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch('/frontmatter.json');
|
|
141
|
+
if (response.ok) {
|
|
142
|
+
const allFm: Record<string, any>[] = await response.json();
|
|
143
|
+
const fm = allFm.find(f => f.path === pathname);
|
|
144
|
+
if (fm) {
|
|
145
|
+
updateNavigationTitle(pathname, fm.title);
|
|
146
|
+
completeFrontMatter = [...completeFrontMatter, fm];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Non-fatal — page will still render, just without title in nav
|
|
151
|
+
}
|
|
166
152
|
}
|
|
167
153
|
|
|
168
154
|
// Load all remaining frontmatter (after initial load) - optimized with fallback
|
|
169
155
|
export async function loadAllMDXFrontMatter(pathname: string) {
|
|
170
|
-
|
|
171
|
-
if (preloadedFrontMatter) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Fallback to dynamic loading
|
|
176
|
-
const mdxFiles = import.meta.glob<{ default: string }>('../../docs/**/*.mdx', { query: '?raw' });
|
|
177
|
-
const fileEntries = Object.entries(mdxFiles);
|
|
156
|
+
if (preloadedFrontMatter) return;
|
|
178
157
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
remainingEntries.map(async ([path, loader]) => {
|
|
184
|
-
const rawContent = (await loader()).default;
|
|
185
|
-
const { data, content } = parseFrontMatter(rawContent);
|
|
186
|
-
const filename = pathFromFilename(path);
|
|
187
|
-
|
|
188
|
-
updateNavigationTitle(filename, data.title);
|
|
189
|
-
await addToSearchIndex(filename, data, content);
|
|
190
|
-
|
|
191
|
-
newFrontmatter.push({ ...data, path: filename });
|
|
192
|
-
})
|
|
193
|
-
);
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch('/frontmatter.json');
|
|
160
|
+
if (!response.ok) return;
|
|
161
|
+
const allFm: Record<string, any>[] = await response.json();
|
|
194
162
|
|
|
195
|
-
|
|
163
|
+
for (const fm of allFm) {
|
|
164
|
+
if (fm.path !== pathname) {
|
|
165
|
+
updateNavigationTitle(fm.path, fm.title);
|
|
166
|
+
completeFrontMatter = [...completeFrontMatter, fm];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Non-fatal
|
|
171
|
+
}
|
|
196
172
|
}
|
|
197
173
|
|
|
198
174
|
// Shared helper
|
package/src/entry-server.tsx
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {renderToString} from 'preact-render-to-string';
|
|
2
|
-
import {Router} from 'wouter-preact';
|
|
3
|
-
import {MDXProvider} from '@mdx-js/preact';
|
|
1
|
+
import { renderToString } from 'preact-render-to-string';
|
|
2
|
+
import { Router } from 'wouter-preact';
|
|
3
|
+
import { MDXProvider } from '@mdx-js/preact';
|
|
4
|
+
import { ThemeProvider } from '@clidey/ux';
|
|
4
5
|
import * as mdxComponents from './mdx/mdx';
|
|
6
|
+
import docsConfig from '../docs/dory.json';
|
|
7
|
+
|
|
8
|
+
const config = docsConfig as any;
|
|
5
9
|
|
|
6
10
|
function pathFromFilename(filename: string): string {
|
|
7
11
|
return filename
|
|
@@ -16,67 +20,24 @@ const allPages = Object.fromEntries(
|
|
|
16
20
|
Object.entries(pages).map(([path, loader]) => [pathFromFilename(path), loader])
|
|
17
21
|
);
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// SSR-safe Latex: renders the raw expression as text.
|
|
35
|
-
// The client-side Latex component lazy-loads KaTeX in useEffect.
|
|
36
|
-
function SSRLatex({ children }: { children: any }) {
|
|
37
|
-
return (
|
|
38
|
-
<div class="my-4">
|
|
39
|
-
<code>{children}</code>
|
|
40
|
-
</div>
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// SSR-safe table: @clidey/ux's Table component renders a vaul Drawer
|
|
45
|
-
// (Preview drawer) alongside every table. vaul's useScaleBackground
|
|
46
|
-
// accesses document.body at render time, crashing in Node.js.
|
|
47
|
-
function SSRTable(props: any) {
|
|
48
|
-
return (
|
|
49
|
-
<div class="overflow-x-auto rounded-lg my-8">
|
|
50
|
-
<table class="table-auto border-collapse min-w-full" {...props} />
|
|
51
|
-
</div>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
function SSRTh(props: any) {
|
|
55
|
-
return <th class="px-4 py-2 text-left text-sm font-medium" {...props} />;
|
|
56
|
-
}
|
|
57
|
-
function SSRTd(props: any) {
|
|
58
|
-
return <td class="px-4 py-2 text-sm" {...props} />;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// SSR-safe placeholders for interactive components that use Suspense/lazy.
|
|
62
|
-
// renderToString cannot handle Suspense boundaries — these components are
|
|
63
|
-
// interactive-only anyway (playgrounds, diagrams) and not meaningful for crawlers.
|
|
64
|
-
function SSRPlaceholder() {
|
|
65
|
-
return null;
|
|
23
|
+
function buildNavigation(frontmatter?: Record<string, any>[]) {
|
|
24
|
+
return config.navigation.tabs.map((tab: any) => ({
|
|
25
|
+
title: tab.tab,
|
|
26
|
+
groups: tab.groups.map((group: any) => ({
|
|
27
|
+
title: group.group,
|
|
28
|
+
pages: group.pages.map((page: string) => {
|
|
29
|
+
const href = `/${page}`;
|
|
30
|
+
const fm = frontmatter?.find(f => f.path === href);
|
|
31
|
+
return {
|
|
32
|
+
title: fm?.title || page.charAt(0).toUpperCase() + page.slice(1).replace(/-/g, ' '),
|
|
33
|
+
href,
|
|
34
|
+
};
|
|
35
|
+
})
|
|
36
|
+
}))
|
|
37
|
+
}));
|
|
66
38
|
}
|
|
67
39
|
|
|
68
|
-
|
|
69
|
-
...mdxComponents,
|
|
70
|
-
pre: SSRPre,
|
|
71
|
-
Latex: SSRLatex,
|
|
72
|
-
table: SSRTable,
|
|
73
|
-
th: SSRTh,
|
|
74
|
-
td: SSRTd,
|
|
75
|
-
APIPlayground: SSRPlaceholder,
|
|
76
|
-
WebSocketPlayground: SSRPlaceholder,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export async function render(routePath: string): Promise<string> {
|
|
40
|
+
export async function render(routePath: string, frontmatter?: Record<string, any>[]): Promise<string> {
|
|
80
41
|
const loader = allPages[routePath];
|
|
81
42
|
if (!loader) return '';
|
|
82
43
|
|
|
@@ -84,20 +45,149 @@ export async function render(routePath: string): Promise<string> {
|
|
|
84
45
|
const module = await loader() as any;
|
|
85
46
|
const Content = module.default;
|
|
86
47
|
|
|
48
|
+
const navigation = buildNavigation(frontmatter);
|
|
49
|
+
const pageFm = frontmatter?.find((fm: any) => fm.path === routePath);
|
|
50
|
+
|
|
51
|
+
// Find group/page titles (Layout uses swapped names: "title: group, group: title")
|
|
52
|
+
let groupTitle = '';
|
|
53
|
+
const currentTab = navigation.find((tab: any) =>
|
|
54
|
+
tab.groups.some((group: any) =>
|
|
55
|
+
group.pages.some((page: any) => page.href === routePath)
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
if (currentTab) {
|
|
59
|
+
for (const group of currentTab.groups) {
|
|
60
|
+
if (group.pages.some((p: any) => p.href === routePath)) {
|
|
61
|
+
groupTitle = group.title;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Prev/next
|
|
67
|
+
const allLinks = navigation.flatMap((tab: any) =>
|
|
68
|
+
tab.groups.flatMap((group: any) => group.pages)
|
|
69
|
+
);
|
|
70
|
+
const linkIndex = allLinks.findIndex((link: any) => link.href === routePath);
|
|
71
|
+
const prevPage = linkIndex > 0 ? allLinks[linkIndex - 1] : null;
|
|
72
|
+
const nextPage = linkIndex < allLinks.length - 1 ? allLinks[linkIndex + 1] : null;
|
|
73
|
+
|
|
87
74
|
return renderToString(
|
|
75
|
+
<ThemeProvider>
|
|
88
76
|
<Router ssrPath={routePath}>
|
|
89
|
-
<MDXProvider components={
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
77
|
+
<MDXProvider components={mdxComponents}>
|
|
78
|
+
<div class="flex w-full flex-col">
|
|
79
|
+
{/* Header — matches src/ui/header.tsx structure */}
|
|
80
|
+
<header class="sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between bg-white px-4 py-5 shadow-md shadow-gray-900/5 sm:px-6 lg:px-8 dark:shadow-none dark:bg-transparent">
|
|
81
|
+
<div class="relative flex justify-between gap-6 sm:gap-8 md:grow items-center w-full px-4">
|
|
82
|
+
<div class="flex items-center grow gap-2">
|
|
83
|
+
<img src={`/${config.logo.light}`} alt="logo" class="w-8 hidden dark:block" />
|
|
84
|
+
<img src={`/${config.logo.dark}`} alt="logo" class="w-8 block dark:hidden" />
|
|
85
|
+
<h1 class="text-base lg:text-2xl font-bold">{config.name}</h1>
|
|
86
|
+
</div>
|
|
87
|
+
<nav class="hidden md:block">
|
|
88
|
+
<ul role="list" class="flex items-center gap-8">
|
|
89
|
+
{navigation.map((tab: any) => {
|
|
90
|
+
const isActive = tab === currentTab;
|
|
91
|
+
return (
|
|
92
|
+
<li key={tab.title}>
|
|
93
|
+
<a
|
|
94
|
+
href={tab.groups[0].pages[0].href}
|
|
95
|
+
class={`text-xs transition hover:opacity-80${isActive ? ' text-brand-foreground' : ''}`}
|
|
96
|
+
>
|
|
97
|
+
{tab.title}
|
|
98
|
+
</a>
|
|
99
|
+
</li>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</ul>
|
|
103
|
+
</nav>
|
|
104
|
+
<div class="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
|
105
|
+
{/* Search, ModeToggle, MobileNav — rendered client-side during hydration.
|
|
106
|
+
Static placeholders cause ref mismatches with Algolia autocomplete. */}
|
|
107
|
+
<div class="flex items-center gap-3" data-testid="header-actions" />
|
|
108
|
+
</div>
|
|
109
|
+
</header>
|
|
110
|
+
|
|
111
|
+
{/* Container — matches src/ui/layout.tsx structure */}
|
|
112
|
+
<div id="container" class="relative flex w-full max-w-8xl sm:px-2 lg:px-8">
|
|
113
|
+
<div class="hidden lg:relative lg:block lg:flex-none">
|
|
114
|
+
<div class="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-x-hidden overflow-y-auto py-16 pr-8 pl-0.5 xl:w-72 xl:pr-16">
|
|
115
|
+
<nav class="dory-navigation text-base lg:text-sm">
|
|
116
|
+
<ul role="list" class="space-y-9">
|
|
117
|
+
{currentTab && (
|
|
118
|
+
<li>
|
|
119
|
+
<h2 class="font-display font-medium text-xs">{currentTab.title}</h2>
|
|
120
|
+
{currentTab.groups.map((group: any) => (
|
|
121
|
+
<div key={group.title}>
|
|
122
|
+
<h3 class="mt-4 text-sm font-semibold">{group.title}</h3>
|
|
123
|
+
<ul role="list" class="mt-2 space-y-2 border-l-2 border-slate-100 lg:mt-4 lg:space-y-4 lg:border-slate-200 dark:border-slate-800">
|
|
124
|
+
{group.pages.map((page: any) => (
|
|
125
|
+
<li key={page.href} class="relative">
|
|
126
|
+
<a
|
|
127
|
+
href={page.href}
|
|
128
|
+
class={routePath === page.href
|
|
129
|
+
? 'block w-full pl-3.5 font-semibold text-brand-foreground before:pointer-events-none before:absolute before:top-1/2 before:-left-1 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full'
|
|
130
|
+
: 'block w-full pl-3.5 text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300 before:pointer-events-none before:absolute before:top-1/2 before:-left-1 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full'}
|
|
131
|
+
>
|
|
132
|
+
{page.title}
|
|
133
|
+
</a>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</li>
|
|
140
|
+
)}
|
|
141
|
+
</ul>
|
|
142
|
+
</nav>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<main class="flex grow max-w-full px-4 py-16 sm:px-6 lg:px-8">
|
|
147
|
+
<div class="min-w-0 w-full">
|
|
148
|
+
<article class="h-full flex flex-1 flex-col">
|
|
149
|
+
<div class="flex justify-between items-start">
|
|
150
|
+
<header class="mb-8">
|
|
151
|
+
{groupTitle && <h1 class="font-display text-lg tracking-tight">{groupTitle}</h1>}
|
|
152
|
+
{pageFm?.title && <p class="text-4xl tracking-tight">{pageFm.title}</p>}
|
|
153
|
+
</header>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="flex-1 min-h-[calc(100vh-2rem)]">
|
|
156
|
+
{/* Matches App > div.antialiased > div.flex > Routes > Content */}
|
|
157
|
+
<div class="h-full antialiased">
|
|
158
|
+
<div class="flex min-h-full">
|
|
159
|
+
<Content />
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
{(prevPage || nextPage) && (
|
|
164
|
+
<div class="mt-12 pt-6 flex border-t border-black/20 dark:border-white/20">
|
|
165
|
+
{prevPage && (
|
|
166
|
+
<div>
|
|
167
|
+
<p class="font-display text-sm font-medium">Previous</p>
|
|
168
|
+
<div class="mt-1">
|
|
169
|
+
<a href={prevPage.href} class="text-base font-semibold hover:opacity-80">{prevPage.title}</a>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{nextPage && (
|
|
174
|
+
<div class="ml-auto text-right">
|
|
175
|
+
<p class="font-display text-sm font-medium">Next</p>
|
|
176
|
+
<div class="mt-1">
|
|
177
|
+
<a href={nextPage.href} class="text-base font-semibold hover:opacity-80">{nextPage.title}</a>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</article>
|
|
184
|
+
</div>
|
|
185
|
+
</main>
|
|
186
|
+
</div>
|
|
96
187
|
</div>
|
|
97
|
-
</main>
|
|
98
|
-
</div>
|
|
99
188
|
</MDXProvider>
|
|
100
189
|
</Router>
|
|
190
|
+
</ThemeProvider>
|
|
101
191
|
);
|
|
102
192
|
} catch (error) {
|
|
103
193
|
console.warn(`SSR render failed for ${routePath}:`, error);
|
package/src/main.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render } from 'preact'
|
|
1
|
+
import { render, hydrate } from 'preact'
|
|
2
2
|
import '@clidey/ux/styles.css';
|
|
3
3
|
import { App } from './app.tsx'
|
|
4
4
|
import { withMDX } from './components/mdx-provider.tsx'
|
|
@@ -7,6 +7,10 @@ import { loadFonts } from './utils/fonts'
|
|
|
7
7
|
import { loadColors } from './utils/colors'
|
|
8
8
|
import { Toaster } from '@clidey/ux'
|
|
9
9
|
import { ThemeProvider } from '@clidey/ux'
|
|
10
|
+
import { preloadFrontmatter } from './components/store'
|
|
11
|
+
|
|
12
|
+
// Start frontmatter loading immediately — if inlined from SSR, this is a no-op.
|
|
13
|
+
preloadFrontmatter()
|
|
10
14
|
|
|
11
15
|
// Load fonts and colors before rendering the app
|
|
12
16
|
loadFonts()
|
|
@@ -14,12 +18,22 @@ loadColors()
|
|
|
14
18
|
|
|
15
19
|
const AppWithMDX = withMDX(App)
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
const appRoot = document.getElementById('app')!
|
|
22
|
+
const hasSSRContent = appRoot.children.length > 0
|
|
23
|
+
|
|
24
|
+
const tree = (
|
|
18
25
|
<ThemeProvider storageKey="@clidey/dory/theme">
|
|
19
26
|
<Toaster />
|
|
20
27
|
<ErrorBoundary>
|
|
21
28
|
<AppWithMDX />
|
|
22
29
|
</ErrorBoundary>
|
|
23
|
-
</ThemeProvider
|
|
24
|
-
document.getElementById('app')!
|
|
30
|
+
</ThemeProvider>
|
|
25
31
|
)
|
|
32
|
+
|
|
33
|
+
// If SSR content exists, hydrate (attach JS to existing DOM).
|
|
34
|
+
// Otherwise, render from scratch (dev mode, non-SSR pages).
|
|
35
|
+
if (hasSSRContent) {
|
|
36
|
+
hydrate(tree, appRoot)
|
|
37
|
+
} else {
|
|
38
|
+
render(tree, appRoot)
|
|
39
|
+
}
|
|
@@ -323,6 +323,8 @@ export function APIPlayground({
|
|
|
323
323
|
authType = 'none',
|
|
324
324
|
servers = []
|
|
325
325
|
}: APIPlaygroundProps) {
|
|
326
|
+
if (typeof window === 'undefined') return null
|
|
327
|
+
|
|
326
328
|
const [selectedTab, setSelectedTab] = useState(0)
|
|
327
329
|
const [isLoading, setIsLoading] = useState(false)
|
|
328
330
|
const [response, setResponse] = useState<ResponseData | null>(null)
|
package/src/mdx/latex.tsx
CHANGED
|
@@ -16,6 +16,11 @@ interface LatexProps {
|
|
|
16
16
|
|
|
17
17
|
export function Latex({ children }: LatexProps) {
|
|
18
18
|
const expression = extractText(children).trim();
|
|
19
|
+
|
|
20
|
+
if (typeof window === 'undefined') {
|
|
21
|
+
return <div className="my-4"><code>{expression}</code></div>
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
20
25
|
const [error, setError] = useState<string | null>(null)
|
|
21
26
|
const [isLoading, setIsLoading] = useState(true)
|
package/src/mdx/mdx.tsx
CHANGED
|
@@ -22,20 +22,28 @@ export { UnorderedList as ul, OrderedList as ol, ListItem as li } from './list';
|
|
|
22
22
|
export { AsyncAPI } from './async-api';
|
|
23
23
|
export { Source } from './source';
|
|
24
24
|
|
|
25
|
-
// Lazy-loaded heavy components — only downloaded when a page uses them
|
|
25
|
+
// Lazy-loaded heavy components — only downloaded when a page uses them.
|
|
26
|
+
// SSR guard at this level prevents Suspense from rendering in Node.js
|
|
27
|
+
// (renderToString can't resolve lazy components).
|
|
26
28
|
const LazyAPIPlayground = lazy(() => import('./api-playground').then(m => ({ default: m.APIPlayground })));
|
|
27
|
-
export const APIPlayground = (props: any) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
export const APIPlayground = (props: any) => {
|
|
30
|
+
if (typeof window === 'undefined') return null;
|
|
31
|
+
return (
|
|
32
|
+
<Suspense fallback={<Loading />}>
|
|
33
|
+
<LazyAPIPlayground {...props} />
|
|
34
|
+
</Suspense>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
32
37
|
|
|
33
38
|
const LazyWebSocketPlayground = lazy(() => import('./websocket-playground').then(m => ({ default: m.WebSocketPlayground })));
|
|
34
|
-
export const WebSocketPlayground = (props: any) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
export const WebSocketPlayground = (props: any) => {
|
|
40
|
+
if (typeof window === 'undefined') return null;
|
|
41
|
+
return (
|
|
42
|
+
<Suspense fallback={<Loading />}>
|
|
43
|
+
<LazyWebSocketPlayground {...props} />
|
|
44
|
+
</Suspense>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
39
47
|
|
|
40
48
|
export const wrapper = ({ children }: { children: preact.ComponentChildren }) => {
|
|
41
49
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -750,6 +750,8 @@ function WebSocketPlaygroundInternal({
|
|
|
750
750
|
|
|
751
751
|
// Main exported component with Sheet wrapper
|
|
752
752
|
export function WebSocketPlayground(props: WebSocketPlaygroundProps) {
|
|
753
|
+
if (typeof window === 'undefined') return null
|
|
754
|
+
|
|
753
755
|
const [showPlayground, setShowPlayground] = useState(false)
|
|
754
756
|
|
|
755
757
|
const handleShowPlayground = () => {
|
|
@@ -137,10 +137,47 @@ function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
|
|
|
137
137
|
return frontmatterArray;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// Strip MDX/JSX syntax to extract plain text for search indexing
|
|
141
|
+
function stripMdxToText(content: string): string {
|
|
142
|
+
return content
|
|
143
|
+
.replace(/^import\s+.*$/gm, '')
|
|
144
|
+
.replace(/^export\s+.*$/gm, '')
|
|
145
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
146
|
+
.replace(/<[^>]+>/g, '')
|
|
147
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
148
|
+
.replace(/[#*_~`>|]/g, '')
|
|
149
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
150
|
+
.trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
140
153
|
export function frontmatterDevServer(): Plugin {
|
|
141
154
|
return {
|
|
142
155
|
name: 'frontmatter-dev-server',
|
|
143
156
|
configureServer(server) {
|
|
157
|
+
// Serve search content for client-side indexing
|
|
158
|
+
server.middlewares.use('/search-content.json', (req, res, next) => {
|
|
159
|
+
if (req.method !== 'GET') return next();
|
|
160
|
+
try {
|
|
161
|
+
const docsDir = path.join(process.cwd(), 'docs');
|
|
162
|
+
const fmArray = generateFrontmatterJson(docsDir);
|
|
163
|
+
const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
|
|
164
|
+
const searchContent = fmArray.map(fm => {
|
|
165
|
+
const entry = Object.entries(mdxFilesMap).find(([, fp]) => pathFromFilename(fp, docsDir) === fm.path);
|
|
166
|
+
if (!entry) return { path: fm.path, title: fm.title || '', content: '' };
|
|
167
|
+
const raw = fs.readFileSync(entry[1], 'utf-8');
|
|
168
|
+
const { content } = parseFrontmatter(raw);
|
|
169
|
+
return { path: fm.path, title: fm.title || '', content: stripMdxToText(content) };
|
|
170
|
+
});
|
|
171
|
+
const json = JSON.stringify(searchContent);
|
|
172
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
173
|
+
res.end(json);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error('Error generating search content:', error);
|
|
176
|
+
res.statusCode = 500;
|
|
177
|
+
res.end('Internal Server Error');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
144
181
|
server.middlewares.use('/frontmatter.json', (req, res, next) => {
|
|
145
182
|
if (req.method !== 'GET') {
|
|
146
183
|
return next();
|
|
@@ -142,6 +142,41 @@ function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
|
|
|
142
142
|
return frontmatterArray;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// Strip MDX/JSX syntax to extract plain text for search indexing
|
|
146
|
+
function stripMdxToText(content: string): string {
|
|
147
|
+
return content
|
|
148
|
+
.replace(/^import\s+.*$/gm, '') // import statements
|
|
149
|
+
.replace(/^export\s+.*$/gm, '') // export statements
|
|
150
|
+
.replace(/```[\s\S]*?```/g, '') // fenced code blocks
|
|
151
|
+
.replace(/<[^>]+>/g, '') // JSX/HTML tags
|
|
152
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown links → text
|
|
153
|
+
.replace(/[#*_~`>|]/g, '') // markdown syntax chars
|
|
154
|
+
.replace(/\n{3,}/g, '\n\n') // collapse blank lines
|
|
155
|
+
.trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Generate search content JSON: [{ path, title, content }]
|
|
159
|
+
function generateSearchContentJson(docsDir: string, frontmatterArray: Array<Record<string, any>>): Array<Record<string, any>> {
|
|
160
|
+
const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
|
|
161
|
+
const searchContent: Array<Record<string, any>> = [];
|
|
162
|
+
|
|
163
|
+
for (const fm of frontmatterArray) {
|
|
164
|
+
// Find the MDX file for this frontmatter entry
|
|
165
|
+
const fileEntry = Object.entries(mdxFilesMap).find(([, filePath]) =>
|
|
166
|
+
pathFromFilename(filePath, docsDir) === fm.path
|
|
167
|
+
);
|
|
168
|
+
if (!fileEntry) continue;
|
|
169
|
+
|
|
170
|
+
const rawContent = fs.readFileSync(fileEntry[1], 'utf-8');
|
|
171
|
+
const { content } = parseFrontmatter(rawContent);
|
|
172
|
+
const text = stripMdxToText(content);
|
|
173
|
+
|
|
174
|
+
searchContent.push({ path: fm.path, title: fm.title || '', content: text });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return searchContent;
|
|
178
|
+
}
|
|
179
|
+
|
|
145
180
|
export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}): Plugin {
|
|
146
181
|
return {
|
|
147
182
|
name: 'frontmatter-generator',
|
|
@@ -156,9 +191,19 @@ export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}):
|
|
|
156
191
|
source: jsonContent
|
|
157
192
|
});
|
|
158
193
|
|
|
194
|
+
// Also generate search content for client-side indexing
|
|
195
|
+
const searchContent = generateSearchContentJson(docsDir, frontmatterArray);
|
|
196
|
+
const searchJson = JSON.stringify(searchContent);
|
|
197
|
+
|
|
198
|
+
this.emitFile({
|
|
199
|
+
type: 'asset',
|
|
200
|
+
fileName: 'search-content.json',
|
|
201
|
+
source: searchJson
|
|
202
|
+
});
|
|
203
|
+
|
|
159
204
|
console.log(`✅ Generated frontmatter JSON file`);
|
|
160
205
|
console.log(`📄 Processed ${frontmatterArray.length} MDX files`);
|
|
161
|
-
console.log(
|
|
206
|
+
console.log(`🔍 Generated search content (${searchContent.length} pages, ${(searchJson.length / 1024).toFixed(0)} KB)`);
|
|
162
207
|
}
|
|
163
208
|
};
|
|
164
209
|
}
|
package/src/plugins/sanitize.ts
CHANGED
|
@@ -24,7 +24,7 @@ const KNOWN_COMPONENTS = [
|
|
|
24
24
|
'Callout', 'Note', 'Warning', 'Info', 'Tip', 'Check',
|
|
25
25
|
'Latex', 'API', 'Expandable', 'ResponseField', 'Properties', 'Property',
|
|
26
26
|
'Steps', 'Step', 'table', 'th', 'td', 'ul', 'ol', 'li',
|
|
27
|
-
'APIPlayground', 'WebSocketPlayground', 'AsyncAPI', 'Source',
|
|
27
|
+
'APIPlayground', 'WebSocketPlayground', 'AsyncAPI', 'Source', 'Tag',
|
|
28
28
|
// Common HTML elements that should be preserved
|
|
29
29
|
'div', 'span', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's',
|
|
30
30
|
'blockquote', 'nav', 'section', 'article', 'header', 'footer', 'main',
|
package/src/ui/layout.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact';
|
|
2
2
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
|
3
3
|
import { Navigation } from '../components/navigation';
|
|
4
|
-
import { ALL_NAVIGATION, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, ALL_PAGES, pathFromFilename, preloadFrontmatter, ALL_ASYNCAPI } from '../components/store';
|
|
4
|
+
import { ALL_NAVIGATION, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, ALL_PAGES, pathFromFilename, preloadFrontmatter, ALL_ASYNCAPI, isFrontmatterReady } from '../components/store';
|
|
5
5
|
import { usePathname } from '../components/hooks';
|
|
6
6
|
import { OpenAPI } from '../mdx/open-api';
|
|
7
7
|
import { AsyncAPI } from '../mdx/async-api';
|
|
@@ -25,8 +25,9 @@ const prompt = (pathname: string) => `Please read and analyze the content from t
|
|
|
25
25
|
`Please provide clear, detailed explanations and examples where relevant.`;
|
|
26
26
|
|
|
27
27
|
export default function Layout({ children }: LayoutProps) {
|
|
28
|
-
const [loading, setLoading] = useState(
|
|
29
|
-
const
|
|
28
|
+
const [loading, setLoading] = useState(!isFrontmatterReady());
|
|
29
|
+
const rawPathname = usePathname();
|
|
30
|
+
const pathname = rawPathname === '/' ? ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href ?? '/' : rawPathname;
|
|
30
31
|
const { showNotification } = useNotification();
|
|
31
32
|
const isEmbedded = useIsEmbedded();
|
|
32
33
|
|
|
@@ -35,9 +36,8 @@ export default function Layout({ children }: LayoutProps) {
|
|
|
35
36
|
}, [pathname]);
|
|
36
37
|
|
|
37
38
|
const handleCopyMDX = useCallback(() => {
|
|
38
|
-
ALL_PAGES[pathFromFilename(pathname)]
|
|
39
|
-
|
|
40
|
-
});
|
|
39
|
+
const mod = ALL_PAGES[pathFromFilename(pathname)];
|
|
40
|
+
if (mod) navigator.clipboard.writeText((mod as any).default.toString());
|
|
41
41
|
showNotification('Copied to clipboard');
|
|
42
42
|
}, []);
|
|
43
43
|
const handleOpenChatGPT = useCallback(() => {
|
|
@@ -69,9 +69,9 @@ export default function Layout({ children }: LayoutProps) {
|
|
|
69
69
|
], [handleOpenMDX, handleCopyMDX, handleOpenChatGPT, handleOpenAnthropic]);
|
|
70
70
|
|
|
71
71
|
useEffect(() => {
|
|
72
|
-
// Try to preload frontmatter first (optimized approach)
|
|
72
|
+
// Try to preload frontmatter first (optimized approach).
|
|
73
|
+
// If inline data was available from SSR, preloadFrontmatter() resolves immediately.
|
|
73
74
|
preloadFrontmatter().then(() => {
|
|
74
|
-
// If preloading succeeds, we're done
|
|
75
75
|
setLoading(false);
|
|
76
76
|
}).catch(() => {
|
|
77
77
|
// Fallback to the original approach
|
|
@@ -87,6 +87,7 @@ export default function Layout({ children }: LayoutProps) {
|
|
|
87
87
|
});
|
|
88
88
|
}, [pathname]);
|
|
89
89
|
|
|
90
|
+
|
|
90
91
|
const { title: group, group: title, page } = useMemo(() => {
|
|
91
92
|
const group = ALL_NAVIGATION.flatMap((tab) =>
|
|
92
93
|
tab.groups.find((group) =>
|
package/vite.config.ssr.ts
CHANGED
|
@@ -14,9 +14,10 @@ export default defineConfig({
|
|
|
14
14
|
mdx(getMdxConfig(false)),
|
|
15
15
|
],
|
|
16
16
|
resolve: {
|
|
17
|
+
dedupe: ['preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
|
|
17
18
|
alias: {
|
|
18
19
|
react: 'preact/compat',
|
|
19
|
-
'react-dom': 'preact/compat'
|
|
20
|
+
'react-dom': 'preact/compat',
|
|
20
21
|
}
|
|
21
22
|
},
|
|
22
23
|
build: {
|
package/vite.config.ts
CHANGED
|
@@ -36,9 +36,10 @@ export default defineConfig(({ command }) => ({
|
|
|
36
36
|
prerender(),
|
|
37
37
|
],
|
|
38
38
|
resolve: {
|
|
39
|
+
dedupe: ['preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
|
|
39
40
|
alias: {
|
|
40
41
|
react: 'preact/compat',
|
|
41
|
-
'react-dom': 'preact/compat'
|
|
42
|
+
'react-dom': 'preact/compat',
|
|
42
43
|
}
|
|
43
44
|
},
|
|
44
45
|
publicDir: false, // Don't use a public directory since we need to import from docs
|