@clidey/dory 0.47.0 → 0.49.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 (46) hide show
  1. package/bin/dist/dory.js +103 -45
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +15 -19
  4. package/package.json +35 -29
  5. package/scripts/render-routes.mjs +45 -14
  6. package/src/components/routes.tsx +19 -22
  7. package/src/components/store.ts +5 -5
  8. package/src/config/mdx.js +7 -3
  9. package/src/config/mdx.ts +6 -2
  10. package/src/entry-server.tsx +53 -20
  11. package/src/main.tsx +9 -1
  12. package/src/mdx/code.tsx +8 -5
  13. package/src/mdx/fence.tsx +10 -2
  14. package/src/mdx/icon.tsx +21 -14
  15. package/src/mdx/mermaid.tsx +10 -1
  16. package/src/mdx/processor.test.ts +47 -0
  17. package/src/mdx/step.tsx +3 -3
  18. package/src/plugins/analytics-injector.ts +7 -5
  19. package/src/plugins/docs-assets.ts +5 -4
  20. package/src/plugins/frontmatter-dev-server.ts +8 -159
  21. package/src/plugins/frontmatter-generator.ts +37 -129
  22. package/src/plugins/html-files-middleware.test.ts +21 -0
  23. package/src/plugins/html-files-middleware.ts +27 -5
  24. package/src/plugins/html-metadata-injector.ts +113 -53
  25. package/src/plugins/llm-txt-dev-server.ts +25 -178
  26. package/src/plugins/llm-txt-generator.ts +94 -133
  27. package/src/plugins/prerender.ts +156 -50
  28. package/src/plugins/robots-generator.ts +15 -16
  29. package/src/plugins/sanitize.test.ts +104 -0
  30. package/src/plugins/sanitize.ts +90 -59
  31. package/src/plugins/shared.ts +139 -0
  32. package/src/plugins/sitemap-generator.ts +58 -44
  33. package/src/theme.scss +138 -15
  34. package/src/types/config.ts +42 -2
  35. package/src/types/globals.d.ts +7 -0
  36. package/src/ui/header.tsx +12 -4
  37. package/src/ui/layout.tsx +66 -47
  38. package/src/ui/table-of-content.tsx +19 -2
  39. package/src/utils/colors.ts +38 -18
  40. package/src/utils/fonts.ts +55 -12
  41. package/src/utils/theme.ts +29 -0
  42. package/vite.config.embed-loader.ts +3 -11
  43. package/vite.config.embed-widget.ts +3 -10
  44. package/vite.config.ts +11 -7
  45. package/scripts/generate-frontmatter-json.js +0 -154
  46. package/scripts/generate-llm-txt.js +0 -115
@@ -19,8 +19,8 @@ export default function Routes() {
19
19
  const route = useMemo(() => {
20
20
  let targetPath = pathname;
21
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;
22
+ const ssrRoute = typeof window !== 'undefined' ? window.__DORY_ROUTE__ : undefined;
23
+ targetPath = ssrRoute || ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href || pathname;
24
24
  }
25
25
 
26
26
  const entry = Object.entries(ALL_PAGES).find(([path]) => path === targetPath);
@@ -33,26 +33,23 @@ export default function Routes() {
33
33
  };
34
34
  }, [pathname]);
35
35
 
36
- // 404
37
- if (!route) {
38
- const page = ALL_NAVIGATION.find((tab) => tab.groups.find((group) => group.pages.find((page) => page.href === pathname)));
39
- if (!page && pathname !== '/') {
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
- );
55
- }
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
+ );
56
53
  }
57
54
 
58
55
  return (
@@ -68,13 +68,13 @@ export function isFrontmatterReady() { return preloadedFrontMatter !== null; }
68
68
  let preloadedFrontMatter: Record<string, any>[] | null = null;
69
69
 
70
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!) {
71
+ if (typeof window !== 'undefined' && window.__DORY_FRONTMATTER__) {
72
+ preloadedFrontMatter = window.__DORY_FRONTMATTER__;
73
+ completeFrontMatter = [...preloadedFrontMatter];
74
+ for (const fm of preloadedFrontMatter) {
75
75
  updateNavigationTitle(fm.path, fm.title);
76
76
  }
77
- delete (window as any).__DORY_FRONTMATTER__;
77
+ delete window.__DORY_FRONTMATTER__;
78
78
  // Build search index in the background after the page has fully rendered
79
79
  setTimeout(() => addPreloadedContentToSearch(), 2000);
80
80
  }
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
  });
@@ -1,11 +1,18 @@
1
+ import type { ComponentType } from 'preact';
1
2
  import { renderToString } from 'preact-render-to-string';
2
3
  import { Router } from 'wouter-preact';
3
4
  import { MDXProvider } from '@mdx-js/preact';
4
5
  import { ThemeProvider } from '@clidey/ux';
5
6
  import * as mdxComponents from './mdx/mdx';
6
- import docsConfig from '../docs/dory.json';
7
+ import docsConfig from '../docs/dory.json' with { type: 'json' };
8
+ import type { DoryConfig } from './types/config';
7
9
 
8
- const config = docsConfig as any;
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
+ }
9
16
 
10
17
  function pathFromFilename(filename: string): string {
11
18
  return filename
@@ -20,12 +27,27 @@ const allPages = Object.fromEntries(
20
27
  Object.entries(pages).map(([path, loader]) => [pathFromFilename(path), loader])
21
28
  );
22
29
 
23
- function buildNavigation(frontmatter?: Record<string, any>[]) {
24
- return config.navigation.tabs.map((tab: any) => ({
30
+ interface NavPage {
31
+ title: string;
32
+ href: string;
33
+ }
34
+
35
+ interface NavGroup {
36
+ title: string;
37
+ pages: NavPage[];
38
+ }
39
+
40
+ interface NavTab {
41
+ title: string;
42
+ groups: NavGroup[];
43
+ }
44
+
45
+ function buildNavigation(frontmatter?: Record<string, any>[]): NavTab[] {
46
+ return navigationTabs.map(tab => ({
25
47
  title: tab.tab,
26
- groups: tab.groups.map((group: any) => ({
48
+ groups: tab.groups.map(group => ({
27
49
  title: group.group,
28
- pages: group.pages.map((page: string) => {
50
+ pages: group.pages.map(page => {
29
51
  const href = `/${page}`;
30
52
  const fm = frontmatter?.find(f => f.path === href);
31
53
  return {
@@ -42,32 +64,32 @@ export async function render(routePath: string, frontmatter?: Record<string, any
42
64
  if (!loader) return '';
43
65
 
44
66
  try {
45
- const module = await loader() as any;
67
+ const module = await loader() as { default: ComponentType };
46
68
  const Content = module.default;
47
69
 
48
70
  const navigation = buildNavigation(frontmatter);
49
- const pageFm = frontmatter?.find((fm: any) => fm.path === routePath);
71
+ const pageFm = frontmatter?.find(fm => fm.path === routePath);
50
72
 
51
73
  // Find group/page titles (Layout uses swapped names: "title: group, group: title")
52
74
  let groupTitle = '';
53
- const currentTab = navigation.find((tab: any) =>
54
- tab.groups.some((group: any) =>
55
- group.pages.some((page: any) => page.href === routePath)
75
+ const currentTab = navigation.find(tab =>
76
+ tab.groups.some(group =>
77
+ group.pages.some(page => page.href === routePath)
56
78
  )
57
79
  );
58
80
  if (currentTab) {
59
81
  for (const group of currentTab.groups) {
60
- if (group.pages.some((p: any) => p.href === routePath)) {
82
+ if (group.pages.some(p => p.href === routePath)) {
61
83
  groupTitle = group.title;
62
84
  }
63
85
  }
64
86
  }
65
87
 
66
88
  // Prev/next
67
- const allLinks = navigation.flatMap((tab: any) =>
68
- tab.groups.flatMap((group: any) => group.pages)
89
+ const allLinks = navigation.flatMap(tab =>
90
+ tab.groups.flatMap(group => group.pages)
69
91
  );
70
- const linkIndex = allLinks.findIndex((link: any) => link.href === routePath);
92
+ const linkIndex = allLinks.findIndex(link => link.href === routePath);
71
93
  const prevPage = linkIndex > 0 ? allLinks[linkIndex - 1] : null;
72
94
  const nextPage = linkIndex < allLinks.length - 1 ? allLinks[linkIndex + 1] : null;
73
95
 
@@ -76,17 +98,28 @@ export async function render(routePath: string, frontmatter?: Record<string, any
76
98
  <Router ssrPath={routePath}>
77
99
  <MDXProvider components={mdxComponents}>
78
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. */}
79
108
  {/* Header — matches src/ui/header.tsx structure */}
80
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">
81
110
  <div class="relative flex justify-between gap-6 sm:gap-8 md:grow items-center w-full px-4">
82
111
  <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" />
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
+ )}
85
118
  <h1 class="text-base lg:text-2xl font-bold">{config.name}</h1>
86
119
  </div>
87
120
  <nav class="hidden md:block">
88
121
  <ul role="list" class="flex items-center gap-8">
89
- {navigation.map((tab: any) => {
122
+ {navigation.map(tab => {
90
123
  const isActive = tab === currentTab;
91
124
  return (
92
125
  <li key={tab.title}>
@@ -117,11 +150,11 @@ export async function render(routePath: string, frontmatter?: Record<string, any
117
150
  {currentTab && (
118
151
  <li>
119
152
  <h2 class="font-display font-medium text-xs">{currentTab.title}</h2>
120
- {currentTab.groups.map((group: any) => (
153
+ {currentTab.groups.map(group => (
121
154
  <div key={group.title}>
122
155
  <h3 class="mt-4 text-sm font-semibold">{group.title}</h3>
123
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">
124
- {group.pages.map((page: any) => (
157
+ {group.pages.map(page => (
125
158
  <li key={page.href} class="relative">
126
159
  <a
127
160
  href={page.href}
package/src/main.tsx CHANGED
@@ -5,9 +5,14 @@ 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'
10
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;
11
16
 
12
17
  // Start frontmatter loading immediately — if inlined from SSR, this is a no-op.
13
18
  preloadFrontmatter()
@@ -15,14 +20,17 @@ preloadFrontmatter()
15
20
  // Load fonts and colors before rendering the app
16
21
  loadFonts()
17
22
  loadColors()
23
+ loadTheme()
18
24
 
19
25
  const AppWithMDX = withMDX(App)
20
26
 
21
27
  const appRoot = document.getElementById('app')!
22
28
  const hasSSRContent = appRoot.children.length > 0
23
29
 
30
+ const defaultTheme = config.theme?.mode === 'dark' ? 'dark' : config.theme?.mode === 'light' ? 'light' : 'system';
31
+
24
32
  const tree = (
25
- <ThemeProvider storageKey="@clidey/dory/theme">
33
+ <ThemeProvider defaultTheme={defaultTheme} storageKey="@clidey/dory/theme">
26
34
  <Toaster />
27
35
  <ErrorBoundary>
28
36
  <AppWithMDX />
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}`)
@@ -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>
@@ -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;
@@ -37,8 +37,9 @@ export function docsAssetsPlugin(): Plugin {
37
37
  const shouldSkip = (name: string) => {
38
38
  if (skipPatterns.has(name)) return true;
39
39
  const ext = extname(name).toLowerCase();
40
- // Skip MDX and JSON files (they're processed by Vite)
41
- if (ext === '.mdx' || ext === '.md' || ext === '.json') return true;
40
+ // Skip Markdown and JSON files (they're processed by Vite).
41
+ // MDX sources ARE copied so /route.mdx URLs work (Open MDX / LLM prompts).
42
+ if (ext === '.md' || ext === '.json') return true;
42
43
  return false;
43
44
  };
44
45
 
@@ -60,8 +61,8 @@ export function docsAssetsPlugin(): Plugin {
60
61
  copyAssets(srcPath, destPath, itemRelativePath);
61
62
  } else if (stats.isFile()) {
62
63
  const ext = extname(item).toLowerCase();
63
- // Copy if it's an asset file
64
- if (assetExtensions.has(ext)) {
64
+ // Copy asset files, plus source .mdx files at their route paths
65
+ if (assetExtensions.has(ext) || ext === '.mdx') {
65
66
  cpSync(srcPath, destPath, { force: true });
66
67
  console.log(`[docs-assets] Copied ${itemRelativePath}`);
67
68
  }