@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,7 +1,18 @@
1
- import {renderToString} from 'preact-render-to-string';
2
- import {Router} from 'wouter-preact';
3
- import {MDXProvider} from '@mdx-js/preact';
1
+ import type { ComponentType } from 'preact';
2
+ import { renderToString } from 'preact-render-to-string';
3
+ import { Router } from 'wouter-preact';
4
+ import { MDXProvider } from '@mdx-js/preact';
5
+ import { ThemeProvider } from '@clidey/ux';
4
6
  import * as mdxComponents from './mdx/mdx';
7
+ import docsConfig from '../docs/dory.json' with { type: 'json' };
8
+ import type { DoryConfig } from './types/config';
9
+
10
+ const config = docsConfig as DoryConfig;
11
+
12
+ const navigationTabs = config.navigation?.tabs ?? [];
13
+ if (!config.navigation?.tabs) {
14
+ console.warn('dory.json has no navigation.tabs — SSR navigation will be empty.');
15
+ }
5
16
 
6
17
  function pathFromFilename(filename: string): string {
7
18
  return filename
@@ -16,88 +27,200 @@ const allPages = Object.fromEntries(
16
27
  Object.entries(pages).map(([path, loader]) => [pathFromFilename(path), loader])
17
28
  );
18
29
 
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
- );
30
+ interface NavPage {
31
+ title: string;
32
+ href: string;
32
33
  }
33
34
 
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
- );
35
+ interface NavGroup {
36
+ title: string;
37
+ pages: NavPage[];
42
38
  }
43
39
 
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} />;
40
+ interface NavTab {
41
+ title: string;
42
+ groups: NavGroup[];
59
43
  }
60
44
 
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;
45
+ function buildNavigation(frontmatter?: Record<string, any>[]): NavTab[] {
46
+ return navigationTabs.map(tab => ({
47
+ title: tab.tab,
48
+ groups: tab.groups.map(group => ({
49
+ title: group.group,
50
+ pages: group.pages.map(page => {
51
+ const href = `/${page}`;
52
+ const fm = frontmatter?.find(f => f.path === href);
53
+ return {
54
+ title: fm?.title || page.charAt(0).toUpperCase() + page.slice(1).replace(/-/g, ' '),
55
+ href,
56
+ };
57
+ })
58
+ }))
59
+ }));
66
60
  }
67
61
 
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> {
62
+ export async function render(routePath: string, frontmatter?: Record<string, any>[]): Promise<string> {
80
63
  const loader = allPages[routePath];
81
64
  if (!loader) return '';
82
65
 
83
66
  try {
84
- const module = await loader() as any;
67
+ const module = await loader() as { default: ComponentType };
85
68
  const Content = module.default;
86
69
 
70
+ const navigation = buildNavigation(frontmatter);
71
+ const pageFm = frontmatter?.find(fm => fm.path === routePath);
72
+
73
+ // Find group/page titles (Layout uses swapped names: "title: group, group: title")
74
+ let groupTitle = '';
75
+ const currentTab = navigation.find(tab =>
76
+ tab.groups.some(group =>
77
+ group.pages.some(page => page.href === routePath)
78
+ )
79
+ );
80
+ if (currentTab) {
81
+ for (const group of currentTab.groups) {
82
+ if (group.pages.some(p => p.href === routePath)) {
83
+ groupTitle = group.title;
84
+ }
85
+ }
86
+ }
87
+
88
+ // Prev/next
89
+ const allLinks = navigation.flatMap(tab =>
90
+ tab.groups.flatMap(group => group.pages)
91
+ );
92
+ const linkIndex = allLinks.findIndex(link => link.href === routePath);
93
+ const prevPage = linkIndex > 0 ? allLinks[linkIndex - 1] : null;
94
+ const nextPage = linkIndex < allLinks.length - 1 ? allLinks[linkIndex + 1] : null;
95
+
87
96
  return renderToString(
97
+ <ThemeProvider>
88
98
  <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>
99
+ <MDXProvider components={mdxComponents}>
100
+ <div class="flex w-full flex-col">
101
+ {/* SYNC NOTE: the static shell below (header chrome, sidebar nav skeleton,
102
+ main/article wrappers) hand-mirrors the client components:
103
+ - <header> block -> src/ui/header.tsx (Header component JSX)
104
+ - #container/nav -> src/ui/layout.tsx (non-landing return) + src/components/navigation.tsx
105
+ When editing structure or classes in either place, update the counterpart,
106
+ or hydration will mismatch. Client-only widgets (Search, ModeToggle,
107
+ MobileNav) are intentionally NOT rendered here — see placeholder below. */}
108
+ {/* Header — matches src/ui/header.tsx structure */}
109
+ <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">
110
+ <div class="relative flex justify-between gap-6 sm:gap-8 md:grow items-center w-full px-4">
111
+ <div class="flex items-center grow gap-2">
112
+ {config.logo && (
113
+ <>
114
+ <img src={`/${config.logo.light}`} alt="logo" class="w-8 hidden dark:block" />
115
+ <img src={`/${config.logo.dark}`} alt="logo" class="w-8 block dark:hidden" />
116
+ </>
117
+ )}
118
+ <h1 class="text-base lg:text-2xl font-bold">{config.name}</h1>
119
+ </div>
120
+ <nav class="hidden md:block">
121
+ <ul role="list" class="flex items-center gap-8">
122
+ {navigation.map(tab => {
123
+ const isActive = tab === currentTab;
124
+ return (
125
+ <li key={tab.title}>
126
+ <a
127
+ href={tab.groups[0].pages[0].href}
128
+ class={`text-xs transition hover:opacity-80${isActive ? ' text-brand-foreground' : ''}`}
129
+ >
130
+ {tab.title}
131
+ </a>
132
+ </li>
133
+ );
134
+ })}
135
+ </ul>
136
+ </nav>
137
+ <div class="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
138
+ {/* Search, ModeToggle, MobileNav — rendered client-side during hydration.
139
+ Static placeholders cause ref mismatches with Algolia autocomplete. */}
140
+ <div class="flex items-center gap-3" data-testid="header-actions" />
141
+ </div>
142
+ </header>
143
+
144
+ {/* Container — matches src/ui/layout.tsx structure */}
145
+ <div id="container" class="relative flex w-full max-w-8xl sm:px-2 lg:px-8">
146
+ <div class="hidden lg:relative lg:block lg:flex-none">
147
+ <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">
148
+ <nav class="dory-navigation text-base lg:text-sm">
149
+ <ul role="list" class="space-y-9">
150
+ {currentTab && (
151
+ <li>
152
+ <h2 class="font-display font-medium text-xs">{currentTab.title}</h2>
153
+ {currentTab.groups.map(group => (
154
+ <div key={group.title}>
155
+ <h3 class="mt-4 text-sm font-semibold">{group.title}</h3>
156
+ <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">
157
+ {group.pages.map(page => (
158
+ <li key={page.href} class="relative">
159
+ <a
160
+ href={page.href}
161
+ class={routePath === page.href
162
+ ? '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'
163
+ : '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'}
164
+ >
165
+ {page.title}
166
+ </a>
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ </div>
171
+ ))}
172
+ </li>
173
+ )}
174
+ </ul>
175
+ </nav>
176
+ </div>
177
+ </div>
178
+
179
+ <main class="flex grow max-w-full px-4 py-16 sm:px-6 lg:px-8">
180
+ <div class="min-w-0 w-full">
181
+ <article class="h-full flex flex-1 flex-col">
182
+ <div class="flex justify-between items-start">
183
+ <header class="mb-8">
184
+ {groupTitle && <h1 class="font-display text-lg tracking-tight">{groupTitle}</h1>}
185
+ {pageFm?.title && <p class="text-4xl tracking-tight">{pageFm.title}</p>}
186
+ </header>
187
+ </div>
188
+ <div class="flex-1 min-h-[calc(100vh-2rem)]">
189
+ {/* Matches App > div.antialiased > div.flex > Routes > Content */}
190
+ <div class="h-full antialiased">
191
+ <div class="flex min-h-full">
192
+ <Content />
193
+ </div>
194
+ </div>
195
+ </div>
196
+ {(prevPage || nextPage) && (
197
+ <div class="mt-12 pt-6 flex border-t border-black/20 dark:border-white/20">
198
+ {prevPage && (
199
+ <div>
200
+ <p class="font-display text-sm font-medium">Previous</p>
201
+ <div class="mt-1">
202
+ <a href={prevPage.href} class="text-base font-semibold hover:opacity-80">{prevPage.title}</a>
203
+ </div>
204
+ </div>
205
+ )}
206
+ {nextPage && (
207
+ <div class="ml-auto text-right">
208
+ <p class="font-display text-sm font-medium">Next</p>
209
+ <div class="mt-1">
210
+ <a href={nextPage.href} class="text-base font-semibold hover:opacity-80">{nextPage.title}</a>
211
+ </div>
212
+ </div>
213
+ )}
214
+ </div>
215
+ )}
216
+ </article>
217
+ </div>
218
+ </main>
219
+ </div>
96
220
  </div>
97
- </main>
98
- </div>
99
221
  </MDXProvider>
100
222
  </Router>
223
+ </ThemeProvider>
101
224
  );
102
225
  } catch (error) {
103
226
  console.warn(`SSR render failed for ${routePath}:`, error);
package/src/main.tsx CHANGED
@@ -1,25 +1,47 @@
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'
5
5
  import { ErrorBoundary } from './components/error-boundary.tsx'
6
6
  import { loadFonts } from './utils/fonts'
7
7
  import { loadColors } from './utils/colors'
8
+ import { loadTheme } from './utils/theme'
8
9
  import { Toaster } from '@clidey/ux'
9
10
  import { ThemeProvider } from '@clidey/ux'
11
+ import { preloadFrontmatter } from './components/store'
12
+ import docsConfig from '../docs/dory.json' with { type: 'json' };
13
+ import type { DoryConfig } from './types/config';
14
+
15
+ const config = docsConfig as DoryConfig;
16
+
17
+ // Start frontmatter loading immediately — if inlined from SSR, this is a no-op.
18
+ preloadFrontmatter()
10
19
 
11
20
  // Load fonts and colors before rendering the app
12
21
  loadFonts()
13
22
  loadColors()
23
+ loadTheme()
14
24
 
15
25
  const AppWithMDX = withMDX(App)
16
26
 
17
- render(
18
- <ThemeProvider storageKey="@clidey/dory/theme">
27
+ const appRoot = document.getElementById('app')!
28
+ const hasSSRContent = appRoot.children.length > 0
29
+
30
+ const defaultTheme = config.theme?.mode === 'dark' ? 'dark' : config.theme?.mode === 'light' ? 'light' : 'system';
31
+
32
+ const tree = (
33
+ <ThemeProvider defaultTheme={defaultTheme} storageKey="@clidey/dory/theme">
19
34
  <Toaster />
20
35
  <ErrorBoundary>
21
36
  <AppWithMDX />
22
37
  </ErrorBoundary>
23
- </ThemeProvider>,
24
- document.getElementById('app')!
38
+ </ThemeProvider>
25
39
  )
40
+
41
+ // If SSR content exists, hydrate (attach JS to existing DOM).
42
+ // Otherwise, render from scratch (dev mode, non-SSR pages).
43
+ if (hasSSRContent) {
44
+ hydrate(tree, appRoot)
45
+ } else {
46
+ render(tree, appRoot)
47
+ }
@@ -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/code.tsx CHANGED
@@ -115,7 +115,7 @@ function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
115
115
  if (!tag && !label) return null
116
116
 
117
117
  return (
118
- <div className="flex h-9 items-center gap-2 border-y border-t-transparent border-b-white/7.5 bg-white/2.5 px-4 dark:border-b-white/5 dark:bg-white/1">
118
+ <div className="flex h-9 items-center gap-2 border-b border-t-transparent px-4" style={{ borderColor: 'var(--border)' }}>
119
119
  {tag && (
120
120
  <div className="dark flex">
121
121
  <Tag variant="small">{tag}</Tag>
@@ -155,7 +155,7 @@ function CodePanel({
155
155
  }
156
156
 
157
157
  return (
158
- <div className="group dark:bg-white/2.5">
158
+ <div className="group">
159
159
  <CodePanelHeader tag={tag} label={label} />
160
160
  <div className="relative">
161
161
  <pre className="overflow-x-auto p-4 text-xs">{children}</pre>
@@ -257,7 +257,7 @@ export function CodeGroup({
257
257
  return (
258
258
  <CodeGroupContext.Provider value={true}>
259
259
  {hasTabs ? (
260
- <Card className="py-0 border-black/10 bg-none overflow-hidden mb-4">
260
+ <Card className="p-0 border-black/10 bg-none overflow-hidden mb-4">
261
261
  <TabGroup
262
262
  as="div"
263
263
  selectedIndex={selectedIndex}
@@ -270,7 +270,7 @@ export function CodeGroup({
270
270
  </TabGroup>
271
271
  </Card>
272
272
  ) : (
273
- <Card className="py-0 border-black/10 bg-none overflow-hidden mb-4">
273
+ <Card className="p-0 border-black/10 bg-none overflow-hidden mb-4">
274
274
  <div className="not-prose">
275
275
  {header}
276
276
  {panels}
@@ -285,5 +285,8 @@ export function Code({
285
285
  children,
286
286
  ...props
287
287
  }: React.ComponentPropsWithoutRef<'code'>) {
288
- return <code className={classNames('dory-mdx-code rounded-md py-1 text-sm px-2', props.className)} {...props}>{children}</code>
288
+ const isBlock = typeof props.className === 'string' && props.className.includes('language-');
289
+ return <code className={classNames(props.className, {
290
+ 'dory-mdx-code rounded-md py-0.5 text-sm px-1.5': !isBlock,
291
+ })} {...props}>{children}</code>
289
292
  }
package/src/mdx/fence.tsx CHANGED
@@ -6,6 +6,14 @@ import { Loading } from '../components/loading'
6
6
  import { useTheme } from '@clidey/ux'
7
7
  import classNames from 'classnames'
8
8
 
9
+ function dedent(code: string): string {
10
+ const lines = code.replace(/^\n+/, '').replace(/\s+$/, '').split('\n');
11
+ const indents = lines.filter(l => l.trim().length > 0).map(l => l.match(/^(\s*)/)?.[1].length ?? 0);
12
+ const minIndent = indents.length > 0 ? Math.min(...indents) : 0;
13
+ if (minIndent === 0) return lines.join('\n');
14
+ return lines.map(l => l.slice(minIndent)).join('\n');
15
+ }
16
+
9
17
 
10
18
  export function Fence({
11
19
  children,
@@ -31,12 +39,12 @@ export function Fence({
31
39
 
32
40
  return (
33
41
  <Highlight
34
- code={children.trimEnd()}
42
+ code={dedent(children)}
35
43
  language={supportedLanguage}
36
44
  theme={isDark ? themes.vsDark : themes.vsLight}>
37
45
  {({ className, style, tokens, getTokenProps }) => (
38
46
  <pre>
39
- <Code className={classNames(className, "whitespace-pre-wrap")} style={style} title={supportedLanguage}>
47
+ <Code className={classNames(className, "whitespace-pre-wrap")} style={{ ...style, backgroundColor: 'transparent' }} title={supportedLanguage}>
40
48
  {tokens.map((line, lineIndex) => (
41
49
  <Fragment key={lineIndex}>
42
50
  {line
package/src/mdx/icon.tsx CHANGED
@@ -51,21 +51,28 @@ export function Icon({
51
51
  }
52
52
 
53
53
  // --- Try Font Awesome ---
54
- const faSet = iconSets[iconType] ?? solidIcons
55
- const iconKey = `fa${pascalCase}` as keyof typeof faSet
56
- const faIcon = faSet[iconKey]
54
+ // Fall back to the brands set: Lucide 1.x removed all brand icons
55
+ // (github, twitter, npm, ...), which live only in FA brands.
56
+ const iconKey = `fa${pascalCase}`
57
+ const setsToTry: Array<keyof typeof iconSets> =
58
+ iconType === 'brands' ? ['brands'] : [iconType, 'brands']
57
59
 
58
- if (faIcon && typeof faIcon === 'object' && 'iconName' in faIcon) {
59
- library.add(faIcon)
60
- return (
61
- <FontAwesomeIcon
62
- icon={[prefixes[iconType] || 'fas', faIcon.iconName] as any}
63
- className={classNames("stroke-black dark:stroke-white", className)}
64
- color={color as any}
65
- size={size as any}
66
- {...rest as any}
67
- />
68
- )
60
+ for (const setName of setsToTry) {
61
+ const faSet = iconSets[setName] ?? solidIcons
62
+ const faIcon = faSet[iconKey as keyof typeof faSet]
63
+
64
+ if (faIcon && typeof faIcon === 'object' && 'iconName' in faIcon) {
65
+ library.add(faIcon)
66
+ return (
67
+ <FontAwesomeIcon
68
+ icon={[prefixes[setName] || 'fas', faIcon.iconName] as any}
69
+ className={classNames("stroke-black dark:stroke-white", className)}
70
+ color={color as any}
71
+ size={size as any}
72
+ {...rest as any}
73
+ />
74
+ )
75
+ }
69
76
  }
70
77
 
71
78
  console.warn(`⚠️ Icon not found: "${icon}" (converted to ${iconKey}) in ${iconType}`)
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);
@@ -16,6 +16,15 @@ function fixNodeLabelsWithSpecialChars(content: string): string {
16
16
  });
17
17
  }
18
18
 
19
+ // Deterministic djb2 hash — btoa throws on non-Latin-1 content (e.g. "→", CJK, emoji)
20
+ function hashContent(content: string): string {
21
+ let hash = 5381;
22
+ for (let i = 0; i < content.length; i++) {
23
+ hash = ((hash << 5) + hash + content.charCodeAt(i)) >>> 0;
24
+ }
25
+ return hash.toString(16);
26
+ }
27
+
19
28
  export function MermaidRenderer({ content }: { content: string }) {
20
29
  const mermaidRef = useRef<HTMLDivElement>(null)
21
30
  const [isFullscreen, setIsFullscreen] = useState(false)
@@ -23,7 +32,7 @@ export function MermaidRenderer({ content }: { content: string }) {
23
32
  const [error, setError] = useState<string | null>(null)
24
33
  const [isValid, setIsValid] = useState<boolean | null>(null)
25
34
 
26
- const diagramId = useMemo(() => `mermaid-${btoa(content).replace(/[^a-zA-Z0-9]/g, '').substring(0, 10)}`, [content])
35
+ const diagramId = useMemo(() => `mermaid-${hashContent(content)}`, [content])
27
36
 
28
37
  useEffect(() => {
29
38
  let mounted = true
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { preprocessMdxContent } from './processor';
3
+
4
+ async function run(input: string): Promise<string> {
5
+ const result = await preprocessMdxContent(input);
6
+ expect(result.success).toBe(true);
7
+ return result.processedContent!;
8
+ }
9
+
10
+ describe('preprocessMdxContent', () => {
11
+ it('strips YAML frontmatter', async () => {
12
+ const output = await run('---\ntitle: Hello\n---\n# Heading\n');
13
+ expect(output).not.toContain('title: Hello');
14
+ expect(output).toContain('# Heading');
15
+ });
16
+
17
+ it('converts URLs in angle brackets to markdown links', async () => {
18
+ const output = await run('Visit <https://example.com/docs> now');
19
+ expect(output).toBe('Visit [https://example.com/docs](https://example.com/docs) now');
20
+ });
21
+
22
+ it('preserves angle-bracket URLs inside fenced code blocks', async () => {
23
+ const input = '```\ncurl <https://example.com>\n```\n';
24
+ expect(await run(input)).toBe(input);
25
+ });
26
+
27
+ it('preserves angle-bracket URLs inside inline code', async () => {
28
+ const input = 'run `curl <https://example.com>` locally';
29
+ expect(await run(input)).toBe(input);
30
+ });
31
+
32
+ it('tracks code-block state across multiple fences', async () => {
33
+ const input = [
34
+ '```',
35
+ '<https://in-block.com>',
36
+ '```',
37
+ '<https://outside.com>',
38
+ '```',
39
+ '<https://in-block-2.com>',
40
+ '```',
41
+ ].join('\n');
42
+ const output = await run(input);
43
+ expect(output).toContain('<https://in-block.com>');
44
+ expect(output).toContain('<https://in-block-2.com>');
45
+ expect(output).toContain('[https://outside.com](https://outside.com)');
46
+ });
47
+ });
package/src/mdx/step.tsx CHANGED
@@ -16,11 +16,11 @@ export function Steps({ children }: { children: preact.ComponentChildren }) {
16
16
  },
17
17
  'relative'
18
18
  )}>
19
- <div aria-hidden="true" className="absolute top-10 left-4 h-full w-[0.5px] bg-gray-300/50 dark:bg-gray-600/50" style={{ top: '40px', height: 'calc(100% - 44px)' }} />
19
+ <div aria-hidden="true" className="absolute top-10 left-4 h-full w-[0.5px]" style={{ top: '40px', height: 'calc(100% - 44px)', backgroundColor: 'color-mix(in oklch, var(--brand-foreground) 30%, transparent)' }} />
20
20
  <div className="group relative flex items-start">
21
21
  <span className="flex h-9 items-center">
22
- <span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-white dark:bg-gray-800 group-hover:border-gray-400">
23
- <span className="text-sm font-medium text-gray-500 dark:text-white">
22
+ <span className="relative z-10 flex size-8 items-center justify-center rounded-full border" style={{ borderColor: 'var(--brand-foreground)', backgroundColor: 'color-mix(in oklch, var(--brand-foreground) 12%, transparent)' }}>
23
+ <span className="text-sm font-medium" style={{ color: 'var(--brand-foreground)' }}>
24
24
  {stepIdx + 1}
25
25
  </span>
26
26
  </span>
@@ -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 = () => {
@@ -17,19 +17,21 @@ export function analyticsInjector(): Plugin {
17
17
  const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
18
18
 
19
19
  if (config.analytics?.posthog?.token) {
20
- const token = config.analytics.posthog.token;
21
- const apiHost = config.analytics.posthog.apiHost || 'https://us.i.posthog.com';
20
+ // JSON.stringify so config-controlled values are safely escaped
21
+ // when inlined into the script (quotes, </script>, etc.)
22
+ const token = JSON.stringify(String(config.analytics.posthog.token)).replace(/</g, '\\u003c');
23
+ const apiHost = JSON.stringify(String(config.analytics.posthog.apiHost || 'https://us.i.posthog.com')).replace(/</g, '\\u003c');
22
24
 
23
25
  const script = `
24
26
  <script>
25
27
  !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture identify setPersonProperties group resetGroups reset get_distinct_id getGroups get_session_id opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
26
- posthog.init('${token}', {
27
- api_host: '${apiHost}',
28
+ posthog.init(${token}, {
29
+ api_host: ${apiHost},
28
30
  person_profiles: 'identified_only',
29
31
  })
30
32
  </script>`;
31
33
 
32
- return html.replace('</head>', `${script}\n </head>`);
34
+ return html.replace('</head>', () => `${script}\n </head>`);
33
35
  }
34
36
 
35
37
  return html;