@clidey/dory 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/dist/dory.js +103 -45
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +11 -15
  4. package/package.json +35 -29
  5. package/scripts/render-routes.mjs +55 -15
  6. package/src/components/routes.tsx +38 -93
  7. package/src/components/search.tsx +1 -0
  8. package/src/components/store.ts +64 -88
  9. package/src/config/mdx.js +7 -3
  10. package/src/config/mdx.ts +6 -2
  11. package/src/entry-server.tsx +189 -66
  12. package/src/main.tsx +27 -5
  13. package/src/mdx/api-playground.tsx +2 -0
  14. package/src/mdx/code.tsx +8 -5
  15. package/src/mdx/fence.tsx +10 -2
  16. package/src/mdx/icon.tsx +21 -14
  17. package/src/mdx/latex.tsx +5 -0
  18. package/src/mdx/mdx.tsx +19 -11
  19. package/src/mdx/mermaid.tsx +10 -1
  20. package/src/mdx/processor.test.ts +47 -0
  21. package/src/mdx/step.tsx +3 -3
  22. package/src/mdx/websocket-playground.tsx +2 -0
  23. package/src/plugins/analytics-injector.ts +7 -5
  24. package/src/plugins/docs-assets.ts +5 -4
  25. package/src/plugins/frontmatter-dev-server.ts +23 -137
  26. package/src/plugins/frontmatter-generator.ts +64 -111
  27. package/src/plugins/html-files-middleware.test.ts +21 -0
  28. package/src/plugins/html-files-middleware.ts +27 -5
  29. package/src/plugins/html-metadata-injector.ts +94 -53
  30. package/src/plugins/llm-txt-dev-server.ts +25 -178
  31. package/src/plugins/llm-txt-generator.ts +94 -133
  32. package/src/plugins/prerender.ts +156 -50
  33. package/src/plugins/robots-generator.ts +15 -16
  34. package/src/plugins/sanitize.test.ts +104 -0
  35. package/src/plugins/sanitize.ts +91 -60
  36. package/src/plugins/shared.ts +139 -0
  37. package/src/plugins/sitemap-generator.ts +58 -44
  38. package/src/theme.scss +138 -15
  39. package/src/types/config.ts +42 -2
  40. package/src/types/globals.d.ts +7 -0
  41. package/src/ui/header.tsx +12 -4
  42. package/src/ui/layout.tsx +72 -52
  43. package/src/ui/table-of-content.tsx +19 -2
  44. package/src/utils/colors.ts +38 -18
  45. package/src/utils/fonts.ts +55 -12
  46. package/src/utils/theme.ts +29 -0
  47. package/vite.config.embed-loader.ts +3 -11
  48. package/vite.config.embed-widget.ts +3 -10
  49. package/vite.config.ssr.ts +2 -1
  50. package/vite.config.ts +13 -8
  51. package/scripts/generate-frontmatter-json.js +0 -154
  52. package/scripts/generate-llm-txt.js +0 -115
@@ -1,115 +1,60 @@
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
- }, []);
46
-
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;
53
-
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
- };
72
-
73
- loadRoutes();
13
+ if (pathname === '/') {
14
+ const firstPage = ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href;
15
+ if (firstPage) navigate(firstPage, { replace: true });
16
+ }
74
17
  }, [pathname]);
75
18
 
76
- useEffect(() => {
77
- const page = ALL_NAVIGATION.find((tab) => tab.groups.find((group) => group.pages.find((page) => page.href === pathname)));
78
- 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);
19
+ const route = useMemo(() => {
20
+ let targetPath = pathname;
21
+ if (pathname === '/') {
22
+ const ssrRoute = typeof window !== 'undefined' ? window.__DORY_ROUTE__ : undefined;
23
+ targetPath = ssrRoute || ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href || pathname;
98
24
  }
99
- }, [pathname]);
100
25
 
101
- useEffect(() => {
102
- window.scrollTo({ top: 0, behavior: 'smooth' });
26
+ const entry = Object.entries(ALL_PAGES).find(([path]) => path === targetPath);
27
+ if (!entry) return null;
28
+
29
+ const [path, mod] = entry;
30
+ return {
31
+ path: pathname === '/' ? '/' : pathFromFilename(path),
32
+ component: (mod as any).default,
33
+ };
103
34
  }, [pathname]);
104
-
105
35
 
106
- if (loading) return <div className="h-[25vh] flex grow"><Loading /></div>;
36
+ // 404 — no MDX module for this path (even if it appears in navigation)
37
+ if (!route && pathname !== '/') {
38
+ return (
39
+ <div className="flex flex-col items-center justify-center min-h-[50vh] p-8">
40
+ <h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
41
+ <h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
42
+ <p className="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">
43
+ 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.
44
+ </p>
45
+ <button
46
+ onClick={() => navigate(ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href ?? '/')}
47
+ className="px-4 py-2 text-sm font-medium rounded-md bg-[var(--brand-primary)] text-white hover:opacity-90 transition-opacity"
48
+ >
49
+ Go to Home
50
+ </button>
51
+ </div>
52
+ );
53
+ }
107
54
 
108
55
  return (
109
56
  <Router>
110
- {routes.map(({ path, component: Component }) => (
111
- <Route key={path} path={path} component={Component} />
112
- ))}
57
+ {route && <Route key={route.path} path={route.path} component={route.component} />}
113
58
  </Router>
114
59
  );
115
60
  }
@@ -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.__DORY_FRONTMATTER__) {
72
+ preloadedFrontMatter = window.__DORY_FRONTMATTER__;
73
+ completeFrontMatter = [...preloadedFrontMatter];
74
+ for (const fm of preloadedFrontMatter) {
75
+ updateNavigationTitle(fm.path, fm.title);
76
+ }
77
+ delete window.__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
package/src/config/mdx.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import remarkGfm from 'remark-gfm';
2
+ import rehypeSlug from 'rehype-slug';
2
3
  import { remarkSafeVars } from '../plugins/sanitize.ts';
3
4
 
4
5
  /**
5
- * Shared MDX configuration used by both main build (vite.config.ts)
6
- * and CLI build:file command (bin/dory.js)
6
+ * Shared MDX configuration used by the main build (vite.config.ts),
7
+ * the SSR build (vite.config.ssr.ts), and the CLI build:file command
8
+ * (bin/dory.js). rehype-slug adds stable ids to headings so anchors
9
+ * match between SSR output and client hydration.
7
10
  */
8
11
  export const getMdxConfig = (isDevelopment = false) => ({
9
12
  providerImportSource: '@mdx-js/preact',
10
13
  remarkPlugins: [remarkGfm, remarkSafeVars],
14
+ rehypePlugins: [rehypeSlug],
11
15
  development: isDevelopment,
12
- });
16
+ });
package/src/config/mdx.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import remarkGfm from 'remark-gfm';
2
+ import rehypeSlug from 'rehype-slug';
2
3
  import { remarkSafeVars } from '../plugins/sanitize';
3
4
 
4
5
  /**
5
- * Shared MDX configuration used by both main build (vite.config.ts)
6
- * and CLI build:file command (bin/dory.js)
6
+ * Shared MDX configuration used by the main build (vite.config.ts),
7
+ * the SSR build (vite.config.ssr.ts), and the CLI build:file command
8
+ * (bin/dory.js). rehype-slug adds stable ids to headings so anchors
9
+ * match between SSR output and client hydration.
7
10
  */
8
11
  export const getMdxConfig = (isDevelopment: boolean = false) => ({
9
12
  providerImportSource: '@mdx-js/preact',
10
13
  remarkPlugins: [remarkGfm, remarkSafeVars],
14
+ rehypePlugins: [rehypeSlug],
11
15
  development: isDevelopment,
12
16
  });