@clidey/dory 0.46.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 (52) 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 +55 -15
  6. package/src/components/routes.tsx +38 -93
  7. package/src/components/search.tsx +1 -0
  8. package/src/components/store.ts +64 -88
  9. package/src/config/mdx.js +7 -3
  10. package/src/config/mdx.ts +6 -2
  11. package/src/entry-server.tsx +189 -66
  12. package/src/main.tsx +27 -5
  13. package/src/mdx/api-playground.tsx +2 -0
  14. package/src/mdx/code.tsx +8 -5
  15. package/src/mdx/fence.tsx +10 -2
  16. package/src/mdx/icon.tsx +21 -14
  17. package/src/mdx/latex.tsx +5 -0
  18. package/src/mdx/mdx.tsx +19 -11
  19. package/src/mdx/mermaid.tsx +10 -1
  20. package/src/mdx/processor.test.ts +47 -0
  21. package/src/mdx/step.tsx +3 -3
  22. package/src/mdx/websocket-playground.tsx +2 -0
  23. package/src/plugins/analytics-injector.ts +7 -5
  24. package/src/plugins/docs-assets.ts +5 -4
  25. package/src/plugins/frontmatter-dev-server.ts +23 -137
  26. package/src/plugins/frontmatter-generator.ts +64 -111
  27. package/src/plugins/html-files-middleware.test.ts +21 -0
  28. package/src/plugins/html-files-middleware.ts +27 -5
  29. package/src/plugins/html-metadata-injector.ts +94 -53
  30. package/src/plugins/llm-txt-dev-server.ts +25 -178
  31. package/src/plugins/llm-txt-generator.ts +94 -133
  32. package/src/plugins/prerender.ts +156 -50
  33. package/src/plugins/robots-generator.ts +15 -16
  34. package/src/plugins/sanitize.test.ts +104 -0
  35. package/src/plugins/sanitize.ts +91 -60
  36. package/src/plugins/shared.ts +139 -0
  37. package/src/plugins/sitemap-generator.ts +58 -44
  38. package/src/theme.scss +138 -15
  39. package/src/types/config.ts +42 -2
  40. package/src/types/globals.d.ts +7 -0
  41. package/src/ui/header.tsx +12 -4
  42. package/src/ui/layout.tsx +72 -52
  43. package/src/ui/table-of-content.tsx +19 -2
  44. package/src/utils/colors.ts +38 -18
  45. package/src/utils/fonts.ts +55 -12
  46. package/src/utils/theme.ts +29 -0
  47. package/vite.config.embed-loader.ts +3 -11
  48. package/vite.config.embed-widget.ts +3 -10
  49. package/vite.config.ssr.ts +2 -1
  50. package/vite.config.ts +13 -8
  51. package/scripts/generate-frontmatter-json.js +0 -154
  52. package/scripts/generate-llm-txt.js +0 -115
@@ -37,8 +37,9 @@ export function docsAssetsPlugin(): Plugin {
37
37
  const shouldSkip = (name: string) => {
38
38
  if (skipPatterns.has(name)) return true;
39
39
  const ext = extname(name).toLowerCase();
40
- // Skip MDX and JSON files (they're processed by Vite)
41
- if (ext === '.mdx' || ext === '.md' || ext === '.json') return true;
40
+ // Skip Markdown and JSON files (they're processed by Vite).
41
+ // MDX sources ARE copied so /route.mdx URLs work (Open MDX / LLM prompts).
42
+ if (ext === '.md' || ext === '.json') return true;
42
43
  return false;
43
44
  };
44
45
 
@@ -60,8 +61,8 @@ export function docsAssetsPlugin(): Plugin {
60
61
  copyAssets(srcPath, destPath, itemRelativePath);
61
62
  } else if (stats.isFile()) {
62
63
  const ext = extname(item).toLowerCase();
63
- // Copy if it's an asset file
64
- if (assetExtensions.has(ext)) {
64
+ // Copy asset files, plus source .mdx files at their route paths
65
+ if (assetExtensions.has(ext) || ext === '.mdx') {
65
66
  cpSync(srcPath, destPath, { force: true });
66
67
  console.log(`[docs-assets] Copied ${itemRelativePath}`);
67
68
  }
@@ -1,146 +1,32 @@
1
1
  import type { Plugin } from 'vite';
2
- import fs from 'fs';
3
2
  import path from 'path';
3
+ import { generateFrontmatterJson, generateSearchContentJson } from './frontmatter-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);
25
- }
26
- }
27
- }
28
-
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
- }
37
- }
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---/);
46
- if (!frontmatterMatch) return { data: {}, content };
47
-
48
- const frontmatter = frontmatterMatch[1];
49
- const data: Record<string, any> = {};
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
- // Convert filename to path format used by the store
63
- function pathFromFilename(filename: string, docsDir: string): string {
64
- return filename
65
- .replace(docsDir, '')
66
- .replace(/\/?index\.mdx$/, '/')
67
- .replace(/\.mdx$/, '')
68
- .replace(/\\/g, '/')
69
- .toLowerCase();
70
- }
71
-
72
- // Function to recursively find all MDX files and map by relative path (without .mdx)
73
- function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
74
- const files: Record<string, string> = {};
75
- const items = fs.readdirSync(dir, { withFileTypes: true });
76
-
77
- for (const item of items) {
78
- const fullPath = path.join(dir, item.name);
79
- if (item.isDirectory()) {
80
- Object.assign(files, findMdxFilesMap(fullPath, baseDir));
81
- } else if (item.isFile() && item.name.endsWith('.mdx')) {
82
- // Key: relative path without .mdx, with forward slashes
83
- let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
84
- if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
85
- files[rel] = fullPath;
86
- }
87
- }
88
-
89
- return files;
90
- }
91
-
92
- // Function to generate frontmatter JSON in dory.json order, then append any extra files
93
- function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
94
- const navOrder = getNavigationOrder(docsDir);
95
- const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
96
- const usedKeys = new Set<string>();
97
- const frontmatterArray: Array<Record<string, any>> = [];
98
-
99
- // Process files in navigation order first
100
- for (const navKey of navOrder) {
101
- // Try both with and without "index"
102
- let fileKey = navKey;
103
- if (mdxFilesMap[fileKey]) {
104
- usedKeys.add(fileKey);
105
- } else if (mdxFilesMap[`${fileKey}/index`]) {
106
- fileKey = `${fileKey}/index`;
107
- usedKeys.add(fileKey);
108
- } else {
109
- continue;
110
- }
111
-
112
- const filePath = mdxFilesMap[fileKey];
113
- const rawContent = fs.readFileSync(filePath, 'utf-8');
114
- const { data } = parseFrontmatter(rawContent);
115
- const pathname = pathFromFilename(filePath, docsDir);
116
-
117
- frontmatterArray.push({
118
- ...data,
119
- path: pathname
120
- });
121
- }
122
-
123
- // Process any extra files not in navigation order, sorted
124
- const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
125
- for (const fileKey of extraKeys) {
126
- const filePath = mdxFilesMap[fileKey];
127
- const rawContent = fs.readFileSync(filePath, 'utf-8');
128
- const { data } = parseFrontmatter(rawContent);
129
- const pathname = pathFromFilename(filePath, docsDir);
130
-
131
- frontmatterArray.push({
132
- ...data,
133
- path: pathname
134
- });
135
- }
136
-
137
- return frontmatterArray;
138
- }
139
-
5
+ /**
6
+ * Dev server plugin that serves frontmatter.json and search-content.json
7
+ * on demand, generated fresh per request from the docs directory.
8
+ */
140
9
  export function frontmatterDevServer(): Plugin {
141
10
  return {
142
11
  name: 'frontmatter-dev-server',
143
12
  configureServer(server) {
13
+ // Serve search content for client-side indexing
14
+ server.middlewares.use('/search-content.json', (req, res, next) => {
15
+ if (req.method !== 'GET') return next();
16
+ try {
17
+ const docsDir = path.join(process.cwd(), 'docs');
18
+ const frontmatterArray = generateFrontmatterJson(docsDir);
19
+ const searchContent = generateSearchContentJson(docsDir, frontmatterArray);
20
+ const json = JSON.stringify(searchContent);
21
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
22
+ res.end(json);
23
+ } catch (error) {
24
+ console.error('Error generating search content:', error);
25
+ res.statusCode = 500;
26
+ res.end('Internal Server Error');
27
+ }
28
+ });
29
+
144
30
  server.middlewares.use('/frontmatter.json', (req, res, next) => {
145
31
  if (req.method !== 'GET') {
146
32
  return next();
@@ -162,4 +48,4 @@ export function frontmatterDevServer(): Plugin {
162
48
  });
163
49
  }
164
50
  };
165
- }
51
+ }
@@ -1,106 +1,38 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import {
5
+ getNavigationOrder,
6
+ parseFrontmatter,
7
+ pathFromFilename,
8
+ findMdxFilesMap,
9
+ stripMdxToText,
10
+ } from './shared';
4
11
 
5
12
  interface FrontmatterGeneratorOptions {
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---/);
51
- if (!frontmatterMatch) return { data: {}, content };
52
-
53
- const frontmatter = frontmatterMatch[1];
54
- const data: Record<string, any> = {};
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
- // Convert filename to path format used by the store
68
- function pathFromFilename(filename: string, docsDir: string): string {
69
- return filename
70
- .replace(docsDir, '')
71
- .replace(/\/?index\.mdx$/, '/')
72
- .replace(/\.mdx$/, '')
73
- .replace(/\\/g, '/')
74
- .toLowerCase();
75
- }
76
-
77
- // Function to recursively find all MDX files and map by relative path (without .mdx)
78
- function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
79
- const files: Record<string, string> = {};
80
- const items = fs.readdirSync(dir, { withFileTypes: true });
81
-
82
- for (const item of items) {
83
- const fullPath = path.join(dir, item.name);
84
- if (item.isDirectory()) {
85
- Object.assign(files, findMdxFilesMap(fullPath, baseDir));
86
- } else if (item.isFile() && item.name.endsWith('.mdx')) {
87
- // Key: relative path without .mdx, with forward slashes
88
- let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
89
- if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
90
- files[rel] = fullPath;
91
- }
92
- }
93
-
94
- return files;
95
- }
96
-
97
- // Function to generate frontmatter JSON in dory.json order, then append any extra files
98
- function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
16
+ /**
17
+ * Generates the frontmatter array for all MDX files in dory.json navigation
18
+ * order, then appends any extra files (sorted).
19
+ */
20
+ export function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
99
21
  const navOrder = getNavigationOrder(docsDir);
100
22
  const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
101
23
  const usedKeys = new Set<string>();
102
24
  const frontmatterArray: Array<Record<string, any>> = [];
103
25
 
26
+ const pushEntry = (fileKey: string) => {
27
+ const filePath = mdxFilesMap[fileKey];
28
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
29
+ const { data } = parseFrontmatter(rawContent);
30
+ frontmatterArray.push({
31
+ ...data,
32
+ path: pathFromFilename(filePath, docsDir),
33
+ });
34
+ };
35
+
104
36
  // Process files in navigation order first
105
37
  for (const navKey of navOrder) {
106
38
  // Try both with and without "index"
@@ -113,35 +45,46 @@ function generateFrontmatterJson(docsDir: string): Array<Record<string, any>> {
113
45
  } else {
114
46
  continue;
115
47
  }
116
-
117
- const filePath = mdxFilesMap[fileKey];
118
- const rawContent = fs.readFileSync(filePath, 'utf-8');
119
- const { data } = parseFrontmatter(rawContent);
120
- const pathname = pathFromFilename(filePath, docsDir);
121
-
122
- frontmatterArray.push({
123
- ...data,
124
- path: pathname
125
- });
48
+ pushEntry(fileKey);
126
49
  }
127
50
 
128
51
  // Process any extra files not in navigation order, sorted
129
52
  const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
130
53
  for (const fileKey of extraKeys) {
131
- const filePath = mdxFilesMap[fileKey];
132
- const rawContent = fs.readFileSync(filePath, 'utf-8');
133
- const { data } = parseFrontmatter(rawContent);
134
- const pathname = pathFromFilename(filePath, docsDir);
135
-
136
- frontmatterArray.push({
137
- ...data,
138
- path: pathname
139
- });
54
+ pushEntry(fileKey);
140
55
  }
141
56
 
142
57
  return frontmatterArray;
143
58
  }
144
59
 
60
+ /**
61
+ * Generates search content entries: [{ path, title, content }] with MDX
62
+ * stripped to plain text for client-side indexing.
63
+ */
64
+ export function generateSearchContentJson(
65
+ docsDir: string,
66
+ frontmatterArray: Array<Record<string, any>>
67
+ ): Array<Record<string, any>> {
68
+ const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
69
+ const searchContent: Array<Record<string, any>> = [];
70
+
71
+ for (const fm of frontmatterArray) {
72
+ const fileEntry = Object.entries(mdxFilesMap).find(([, filePath]) =>
73
+ pathFromFilename(filePath, docsDir) === fm.path
74
+ );
75
+ if (!fileEntry) continue;
76
+
77
+ const rawContent = fs.readFileSync(fileEntry[1], 'utf-8');
78
+ const { content } = parseFrontmatter(rawContent);
79
+ searchContent.push({ path: fm.path, title: fm.title || '', content: stripMdxToText(content) });
80
+ }
81
+
82
+ return searchContent;
83
+ }
84
+
85
+ /**
86
+ * Build plugin that emits frontmatter.json and search-content.json.
87
+ */
145
88
  export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}): Plugin {
146
89
  return {
147
90
  name: 'frontmatter-generator',
@@ -156,9 +99,19 @@ export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}):
156
99
  source: jsonContent
157
100
  });
158
101
 
102
+ // Also generate search content for client-side indexing
103
+ const searchContent = generateSearchContentJson(docsDir, frontmatterArray);
104
+ const searchJson = JSON.stringify(searchContent);
105
+
106
+ this.emitFile({
107
+ type: 'asset',
108
+ fileName: 'search-content.json',
109
+ source: searchJson
110
+ });
111
+
159
112
  console.log(`✅ Generated frontmatter JSON file`);
160
113
  console.log(`📄 Processed ${frontmatterArray.length} MDX files`);
161
- console.log(`📝 Total JSON size: ${jsonContent.length} characters`);
114
+ console.log(`🔍 Generated search content (${searchContent.length} pages, ${(searchJson.length / 1024).toFixed(0)} KB)`);
162
115
  }
163
116
  };
164
- }
117
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolve } from 'path';
3
+ import { isPathInside } from './html-files-middleware';
4
+
5
+ describe('isPathInside', () => {
6
+ const root = resolve('/srv/app/dist');
7
+
8
+ it('accepts the root itself and children', () => {
9
+ expect(isPathInside(root, root)).toBe(true);
10
+ expect(isPathInside(root, resolve(root, 'assets/app.js'))).toBe(true);
11
+ });
12
+
13
+ it('rejects traversal outside the root', () => {
14
+ expect(isPathInside(root, resolve(root, '../../../etc/passwd'))).toBe(false);
15
+ expect(isPathInside(root, resolve('/etc/passwd'))).toBe(false);
16
+ });
17
+
18
+ it('rejects sibling directories sharing the root as a string prefix', () => {
19
+ expect(isPathInside(root, resolve('/srv/app/dist-other/file'))).toBe(false);
20
+ });
21
+ });
@@ -1,6 +1,14 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import { readFileSync, existsSync, statSync } from 'fs';
3
- import { resolve, extname } from 'path';
3
+ import { resolve, extname, sep } from 'path';
4
+
5
+ /**
6
+ * Returns true if `target` is the `root` path itself or is contained within it.
7
+ * Both paths must already be resolved (absolute, normalized).
8
+ */
9
+ export function isPathInside(root: string, target: string): boolean {
10
+ return target === root || target.startsWith(root + sep);
11
+ }
4
12
 
5
13
  /**
6
14
  * Vite plugin to serve HTML files and assets directly from the docs directory
@@ -53,17 +61,24 @@ export function htmlFilesMiddleware(): Plugin {
53
61
 
54
62
  // Check if the request is for a file in the dist directory
55
63
  if (url.startsWith('/dist/')) {
64
+ const distRoot = resolve(process.cwd(), 'dist');
56
65
  const distPath = resolve(process.cwd(), url.slice(1));
57
66
 
67
+ // Reject paths that escape the dist directory (e.g. /dist/../../etc/passwd)
68
+ if (!isPathInside(distRoot, distPath)) {
69
+ res.statusCode = 403;
70
+ res.end('Forbidden');
71
+ return;
72
+ }
73
+
58
74
  if (existsSync(distPath)) {
59
75
  try {
60
- const content = readFileSync(distPath, 'utf-8');
76
+ const content = readFileSync(distPath);
61
77
  const ext = extname(url).toLowerCase();
62
78
  const contentType = mimeTypes[ext] || 'application/octet-stream';
63
79
 
64
80
  res.setHeader('Content-Type', contentType);
65
81
  res.setHeader('Cache-Control', 'no-cache');
66
- res.setHeader('Access-Control-Allow-Origin', '*');
67
82
  res.end(content);
68
83
  return;
69
84
  } catch (error) {
@@ -74,7 +89,15 @@ export function htmlFilesMiddleware(): Plugin {
74
89
 
75
90
  // Check if the request is for a file in the docs directory
76
91
  // This handles both direct file requests and directory requests
77
- const docsPath = resolve(process.cwd(), 'docs', url.slice(1));
92
+ const docsRoot = resolve(process.cwd(), 'docs');
93
+ const docsPath = resolve(docsRoot, url.slice(1));
94
+
95
+ // Reject paths that escape the docs directory
96
+ if (!isPathInside(docsRoot, docsPath)) {
97
+ res.statusCode = 403;
98
+ res.end('Forbidden');
99
+ return;
100
+ }
78
101
 
79
102
  if (existsSync(docsPath)) {
80
103
  try {
@@ -91,7 +114,6 @@ export function htmlFilesMiddleware(): Plugin {
91
114
 
92
115
  res.setHeader('Content-Type', contentType);
93
116
  res.setHeader('Cache-Control', 'no-cache');
94
- res.setHeader('Access-Control-Allow-Origin', '*');
95
117
  res.end(content);
96
118
  return;
97
119
  }