@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,155 +1,11 @@
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
-
140
- // Strip MDX/JSX syntax to extract plain text for search indexing
141
- function stripMdxToText(content: string): string {
142
- return content
143
- .replace(/^import\s+.*$/gm, '')
144
- .replace(/^export\s+.*$/gm, '')
145
- .replace(/```[\s\S]*?```/g, '')
146
- .replace(/<[^>]+>/g, '')
147
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
148
- .replace(/[#*_~`>|]/g, '')
149
- .replace(/\n{3,}/g, '\n\n')
150
- .trim();
151
- }
152
-
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
+ */
153
9
  export function frontmatterDevServer(): Plugin {
154
10
  return {
155
11
  name: 'frontmatter-dev-server',
@@ -159,15 +15,8 @@ export function frontmatterDevServer(): Plugin {
159
15
  if (req.method !== 'GET') return next();
160
16
  try {
161
17
  const docsDir = path.join(process.cwd(), 'docs');
162
- const fmArray = generateFrontmatterJson(docsDir);
163
- const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
164
- const searchContent = fmArray.map(fm => {
165
- const entry = Object.entries(mdxFilesMap).find(([, fp]) => pathFromFilename(fp, docsDir) === fm.path);
166
- if (!entry) return { path: fm.path, title: fm.title || '', content: '' };
167
- const raw = fs.readFileSync(entry[1], 'utf-8');
168
- const { content } = parseFrontmatter(raw);
169
- return { path: fm.path, title: fm.title || '', content: stripMdxToText(content) };
170
- });
18
+ const frontmatterArray = generateFrontmatterJson(docsDir);
19
+ const searchContent = generateSearchContentJson(docsDir, frontmatterArray);
171
20
  const json = JSON.stringify(searchContent);
172
21
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
173
22
  res.end(json);
@@ -199,4 +48,4 @@ export function frontmatterDevServer(): Plugin {
199
48
  });
200
49
  }
201
50
  };
202
- }
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,55 +45,30 @@ 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
 
145
- // Strip MDX/JSX syntax to extract plain text for search indexing
146
- function stripMdxToText(content: string): string {
147
- return content
148
- .replace(/^import\s+.*$/gm, '') // import statements
149
- .replace(/^export\s+.*$/gm, '') // export statements
150
- .replace(/```[\s\S]*?```/g, '') // fenced code blocks
151
- .replace(/<[^>]+>/g, '') // JSX/HTML tags
152
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown links → text
153
- .replace(/[#*_~`>|]/g, '') // markdown syntax chars
154
- .replace(/\n{3,}/g, '\n\n') // collapse blank lines
155
- .trim();
156
- }
157
-
158
- // Generate search content JSON: [{ path, title, content }]
159
- function generateSearchContentJson(docsDir: string, frontmatterArray: Array<Record<string, any>>): Array<Record<string, any>> {
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>> {
160
68
  const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
161
69
  const searchContent: Array<Record<string, any>> = [];
162
70
 
163
71
  for (const fm of frontmatterArray) {
164
- // Find the MDX file for this frontmatter entry
165
72
  const fileEntry = Object.entries(mdxFilesMap).find(([, filePath]) =>
166
73
  pathFromFilename(filePath, docsDir) === fm.path
167
74
  );
@@ -169,14 +76,15 @@ function generateSearchContentJson(docsDir: string, frontmatterArray: Array<Reco
169
76
 
170
77
  const rawContent = fs.readFileSync(fileEntry[1], 'utf-8');
171
78
  const { content } = parseFrontmatter(rawContent);
172
- const text = stripMdxToText(content);
173
-
174
- searchContent.push({ path: fm.path, title: fm.title || '', content: text });
79
+ searchContent.push({ path: fm.path, title: fm.title || '', content: stripMdxToText(content) });
175
80
  }
176
81
 
177
82
  return searchContent;
178
83
  }
179
84
 
85
+ /**
86
+ * Build plugin that emits frontmatter.json and search-content.json.
87
+ */
180
88
  export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}): Plugin {
181
89
  return {
182
90
  name: 'frontmatter-generator',
@@ -206,4 +114,4 @@ export function frontmatterGenerator(options: FrontmatterGeneratorOptions = {}):
206
114
  console.log(`🔍 Generated search content (${searchContent.length} pages, ${(searchJson.length / 1024).toFixed(0)} KB)`);
207
115
  }
208
116
  };
209
- }
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
  }