@clidey/dory 0.46.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clidey/dory",
3
3
  "private": false,
4
- "version": "0.46.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": [
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@algolia/autocomplete-core": "1.19.5",
51
- "@clidey/ux": "0.39.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, useState } from 'preact/hooks';
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
- const links = document.querySelectorAll('.dory-navigation a');
16
- const listeners: (() => void)[] = [];
17
- const preloaded = new Set();
18
-
19
- links.forEach(link => {
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
- useEffect(() => {
48
- setLoading(true);
49
- const loadRoutes = async () => {
50
- try {
51
- // For root path, load the first navigation page
52
- const targetPath = pathname === '/' ? ALL_NAVIGATION[0].groups[0].pages[0].href : pathname;
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
- const entries = await Promise.all(
55
- Object.entries(ALL_PAGES).filter(([path]) => path === targetPath).map(async ([path, loader]) => {
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
- loadRoutes();
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
- useEffect(() => {
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
- setRoutes([{
80
- path: pathname,
81
- component: () => (
82
- <div className="flex flex-col items-center justify-center min-h-[50vh] p-8">
83
- <h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
84
- <h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
85
- <p className="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">
86
- 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.
87
- </p>
88
- <button
89
- onClick={() => navigate(ALL_NAVIGATION[0].groups[0].pages[0].href)}
90
- className="px-4 py-2 text-sm font-medium rounded-md bg-[var(--brand-primary)] text-white hover:opacity-90 transition-opacity"
91
- >
92
- Go to Home
93
- </button>
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
- }, [pathname]);
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
- {routes.map(({ path, component: Component }) => (
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
  }
@@ -374,6 +374,7 @@ function useSearchProps() {
374
374
  return {
375
375
  inputProps: {
376
376
  ref: inputRef,
377
+ readOnly: true,
377
378
  onClick() {
378
379
  setOpen(true)
379
380
  },
@@ -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, loader]) => [pathFromFilename(path), loader])
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
- // Preload frontmatter from JSON file (optimized approach)
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
- if (!preloadedFrontMatter) return;
112
-
113
- const mdxFiles = import.meta.glob<{ default: string }>('../../docs/**/*.mdx', { query: '?raw' });
114
- const fileEntries = Object.entries(mdxFiles);
115
-
116
- // Index in batches to avoid firing hundreds of requests at once
117
- const BATCH_SIZE = 10;
118
- for (let i = 0; i < preloadedFrontMatter.length; i += BATCH_SIZE) {
119
- const batch = preloadedFrontMatter.slice(i, i + BATCH_SIZE);
120
- await Promise.all(
121
- batch.map(async (fm) => {
122
- const fileEntry = fileEntries.find(([filePath]) => pathFromFilename(filePath) === fm.path);
123
- if (fileEntry) {
124
- try {
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 to dynamic loading
150
- const mdxFiles = import.meta.glob<{ default: string }>('../../docs/**/*.mdx', { query: '?raw' });
151
- const fileEntries = Object.entries(mdxFiles);
152
-
153
- const fileEntry = fileEntries.find(([path]) => pathFromFilename(path) === pathname);
154
- if (!fileEntry) return;
155
-
156
- const [path, loader] = fileEntry;
157
- const rawContent = (await loader()).default;
158
- const { data, content } = parseFrontMatter(rawContent);
159
- const filename = pathFromFilename(path);
160
-
161
- updateNavigationTitle(filename, data.title);
162
- await addToSearchIndex(filename, data, content);
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
- // If we have preloaded data, we're already done
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
- const remainingEntries = fileEntries.filter(([path]) => pathFromFilename(path) !== pathname);
180
- const newFrontmatter: Record<string, any>[] = [];
181
-
182
- await Promise.all(
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
- completeFrontMatter = [...completeFrontMatter, ...newFrontmatter];
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
@@ -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
- // SSR-safe Pre: renders code blocks without syntax highlighting.
20
- // The client-side Fence component uses useTheme() from @clidey/ux which
21
- // requires ThemeProvider (localStorage access). Plain pre/code is sufficient
22
- // for crawlers — syntax highlighting is a visual enhancement.
23
- function SSRPre(props: any) {
24
- const child = props.children?.props;
25
- const className = child?.className || '';
26
- const code = child?.children || '';
27
- return (
28
- <pre class="overflow-x-auto p-4 text-xs">
29
- <code class={className}>{code}</code>
30
- </pre>
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
- const ssrComponents: Record<string, any> = {
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={ssrComponents}>
90
- <div class="flex w-full flex-col">
91
- <main class="flex grow max-w-full px-4 py-16 sm:px-6 lg:px-8">
92
- <div class="min-w-0 w-full">
93
- <article class="h-full flex flex-1 flex-col">
94
- <Content/>
95
- </article>
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
- render(
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
- <Suspense fallback={<Loading />}>
29
- <LazyAPIPlayground {...props} />
30
- </Suspense>
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
- <Suspense fallback={<Loading />}>
36
- <LazyWebSocketPlayground {...props} />
37
- </Suspense>
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(`📝 Total JSON size: ${jsonContent.length} characters`);
206
+ console.log(`🔍 Generated search content (${searchContent.length} pages, ${(searchJson.length / 1024).toFixed(0)} KB)`);
162
207
  }
163
208
  };
164
209
  }
@@ -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(true);
29
- const pathname = usePathname();
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)]().then((mdx) => {
39
- navigator.clipboard.writeText(mdx.default.toString());
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) =>
@@ -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