@clidey/dory 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html CHANGED
@@ -26,12 +26,13 @@
26
26
  <meta name="twitter:title" content="Docucod: Generate and Maintain Documentation With No Work" />
27
27
  <meta name="twitter:description" content="Docucod auto-generates and maintains up-to-date documentation from your codebase so your team can move fast, onboard quickly, and stay focused on building." />
28
28
  <meta name="twitter:image" content="./docs/favicon.svg" />
29
- <meta name="twitter:site" content="@ClideyOfficial" />
30
- <meta name="twitter:creator" content="@ClideyOfficial" />
29
+ <meta name="twitter:site" content="" />
30
+ <meta name="twitter:creator" content="" />
31
31
 
32
+ <link rel="canonical" href="" />
32
33
  <link rel="apple-touch-icon" href="./docs/favicon.svg" />
33
34
  <link rel="manifest" href="/manifest.json" />
34
-
35
+
35
36
  <title>Docucod - Instantly Generate and Maintain Your Documentation</title>
36
37
 
37
38
  <link rel="preconnect" href="https://fonts.googleapis.com" />
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clidey/dory",
3
3
  "private": false,
4
- "version": "0.41.0",
4
+ "version": "0.43.0",
5
5
  "type": "module",
6
6
  "description": "A CLI tool for building and previewing documentation sites",
7
7
  "keywords": [
@@ -17,8 +17,10 @@
17
17
  "files": [
18
18
  "bin/dist",
19
19
  "src",
20
+ "scripts",
20
21
  "index.html",
21
22
  "vite.config.ts",
23
+ "vite.config.ssr.ts",
22
24
  "vite.config.embed-loader.ts",
23
25
  "vite.config.embed-widget.ts"
24
26
  ],
@@ -34,9 +36,9 @@
34
36
  },
35
37
  "scripts": {
36
38
  "dev": "vite",
37
- "build": "npm run build:cli && tsc -b && vite build",
39
+ "build": "npm run build:cli && tsc -b && npm run build:docs",
38
40
  "build:cli": "tsc -p tsconfig.bin.json",
39
- "build:docs": "vite build",
41
+ "build:docs": "vite build && vite build --config vite.config.ssr.ts && node scripts/render-routes.mjs",
40
42
  "build:embed": "npm run build:embed:loader && npm run build:embed:widget",
41
43
  "build:embed:loader": "vite build -c vite.config.embed-loader.ts",
42
44
  "build:embed:widget": "vite build -c vite.config.embed-widget.ts",
@@ -69,6 +71,7 @@
69
71
  "mermaid": "11.12.2",
70
72
  "motion": "12.34.0",
71
73
  "preact": "10.28.3",
74
+ "preact-render-to-string": "6.6.5",
72
75
  "prism-react-renderer": "2.4.1",
73
76
  "react-highlight-words": "0.21.0",
74
77
  "remark-gfm": "4.0.1",
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ // Read dory.json to get navigation order (flat list, in order)
11
+ function getNavigationOrder(docsDir) {
12
+ const doryJsonPath = path.join(docsDir, 'dory.json');
13
+ if (!fs.existsSync(doryJsonPath)) return [];
14
+ const doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
15
+ const order = [];
16
+
17
+ function walkPages(pages) {
18
+ for (const page of pages) {
19
+ if (typeof page === 'string') {
20
+ order.push(page);
21
+ } else if (typeof page === 'object' && page.pages) {
22
+ walkPages(page.pages);
23
+ }
24
+ }
25
+ }
26
+
27
+ if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
28
+ for (const tab of doryJson.navigation.tabs) {
29
+ if (tab.groups && Array.isArray(tab.groups)) {
30
+ for (const group of tab.groups) {
31
+ if (group.pages && Array.isArray(group.pages)) {
32
+ walkPages(group.pages);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return order;
39
+ }
40
+
41
+ // Function to parse frontmatter from MDX content
42
+ function parseFrontmatter(content) {
43
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
44
+ if (!frontmatterMatch) return { data: {}, content };
45
+
46
+ const frontmatter = frontmatterMatch[1];
47
+ const data = {};
48
+
49
+ frontmatter.split('\n').forEach(line => {
50
+ const [key, ...valueParts] = line.split(':');
51
+ if (key && valueParts.length) {
52
+ const value = valueParts.join(':').trim();
53
+ data[key.trim()] = value.replace(/^["']|["']$/g, '');
54
+ }
55
+ });
56
+
57
+ return { data, content: content.slice(frontmatterMatch[0].length) };
58
+ }
59
+
60
+ // Convert filename to path format used by the store
61
+ function pathFromFilename(filename, docsDir) {
62
+ return filename
63
+ .replace(docsDir, '')
64
+ .replace(/\/?index\.mdx$/, '/')
65
+ .replace(/\.mdx$/, '')
66
+ .replace(/\\/g, '/')
67
+ .toLowerCase();
68
+ }
69
+
70
+ // Function to recursively find all MDX files and map by relative path (without .mdx)
71
+ function findMdxFilesMap(dir, baseDir) {
72
+ const files = {};
73
+ const items = fs.readdirSync(dir, { withFileTypes: true });
74
+
75
+ for (const item of items) {
76
+ const fullPath = path.join(dir, item.name);
77
+ if (item.isDirectory()) {
78
+ Object.assign(files, findMdxFilesMap(fullPath, baseDir));
79
+ } else if (item.isFile() && item.name.endsWith('.mdx')) {
80
+ // Key: relative path without .mdx, with forward slashes
81
+ let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
82
+ if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
83
+ files[rel] = fullPath;
84
+ }
85
+ }
86
+
87
+ return files;
88
+ }
89
+
90
+ // Main function to generate frontmatter JSON
91
+ async function generateFrontmatterJson() {
92
+ const docsDir = path.join(__dirname, '..', 'docs');
93
+ const outputDir = path.join(__dirname, '..', 'dist');
94
+
95
+ // Ensure output directory exists
96
+ if (!fs.existsSync(outputDir)) {
97
+ fs.mkdirSync(outputDir, { recursive: true });
98
+ }
99
+
100
+ const navOrder = getNavigationOrder(docsDir);
101
+ const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
102
+ const usedKeys = new Set();
103
+ const frontmatterArray = [];
104
+
105
+ // Process files in navigation order first
106
+ for (const navKey of navOrder) {
107
+ // Try both with and without "index"
108
+ let fileKey = navKey;
109
+ if (mdxFilesMap[fileKey]) {
110
+ usedKeys.add(fileKey);
111
+ } else if (mdxFilesMap[`${fileKey}/index`]) {
112
+ fileKey = `${fileKey}/index`;
113
+ usedKeys.add(fileKey);
114
+ } else {
115
+ continue;
116
+ }
117
+
118
+ const filePath = mdxFilesMap[fileKey];
119
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
120
+ const { data } = parseFrontmatter(rawContent);
121
+ const pathname = pathFromFilename(filePath, docsDir);
122
+
123
+ frontmatterArray.push({
124
+ ...data,
125
+ path: pathname
126
+ });
127
+ }
128
+
129
+ // Process any extra files not in navigation order, sorted
130
+ const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
131
+ for (const fileKey of extraKeys) {
132
+ const filePath = mdxFilesMap[fileKey];
133
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
134
+ const { data } = parseFrontmatter(rawContent);
135
+ const pathname = pathFromFilename(filePath, docsDir);
136
+
137
+ frontmatterArray.push({
138
+ ...data,
139
+ path: pathname
140
+ });
141
+ }
142
+
143
+ // Write to output file
144
+ const jsonContent = JSON.stringify(frontmatterArray, null, 2);
145
+ const outputPath = path.join(outputDir, 'frontmatter.json');
146
+ fs.writeFileSync(outputPath, jsonContent);
147
+
148
+ console.log(`✅ Generated frontmatter JSON file: ${outputPath}`);
149
+ console.log(`📄 Processed ${frontmatterArray.length} MDX files`);
150
+ console.log(`📝 Total JSON size: ${jsonContent.length} characters`);
151
+ }
152
+
153
+ // Run the script
154
+ generateFrontmatterJson().catch(console.error);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ // Function to parse frontmatter from MDX content
11
+ function parseFrontmatter(content) {
12
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
13
+ if (!frontmatterMatch) return { data: {}, content };
14
+
15
+ const frontmatter = frontmatterMatch[1];
16
+ const data = {};
17
+
18
+ frontmatter.split('\n').forEach(line => {
19
+ const [key, ...valueParts] = line.split(':');
20
+ if (key && valueParts.length) {
21
+ const value = valueParts.join(':').trim();
22
+ data[key.trim()] = value.replace(/^["']|["']$/g, '');
23
+ }
24
+ });
25
+
26
+ return { data, content: content.slice(frontmatterMatch[0].length) };
27
+ }
28
+
29
+ // Function to recursively find all MDX files
30
+ function findMdxFiles(dir) {
31
+ const files = [];
32
+ const items = fs.readdirSync(dir, { withFileTypes: true });
33
+
34
+ for (const item of items) {
35
+ const fullPath = path.join(dir, item.name);
36
+ if (item.isDirectory()) {
37
+ files.push(...findMdxFiles(fullPath));
38
+ } else if (item.isFile() && item.name.endsWith('.mdx')) {
39
+ files.push(fullPath);
40
+ }
41
+ }
42
+
43
+ return files;
44
+ }
45
+
46
+ // Function to clean MDX content for LLM consumption
47
+ function cleanMdxContent(content) {
48
+ // Remove JSX components but keep their content
49
+ let cleaned = content
50
+ // Remove JSX opening/closing tags but keep content
51
+ .replace(/<(\w+)([^>]*?)>/g, '')
52
+ .replace(/<\/\w+>/g, '')
53
+ // Remove code block language specifiers
54
+ .replace(/```(\w+)/g, '```')
55
+ // Remove HTML comments
56
+ .replace(/<!--[\s\S]*?-->/g, '')
57
+ // Remove excessive whitespace
58
+ .replace(/\n\s*\n\s*\n/g, '\n\n')
59
+ // Remove leading/trailing whitespace
60
+ .trim();
61
+
62
+ return cleaned;
63
+ }
64
+
65
+ // Main function to generate LLM text
66
+ async function generateLlmTxt() {
67
+ const docsDir = path.join(__dirname, '..', 'docs');
68
+ const outputDir = path.join(__dirname, '..', 'dist');
69
+
70
+ // Ensure output directory exists
71
+ if (!fs.existsSync(outputDir)) {
72
+ fs.mkdirSync(outputDir, { recursive: true });
73
+ }
74
+
75
+ const mdxFiles = findMdxFiles(docsDir);
76
+ let aggregatedContent = '';
77
+
78
+ // Sort files for consistent output
79
+ mdxFiles.sort();
80
+
81
+ for (const filePath of mdxFiles) {
82
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
83
+ const { data, content } = parseFrontmatter(rawContent);
84
+ const cleanedContent = cleanMdxContent(content);
85
+
86
+ // Add file header
87
+ const relativePath = path.relative(docsDir, filePath);
88
+ aggregatedContent += `\n# ${relativePath}\n`;
89
+
90
+ // Add title if available
91
+ if (data.title) {
92
+ aggregatedContent += `## ${data.title}\n\n`;
93
+ }
94
+
95
+ // Add description if available
96
+ if (data.description) {
97
+ aggregatedContent += `${data.description}\n\n`;
98
+ }
99
+
100
+ // Add cleaned content
101
+ aggregatedContent += cleanedContent + '\n\n';
102
+ aggregatedContent += '---\n\n';
103
+ }
104
+
105
+ // Write to output file
106
+ const outputPath = path.join(outputDir, 'llms.txt');
107
+ fs.writeFileSync(outputPath, aggregatedContent.trim());
108
+
109
+ console.log(`✅ Generated LLM text file: ${outputPath}`);
110
+ console.log(`📄 Processed ${mdxFiles.length} MDX files`);
111
+ console.log(`📝 Total content size: ${aggregatedContent.length} characters`);
112
+ }
113
+
114
+ // Run the script
115
+ generateLlmTxt().catch(console.error);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Post-build SSR rendering script.
3
+ * Reads the SSR bundle from dist-ssr/, renders each route to HTML,
4
+ * and injects the content into the prerendered HTML files in dist/.
5
+ */
6
+ import { readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
7
+ import { resolve, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const distDir = resolve(__dirname, '..', 'dist');
12
+ const ssrDir = resolve(__dirname, '..', 'dist-ssr');
13
+
14
+ // Check prerequisites
15
+ const frontmatterPath = resolve(distDir, 'frontmatter.json');
16
+ if (!existsSync(frontmatterPath)) {
17
+ console.warn('⚠️ frontmatter.json not found, skipping SSR rendering');
18
+ process.exit(0);
19
+ }
20
+
21
+ const ssrEntryPath = resolve(ssrDir, 'entry-server.js');
22
+ if (!existsSync(ssrEntryPath)) {
23
+ console.warn('⚠️ SSR bundle not found, skipping SSR rendering');
24
+ process.exit(0);
25
+ }
26
+
27
+ // Import SSR bundle
28
+ const { render } = await import(ssrEntryPath);
29
+ const frontmatter = JSON.parse(readFileSync(frontmatterPath, 'utf-8'));
30
+
31
+ let count = 0;
32
+ let failed = 0;
33
+
34
+ function injectSSR(htmlPath, ssrHtml) {
35
+ let html = readFileSync(htmlPath, 'utf-8');
36
+
37
+ html = html.replace(
38
+ '<div id="app"></div>',
39
+ `<div id="app">${ssrHtml}</div>`
40
+ );
41
+
42
+ html = html.replace(
43
+ '<noscript>You need to enable JavaScript to run this app.</noscript>',
44
+ '<noscript>JavaScript enhances this page with interactive features.</noscript>'
45
+ );
46
+
47
+ writeFileSync(htmlPath, html, 'utf-8');
48
+ }
49
+
50
+ // Render each route
51
+ for (const page of frontmatter) {
52
+ const routePath = page.path;
53
+ if (!routePath || routePath === '/') continue;
54
+
55
+ const htmlPath = resolve(distDir, routePath.replace(/^\//, ''), 'index.html');
56
+ if (!existsSync(htmlPath)) continue;
57
+
58
+ try {
59
+ const ssrHtml = await render(routePath);
60
+ if (!ssrHtml) continue;
61
+
62
+ injectSSR(htmlPath, ssrHtml);
63
+ count++;
64
+ } catch (error) {
65
+ console.warn(`⚠️ SSR failed for ${routePath}: ${error.message}`);
66
+ failed++;
67
+ }
68
+ }
69
+
70
+ // Also render the root index.html with the first page's content
71
+ try {
72
+ const rootHtmlPath = resolve(distDir, 'index.html');
73
+ if (existsSync(rootHtmlPath) && frontmatter.length > 0) {
74
+ const firstRoute = frontmatter[0].path;
75
+ if (firstRoute) {
76
+ const ssrHtml = await render(firstRoute);
77
+ if (ssrHtml) {
78
+ injectSSR(rootHtmlPath, ssrHtml);
79
+ count++;
80
+ }
81
+ }
82
+ }
83
+ } catch (error) {
84
+ console.warn(`⚠️ SSR failed for root: ${error.message}`);
85
+ }
86
+
87
+ // Cleanup SSR build artifacts
88
+ try {
89
+ rmSync(ssrDir, { recursive: true, force: true });
90
+ } catch {
91
+ // Non-fatal: dist-ssr will be overwritten on next build
92
+ }
93
+
94
+ console.log(`✅ SSR rendered ${count} routes${failed > 0 ? ` (${failed} failed)` : ''}`);
@@ -2,18 +2,38 @@ import { useEffect, useState } from 'preact/hooks';
2
2
  import { useLocation } from 'wouter-preact';
3
3
 
4
4
  /**
5
- * Custom hook to get the current pathname
5
+ * Strips trailing slash from a pathname (except root "/").
6
+ */
7
+ function normalizePathname(pathname: string): string {
8
+ if (pathname !== '/' && pathname.endsWith('/')) {
9
+ return pathname.slice(0, -1);
10
+ }
11
+ return pathname;
12
+ }
13
+
14
+ /**
15
+ * Custom hook to get the current pathname, normalized to strip trailing slashes.
6
16
  */
7
17
  export const usePathname = () => {
8
18
  const [pathname] = useLocation();
9
- return pathname;
19
+ const normalized = normalizePathname(pathname);
20
+
21
+ useEffect(() => {
22
+ if (pathname !== normalized) {
23
+ window.history.replaceState(null, '', normalized + window.location.search + window.location.hash);
24
+ }
25
+ }, [pathname, normalized]);
26
+
27
+ return normalized;
10
28
  };
11
29
 
12
30
  /**
13
31
  * Custom hook to get the URL search params as a URLSearchParams instance
14
32
  */
15
33
  export const useSearchParams = () => {
16
- const [searchParams, setSearchParams] = useState(new URLSearchParams(window.location.search));
34
+ const [searchParams, setSearchParams] = useState(
35
+ typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams()
36
+ );
17
37
 
18
38
  useEffect(() => {
19
39
  const handlePopState = () => {
@@ -0,0 +1,106 @@
1
+ import {renderToString} from 'preact-render-to-string';
2
+ import {Router} from 'wouter-preact';
3
+ import {MDXProvider} from '@mdx-js/preact';
4
+ import * as mdxComponents from './mdx/mdx';
5
+
6
+ function pathFromFilename(filename: string): string {
7
+ return filename
8
+ .replace(/^.*?docs/, '')
9
+ .replace(/\/?index\.mdx$/, '/')
10
+ .replace(/\.mdx$/, '')
11
+ .toLowerCase();
12
+ }
13
+
14
+ const pages = import.meta.glob('../docs/**/*.mdx');
15
+ const allPages = Object.fromEntries(
16
+ Object.entries(pages).map(([path, loader]) => [pathFromFilename(path), loader])
17
+ );
18
+
19
+ // SSR-safe Pre: renders code blocks without syntax highlighting.
20
+ // The client-side Fence component uses useTheme() from @clidey/ux which
21
+ // requires ThemeProvider (localStorage access). Plain pre/code is sufficient
22
+ // for crawlers — syntax highlighting is a visual enhancement.
23
+ function SSRPre(props: any) {
24
+ const child = props.children?.props;
25
+ const className = child?.className || '';
26
+ const code = child?.children || '';
27
+ return (
28
+ <pre class="overflow-x-auto p-4 text-xs">
29
+ <code class={className}>{code}</code>
30
+ </pre>
31
+ );
32
+ }
33
+
34
+ // SSR-safe Latex: renders the raw expression as text.
35
+ // The client-side Latex component lazy-loads KaTeX in useEffect.
36
+ function SSRLatex({ children }: { children: any }) {
37
+ return (
38
+ <div class="my-4">
39
+ <code>{children}</code>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ // SSR-safe table: @clidey/ux's Table component renders a vaul Drawer
45
+ // (Preview drawer) alongside every table. vaul's useScaleBackground
46
+ // accesses document.body at render time, crashing in Node.js.
47
+ function SSRTable(props: any) {
48
+ return (
49
+ <div class="overflow-x-auto rounded-lg my-8">
50
+ <table class="table-auto border-collapse min-w-full" {...props} />
51
+ </div>
52
+ );
53
+ }
54
+ function SSRTh(props: any) {
55
+ return <th class="px-4 py-2 text-left text-sm font-medium" {...props} />;
56
+ }
57
+ function SSRTd(props: any) {
58
+ return <td class="px-4 py-2 text-sm" {...props} />;
59
+ }
60
+
61
+ // SSR-safe placeholders for interactive components that use Suspense/lazy.
62
+ // renderToString cannot handle Suspense boundaries — these components are
63
+ // interactive-only anyway (playgrounds, diagrams) and not meaningful for crawlers.
64
+ function SSRPlaceholder() {
65
+ return null;
66
+ }
67
+
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> {
80
+ const loader = allPages[routePath];
81
+ if (!loader) return '';
82
+
83
+ try {
84
+ const module = await loader() as any;
85
+ const Content = module.default;
86
+
87
+ return renderToString(
88
+ <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>
96
+ </div>
97
+ </main>
98
+ </div>
99
+ </MDXProvider>
100
+ </Router>
101
+ );
102
+ } catch (error) {
103
+ console.warn(`SSR render failed for ${routePath}:`, error);
104
+ return '';
105
+ }
106
+ }
package/src/mdx/fence.tsx CHANGED
@@ -15,7 +15,7 @@ export function Fence({
15
15
  language: string
16
16
  }) {
17
17
  const { theme } = useTheme();
18
- const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
18
+ const isDark = theme === 'dark' || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
19
19
 
20
20
  if (language === 'mermaid') {
21
21
  return (
package/src/mdx/mdx.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef } from 'preact/hooks';
2
2
  import { lazy, Suspense } from 'preact/compat';
3
- import { usePathname } from 'wouter-preact/use-browser-location';
3
+ import { usePathname } from '../components/hooks';
4
4
  import { completeFrontMatter } from '../components/store';
5
5
  import { Loading } from '../components/loading';
6
6
 
@@ -25,12 +25,28 @@ export async function preprocessMdxContent(
25
25
  error?: Error;
26
26
  }> {
27
27
  try {
28
+ // Step 0: Strip YAML frontmatter before any processing.
29
+ // Frontmatter is parsed separately by the frontmatter-generator plugin.
30
+ let processedContent = content;
31
+ const frontMatterMatch = processedContent.match(/^---\n([\s\S]*?)\n---\n?/);
32
+ if (frontMatterMatch) {
33
+ processedContent = processedContent.slice(frontMatterMatch[0].length);
34
+ }
35
+
36
+ // Step 0b: Strip stray ```yaml metadata blocks that follow frontmatter.
37
+ // Some content generators produce a second YAML block (```yaml...---) after
38
+ // the frontmatter. This is a broken code fence (closed with --- instead of ```)
39
+ // that leaks metadata into the rendered page.
40
+ const yamlBlockMatch = processedContent.match(/^```yaml\n([\s\S]*?)\n---\n?/);
41
+ if (yamlBlockMatch) {
42
+ processedContent = processedContent.slice(yamlBlockMatch[0].length);
43
+ }
44
+
28
45
  // Step 1: Apply preprocessor (handles MDX tags, code blocks, etc.)
29
46
  const preprocessor = preprocessMdxTags();
30
- let processedContent = content;
31
47
 
32
48
  if (preprocessor.transform) {
33
- const result = preprocessor.transform(content, fileName);
49
+ const result = preprocessor.transform(processedContent, fileName);
34
50
  if (result && typeof result === 'object' && result.code) {
35
51
  processedContent = result.code;
36
52
  } else if (typeof result === 'string') {
@@ -3,14 +3,13 @@ import { readFileSync, existsSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
 
5
5
  /**
6
- * Vite plugin that injects site metadata from dory.json into index.html
7
- * This provides proper defaults before JavaScript hydrates the page
6
+ * Vite plugin that injects site metadata from dory.json into index.html.
7
+ * Provides proper defaults before JavaScript hydrates the page.
8
8
  */
9
9
  export function htmlMetadataInjector(): Plugin {
10
10
  return {
11
11
  name: 'html-metadata-injector',
12
12
  transformIndexHtml(html) {
13
- // Read dory.json configuration
14
13
  const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
15
14
 
16
15
  if (!existsSync(doryJsonPath)) {
@@ -24,7 +23,13 @@ export function htmlMetadataInjector(): Plugin {
24
23
  const defaultTitle = config.title || siteName;
25
24
  const defaultDescription = config.description || `${siteName} - Technical Documentation`;
26
25
  const siteUrl = config.url || '';
27
- const siteImage = config.image || './docs/favicon.svg';
26
+ const twitterHandle = config.twitter || '';
27
+
28
+ // Resolve image to absolute URL
29
+ const rawImage = config.image || '';
30
+ const siteImage = rawImage && siteUrl && !rawImage.startsWith('http')
31
+ ? `${siteUrl}${rawImage.startsWith('/') ? '' : '/'}${rawImage}`
32
+ : rawImage;
28
33
 
29
34
  // Replace title
30
35
  html = html.replace(
@@ -38,31 +43,25 @@ export function htmlMetadataInjector(): Plugin {
38
43
  `<meta name="description" content="${defaultDescription}" />`
39
44
  );
40
45
 
41
- // Replace Open Graph title
46
+ // Replace Open Graph tags
42
47
  html = html.replace(
43
48
  /<meta property="og:title" content=".*?" \/>/,
44
49
  `<meta property="og:title" content="${defaultTitle}" />`
45
50
  );
46
-
47
- // Replace Open Graph description
48
51
  html = html.replace(
49
52
  /<meta property="og:description" content=".*?" \/>/,
50
53
  `<meta property="og:description" content="${defaultDescription}" />`
51
54
  );
52
-
53
- // Replace Open Graph site name
54
55
  html = html.replace(
55
56
  /<meta property="og:site_name" content=".*?" \/>/,
56
57
  `<meta property="og:site_name" content="${siteName}" />`
57
58
  );
58
-
59
- // Replace Open Graph image
60
- html = html.replace(
61
- /<meta property="og:image" content=".*?" \/>/,
62
- `<meta property="og:image" content="${siteImage}" />`
63
- );
64
-
65
- // Replace Open Graph URL
59
+ if (siteImage) {
60
+ html = html.replace(
61
+ /<meta property="og:image" content=".*?" \/>/,
62
+ `<meta property="og:image" content="${siteImage}" />`
63
+ );
64
+ }
66
65
  if (siteUrl) {
67
66
  html = html.replace(
68
67
  /<meta property="og:url" content=".*?" \/>/,
@@ -70,24 +69,38 @@ export function htmlMetadataInjector(): Plugin {
70
69
  );
71
70
  }
72
71
 
73
- // Replace Twitter title
72
+ // Replace Twitter tags
74
73
  html = html.replace(
75
74
  /<meta name="twitter:title" content=".*?" \/>/,
76
75
  `<meta name="twitter:title" content="${defaultTitle}" />`
77
76
  );
78
-
79
- // Replace Twitter description
80
77
  html = html.replace(
81
78
  /<meta name="twitter:description" content=".*?" \/>/,
82
79
  `<meta name="twitter:description" content="${defaultDescription}" />`
83
80
  );
84
-
85
- // Replace Twitter image
81
+ if (siteImage) {
82
+ html = html.replace(
83
+ /<meta name="twitter:image" content=".*?" \/>/,
84
+ `<meta name="twitter:image" content="${siteImage}" />`
85
+ );
86
+ }
87
+ html = html.replace(
88
+ /<meta name="twitter:site" content=".*?" \/>/,
89
+ `<meta name="twitter:site" content="${twitterHandle}" />`
90
+ );
86
91
  html = html.replace(
87
- /<meta name="twitter:image" content=".*?" \/>/,
88
- `<meta name="twitter:image" content="${siteImage}" />`
92
+ /<meta name="twitter:creator" content=".*?" \/>/,
93
+ `<meta name="twitter:creator" content="${twitterHandle}" />`
89
94
  );
90
95
 
96
+ // Set canonical URL for the base page
97
+ if (siteUrl) {
98
+ html = html.replace(
99
+ /<link rel="canonical" href=".*?" \/>/,
100
+ `<link rel="canonical" href="${siteUrl}" />`
101
+ );
102
+ }
103
+
91
104
  return html;
92
105
  } catch (error) {
93
106
  console.error('❌ Failed to parse dory.json for metadata injection:', error);
@@ -3,10 +3,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
 
5
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.
6
+ * Generates per-route HTML files with page-specific meta tags and JSON-LD
7
+ * at build time. The SSR step (render-routes.mjs) later injects rendered
8
+ * content into these files.
10
9
  */
11
10
  export function prerender(): Plugin {
12
11
  let config: ResolvedConfig;
@@ -70,12 +69,10 @@ export function prerender(): Plugin {
70
69
  /<meta property="og:description" content=".*?" \/>/,
71
70
  `<meta property="og:description" content="${escapeAttr(description)}" />`
72
71
  );
73
- if (fullUrl) {
74
- html = html.replace(
75
- /<meta property="og:url" content=".*?" \/>/,
76
- `<meta property="og:url" content="${escapeAttr(fullUrl)}" />`
77
- );
78
- }
72
+ html = html.replace(
73
+ /<meta property="og:url" content=".*?" \/>/,
74
+ `<meta property="og:url" content="${escapeAttr(fullUrl)}" />`
75
+ );
79
76
 
80
77
  // Replace Twitter tags
81
78
  html = html.replace(
@@ -87,14 +84,55 @@ export function prerender(): Plugin {
87
84
  `<meta name="twitter:description" content="${escapeAttr(description)}" />`
88
85
  );
89
86
 
90
- // Add canonical link
91
- if (!html.includes('rel="canonical"')) {
87
+ // Update canonical link (always replace, never skip)
88
+ html = html.replace(
89
+ /<link rel="canonical" href=".*?" \/>/,
90
+ `<link rel="canonical" href="${escapeAttr(fullUrl)}" />`
91
+ );
92
+
93
+ // Inject per-page robots directive if specified in frontmatter
94
+ if (page.robots) {
92
95
  html = html.replace(
93
96
  '</head>',
94
- ` <link rel="canonical" href="${escapeAttr(fullUrl)}" />\n </head>`
97
+ ` <meta name="robots" content="${escapeAttr(page.robots)}" />\n </head>`
95
98
  );
96
99
  }
97
100
 
101
+ // Inject JSON-LD structured data
102
+ const jsonLd = JSON.stringify({
103
+ '@context': 'https://schema.org',
104
+ '@graph': [
105
+ {
106
+ '@type': 'Article',
107
+ 'headline': title,
108
+ 'description': description,
109
+ ...(fullUrl ? { 'url': fullUrl } : {}),
110
+ },
111
+ {
112
+ '@type': 'BreadcrumbList',
113
+ 'itemListElement': [
114
+ {
115
+ '@type': 'ListItem',
116
+ 'position': 1,
117
+ 'name': 'Home',
118
+ ...(baseUrl ? { 'item': baseUrl } : {}),
119
+ },
120
+ {
121
+ '@type': 'ListItem',
122
+ 'position': 2,
123
+ 'name': title,
124
+ ...(fullUrl ? { 'item': fullUrl } : {}),
125
+ }
126
+ ]
127
+ }
128
+ ]
129
+ });
130
+
131
+ html = html.replace(
132
+ '</head>',
133
+ ` <script type="application/ld+json">${jsonLd}</script>\n </head>`
134
+ );
135
+
98
136
  // Write to route directory
99
137
  const routeDir = resolve(outDir, routePath.replace(/^\//, ''));
100
138
  mkdirSync(routeDir, { recursive: true });
@@ -1,16 +1,18 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync } from 'fs';
2
+ import { readFileSync, existsSync, statSync } from 'fs';
3
3
  import { resolve } from 'path';
4
4
 
5
5
  /**
6
6
  * Generates sitemap.xml from dory.json navigation structure at build time.
7
- * Uses the `url` field from dory.json as the base URL.
7
+ * Uses actual file modification times for lastmod and calculates priority
8
+ * based on route depth.
8
9
  */
9
10
  export function sitemapGenerator(): Plugin {
10
11
  return {
11
12
  name: 'sitemap-generator',
12
13
  generateBundle() {
13
- const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
14
+ const docsDir = resolve(process.cwd(), 'docs');
15
+ const doryJsonPath = resolve(docsDir, 'dory.json');
14
16
  if (!existsSync(doryJsonPath)) {
15
17
  console.warn('sitemap-generator: dory.json not found, skipping');
16
18
  return;
@@ -25,7 +27,7 @@ export function sitemapGenerator(): Plugin {
25
27
  function walkPages(pages: any[]) {
26
28
  for (const page of pages) {
27
29
  if (typeof page === 'string') {
28
- routes.push(`/${page}`);
30
+ routes.push(page);
29
31
  } else if (typeof page === 'object' && page.pages) {
30
32
  walkPages(page.pages);
31
33
  }
@@ -44,14 +46,26 @@ export function sitemapGenerator(): Plugin {
44
46
  }
45
47
  }
46
48
 
47
- const now = new Date().toISOString().split('T')[0];
49
+ const fallbackDate = new Date().toISOString().split('T')[0];
48
50
  let sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n';
49
51
  sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
50
52
 
51
53
  for (const route of routes) {
54
+ // Get actual file modification time
55
+ const mdxPath = resolve(docsDir, `${route}.mdx`);
56
+ let lastmod = fallbackDate;
57
+ if (existsSync(mdxPath)) {
58
+ lastmod = statSync(mdxPath).mtime.toISOString().split('T')[0];
59
+ }
60
+
61
+ // Priority based on route depth: fewer segments = higher priority
62
+ const depth = route.split('/').length;
63
+ const priority = Math.max(0.3, 1.0 - (depth - 1) * 0.2).toFixed(1);
64
+
52
65
  sitemap += ' <url>\n';
53
- sitemap += ` <loc>${baseUrl}${route}</loc>\n`;
54
- sitemap += ` <lastmod>${now}</lastmod>\n`;
66
+ sitemap += ` <loc>${baseUrl}/${route}</loc>\n`;
67
+ sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
68
+ sitemap += ` <priority>${priority}</priority>\n`;
55
69
  sitemap += ' </url>\n';
56
70
  }
57
71
 
@@ -27,7 +27,7 @@ export function unifiedMdxPreprocessor(): Plugin {
27
27
  // Return the preprocessed content
28
28
  // The actual MDX compilation will be handled by @mdx-js/rollup plugin
29
29
  return {
30
- code: result.processedContent || code,
30
+ code: result.processedContent ?? code,
31
31
  map: null
32
32
  };
33
33
  }
@@ -91,6 +91,7 @@ export interface DoryConfig {
91
91
  description?: string;
92
92
  image?: string;
93
93
  url?: string;
94
+ twitter?: string;
94
95
  favicon: string;
95
96
  colors?: DoryColors;
96
97
  fonts?: DoryFonts;
package/src/ui/header.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import classNames from 'classnames';
2
2
  import { useEffect, useMemo, useState } from 'preact/hooks';
3
3
  import { Link } from 'wouter-preact';
4
- import { usePathname } from 'wouter-preact/use-browser-location';
4
+ import { usePathname } from '../components/hooks';
5
5
  import docsConfig from '../../docs/dory.json' with { type: 'json' };
6
6
  import type { DoryConfig } from '../types/config';
7
7
  import { MobileSearch, Search } from '../components/search';
package/src/ui/layout.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ComponentChildren } from 'preact';
2
2
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
3
- import { useLocation } from 'wouter-preact';
4
3
  import { Navigation } from '../components/navigation';
5
4
  import { ALL_NAVIGATION, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, ALL_PAGES, pathFromFilename, preloadFrontmatter, ALL_ASYNCAPI } from '../components/store';
5
+ import { usePathname } from '../components/hooks';
6
6
  import { OpenAPI } from '../mdx/open-api';
7
7
  import { AsyncAPI } from '../mdx/async-api';
8
8
  import { Header } from './header';
@@ -26,7 +26,7 @@ const prompt = (pathname: string) => `Please read and analyze the content from t
26
26
 
27
27
  export default function Layout({ children }: LayoutProps) {
28
28
  const [loading, setLoading] = useState(true);
29
- const [pathname] = useLocation();
29
+ const pathname = usePathname();
30
30
  const { showNotification } = useNotification();
31
31
  const isEmbedded = useIsEmbedded();
32
32
 
@@ -0,0 +1,32 @@
1
+ import { defineConfig } from 'vite';
2
+ import preact from '@preact/preset-vite';
3
+ import mdx from '@mdx-js/rollup';
4
+ import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin';
5
+ import { getMdxConfig } from './src/config/mdx';
6
+
7
+ // Minimal Vite config for the SSR build.
8
+ // Only includes plugins needed for MDX compilation — no sitemap, prerender,
9
+ // analytics, or other build-time plugins that target the client output.
10
+ export default defineConfig({
11
+ plugins: [
12
+ unifiedMdxPreprocessor(),
13
+ preact(),
14
+ mdx(getMdxConfig(false)),
15
+ ],
16
+ resolve: {
17
+ alias: {
18
+ react: 'preact/compat',
19
+ 'react-dom': 'preact/compat'
20
+ }
21
+ },
22
+ build: {
23
+ ssr: 'src/entry-server.tsx',
24
+ outDir: 'dist-ssr',
25
+ },
26
+ ssr: {
27
+ // Bundle all dependencies so preact/compat aliases resolve correctly.
28
+ // Without this, externalized imports like @headlessui/react would
29
+ // import 'react' directly, bypassing the preact alias.
30
+ noExternal: true,
31
+ },
32
+ });
package/vite.config.ts CHANGED
@@ -11,7 +11,6 @@ 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
13
  import { analyticsInjector } from './src/plugins/analytics-injector';
14
- import { cspGenerator } from './src/plugins/csp-generator';
15
14
  import { sitemapGenerator } from './src/plugins/sitemap-generator';
16
15
  import { robotsGenerator } from './src/plugins/robots-generator';
17
16
  import { prerender } from './src/plugins/prerender';
@@ -32,7 +31,6 @@ export default defineConfig(({ command }) => ({
32
31
  docsAssetsPlugin(),
33
32
  htmlMetadataInjector(),
34
33
  analyticsInjector(),
35
- cspGenerator(),
36
34
  sitemapGenerator(),
37
35
  robotsGenerator(),
38
36
  prerender(),
@@ -1,68 +0,0 @@
1
- import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync } from 'fs';
3
- import { resolve } from 'path';
4
-
5
- /**
6
- * Generates a Content-Security-Policy meta tag based on dory.json config.
7
- * Dynamically adds font sources and analytics domains as needed.
8
- */
9
- export function cspGenerator(): Plugin {
10
- return {
11
- name: 'csp-generator',
12
- transformIndexHtml(html) {
13
- const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
14
- if (!existsSync(doryJsonPath)) return html;
15
-
16
- try {
17
- const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
18
-
19
- const scriptSrc = ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
20
- const styleSrc = ["'self'", "'unsafe-inline'"];
21
- const fontSrc = ["'self'", "data:"];
22
- const connectSrc = ["'self'"];
23
- const imgSrc = ["'self'", "data:", "https:"];
24
-
25
- // Add Google Fonts if fonts are configured with external source
26
- if (config.fonts?.source) {
27
- try {
28
- const fontUrl = new URL(config.fonts.source);
29
- fontSrc.push(fontUrl.origin);
30
- } catch {
31
- // Not a URL, skip
32
- }
33
- }
34
- styleSrc.push("https://fonts.googleapis.com");
35
- fontSrc.push("https://fonts.gstatic.com");
36
-
37
- // Add PostHog if analytics configured
38
- if (config.analytics?.posthog?.token) {
39
- const apiHost = config.analytics.posthog.apiHost || 'https://us.i.posthog.com';
40
- const assetsHost = apiHost.replace('.i.posthog.com', '-assets.i.posthog.com');
41
- scriptSrc.push(apiHost);
42
- scriptSrc.push(assetsHost);
43
- connectSrc.push(apiHost);
44
- }
45
-
46
- // Note: frame-ancestors is omitted because it's ignored in <meta> CSP tags.
47
- // Clickjacking protection is handled via X-Frame-Options in nginx config.
48
- const csp = [
49
- `default-src 'self'`,
50
- `script-src ${scriptSrc.join(' ')}`,
51
- `style-src ${styleSrc.join(' ')}`,
52
- `font-src ${fontSrc.join(' ')}`,
53
- `img-src ${imgSrc.join(' ')}`,
54
- `connect-src ${connectSrc.join(' ')}`,
55
- ].join('; ');
56
-
57
- return html.replace(
58
- '</head>',
59
- ` <meta http-equiv="Content-Security-Policy" content="${csp}">\n </head>`
60
- );
61
- } catch (error) {
62
- const detail = error instanceof SyntaxError ? error.message : String(error);
63
- console.error(`Failed to generate CSP: ${detail}`);
64
- return html;
65
- }
66
- },
67
- };
68
- }