@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
@@ -1,6 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync } from 'fs';
3
- import { resolve } from 'path';
2
+ import { readDoryConfig, escapeHtml, applyTitleTemplate } from './shared';
4
3
 
5
4
  /**
6
5
  * Vite plugin that injects site metadata from dory.json into index.html.
@@ -10,100 +9,161 @@ export function htmlMetadataInjector(): Plugin {
10
9
  return {
11
10
  name: 'html-metadata-injector',
12
11
  transformIndexHtml(html) {
13
- const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
14
-
15
- if (!existsSync(doryJsonPath)) {
12
+ const config = readDoryConfig();
13
+ if (!config) {
16
14
  console.warn('⚠️ dory.json not found, using default metadata');
17
15
  return html;
18
16
  }
19
17
 
20
18
  try {
21
- const config = JSON.parse(readFileSync(doryJsonPath, 'utf-8'));
22
- const siteName = config.name || 'Documentation';
23
- const defaultTitle = config.title || siteName;
24
- const defaultDescription = config.description || `${siteName} - Technical Documentation`;
25
- const siteUrl = config.url || '';
26
- const twitterHandle = config.twitter || '';
19
+ const seo = config.seo || {};
20
+ const siteName: string = config.name || 'Documentation';
21
+ const defaultTitle = applyTitleTemplate(config.title || siteName, seo.titleTemplate, siteName);
22
+ const defaultDescription: string = config.description || `${siteName} - Technical Documentation`;
23
+ const siteUrl: string = config.url || '';
24
+ const twitterHandle: string = config.twitter || '';
27
25
 
28
26
  // Resolve image to absolute URL
29
- const rawImage = config.image || '';
27
+ const rawImage: string = config.image || '';
30
28
  const siteImage = rawImage && siteUrl && !rawImage.startsWith('http')
31
29
  ? `${siteUrl}${rawImage.startsWith('/') ? '' : '/'}${rawImage}`
32
30
  : rawImage;
33
31
 
34
- // Replace title
35
- html = html.replace(
36
- /<title>.*?<\/title>/,
37
- `<title>${defaultTitle}</title>`
38
- );
32
+ // Favicon: config.favicon (copied to dist root by docs-assets), else
33
+ // fall back to the logo so sites without a dedicated favicon still
34
+ // get an icon instead of a broken link.
35
+ const rawFavicon: string = config.favicon || config.logo?.light || config.logo?.dark || '';
36
+ const faviconHref = rawFavicon ? `/${rawFavicon.replace(/^\.?\/*/, '')}` : '';
37
+
38
+ // Function replacements everywhere so `$&` etc. in config values
39
+ // cannot corrupt the output.
40
+ const setTag = (re: RegExp, replacement: string) => {
41
+ html = html.replace(re, () => replacement);
42
+ };
43
+ const removeTag = (re: RegExp) => {
44
+ html = html.replace(re, '');
45
+ };
46
+ const injectHeadTag = (tag: string) => {
47
+ html = html.replace('</head>', () => ` ${tag}\n </head>`);
48
+ };
49
+
50
+ // Title
51
+ setTag(/<title>.*?<\/title>/, `<title>${escapeHtml(defaultTitle)}</title>`);
52
+
53
+ // Favicon (icon + apple-touch-icon); left as empty attributes in
54
+ // index.html and removed entirely when there's nothing to point at.
55
+ if (faviconHref) {
56
+ setTag(/<link rel="icon" href=".*?" \/>/, `<link rel="icon" href="${escapeHtml(faviconHref)}" />`);
57
+ setTag(
58
+ /<link rel="apple-touch-icon" href=".*?" \/>/,
59
+ `<link rel="apple-touch-icon" href="${escapeHtml(faviconHref)}" />`
60
+ );
61
+ } else {
62
+ removeTag(/[ \t]*<link rel="icon" href=".*?" \/>\n?/);
63
+ removeTag(/[ \t]*<link rel="apple-touch-icon" href=".*?" \/>\n?/);
64
+ }
39
65
 
40
- // Replace meta description
41
- html = html.replace(
66
+ // Description
67
+ setTag(
42
68
  /<meta name="description" content=".*?" \/>/,
43
- `<meta name="description" content="${defaultDescription}" />`
69
+ `<meta name="description" content="${escapeHtml(defaultDescription)}" />`
44
70
  );
45
71
 
46
- // Replace Open Graph tags
47
- html = html.replace(
72
+ // Keywords / author — injected only when configured
73
+ if (Array.isArray(seo.keywords) && seo.keywords.length > 0) {
74
+ injectHeadTag(`<meta name="keywords" content="${escapeHtml(seo.keywords.join(', '))}" />`);
75
+ }
76
+ if (seo.author) {
77
+ injectHeadTag(`<meta name="author" content="${escapeHtml(seo.author)}" />`);
78
+ }
79
+
80
+ // Site-wide noindex (staging)
81
+ if (seo.noindex) {
82
+ injectHeadTag('<meta name="robots" content="noindex" />');
83
+ }
84
+
85
+ // Locale — html lang + og:locale
86
+ if (seo.locale) {
87
+ setTag(/<html lang=".*?">/, `<html lang="${escapeHtml(seo.locale.replace(/_/g, '-'))}">`);
88
+ setTag(
89
+ /<meta property="og:locale" content=".*?" \/>/,
90
+ `<meta property="og:locale" content="${escapeHtml(seo.locale)}" />`
91
+ );
92
+ }
93
+
94
+ // Open Graph tags
95
+ setTag(
48
96
  /<meta property="og:title" content=".*?" \/>/,
49
- `<meta property="og:title" content="${defaultTitle}" />`
97
+ `<meta property="og:title" content="${escapeHtml(defaultTitle)}" />`
50
98
  );
51
- html = html.replace(
99
+ setTag(
52
100
  /<meta property="og:description" content=".*?" \/>/,
53
- `<meta property="og:description" content="${defaultDescription}" />`
101
+ `<meta property="og:description" content="${escapeHtml(defaultDescription)}" />`
54
102
  );
55
- html = html.replace(
103
+ setTag(
56
104
  /<meta property="og:site_name" content=".*?" \/>/,
57
- `<meta property="og:site_name" content="${siteName}" />`
105
+ `<meta property="og:site_name" content="${escapeHtml(siteName)}" />`
58
106
  );
59
107
  if (siteImage) {
60
- html = html.replace(
108
+ setTag(
61
109
  /<meta property="og:image" content=".*?" \/>/,
62
- `<meta property="og:image" content="${siteImage}" />`
110
+ `<meta property="og:image" content="${escapeHtml(siteImage)}" />`
63
111
  );
64
112
  }
113
+
114
+ // og:url and canonical — absolute when config.url is set, removed otherwise
65
115
  if (siteUrl) {
66
- html = html.replace(
116
+ setTag(
67
117
  /<meta property="og:url" content=".*?" \/>/,
68
- `<meta property="og:url" content="${siteUrl}" />`
118
+ `<meta property="og:url" content="${escapeHtml(siteUrl)}" />`
69
119
  );
120
+ setTag(
121
+ /<link rel="canonical" href=".*?" \/>/,
122
+ `<link rel="canonical" href="${escapeHtml(siteUrl)}" />`
123
+ );
124
+ } else {
125
+ removeTag(/[ \t]*<meta property="og:url" content=".*?" \/>\n?/);
126
+ removeTag(/[ \t]*<link rel="canonical" href=".*?" \/>\n?/);
70
127
  }
71
128
 
72
- // Replace Twitter tags
73
- html = html.replace(
129
+ // Twitter tags
130
+ if (seo.twitterCard) {
131
+ setTag(
132
+ /<meta name="twitter:card" content=".*?" \/>/,
133
+ `<meta name="twitter:card" content="${escapeHtml(seo.twitterCard)}" />`
134
+ );
135
+ }
136
+ setTag(
74
137
  /<meta name="twitter:title" content=".*?" \/>/,
75
- `<meta name="twitter:title" content="${defaultTitle}" />`
138
+ `<meta name="twitter:title" content="${escapeHtml(defaultTitle)}" />`
76
139
  );
77
- html = html.replace(
140
+ setTag(
78
141
  /<meta name="twitter:description" content=".*?" \/>/,
79
- `<meta name="twitter:description" content="${defaultDescription}" />`
142
+ `<meta name="twitter:description" content="${escapeHtml(defaultDescription)}" />`
80
143
  );
81
144
  if (siteImage) {
82
- html = html.replace(
145
+ setTag(
83
146
  /<meta name="twitter:image" content=".*?" \/>/,
84
- `<meta name="twitter:image" content="${siteImage}" />`
147
+ `<meta name="twitter:image" content="${escapeHtml(siteImage)}" />`
85
148
  );
86
149
  }
87
- html = html.replace(
88
- /<meta name="twitter:site" content=".*?" \/>/,
89
- `<meta name="twitter:site" content="${twitterHandle}" />`
90
- );
91
- html = html.replace(
92
- /<meta name="twitter:creator" content=".*?" \/>/,
93
- `<meta name="twitter:creator" content="${twitterHandle}" />`
94
- );
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}" />`
150
+ if (twitterHandle) {
151
+ setTag(
152
+ /<meta name="twitter:site" content=".*?" \/>/,
153
+ `<meta name="twitter:site" content="${escapeHtml(twitterHandle)}" />`
154
+ );
155
+ setTag(
156
+ /<meta name="twitter:creator" content=".*?" \/>/,
157
+ `<meta name="twitter:creator" content="${escapeHtml(twitterHandle)}" />`
101
158
  );
159
+ } else {
160
+ removeTag(/[ \t]*<meta name="twitter:site" content=".*?" \/>\n?/);
161
+ removeTag(/[ \t]*<meta name="twitter:creator" content=".*?" \/>\n?/);
102
162
  }
103
163
 
104
164
  return html;
105
165
  } catch (error) {
106
- console.error('❌ Failed to parse dory.json for metadata injection:', error);
166
+ console.error('❌ Failed to inject metadata from dory.json:', error);
107
167
  return html;
108
168
  }
109
169
  },
@@ -1,190 +1,37 @@
1
1
  import type { Plugin } from 'vite';
2
- import fs from 'fs';
3
2
  import path from 'path';
3
+ import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator';
4
4
 
5
- // Read dory.json to get navigation order (flat list, in order)
6
- function getNavigationOrder(docsDir: string): string[] {
7
- const doryJsonPath = path.join(docsDir, 'dory.json');
8
- if (!fs.existsSync(doryJsonPath)) return [];
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
- }
17
- const order: string[] = [];
18
-
19
- function walkPages(pages: any[]) {
20
- for (const page of pages) {
21
- if (typeof page === 'string') {
22
- order.push(page);
23
- } else if (typeof page === 'object' && page.pages) {
24
- walkPages(page.pages);
5
+ /**
6
+ * Dev server plugin that serves llms.txt and llms-full.txt on demand,
7
+ * generated fresh per request from the docs directory.
8
+ */
9
+ export function llmTxtDevServer(): Plugin {
10
+ const serveText = (generate: (docsDir: string) => string) =>
11
+ (req: any, res: any, next: () => void) => {
12
+ if (req.method !== 'GET') {
13
+ return next();
25
14
  }
26
- }
27
- }
28
15
 
29
- if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
30
- for (const tab of doryJson.navigation.tabs) {
31
- if (tab.groups && Array.isArray(tab.groups)) {
32
- for (const group of tab.groups) {
33
- if (group.pages && Array.isArray(group.pages)) {
34
- walkPages(group.pages);
35
- }
36
- }
16
+ try {
17
+ const docsDir = path.join(process.cwd(), 'docs');
18
+ const content = generate(docsDir);
19
+
20
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
21
+ res.setHeader('Content-Length', Buffer.byteLength(content));
22
+ res.end(content);
23
+ } catch (error) {
24
+ console.error('Error generating LLM text:', error);
25
+ res.statusCode = 500;
26
+ res.end('Internal Server Error');
37
27
  }
38
- }
39
- }
40
- return order;
41
- }
42
-
43
- // Function to parse frontmatter from MDX content
44
- function parseFrontmatter(content: string) {
45
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
46
- if (!frontmatterMatch) return { data: {}, content };
47
-
48
- const frontmatter = frontmatterMatch[1];
49
- const data: Record<string, string> = {};
50
-
51
- frontmatter.split('\n').forEach(line => {
52
- const [key, ...valueParts] = line.split(':');
53
- if (key && valueParts.length) {
54
- const value = valueParts.join(':').trim();
55
- data[key.trim()] = value.replace(/^["']|["']$/g, '');
56
- }
57
- });
58
-
59
- return { data, content: content.slice(frontmatterMatch[0].length) };
60
- }
61
-
62
- // Function to recursively find all MDX files and map by relative path (without .mdx)
63
- function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
64
- const files: Record<string, string> = {};
65
- const items = fs.readdirSync(dir, { withFileTypes: true });
66
-
67
- for (const item of items) {
68
- const fullPath = path.join(dir, item.name);
69
- if (item.isDirectory()) {
70
- Object.assign(files, findMdxFilesMap(fullPath, baseDir));
71
- } else if (item.isFile() && item.name.endsWith('.mdx')) {
72
- // Key: relative path without .mdx, with forward slashes
73
- let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
74
- if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
75
- files[rel] = fullPath;
76
- }
77
- }
78
-
79
- return files;
80
- }
81
-
82
- // Function to clean MDX content for LLM consumption
83
- function cleanMdxContent(content: string): string {
84
- // Remove JSX components but keep their content
85
- let cleaned = content
86
- // Remove JSX opening/closing tags but keep content
87
- .replace(/<(\w+)([^>]*?)>/g, '')
88
- .replace(/<\/\w+>/g, '')
89
- // Remove code block language specifiers
90
- .replace(/```(\w+)/g, '```')
91
- // Remove HTML comments
92
- .replace(/<!--[\s\S]*?-->/g, '')
93
- // Remove excessive whitespace
94
- .replace(/\n\s*\n\s*\n/g, '\n\n')
95
- // Remove leading/trailing whitespace
96
- .trim();
97
-
98
- return cleaned;
99
- }
28
+ };
100
29
 
101
- // Function to generate LLM text content in dory.json order, then append any extra files
102
- function generateLlmContent(docsDir: string): string {
103
- const navOrder = getNavigationOrder(docsDir);
104
- const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
105
- const usedKeys = new Set<string>();
106
- let aggregatedContent = '';
107
-
108
- // Output files in navigation order, in order as in dory.json
109
- for (const navKey of navOrder) {
110
- // Try both with and without "index"
111
- let fileKey = navKey;
112
- if (mdxFilesMap[fileKey]) {
113
- usedKeys.add(fileKey);
114
- } else if (mdxFilesMap[`${fileKey}/index`]) {
115
- fileKey = `${fileKey}/index`;
116
- usedKeys.add(fileKey);
117
- } else {
118
- continue;
119
- }
120
- const filePath = mdxFilesMap[fileKey];
121
- const rawContent = fs.readFileSync(filePath, 'utf-8');
122
- const { data, content } = parseFrontmatter(rawContent);
123
- const cleanedContent = cleanMdxContent(content);
124
-
125
- // Add file header
126
- aggregatedContent += `\n# ${fileKey}.mdx\n`;
127
-
128
- // Add title if available
129
- if (data.title) {
130
- aggregatedContent += `## ${data.title}\n\n`;
131
- }
132
-
133
- // Add description if available
134
- if (data.description) {
135
- aggregatedContent += `${data.description}\n\n`;
136
- }
137
-
138
- // Add cleaned content
139
- aggregatedContent += cleanedContent + '\n\n';
140
- aggregatedContent += '---\n\n';
141
- }
142
-
143
- // Output any extra files not in navigation order, sorted
144
- const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
145
- for (const fileKey of extraKeys) {
146
- const filePath = mdxFilesMap[fileKey];
147
- const rawContent = fs.readFileSync(filePath, 'utf-8');
148
- const { data, content } = parseFrontmatter(rawContent);
149
- const cleanedContent = cleanMdxContent(content);
150
-
151
- aggregatedContent += `\n# ${fileKey}.mdx\n`;
152
- if (data.title) {
153
- aggregatedContent += `## ${data.title}\n\n`;
154
- }
155
- if (data.description) {
156
- aggregatedContent += `${data.description}\n\n`;
157
- }
158
- aggregatedContent += cleanedContent + '\n\n';
159
- aggregatedContent += '---\n\n';
160
- }
161
-
162
- // Remove leading newlines for the very first file, so the first file is at the very top
163
- return aggregatedContent.replace(/^\n+/, '').trim();
164
- }
165
-
166
- export function llmTxtDevServer(): Plugin {
167
30
  return {
168
31
  name: 'llm-txt-dev-server',
169
32
  configureServer(server) {
170
- server.middlewares.use('/llms.txt', (req, res, next) => {
171
- if (req.method !== 'GET') {
172
- return next();
173
- }
174
-
175
- try {
176
- const docsDir = path.join(process.cwd(), 'docs');
177
- const llmContent = generateLlmContent(docsDir);
178
-
179
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
180
- res.setHeader('Content-Length', Buffer.byteLength(llmContent));
181
- res.end(llmContent);
182
- } catch (error) {
183
- console.error('Error generating LLM text:', error);
184
- res.statusCode = 500;
185
- res.end('Internal Server Error');
186
- }
187
- });
33
+ server.middlewares.use('/llms.txt', serveText(generateLlmsIndex));
34
+ server.middlewares.use('/llms-full.txt', serveText(generateLlmContent));
188
35
  }
189
36
  };
190
- }
37
+ }