@clidey/dory 0.34.0 → 0.35.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.
@@ -11,7 +11,14 @@ interface FrontmatterGeneratorOptions {
11
11
  function getNavigationOrder(docsDir: string): string[] {
12
12
  const doryJsonPath = path.join(docsDir, 'dory.json');
13
13
  if (!fs.existsSync(doryJsonPath)) return [];
14
- const doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
14
+ let doryJson;
15
+ try {
16
+ doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
17
+ } catch (error) {
18
+ const detail = error instanceof SyntaxError ? error.message : String(error);
19
+ console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
20
+ return [];
21
+ }
15
22
  const order: string[] = [];
16
23
 
17
24
  function walkPages(pages: any[]) {
@@ -109,13 +116,12 @@ function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
109
116
 
110
117
  const filePath = mdxFilesMap[fileKey];
111
118
  const rawContent = fs.readFileSync(filePath, 'utf-8');
112
- const { data, content } = parseFrontmatter(rawContent);
119
+ const { data } = parseFrontmatter(rawContent);
113
120
  const pathname = pathFromFilename(filePath, docsDir);
114
121
 
115
122
  frontmatterArray.push({
116
123
  ...data,
117
- path: pathname,
118
- content: content.trim()
124
+ path: pathname
119
125
  });
120
126
  }
121
127
 
@@ -124,13 +130,12 @@ function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
124
130
  for (const fileKey of extraKeys) {
125
131
  const filePath = mdxFilesMap[fileKey];
126
132
  const rawContent = fs.readFileSync(filePath, 'utf-8');
127
- const { data, content } = parseFrontmatter(rawContent);
133
+ const { data } = parseFrontmatter(rawContent);
128
134
  const pathname = pathFromFilename(filePath, docsDir);
129
135
 
130
136
  frontmatterArray.push({
131
137
  ...data,
132
- path: pathname,
133
- content: content.trim()
138
+ path: pathname
134
139
  });
135
140
  }
136
141
 
@@ -6,7 +6,14 @@ import path from 'path';
6
6
  function getNavigationOrder(docsDir: string): string[] {
7
7
  const doryJsonPath = path.join(docsDir, 'dory.json');
8
8
  if (!fs.existsSync(doryJsonPath)) return [];
9
- const doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
9
+ let doryJson;
10
+ try {
11
+ doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
12
+ } catch (error) {
13
+ const detail = error instanceof SyntaxError ? error.message : String(error);
14
+ console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
15
+ return [];
16
+ }
10
17
  const order: string[] = [];
11
18
 
12
19
  function walkPages(pages: any[]) {
@@ -11,7 +11,14 @@ interface LlmTxtGeneratorOptions {
11
11
  function getNavigationOrder(docsDir: string): string[] {
12
12
  const doryJsonPath = path.join(docsDir, 'dory.json');
13
13
  if (!fs.existsSync(doryJsonPath)) return [];
14
- const doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
14
+ let doryJson;
15
+ try {
16
+ doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
17
+ } catch (error) {
18
+ const detail = error instanceof SyntaxError ? error.message : String(error);
19
+ console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
20
+ return [];
21
+ }
15
22
  const order: string[] = [];
16
23
 
17
24
  function walkPages(pages: any[]) {
@@ -0,0 +1,119 @@
1
+ import type { Plugin, ResolvedConfig } from 'vite';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ /**
6
+ * Generates per-route HTML files with page-specific meta tags at build time.
7
+ * This is NOT full SSR — the Preact app still hydrates client-side.
8
+ * It provides correct <title>, <meta>, canonical, and JSON-LD per route
9
+ * so crawlers see unique, meaningful HTML for each documentation page.
10
+ */
11
+ export function prerender(): Plugin {
12
+ let config: ResolvedConfig;
13
+
14
+ return {
15
+ name: 'prerender',
16
+ configResolved(resolvedConfig) {
17
+ config = resolvedConfig;
18
+ },
19
+
20
+ closeBundle() {
21
+ const outDir = config.build.outDir;
22
+ const frontmatterPath = resolve(outDir, 'frontmatter.json');
23
+ const indexHtmlPath = resolve(outDir, 'index.html');
24
+
25
+ if (!existsSync(frontmatterPath) || !existsSync(indexHtmlPath)) {
26
+ console.warn('prerender: frontmatter.json or index.html not found, skipping');
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const frontmatter: Array<Record<string, any>> = JSON.parse(readFileSync(frontmatterPath, 'utf-8'));
32
+ const baseHtml = readFileSync(indexHtmlPath, 'utf-8');
33
+
34
+ // Read dory.json for site-level config
35
+ const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
36
+ let siteConfig: Record<string, any> = {};
37
+ if (existsSync(doryJsonPath)) {
38
+ siteConfig = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
39
+ }
40
+
41
+ const baseUrl = siteConfig.url || '';
42
+ const siteName = siteConfig.name || 'Documentation';
43
+ let count = 0;
44
+
45
+ for (const page of frontmatter) {
46
+ const routePath = page.path;
47
+ if (!routePath || routePath === '/') continue;
48
+
49
+ const title = page.title || siteName;
50
+ const description = page.description || `${siteName} - ${title}`;
51
+ const fullUrl = `${baseUrl}${routePath}`;
52
+
53
+ let html = baseHtml;
54
+
55
+ // Replace title
56
+ html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(title)}</title>`);
57
+
58
+ // Replace meta description
59
+ html = html.replace(
60
+ /<meta name="description" content=".*?" \/>/,
61
+ `<meta name="description" content="${escapeAttr(description)}" />`
62
+ );
63
+
64
+ // Replace OG tags
65
+ html = html.replace(
66
+ /<meta property="og:title" content=".*?" \/>/,
67
+ `<meta property="og:title" content="${escapeAttr(title)}" />`
68
+ );
69
+ html = html.replace(
70
+ /<meta property="og:description" content=".*?" \/>/,
71
+ `<meta property="og:description" content="${escapeAttr(description)}" />`
72
+ );
73
+ if (fullUrl) {
74
+ html = html.replace(
75
+ /<meta property="og:url" content=".*?" \/>/,
76
+ `<meta property="og:url" content="${escapeAttr(fullUrl)}" />`
77
+ );
78
+ }
79
+
80
+ // Replace Twitter tags
81
+ html = html.replace(
82
+ /<meta name="twitter:title" content=".*?" \/>/,
83
+ `<meta name="twitter:title" content="${escapeAttr(title)}" />`
84
+ );
85
+ html = html.replace(
86
+ /<meta name="twitter:description" content=".*?" \/>/,
87
+ `<meta name="twitter:description" content="${escapeAttr(description)}" />`
88
+ );
89
+
90
+ // Add canonical link
91
+ if (!html.includes('rel="canonical"')) {
92
+ html = html.replace(
93
+ '</head>',
94
+ ` <link rel="canonical" href="${escapeAttr(fullUrl)}" />\n </head>`
95
+ );
96
+ }
97
+
98
+ // Write to route directory
99
+ const routeDir = resolve(outDir, routePath.replace(/^\//, ''));
100
+ mkdirSync(routeDir, { recursive: true });
101
+ writeFileSync(resolve(routeDir, 'index.html'), html, 'utf-8');
102
+ count++;
103
+ }
104
+
105
+ console.log(`✅ Prerendered ${count} routes with page-specific meta tags`);
106
+ } catch (error) {
107
+ console.error('prerender failed:', error);
108
+ }
109
+ }
110
+ };
111
+ }
112
+
113
+ function escapeHtml(str: string): string {
114
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
115
+ }
116
+
117
+ function escapeAttr(str: string): string {
118
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
119
+ }
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from 'vite';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ /**
6
+ * Generates robots.txt with a sitemap reference at build time.
7
+ */
8
+ export function robotsGenerator(): Plugin {
9
+ return {
10
+ name: 'robots-generator',
11
+ generateBundle() {
12
+ const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
13
+
14
+ let sitemapLine = '';
15
+ if (existsSync(doryJsonPath)) {
16
+ try {
17
+ const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
18
+ if (config.url) {
19
+ sitemapLine = `\nSitemap: ${config.url}/sitemap.xml\n`;
20
+ }
21
+ } catch {
22
+ // Non-critical, continue without sitemap reference
23
+ }
24
+ }
25
+
26
+ const robots = `User-agent: *\nAllow: /\n${sitemapLine}`;
27
+
28
+ this.emitFile({
29
+ type: 'asset',
30
+ fileName: 'robots.txt',
31
+ source: robots
32
+ });
33
+
34
+ console.log('✅ Generated robots.txt');
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,73 @@
1
+ import type { Plugin } from 'vite';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+
5
+ /**
6
+ * Generates sitemap.xml from dory.json navigation structure at build time.
7
+ * Uses the `url` field from dory.json as the base URL.
8
+ */
9
+ export function sitemapGenerator(): Plugin {
10
+ return {
11
+ name: 'sitemap-generator',
12
+ generateBundle() {
13
+ const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
14
+ if (!existsSync(doryJsonPath)) {
15
+ console.warn('sitemap-generator: dory.json not found, skipping');
16
+ return;
17
+ }
18
+
19
+ try {
20
+ const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
21
+ const baseUrl = config.url || '';
22
+
23
+ // Extract all routes from navigation
24
+ const routes: string[] = [];
25
+ function walkPages(pages: any[]) {
26
+ for (const page of pages) {
27
+ if (typeof page === 'string') {
28
+ routes.push(`/${page}`);
29
+ } else if (typeof page === 'object' && page.pages) {
30
+ walkPages(page.pages);
31
+ }
32
+ }
33
+ }
34
+
35
+ if (config.navigation?.tabs) {
36
+ for (const tab of config.navigation.tabs) {
37
+ if (tab.groups) {
38
+ for (const group of tab.groups) {
39
+ if (group.pages) {
40
+ walkPages(group.pages);
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ const now = new Date().toISOString().split('T')[0];
48
+ let sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n';
49
+ sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
50
+
51
+ for (const route of routes) {
52
+ sitemap += ' <url>\n';
53
+ sitemap += ` <loc>${baseUrl}${route}</loc>\n`;
54
+ sitemap += ` <lastmod>${now}</lastmod>\n`;
55
+ sitemap += ' </url>\n';
56
+ }
57
+
58
+ sitemap += '</urlset>';
59
+
60
+ this.emitFile({
61
+ type: 'asset',
62
+ fileName: 'sitemap.xml',
63
+ source: sitemap
64
+ });
65
+
66
+ console.log(`✅ Generated sitemap.xml with ${routes.length} URLs`);
67
+ } catch (error) {
68
+ const detail = error instanceof SyntaxError ? error.message : String(error);
69
+ console.error(`Failed to generate sitemap: ${detail}`);
70
+ }
71
+ }
72
+ };
73
+ }
@@ -78,12 +78,21 @@ export interface DoryEmbedConfig {
78
78
  };
79
79
  }
80
80
 
81
+ export interface DoryAnalytics {
82
+ posthog?: {
83
+ token: string;
84
+ apiHost?: string;
85
+ };
86
+ }
87
+
81
88
  export interface DoryConfig {
82
89
  name: string;
83
90
  favicon: string;
91
+ url?: string;
84
92
  colors?: DoryColors;
85
93
  fonts?: DoryFonts;
86
94
  navigation: DoryNavigation;
87
95
  logo: DoryLogo;
88
96
  embed?: DoryEmbedConfig;
97
+ analytics?: DoryAnalytics;
89
98
  }
package/src/ui/header.tsx CHANGED
@@ -36,12 +36,7 @@ export function Header({ className }: { className?: string }) {
36
36
  const isEmbedded = useIsEmbedded();
37
37
 
38
38
  const { light, dark } = useMemo(() => {
39
- // Remove leading ./ from paths to avoid /./path issues
40
- const normalizePath = (path: string) => path.replace(/^\.\//, '');
41
- return {
42
- light: normalizePath(config.logo.light),
43
- dark: normalizePath(config.logo.dark)
44
- };
39
+ return { light: config.logo.light, dark: config.logo.dark };
45
40
  }, []);
46
41
 
47
42
  useEffect(() => {
@@ -68,8 +63,8 @@ export function Header({ className }: { className?: string }) {
68
63
  >
69
64
  <div className="relative flex justify-between gap-6 sm:gap-8 md:grow items-center w-full px-4">
70
65
  <div className="flex items-center grow gap-2">
71
- <img src={`${import.meta.env.BASE_URL}${light}`} alt="logo" className="w-8 hidden dark:block" />
72
- <img src={`${import.meta.env.BASE_URL}${dark}`} alt="logo" className="w-8 block dark:hidden" />
66
+ <img src={`/${light}`} alt="logo" className="w-8 hidden dark:block" />
67
+ <img src={`/${dark}`} alt="logo" className="w-8 block dark:hidden" />
73
68
  <h1 className="text-base lg:text-2xl font-bold">{config.name}</h1>
74
69
  </div>
75
70
  <nav className="hidden md:block">
package/src/ui/layout.tsx CHANGED
@@ -13,6 +13,7 @@ import Dropdown from '../components/dropdown';
13
13
  import { useNotification } from '../components/notification';
14
14
  import { SparkleIcon } from 'lucide-react';
15
15
  import { useIsEmbedded } from '../components/hooks';
16
+ import { ErrorBoundary } from '../components/error-boundary';
16
17
  import { cn } from '@clidey/ux';
17
18
 
18
19
  interface LayoutProps {
@@ -140,6 +141,52 @@ export default function Layout({ children }: LayoutProps) {
140
141
  if (ogUrl) {
141
142
  ogUrl.setAttribute('content', window.location.href);
142
143
  }
144
+
145
+ // Add/update canonical link
146
+ let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
147
+ if (!canonical) {
148
+ canonical = document.createElement('link');
149
+ canonical.setAttribute('rel', 'canonical');
150
+ document.head.appendChild(canonical);
151
+ }
152
+ canonical.setAttribute('href', `${window.location.origin}${window.location.pathname}`);
153
+
154
+ // Add/update JSON-LD structured data
155
+ const structuredData = {
156
+ '@context': 'https://schema.org',
157
+ '@graph': [
158
+ {
159
+ '@type': 'Article',
160
+ 'headline': page?.title || document.title,
161
+ 'description': page?.description || '',
162
+ },
163
+ {
164
+ '@type': 'BreadcrumbList',
165
+ 'itemListElement': [
166
+ {
167
+ '@type': 'ListItem',
168
+ 'position': 1,
169
+ 'name': 'Home',
170
+ 'item': window.location.origin
171
+ },
172
+ ...(page?.title ? [{
173
+ '@type': 'ListItem',
174
+ 'position': 2,
175
+ 'name': page.title,
176
+ 'item': window.location.href
177
+ }] : [])
178
+ ]
179
+ }
180
+ ]
181
+ };
182
+
183
+ let ldScript = document.querySelector('script[type="application/ld+json"]');
184
+ if (!ldScript) {
185
+ ldScript = document.createElement('script');
186
+ ldScript.setAttribute('type', 'application/ld+json');
187
+ document.head.appendChild(ldScript);
188
+ }
189
+ ldScript.textContent = JSON.stringify(structuredData);
143
190
  }, [page]);
144
191
 
145
192
  const tab = useMemo(() => {
@@ -217,11 +264,22 @@ export default function Layout({ children }: LayoutProps) {
217
264
  }
218
265
  </div>
219
266
  <div className="flex-1 min-h-[calc(100vh-2rem)]">
220
- {openAPIJSON && tab && "openapi" in tab && method && path ?
221
- <OpenAPI openAPIJson={JSON.parse(openAPIJSON)} method={method} path={path} />
222
- : asyncAPIJSON && tab && "asyncapi" in tab && operation && channel ?
223
- <AsyncAPI asyncAPIJson={JSON.parse(asyncAPIJSON)} operation={operation} channel={channel} />
224
- : children}
267
+ <ErrorBoundary fallback={(error) => (
268
+ <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
269
+ <p className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
270
+ This page failed to render
271
+ </p>
272
+ <pre className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-3 max-w-md overflow-auto">
273
+ {error.message}
274
+ </pre>
275
+ </div>
276
+ )}>
277
+ {openAPIJSON && tab && "openapi" in tab && method && path ?
278
+ <OpenAPI openAPIJson={JSON.parse(openAPIJSON)} method={method} path={path} />
279
+ : asyncAPIJSON && tab && "asyncapi" in tab && operation && channel ?
280
+ <AsyncAPI asyncAPIJson={JSON.parse(asyncAPIJSON)} operation={operation} channel={channel} />
281
+ : children}
282
+ </ErrorBoundary>
225
283
  </div>
226
284
  <PrevNextLinks />
227
285
  </article>
package/vite.config.ts CHANGED
@@ -10,11 +10,14 @@ import { frontmatterDevServer } from './src/plugins/frontmatter-dev-server';
10
10
  import { docsAssetsPlugin } from './src/plugins/docs-assets';
11
11
  import { htmlMetadataInjector } from './src/plugins/html-metadata-injector';
12
12
  import { htmlFilesMiddleware } from './src/plugins/html-files-middleware';
13
- import { manifestGenerator } from './src/plugins/manifest-generator';
13
+ import { analyticsInjector } from './src/plugins/analytics-injector';
14
+ import { cspGenerator } from './src/plugins/csp-generator';
15
+ import { sitemapGenerator } from './src/plugins/sitemap-generator';
16
+ import { robotsGenerator } from './src/plugins/robots-generator';
17
+ import { prerender } from './src/plugins/prerender';
14
18
  import { getMdxConfig } from './src/config/mdx';
15
19
 
16
20
  export default defineConfig(({ command }) => ({
17
- base: process.env.VITE_BASE || '/',
18
21
  plugins: [
19
22
  // SINGLE SOURCE OF TRUTH: All MDX preprocessing happens in src/mdx/processor.ts
20
23
  htmlFilesMiddleware(), // Must be first to intercept HTML file requests
@@ -26,9 +29,13 @@ export default defineConfig(({ command }) => ({
26
29
  llmTxtDevServer(),
27
30
  frontmatterGenerator(),
28
31
  frontmatterDevServer(),
29
- manifestGenerator(),
30
32
  docsAssetsPlugin(),
31
33
  htmlMetadataInjector(),
34
+ analyticsInjector(),
35
+ cspGenerator(),
36
+ sitemapGenerator(),
37
+ robotsGenerator(),
38
+ prerender(),
32
39
  ],
33
40
  resolve: {
34
41
  alias: {
@@ -37,6 +44,19 @@ export default defineConfig(({ command }) => ({
37
44
  }
38
45
  },
39
46
  publicDir: false, // Don't use a public directory since we need to import from docs
47
+ build: {
48
+ rollupOptions: {
49
+ output: {
50
+ manualChunks: {
51
+ 'vendor-preact': ['preact', 'preact/compat', 'preact/hooks'],
52
+ 'vendor-ui': ['@clidey/ux', '@headlessui/react'],
53
+ 'vendor-icons': ['lucide-react'],
54
+ 'katex': ['katex'],
55
+ 'flexsearch': ['flexsearch'],
56
+ }
57
+ }
58
+ }
59
+ },
40
60
  server: {
41
61
  allowedHosts: true,
42
62
  port: 3000,
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
3
- //# sourceMappingURL=dory.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"dory.d.ts","sourceRoot":"","sources":["../dory.ts"],"names":[],"mappings":""}