@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.
- package/bin/dist/dory.js +103 -45
- package/bin/dist/dory.js.map +1 -1
- package/index.html +15 -19
- package/package.json +35 -29
- package/scripts/render-routes.mjs +45 -14
- package/src/components/routes.tsx +19 -22
- package/src/components/store.ts +5 -5
- package/src/config/mdx.js +7 -3
- package/src/config/mdx.ts +6 -2
- package/src/entry-server.tsx +53 -20
- package/src/main.tsx +9 -1
- package/src/mdx/code.tsx +8 -5
- package/src/mdx/fence.tsx +10 -2
- package/src/mdx/icon.tsx +21 -14
- package/src/mdx/mermaid.tsx +10 -1
- package/src/mdx/processor.test.ts +47 -0
- package/src/mdx/step.tsx +3 -3
- package/src/plugins/analytics-injector.ts +7 -5
- package/src/plugins/docs-assets.ts +5 -4
- package/src/plugins/frontmatter-dev-server.ts +8 -159
- package/src/plugins/frontmatter-generator.ts +37 -129
- package/src/plugins/html-files-middleware.test.ts +21 -0
- package/src/plugins/html-files-middleware.ts +27 -5
- package/src/plugins/html-metadata-injector.ts +113 -53
- package/src/plugins/llm-txt-dev-server.ts +25 -178
- package/src/plugins/llm-txt-generator.ts +94 -133
- package/src/plugins/prerender.ts +156 -50
- package/src/plugins/robots-generator.ts +15 -16
- package/src/plugins/sanitize.test.ts +104 -0
- package/src/plugins/sanitize.ts +90 -59
- package/src/plugins/shared.ts +139 -0
- package/src/plugins/sitemap-generator.ts +58 -44
- package/src/theme.scss +138 -15
- package/src/types/config.ts +42 -2
- package/src/types/globals.d.ts +7 -0
- package/src/ui/header.tsx +12 -4
- package/src/ui/layout.tsx +66 -47
- package/src/ui/table-of-content.tsx +19 -2
- package/src/utils/colors.ts +38 -18
- package/src/utils/fonts.ts +55 -12
- package/src/utils/theme.ts +29 -0
- package/vite.config.embed-loader.ts +3 -11
- package/vite.config.embed-widget.ts +3 -10
- package/vite.config.ts +11 -7
- package/scripts/generate-frontmatter-json.js +0 -154
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
163
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|