@clidey/dory 0.47.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 (46) 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 +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 +94 -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
@@ -1,193 +1,154 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import {
5
+ readDoryConfig,
6
+ getNavigationOrder,
7
+ parseFrontmatter,
8
+ findMdxFilesMap,
9
+ pathFromFilename,
10
+ } from './shared';
4
11
 
5
12
  interface LlmTxtGeneratorOptions {
6
13
  docsDir?: string;
7
- outputPath?: string;
8
14
  }
9
15
 
10
- // Read dory.json to get navigation order (flat list, in order)
11
- function getNavigationOrder(docsDir: string): string[] {
12
- const doryJsonPath = path.join(docsDir, 'dory.json');
13
- if (!fs.existsSync(doryJsonPath)) return [];
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
- }
22
- const order: string[] = [];
23
-
24
- function walkPages(pages: any[]) {
25
- for (const page of pages) {
26
- if (typeof page === 'string') {
27
- order.push(page);
28
- } else if (typeof page === 'object' && page.pages) {
29
- walkPages(page.pages);
30
- }
31
- }
32
- }
33
-
34
- if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
35
- for (const tab of doryJson.navigation.tabs) {
36
- if (tab.groups && Array.isArray(tab.groups)) {
37
- for (const group of tab.groups) {
38
- if (group.pages && Array.isArray(group.pages)) {
39
- walkPages(group.pages);
40
- }
41
- }
42
- }
43
- }
44
- }
45
- return order;
46
- }
47
-
48
- // Function to parse frontmatter from MDX content
49
- function parseFrontmatter(content: string) {
50
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
51
- if (!frontmatterMatch) return { data: {}, content };
52
-
53
- const frontmatter = frontmatterMatch[1];
54
- const data: Record<string, string> = {};
55
-
56
- frontmatter.split('\n').forEach(line => {
57
- const [key, ...valueParts] = line.split(':');
58
- if (key && valueParts.length) {
59
- const value = valueParts.join(':').trim();
60
- data[key.trim()] = value.replace(/^["']|["']$/g, '');
61
- }
62
- });
63
-
64
- return { data, content: content.slice(frontmatterMatch[0].length) };
65
- }
66
-
67
- // Function to recursively find all MDX files and map by relative path (without .mdx)
68
- function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
69
- const files: Record<string, string> = {};
70
- const items = fs.readdirSync(dir, { withFileTypes: true });
71
-
72
- for (const item of items) {
73
- const fullPath = path.join(dir, item.name);
74
- if (item.isDirectory()) {
75
- Object.assign(files, findMdxFilesMap(fullPath, baseDir));
76
- } else if (item.isFile() && item.name.endsWith('.mdx')) {
77
- // Key: relative path without .mdx, with forward slashes
78
- let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
79
- if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
80
- files[rel] = fullPath;
81
- }
82
- }
83
-
84
- return files;
85
- }
86
-
87
- // Function to clean MDX content for LLM consumption
16
+ /** Cleans MDX content for LLM consumption (keeps code-fence languages). */
88
17
  function cleanMdxContent(content: string): string {
89
- // Remove JSX components but keep their content
90
- let cleaned = content
18
+ return content
91
19
  // Remove JSX opening/closing tags but keep content
92
20
  .replace(/<(\w+)([^>]*?)>/g, '')
93
21
  .replace(/<\/\w+>/g, '')
94
- // Remove code block language specifiers
95
- .replace(/```(\w+)/g, '```')
96
22
  // Remove HTML comments
97
23
  .replace(/<!--[\s\S]*?-->/g, '')
98
24
  // Remove excessive whitespace
99
25
  .replace(/\n\s*\n\s*\n/g, '\n\n')
100
- // Remove leading/trailing whitespace
101
26
  .trim();
27
+ }
102
28
 
103
- return cleaned;
29
+ /** Resolves a navigation page key to its MDX file key, or null when missing. */
30
+ function resolveFileKey(navKey: string, mdxFilesMap: Record<string, string>): string | null {
31
+ if (mdxFilesMap[navKey]) return navKey;
32
+ if (mdxFilesMap[`${navKey}/index`]) return `${navKey}/index`;
33
+ return null;
104
34
  }
105
35
 
106
- // Function to generate LLM text content in dory.json order, then append any extra files
107
- function generateLlmContent(docsDir: string): string {
36
+ /**
37
+ * Generates a spec-shaped llms.txt (https://llmstxt.org): H1 site name,
38
+ * blockquote summary, then per-tab sections with link lists to the .mdx
39
+ * source files.
40
+ */
41
+ export function generateLlmsIndex(docsDir: string): string {
42
+ const config = readDoryConfig(docsDir) || {};
43
+ const siteName: string = config.name || 'Documentation';
44
+ const baseUrl: string = config.url || '';
45
+ const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
46
+
47
+ let output = `# ${siteName}\n`;
48
+ if (config.description) {
49
+ output += `\n> ${config.description}\n`;
50
+ }
51
+
52
+ const tabs: any[] = config.navigation?.tabs || [];
53
+ for (const tab of tabs) {
54
+ const links: string[] = [];
55
+ const walkPages = (pages: any[]) => {
56
+ for (const page of pages) {
57
+ if (typeof page === 'string') {
58
+ const fileKey = resolveFileKey(page, mdxFilesMap);
59
+ if (!fileKey) continue;
60
+ const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
61
+ const { data } = parseFrontmatter(raw);
62
+ const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
63
+ const url = `${baseUrl}${routePath}.mdx`;
64
+ const title = data.title || page;
65
+ links.push(data.description ? `- [${title}](${url}): ${data.description}` : `- [${title}](${url})`);
66
+ } else if (typeof page === 'object' && page.pages) {
67
+ walkPages(page.pages);
68
+ }
69
+ }
70
+ };
71
+ for (const group of tab.groups || []) {
72
+ if (Array.isArray(group.pages)) walkPages(group.pages);
73
+ }
74
+ if (links.length > 0) {
75
+ output += `\n## ${tab.tab || 'Documentation'}\n\n${links.join('\n')}\n`;
76
+ }
77
+ }
78
+
79
+ return output.trim() + '\n';
80
+ }
81
+
82
+ /**
83
+ * Generates the full concatenated documentation content (llms-full.txt),
84
+ * in dory.json navigation order with extra files appended.
85
+ */
86
+ export function generateLlmContent(docsDir: string): string {
108
87
  const navOrder = getNavigationOrder(docsDir);
109
88
  const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
110
89
  const usedKeys = new Set<string>();
111
90
  let aggregatedContent = '';
112
91
 
113
- // Output files in navigation order, in order as in dory.json
114
- for (const navKey of navOrder) {
115
- // Try both with and without "index"
116
- let fileKey = navKey;
117
- if (mdxFilesMap[fileKey]) {
118
- usedKeys.add(fileKey);
119
- } else if (mdxFilesMap[`${fileKey}/index`]) {
120
- fileKey = `${fileKey}/index`;
121
- usedKeys.add(fileKey);
122
- } else {
123
- continue;
124
- }
125
- const filePath = mdxFilesMap[fileKey];
126
- const rawContent = fs.readFileSync(filePath, 'utf-8');
92
+ const appendFile = (fileKey: string) => {
93
+ const rawContent = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
127
94
  const { data, content } = parseFrontmatter(rawContent);
128
- const cleanedContent = cleanMdxContent(content);
129
95
 
130
- // Add file header
131
96
  aggregatedContent += `\n# ${fileKey}.mdx\n`;
132
-
133
- // Add title if available
134
97
  if (data.title) {
135
98
  aggregatedContent += `## ${data.title}\n\n`;
136
99
  }
137
-
138
- // Add description if available
139
100
  if (data.description) {
140
101
  aggregatedContent += `${data.description}\n\n`;
141
102
  }
142
-
143
- // Add cleaned content
144
- aggregatedContent += cleanedContent + '\n\n';
103
+ aggregatedContent += cleanMdxContent(content) + '\n\n';
145
104
  aggregatedContent += '---\n\n';
105
+ };
106
+
107
+ // Output files in navigation order, in order as in dory.json
108
+ for (const navKey of navOrder) {
109
+ const fileKey = resolveFileKey(navKey, mdxFilesMap);
110
+ if (!fileKey) continue;
111
+ usedKeys.add(fileKey);
112
+ appendFile(fileKey);
146
113
  }
147
114
 
148
115
  // Output any extra files not in navigation order, sorted
149
116
  const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
150
117
  for (const fileKey of extraKeys) {
151
- const filePath = mdxFilesMap[fileKey];
152
- const rawContent = fs.readFileSync(filePath, 'utf-8');
153
- const { data, content } = parseFrontmatter(rawContent);
154
- const cleanedContent = cleanMdxContent(content);
155
-
156
- aggregatedContent += `\n# ${fileKey}.mdx\n`;
157
- if (data.title) {
158
- aggregatedContent += `## ${data.title}\n\n`;
159
- }
160
- if (data.description) {
161
- aggregatedContent += `${data.description}\n\n`;
162
- }
163
- aggregatedContent += cleanedContent + '\n\n';
164
- aggregatedContent += '---\n\n';
118
+ appendFile(fileKey);
165
119
  }
166
120
 
167
- // Remove leading newlines for the very first file, so the first file is at the very top
168
121
  return aggregatedContent.replace(/^\n+/, '').trim();
169
122
  }
170
123
 
124
+ /**
125
+ * Build plugin that emits llms.txt (spec-shaped index) and llms-full.txt
126
+ * (full concatenated documentation).
127
+ */
171
128
  export function llmTxtGenerator(options: LlmTxtGeneratorOptions = {}): Plugin {
172
129
  return {
173
130
  name: 'llm-txt-generator',
174
131
  generateBundle() {
175
132
  const docsDir = options.docsDir || path.join(process.cwd(), 'docs');
176
- const aggregatedContent = generateLlmContent(docsDir);
133
+ const indexContent = generateLlmsIndex(docsDir);
134
+ const fullContent = generateLlmContent(docsDir);
177
135
 
178
136
  this.emitFile({
179
137
  type: 'asset',
180
138
  fileName: 'llms.txt',
181
- source: aggregatedContent
139
+ source: indexContent
182
140
  });
183
141
 
184
- // For logging, count the number of MDX files processed
185
- const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
186
- const mdxFilesCount = Object.keys(mdxFilesMap).length;
142
+ this.emitFile({
143
+ type: 'asset',
144
+ fileName: 'llms-full.txt',
145
+ source: fullContent
146
+ });
187
147
 
188
- console.log(`✅ Generated LLM text file`);
148
+ const mdxFilesCount = Object.keys(findMdxFilesMap(docsDir, docsDir)).length;
149
+ console.log(`✅ Generated llms.txt and llms-full.txt`);
189
150
  console.log(`📄 Processed ${mdxFilesCount} MDX files`);
190
- console.log(`📝 Total content size: ${aggregatedContent.length} characters`);
151
+ console.log(`📝 Full content size: ${fullContent.length} characters`);
191
152
  }
192
153
  };
193
- }
154
+ }
@@ -1,11 +1,20 @@
1
1
  import type { Plugin, ResolvedConfig } from 'vite';
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
3
  import { resolve } from 'path';
4
+ import {
5
+ readDoryConfig,
6
+ findMdxFilesMap,
7
+ pathFromFilename,
8
+ parseFrontmatter,
9
+ stripMdxToText,
10
+ escapeHtml,
11
+ applyTitleTemplate,
12
+ } from './shared';
4
13
 
5
14
  /**
6
15
  * Generates per-route HTML files with page-specific meta tags and JSON-LD
7
16
  * at build time. The SSR step (render-routes.mjs) later injects rendered
8
- * content into these files.
17
+ * content into these files. Also emits a prerendered 404.html.
9
18
  */
10
19
  export function prerender(): Plugin {
11
20
  let config: ResolvedConfig;
@@ -30,83 +39,140 @@ export function prerender(): Plugin {
30
39
  const frontmatter: Array<Record<string, any>> = JSON.parse(readFileSync(frontmatterPath, 'utf-8'));
31
40
  const baseHtml = readFileSync(indexHtmlPath, 'utf-8');
32
41
 
33
- // Read dory.json for site-level config
34
- const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
35
- let siteConfig: Record<string, any> = {};
36
- if (existsSync(doryJsonPath)) {
37
- siteConfig = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
42
+ const siteConfig = readDoryConfig() || {};
43
+ const seo = siteConfig.seo || {};
44
+ const baseUrl: string = siteConfig.url || '';
45
+ const siteName: string = siteConfig.name || 'Documentation';
46
+
47
+ if (!baseUrl) {
48
+ console.warn(
49
+ '⚠️ prerender: `url` is not set in dory.json — canonical and og:url tags will be omitted from all pages. Set `url` for proper SEO.'
50
+ );
38
51
  }
39
52
 
40
- const baseUrl = siteConfig.url || '';
41
- const siteName = siteConfig.name || 'Documentation';
53
+ // Resolve seo.ogImage to an absolute URL when possible
54
+ const rawOgImage: string = seo.ogImage || '';
55
+ const ogImage = rawOgImage && baseUrl && !rawOgImage.startsWith('http')
56
+ ? `${baseUrl}${rawOgImage.startsWith('/') ? '' : '/'}${rawOgImage}`
57
+ : rawOgImage;
58
+ const hasImage = Boolean(ogImage || siteConfig.image);
59
+
60
+ // Map route paths to MDX source files for description fallbacks
61
+ const docsDir = resolve(process.cwd(), 'docs');
62
+ const mdxFilesMap = existsSync(docsDir) ? findMdxFilesMap(docsDir, docsDir) : {};
63
+ const fileByRoute: Record<string, string> = {};
64
+ for (const filePath of Object.values(mdxFilesMap)) {
65
+ fileByRoute[pathFromFilename(filePath, docsDir)] = filePath;
66
+ }
67
+
68
+ const missingDescriptions: string[] = [];
42
69
  let count = 0;
43
70
 
44
71
  for (const page of frontmatter) {
45
72
  const routePath = page.path;
46
73
  if (!routePath || routePath === '/') continue;
47
74
 
48
- const title = page.title || siteName;
49
- const description = page.description || `${siteName} - ${title}`;
50
- const fullUrl = `${baseUrl}${routePath}`;
75
+ const title = applyTitleTemplate(page.title || siteName, seo.titleTemplate, siteName);
76
+ const description = page.description || fallbackDescription(fileByRoute[routePath]);
77
+ if (!page.description) {
78
+ missingDescriptions.push(routePath);
79
+ }
80
+ const fullUrl = baseUrl ? `${baseUrl}${routePath}` : '';
51
81
 
52
82
  let html = baseHtml;
83
+ // Function replacements so `$&` etc. in values cannot corrupt output
84
+ const setTag = (re: RegExp, replacement: string) => {
85
+ html = html.replace(re, () => replacement);
86
+ };
87
+ const injectHeadTag = (tag: string) => {
88
+ html = html.replace('</head>', () => ` ${tag}\n </head>`);
89
+ };
53
90
 
54
- // Replace title
55
- html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(title)}</title>`);
56
-
57
- // Replace meta description
58
- html = html.replace(
91
+ setTag(/<title>.*?<\/title>/, `<title>${escapeHtml(title)}</title>`);
92
+ setTag(
59
93
  /<meta name="description" content=".*?" \/>/,
60
- `<meta name="description" content="${escapeAttr(description)}" />`
94
+ `<meta name="description" content="${escapeHtml(description)}" />`
61
95
  );
62
96
 
63
- // Replace OG tags
64
- html = html.replace(
97
+ // Open Graph tags
98
+ setTag(
65
99
  /<meta property="og:title" content=".*?" \/>/,
66
- `<meta property="og:title" content="${escapeAttr(title)}" />`
100
+ `<meta property="og:title" content="${escapeHtml(title)}" />`
67
101
  );
68
- html = html.replace(
102
+ setTag(
69
103
  /<meta property="og:description" content=".*?" \/>/,
70
- `<meta property="og:description" content="${escapeAttr(description)}" />`
104
+ `<meta property="og:description" content="${escapeHtml(description)}" />`
71
105
  );
72
- html = html.replace(
73
- /<meta property="og:url" content=".*?" \/>/,
74
- `<meta property="og:url" content="${escapeAttr(fullUrl)}" />`
106
+ setTag(
107
+ /<meta property="og:type" content=".*?" \/>/,
108
+ '<meta property="og:type" content="article" />'
75
109
  );
110
+ if (ogImage) {
111
+ setTag(
112
+ /<meta property="og:image" content=".*?" \/>/,
113
+ `<meta property="og:image" content="${escapeHtml(ogImage)}" />`
114
+ );
115
+ setTag(
116
+ /<meta name="twitter:image" content=".*?" \/>/,
117
+ `<meta name="twitter:image" content="${escapeHtml(ogImage)}" />`
118
+ );
119
+ }
120
+ if (!hasImage) {
121
+ // No shareable image — a large-image card would render broken
122
+ setTag(
123
+ /<meta name="twitter:card" content=".*?" \/>/,
124
+ '<meta name="twitter:card" content="summary" />'
125
+ );
126
+ }
76
127
 
77
- // Replace Twitter tags
78
- html = html.replace(
128
+ // Twitter tags
129
+ setTag(
79
130
  /<meta name="twitter:title" content=".*?" \/>/,
80
- `<meta name="twitter:title" content="${escapeAttr(title)}" />`
131
+ `<meta name="twitter:title" content="${escapeHtml(title)}" />`
81
132
  );
82
- html = html.replace(
133
+ setTag(
83
134
  /<meta name="twitter:description" content=".*?" \/>/,
84
- `<meta name="twitter:description" content="${escapeAttr(description)}" />`
135
+ `<meta name="twitter:description" content="${escapeHtml(description)}" />`
85
136
  );
86
137
 
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
- );
138
+ // Canonical + og:url — only when config.url is set. When it is not,
139
+ // the metadata injector already removed these tags from the base HTML.
140
+ if (fullUrl) {
141
+ setTag(
142
+ /<meta property="og:url" content=".*?" \/>/,
143
+ `<meta property="og:url" content="${escapeHtml(fullUrl)}" />`
144
+ );
145
+ setTag(
146
+ /<link rel="canonical" href=".*?" \/>/,
147
+ `<link rel="canonical" href="${escapeHtml(fullUrl)}" />`
148
+ );
149
+ }
92
150
 
93
- // Inject per-page robots directive if specified in frontmatter
151
+ // Per-page robots directive from frontmatter
94
152
  if (page.robots) {
95
- html = html.replace(
96
- '</head>',
97
- ` <meta name="robots" content="${escapeAttr(page.robots)}" />\n </head>`
98
- );
153
+ injectHeadTag(`<meta name="robots" content="${escapeHtml(String(page.robots))}" />`);
99
154
  }
100
155
 
101
- // Inject JSON-LD structured data
156
+ // JSON-LD structured data
157
+ const dateModified = toIsoDate(page.lastUpdated);
102
158
  const jsonLd = JSON.stringify({
103
159
  '@context': 'https://schema.org',
104
160
  '@graph': [
161
+ {
162
+ '@type': 'WebSite',
163
+ 'name': siteName,
164
+ ...(baseUrl ? { '@id': `${baseUrl}/#website`, 'url': baseUrl } : {}),
165
+ },
105
166
  {
106
167
  '@type': 'Article',
107
168
  'headline': title,
108
169
  'description': description,
109
170
  ...(fullUrl ? { 'url': fullUrl } : {}),
171
+ ...(dateModified ? { 'dateModified': dateModified } : {}),
172
+ 'publisher': { '@type': 'Organization', 'name': siteName },
173
+ 'isPartOf': baseUrl
174
+ ? { '@id': `${baseUrl}/#website` }
175
+ : { '@type': 'WebSite', 'name': siteName },
110
176
  },
111
177
  {
112
178
  '@type': 'BreadcrumbList',
@@ -126,12 +192,9 @@ export function prerender(): Plugin {
126
192
  ]
127
193
  }
128
194
  ]
129
- });
195
+ }).replace(/</g, '\\u003c');
130
196
 
131
- html = html.replace(
132
- '</head>',
133
- ` <script type="application/ld+json">${jsonLd}</script>\n </head>`
134
- );
197
+ injectHeadTag(`<script type="application/ld+json">${jsonLd}</script>`);
135
198
 
136
199
  // Write to route directory
137
200
  const routeDir = resolve(outDir, routePath.replace(/^\//, ''));
@@ -140,6 +203,14 @@ export function prerender(): Plugin {
140
203
  count++;
141
204
  }
142
205
 
206
+ if (missingDescriptions.length > 0) {
207
+ console.warn(
208
+ `⚠️ prerender: ${missingDescriptions.length} page(s) missing a frontmatter description (using page text fallback): ${missingDescriptions.join(', ')}`
209
+ );
210
+ }
211
+
212
+ write404(outDir, baseHtml, siteName, frontmatter);
213
+
143
214
  console.log(`✅ Prerendered ${count} routes with page-specific meta tags`);
144
215
  } catch (error) {
145
216
  console.error('prerender failed:', error);
@@ -148,10 +219,45 @@ export function prerender(): Plugin {
148
219
  };
149
220
  }
150
221
 
151
- function escapeHtml(str: string): string {
152
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
222
+ /** Builds a ~155 char description from the page's stripped MDX text. */
223
+ function fallbackDescription(mdxFilePath: string | undefined): string {
224
+ if (!mdxFilePath || !existsSync(mdxFilePath)) return '';
225
+ const { content } = parseFrontmatter(readFileSync(mdxFilePath, 'utf-8'));
226
+ const text = stripMdxToText(content).replace(/\s+/g, ' ').trim();
227
+ if (text.length <= 155) return text;
228
+ return `${text.slice(0, 155).replace(/\s+\S*$/, '')}…`;
153
229
  }
154
230
 
155
- function escapeAttr(str: string): string {
156
- return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
231
+ /** Normalizes a frontmatter lastUpdated value (string or Date) to an ISO date. */
232
+ function toIsoDate(value: unknown): string {
233
+ if (!value) return '';
234
+ const date = new Date(value as string | number | Date);
235
+ if (isNaN(date.getTime())) return '';
236
+ return date.toISOString().split('T')[0];
237
+ }
238
+
239
+ /** Emits a prerendered 404.html with noindex and a link to the first nav page. */
240
+ function write404(
241
+ outDir: string,
242
+ baseHtml: string,
243
+ siteName: string,
244
+ frontmatter: Array<Record<string, any>>
245
+ ): void {
246
+ const firstPage = frontmatter.find(page => page.path && page.path !== '/');
247
+ const firstHref: string = firstPage?.path || '/';
248
+ const firstTitle: string = firstPage?.title || siteName;
249
+
250
+ let html = baseHtml;
251
+ html = html.replace(/<title>.*?<\/title>/, () => `<title>Page not found — ${escapeHtml(siteName)}</title>`);
252
+ html = html.replace('</head>', () => ' <meta name="robots" content="noindex" />\n </head>');
253
+ html = html.replace(
254
+ '<div id="app"></div>',
255
+ () =>
256
+ '<div id="app"><main style="max-width:36rem;margin:6rem auto;padding:0 1.5rem;font-family:sans-serif;text-align:center">' +
257
+ '<h1>Page not found</h1>' +
258
+ '<p>The page you are looking for does not exist.</p>' +
259
+ `<p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p>` +
260
+ '</main></div>'
261
+ );
262
+ writeFileSync(resolve(outDir, '404.html'), html, 'utf-8');
157
263
  }
@@ -1,34 +1,33 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync } from 'fs';
3
- import { resolve } from 'path';
2
+ import { readDoryConfig } from './shared';
4
3
 
5
4
  /**
6
- * Generates robots.txt with a sitemap reference at build time.
5
+ * Generates robots.txt at build time. Emits `Disallow: /` when seo.noindex
6
+ * is set, appends any seo.robotsTxt lines, and references the sitemap when
7
+ * `url` is configured.
7
8
  */
8
9
  export function robotsGenerator(): Plugin {
9
10
  return {
10
11
  name: 'robots-generator',
11
12
  generateBundle() {
12
- const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
13
+ const config = readDoryConfig() || {};
14
+ const seo = config.seo || {};
13
15
 
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
- }
16
+ const lines = ['User-agent: *'];
17
+ lines.push(seo.noindex ? 'Disallow: /' : 'Allow: /');
18
+
19
+ if (Array.isArray(seo.robotsTxt) && seo.robotsTxt.length > 0) {
20
+ lines.push('', ...seo.robotsTxt);
24
21
  }
25
22
 
26
- const robots = `User-agent: *\nAllow: /\n${sitemapLine}`;
23
+ if (config.url && !seo.noindex) {
24
+ lines.push('', `Sitemap: ${config.url}/sitemap.xml`);
25
+ }
27
26
 
28
27
  this.emitFile({
29
28
  type: 'asset',
30
29
  fileName: 'robots.txt',
31
- source: robots
30
+ source: lines.join('\n') + '\n'
32
31
  });
33
32
 
34
33
  console.log('✅ Generated robots.txt');