docnest 0.1.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +83 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +79 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +814 -0
- package/package.json +60 -0
- package/server/favicon.ico +0 -0
- package/server/static/css/highlight.css +72 -0
- package/server/static/css/styles.css +5762 -0
- package/server/static/favicon.ico +0 -0
- package/server/static/img/404-deep-space-light.webp +0 -0
- package/server/static/img/404-deep-space.webp +0 -0
- package/server/static/js/core/doc-client-nav.js +260 -0
- package/server/static/js/core/doc-pdf-export.js +579 -0
- package/server/static/js/core/mermaid-init.js +36 -0
- package/server/static/js/core/sidebar.js +141 -0
- package/server/static/js/core/theme.js +138 -0
- package/server/static/js/diagram/diagram-controls.js +90 -0
- package/server/static/js/diagram/diagram-download.js +594 -0
- package/server/static/js/diagram/diagram-drag.js +157 -0
- package/server/static/js/diagram/diagram-viewer.js +405 -0
- package/server/static/js/diagram/diagram-zoom.js +67 -0
- package/server/static/js/utils/app.js +14 -0
- package/server/static/js/utils/modal-stack.js +33 -0
- package/server/views/dir.ejs +142 -0
- package/server/views/doc.ejs +483 -0
- package/server/views/index.ejs +77 -0
- package/server/views/not-found.ejs +57 -0
- package/server/views/partials/breadcrumbs.ejs +15 -0
- package/server/views/partials/diagram-viewer.ejs +16 -0
- package/server/views/partials/header.ejs +37 -0
- package/server/views/partials/sidebar.ejs +19 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.docsDir = exports.server = exports.io = exports.app = void 0;
|
|
4
|
+
exports.startServer = startServer;
|
|
5
|
+
// This file is intentionally kept close to the proven document-center runtime
|
|
6
|
+
// while the public package is extracted. The typed configuration and CLI are
|
|
7
|
+
// the stable public boundary; the browser assets remain framework-free.
|
|
8
|
+
// @ts-nocheck
|
|
9
|
+
const express = require('express');
|
|
10
|
+
const fs = require('fs').promises;
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { createServer } = require('http');
|
|
13
|
+
const { Server } = require('socket.io');
|
|
14
|
+
const chokidar = require('chokidar');
|
|
15
|
+
const MarkdownIt = require('markdown-it');
|
|
16
|
+
const hljs = require('highlight.js');
|
|
17
|
+
const app = express();
|
|
18
|
+
exports.app = app;
|
|
19
|
+
const server = createServer(app);
|
|
20
|
+
exports.server = server;
|
|
21
|
+
const io = new Server(server);
|
|
22
|
+
exports.io = io;
|
|
23
|
+
const HOST = process.env.DOCS_HOST || '127.0.0.1';
|
|
24
|
+
const PORT_START = Number(process.env.DOCS_PORT_START) || 3000;
|
|
25
|
+
const PORT_FALLBACK_ENABLED = process.env.DOCS_PORT_FALLBACK !== 'false';
|
|
26
|
+
const ASSET_VERSION = process.env.ASSET_VERSION || 'dev';
|
|
27
|
+
const WATCH_ENABLED = process.env.DOCS_WATCH_ENABLED === undefined
|
|
28
|
+
? true
|
|
29
|
+
: process.env.DOCS_WATCH_ENABLED === 'true';
|
|
30
|
+
const OPEN_BROWSER = process.env.DOCS_OPEN_BROWSER !== 'false';
|
|
31
|
+
const DOCS_TITLE = process.env.DOCS_TITLE || 'DocNest';
|
|
32
|
+
const DOCS_SUBTITLE = process.env.DOCS_SUBTITLE || '本地 Markdown 文档服务';
|
|
33
|
+
const DOCS_LOGO = process.env.DOCS_LOGO || '';
|
|
34
|
+
const DOCS_STORAGE_KEY_PREFIX = process.env.DOCS_STORAGE_KEY_PREFIX || 'docnest';
|
|
35
|
+
const DOCS_WATERMARK_ENABLED = process.env.DOCS_WATERMARK_ENABLED === 'true';
|
|
36
|
+
const DOCS_WATERMARK_TEXT = process.env.DOCS_WATERMARK_TEXT || DOCS_TITLE;
|
|
37
|
+
const DOCS_DIR = path.resolve(process.env.DOCS_ROOT || path.join(process.cwd(), 'docs'));
|
|
38
|
+
exports.docsDir = DOCS_DIR;
|
|
39
|
+
const ROOT_DIRECTORY_ORDER = (() => {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(process.env.DOCS_ROOT_DIRECTORY_ORDER || '[]');
|
|
42
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
const SERVER_DIR = path.join(__dirname, '..', 'server');
|
|
49
|
+
const PROJECT_NODE_MODULES_DIR = path.join(process.cwd(), 'node_modules');
|
|
50
|
+
function dependencyDir(packageName, fallbackName) {
|
|
51
|
+
try {
|
|
52
|
+
return path.dirname(require.resolve(packageName));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return path.join(PROJECT_NODE_MODULES_DIR, fallbackName || packageName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const MERMAID_DIR = dependencyDir('mermaid');
|
|
59
|
+
const MARKDOWN_THEME_DIR = dependencyDir('github-markdown-css');
|
|
60
|
+
const HTML2CANVAS_DIR = dependencyDir('html2canvas');
|
|
61
|
+
const DOMPURIFY_DIR = dependencyDir('dompurify');
|
|
62
|
+
const JSPDF_DIR = dependencyDir('jspdf');
|
|
63
|
+
// Projects may provide their own document source (for example, an object-store
|
|
64
|
+
// backed source) without taking the generic viewer back into project-specific
|
|
65
|
+
// infrastructure. The adapter is installed before importing this module.
|
|
66
|
+
const injectedDocumentStore = globalThis.__DOCNEST_DOCUMENT_STORE__ || null;
|
|
67
|
+
const STORAGE_DRIVER = injectedDocumentStore?.name || 'local';
|
|
68
|
+
const DOCS_ROOT_LABEL = injectedDocumentStore?.rootLabel || DOCS_DIR;
|
|
69
|
+
const AUTH_ENABLED = globalThis.__DOCNEST_AUTH_ENABLED__ === true;
|
|
70
|
+
// 配置 EJS 模板引擎
|
|
71
|
+
app.set('view engine', 'ejs');
|
|
72
|
+
app.set('views', path.join(SERVER_DIR, 'views'));
|
|
73
|
+
app.locals.docsTitle = DOCS_TITLE;
|
|
74
|
+
app.locals.docsSubtitle = DOCS_SUBTITLE;
|
|
75
|
+
app.locals.docsLogo = DOCS_LOGO;
|
|
76
|
+
app.locals.docsStorageKeyPrefix = DOCS_STORAGE_KEY_PREFIX;
|
|
77
|
+
app.locals.docsWatermarkEnabled = DOCS_WATERMARK_ENABLED;
|
|
78
|
+
app.locals.docsWatermarkText = DOCS_WATERMARK_TEXT;
|
|
79
|
+
function normalizeDocRelativePath(value) {
|
|
80
|
+
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
81
|
+
const normalized = path.posix.normalize(raw);
|
|
82
|
+
if (!normalized || normalized === '.')
|
|
83
|
+
return '';
|
|
84
|
+
if (normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
function joinDocPath(...parts) {
|
|
90
|
+
return parts
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('/')
|
|
93
|
+
.replace(/\\/g, '/')
|
|
94
|
+
.replace(/\/+/g, '/')
|
|
95
|
+
.replace(/^\/+|\/+$/g, '');
|
|
96
|
+
}
|
|
97
|
+
async function storageReadDir(dirPath = '') {
|
|
98
|
+
const safePath = normalizeDocRelativePath(dirPath);
|
|
99
|
+
if (safePath === null) {
|
|
100
|
+
throw new Error('Invalid docs directory path');
|
|
101
|
+
}
|
|
102
|
+
if (injectedDocumentStore?.readDir) {
|
|
103
|
+
return await injectedDocumentStore.readDir(safePath);
|
|
104
|
+
}
|
|
105
|
+
const fullPath = path.join(DOCS_DIR, safePath);
|
|
106
|
+
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
107
|
+
return entries.map((entry) => ({
|
|
108
|
+
name: entry.name,
|
|
109
|
+
isDirectory: entry.isDirectory(),
|
|
110
|
+
isFile: entry.isFile(),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
async function storageFileExists(docPath) {
|
|
114
|
+
const safePath = normalizeDocRelativePath(docPath);
|
|
115
|
+
if (!safePath || safePath === null)
|
|
116
|
+
return false;
|
|
117
|
+
if (injectedDocumentStore?.fileExists) {
|
|
118
|
+
return await injectedDocumentStore.fileExists(safePath);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const stat = await fs.stat(path.join(DOCS_DIR, safePath));
|
|
122
|
+
return stat.isFile();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function storageReadFile(docPath) {
|
|
129
|
+
const safePath = normalizeDocRelativePath(docPath);
|
|
130
|
+
if (!safePath || safePath === null) {
|
|
131
|
+
throw new Error('Invalid docs file path');
|
|
132
|
+
}
|
|
133
|
+
if (injectedDocumentStore?.readFile) {
|
|
134
|
+
return await injectedDocumentStore.readFile(safePath);
|
|
135
|
+
}
|
|
136
|
+
return await fs.readFile(path.join(DOCS_DIR, safePath), 'utf-8');
|
|
137
|
+
}
|
|
138
|
+
async function storageReadAsset(assetPath) {
|
|
139
|
+
const safePath = normalizeDocRelativePath(assetPath);
|
|
140
|
+
if (!safePath || safePath === null) {
|
|
141
|
+
throw new Error('Invalid docs asset path');
|
|
142
|
+
}
|
|
143
|
+
if (injectedDocumentStore?.readAsset) {
|
|
144
|
+
return await injectedDocumentStore.readAsset(safePath);
|
|
145
|
+
}
|
|
146
|
+
return await fs.readFile(path.join(DOCS_DIR, safePath));
|
|
147
|
+
}
|
|
148
|
+
// 配置 markdown-it
|
|
149
|
+
const md = new MarkdownIt({
|
|
150
|
+
html: true,
|
|
151
|
+
linkify: true,
|
|
152
|
+
typographer: true,
|
|
153
|
+
highlight: function (str, lang) {
|
|
154
|
+
// 特殊处理 Mermaid 图表
|
|
155
|
+
if (lang === 'mermaid') {
|
|
156
|
+
return `<div class="mermaid">${str}</div>`;
|
|
157
|
+
}
|
|
158
|
+
// 其他代码块使用 highlight.js
|
|
159
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
160
|
+
try {
|
|
161
|
+
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
|
|
162
|
+
}
|
|
163
|
+
catch (__) { }
|
|
164
|
+
}
|
|
165
|
+
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
function slugifyHeading(text) {
|
|
169
|
+
return (text || '')
|
|
170
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
171
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
172
|
+
.replace(/<[^>]+>/g, '')
|
|
173
|
+
.trim()
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~]/g, '')
|
|
176
|
+
.replace(/[,。!?、;:“”‘’()【】《》〈〉]/g, '')
|
|
177
|
+
.replace(/\s+/g, '-')
|
|
178
|
+
.replace(/-+/g, '-')
|
|
179
|
+
.replace(/^-+|-+$/g, '');
|
|
180
|
+
}
|
|
181
|
+
md.core.ruler.push('heading_ids', function headingIds(state) {
|
|
182
|
+
const slugCounter = new Map();
|
|
183
|
+
for (let i = 0; i < state.tokens.length; i++) {
|
|
184
|
+
const token = state.tokens[i];
|
|
185
|
+
if (token.type !== 'heading_open')
|
|
186
|
+
continue;
|
|
187
|
+
const inline = state.tokens[i + 1];
|
|
188
|
+
if (!inline || inline.type !== 'inline')
|
|
189
|
+
continue;
|
|
190
|
+
const baseSlug = slugifyHeading(inline.content) || 'section';
|
|
191
|
+
const count = (slugCounter.get(baseSlug) || 0) + 1;
|
|
192
|
+
slugCounter.set(baseSlug, count);
|
|
193
|
+
const slug = count === 1 ? baseSlug : `${baseSlug}-${count}`;
|
|
194
|
+
token.attrSet('id', slug);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
// 静态文件服务
|
|
198
|
+
app.use('/static', express.static(path.join(SERVER_DIR, 'static')));
|
|
199
|
+
app.get('/favicon.ico', (req, res) => {
|
|
200
|
+
res.sendFile(path.join(SERVER_DIR, 'static', 'favicon.ico'));
|
|
201
|
+
});
|
|
202
|
+
// Mermaid.js 静态文件(相对于项目根目录)
|
|
203
|
+
app.use('/mermaid', express.static(MERMAID_DIR));
|
|
204
|
+
// GitHub Markdown 样式(本地,避免 CDN 慢)
|
|
205
|
+
app.use('/vendor/github-markdown-css', express.static(MARKDOWN_THEME_DIR));
|
|
206
|
+
// html2canvas / DOMPurify / jsPDF(本地,用于前端导出 PDF)
|
|
207
|
+
app.use('/vendor/html2canvas', express.static(HTML2CANVAS_DIR));
|
|
208
|
+
app.use('/vendor/dompurify', express.static(DOMPURIFY_DIR));
|
|
209
|
+
// jsPDF(本地,用于 Mermaid 图导出 PDF)
|
|
210
|
+
app.use('/vendor/jspdf', express.static(JSPDF_DIR));
|
|
211
|
+
// 文档中的本地图片、附件等静态资源,只允许从文档根目录读取。
|
|
212
|
+
app.get('/doc-asset/*', async (req, res) => {
|
|
213
|
+
let requestedPath = req.path.slice('/doc-asset/'.length);
|
|
214
|
+
try {
|
|
215
|
+
requestedPath = decodeURIComponent(requestedPath);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return res.status(400).send('资源路径无效');
|
|
219
|
+
}
|
|
220
|
+
const safePath = normalizeDocRelativePath(requestedPath);
|
|
221
|
+
if (!safePath || safePath.split('/').some((part) => part.startsWith('.'))) {
|
|
222
|
+
return res.status(403).send('禁止访问');
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (safePath.toLowerCase().endsWith('.md')) {
|
|
226
|
+
return res.status(404).send('资源不存在');
|
|
227
|
+
}
|
|
228
|
+
const asset = await storageReadAsset(safePath);
|
|
229
|
+
return res.type(path.extname(safePath)).send(asset);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return res.status(404).send('资源不存在');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
app.get('/health', (req, res) => {
|
|
236
|
+
res.json({
|
|
237
|
+
ok: true,
|
|
238
|
+
docsTitle: DOCS_TITLE,
|
|
239
|
+
docsRoot: DOCS_ROOT_LABEL,
|
|
240
|
+
authEnabled: AUTH_ENABLED,
|
|
241
|
+
watchEnabled: WATCH_ENABLED,
|
|
242
|
+
storageDriver: STORAGE_DRIVER,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
app.get('/ready', async (req, res) => {
|
|
246
|
+
try {
|
|
247
|
+
if (injectedDocumentStore?.ready) {
|
|
248
|
+
await injectedDocumentStore.ready();
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const stat = await fs.stat(DOCS_DIR);
|
|
252
|
+
if (!stat.isDirectory()) {
|
|
253
|
+
throw new Error('DOCS_ROOT is not a directory');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await storageReadDir('');
|
|
257
|
+
return res.json({
|
|
258
|
+
ok: true,
|
|
259
|
+
docsRoot: DOCS_ROOT_LABEL,
|
|
260
|
+
authEnabled: AUTH_ENABLED,
|
|
261
|
+
storageDriver: STORAGE_DRIVER,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return res.status(503).json({
|
|
266
|
+
ok: false,
|
|
267
|
+
docsRoot: DOCS_ROOT_LABEL,
|
|
268
|
+
authEnabled: AUTH_ENABLED,
|
|
269
|
+
storageDriver: STORAGE_DRIVER,
|
|
270
|
+
error: error.message,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
// 消费项目可以在通用文档路由前注入自己的访问控制;DocNest 不实现任何项目身份系统。
|
|
275
|
+
const beforeDocumentRoutes = globalThis.__DOCNEST_BEFORE_DOCUMENT_ROUTES__ || [];
|
|
276
|
+
beforeDocumentRoutes.forEach((middleware) => app.use(middleware));
|
|
277
|
+
// 获取文档目录树(排除隐藏目录和系统文件)
|
|
278
|
+
async function getDocTree(dir, basePath = '') {
|
|
279
|
+
const entries = await storageReadDir(basePath);
|
|
280
|
+
const tree = [];
|
|
281
|
+
const excludeDirs = ['node_modules', '.git'];
|
|
282
|
+
const excludeFiles = [];
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
// 跳过隐藏文件和排除的文件/目录
|
|
285
|
+
if (entry.name.startsWith('.'))
|
|
286
|
+
continue;
|
|
287
|
+
if (excludeFiles.includes(entry.name))
|
|
288
|
+
continue;
|
|
289
|
+
if (entry.isDirectory && excludeDirs.includes(entry.name))
|
|
290
|
+
continue;
|
|
291
|
+
const relativePath = joinDocPath(basePath, entry.name);
|
|
292
|
+
if (entry.isDirectory) {
|
|
293
|
+
const children = await getDocTree(null, relativePath);
|
|
294
|
+
if (children.length > 0) {
|
|
295
|
+
tree.push({
|
|
296
|
+
name: entry.name,
|
|
297
|
+
type: 'directory',
|
|
298
|
+
path: relativePath,
|
|
299
|
+
children,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (entry.isFile && entry.name.endsWith('.md')) {
|
|
304
|
+
// 只显示 Markdown 文件,排除 PDF 等其他文件
|
|
305
|
+
tree.push({
|
|
306
|
+
name: entry.name,
|
|
307
|
+
type: 'file',
|
|
308
|
+
path: relativePath,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// 明确排除 PDF 文件和其他非 Markdown 文件
|
|
312
|
+
}
|
|
313
|
+
return tree.sort((a, b) => {
|
|
314
|
+
const isReadme = (item) => item.type === 'file' && item.name.toLowerCase() === 'readme.md';
|
|
315
|
+
if (isReadme(a) && !isReadme(b))
|
|
316
|
+
return -1;
|
|
317
|
+
if (!isReadme(a) && isReadme(b))
|
|
318
|
+
return 1;
|
|
319
|
+
if (a.type !== b.type)
|
|
320
|
+
return a.type === 'directory' ? -1 : 1;
|
|
321
|
+
if (!basePath && a.type === 'directory' && b.type === 'directory') {
|
|
322
|
+
const orderA = ROOT_DIRECTORY_ORDER.indexOf(a.name);
|
|
323
|
+
const orderB = ROOT_DIRECTORY_ORDER.indexOf(b.name);
|
|
324
|
+
const hasOrderA = orderA !== -1;
|
|
325
|
+
const hasOrderB = orderB !== -1;
|
|
326
|
+
if (hasOrderA && hasOrderB)
|
|
327
|
+
return orderA - orderB;
|
|
328
|
+
if (hasOrderA)
|
|
329
|
+
return -1;
|
|
330
|
+
if (hasOrderB)
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
return a.name.localeCompare(b.name, 'zh-CN');
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// 首页:显示文档目录
|
|
337
|
+
app.get('/', async (req, res) => {
|
|
338
|
+
try {
|
|
339
|
+
const tree = await getDocTree(DOCS_DIR);
|
|
340
|
+
const treeHtml = renderTree(tree);
|
|
341
|
+
res.render('index', {
|
|
342
|
+
treeHtml,
|
|
343
|
+
assetVersion: ASSET_VERSION,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
res.status(500).send(`<h1>错误</h1><p>${error.message}</p>`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
// 渲染目录树为 HTML
|
|
351
|
+
function renderTree(tree, level = 0) {
|
|
352
|
+
let html = '<ul>';
|
|
353
|
+
for (const item of tree) {
|
|
354
|
+
const itemName = md.utils.escapeHtml(item.name || '');
|
|
355
|
+
if (item.type === 'directory') {
|
|
356
|
+
const dirPathNorm = (item.path || '').split(path.sep).join('/');
|
|
357
|
+
const dataDirPath = ` data-dir-path="${encodeURIComponent(dirPathNorm)}"`;
|
|
358
|
+
html += `<li class="directory collapsed"${dataDirPath}>
|
|
359
|
+
<span class="dir-toggle">
|
|
360
|
+
<span class="toggle-icon">▼</span>
|
|
361
|
+
<span class="dir-name" title="${itemName}">📁 ${itemName}</span>
|
|
362
|
+
</span>
|
|
363
|
+
<div class="dir-children">${renderTree(item.children, level + 1)}</div>
|
|
364
|
+
</li>`;
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// 文件路径直接使用,路径重定向在 resolveDocPath 中处理
|
|
368
|
+
const url = `/doc?path=${encodeURIComponent(item.path)}`;
|
|
369
|
+
html += `<li class="file">
|
|
370
|
+
<a href="${url}" class="file-link" title="${itemName}">📄 ${itemName}</a>
|
|
371
|
+
</li>`;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
html += '</ul>';
|
|
375
|
+
return html;
|
|
376
|
+
}
|
|
377
|
+
function encodeDocAssetPath(assetPath) {
|
|
378
|
+
return assetPath.split('/').map((part) => encodeURIComponent(part)).join('/');
|
|
379
|
+
}
|
|
380
|
+
function rewriteDocAssetUrls(htmlContent, currentDir) {
|
|
381
|
+
return htmlContent.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (match, prefix, source, suffix) => {
|
|
382
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|data:|#)/i.test(source)) {
|
|
383
|
+
return match;
|
|
384
|
+
}
|
|
385
|
+
let decodedSource = source;
|
|
386
|
+
try {
|
|
387
|
+
decodedSource = decodeURIComponent(source);
|
|
388
|
+
}
|
|
389
|
+
catch { }
|
|
390
|
+
const resolvedPath = normalizeDocRelativePath(path.posix.normalize(path.posix.join(currentDir, decodedSource)));
|
|
391
|
+
if (!resolvedPath || resolvedPath.toLowerCase().endsWith('.md')) {
|
|
392
|
+
return match;
|
|
393
|
+
}
|
|
394
|
+
return `${prefix}/doc-asset/${encodeDocAssetPath(resolvedPath)}${suffix}`;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function renderDocNotFound(res, requestedPath) {
|
|
398
|
+
const tree = await getDocTree(DOCS_DIR);
|
|
399
|
+
const treeHtml = renderTree(tree);
|
|
400
|
+
return res.status(404).render('not-found', {
|
|
401
|
+
treeHtml,
|
|
402
|
+
requestedPath,
|
|
403
|
+
assetVersion: ASSET_VERSION,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// 智能路径解析:基于文件系统实际路径,自动适配目录重命名
|
|
407
|
+
async function resolveDocPath(requestedPath) {
|
|
408
|
+
requestedPath = normalizeDocRelativePath(requestedPath);
|
|
409
|
+
if (!requestedPath) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
// 先尝试直接路径
|
|
413
|
+
if (await storageFileExists(requestedPath)) {
|
|
414
|
+
return { resolvedPath: requestedPath };
|
|
415
|
+
}
|
|
416
|
+
// 将路径拆分为目录和文件名
|
|
417
|
+
const pathParts = requestedPath.split('/');
|
|
418
|
+
const fileName = pathParts.pop();
|
|
419
|
+
// 如果路径包含目录,尝试逐级查找
|
|
420
|
+
if (pathParts.length > 0) {
|
|
421
|
+
let currentDir = '';
|
|
422
|
+
const resolvedParts = [];
|
|
423
|
+
// 逐级解析路径
|
|
424
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
425
|
+
const requestedDir = pathParts[i];
|
|
426
|
+
// 查找父目录下的所有目录,尝试匹配
|
|
427
|
+
try {
|
|
428
|
+
const entries = await storageReadDir(currentDir);
|
|
429
|
+
const matchingDir = entries.find(entry => entry.isDirectory &&
|
|
430
|
+
!entry.name.startsWith('.') &&
|
|
431
|
+
// 尝试多种匹配策略
|
|
432
|
+
(entry.name === requestedDir ||
|
|
433
|
+
entry.name.toLowerCase() === requestedDir.toLowerCase() ||
|
|
434
|
+
// 如果请求的是英文名,尝试查找中文名(或反之)
|
|
435
|
+
entry.name.includes(requestedDir) ||
|
|
436
|
+
requestedDir.includes(entry.name)));
|
|
437
|
+
if (matchingDir) {
|
|
438
|
+
resolvedParts.push(matchingDir.name);
|
|
439
|
+
currentDir = joinDocPath(currentDir, matchingDir.name);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// 找不到匹配的目录,返回 null
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// 构建解析后的路径
|
|
451
|
+
const resolvedDirPath = resolvedParts.join('/');
|
|
452
|
+
const resolvedPath = resolvedDirPath ? `${resolvedDirPath}/${fileName}` : fileName;
|
|
453
|
+
// 再次检查文件是否存在
|
|
454
|
+
if (await storageFileExists(resolvedPath)) {
|
|
455
|
+
return { resolvedPath };
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
// 如果只是文件名,尝试在整个文档目录中查找
|
|
460
|
+
try {
|
|
461
|
+
const foundPath = await findFileInTree('', fileName);
|
|
462
|
+
if (foundPath) {
|
|
463
|
+
return { resolvedPath: foundPath };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
// 查找失败
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
// 在目录树中查找文件
|
|
472
|
+
async function findFileInTree(dir, fileName, basePath = '') {
|
|
473
|
+
try {
|
|
474
|
+
const effectiveBasePath = basePath || dir || '';
|
|
475
|
+
const entries = await storageReadDir(effectiveBasePath);
|
|
476
|
+
for (const entry of entries) {
|
|
477
|
+
if (entry.name.startsWith('.'))
|
|
478
|
+
continue;
|
|
479
|
+
const relativePath = joinDocPath(effectiveBasePath, entry.name);
|
|
480
|
+
if (entry.isDirectory) {
|
|
481
|
+
const found = await findFileInTree('', fileName, relativePath);
|
|
482
|
+
if (found)
|
|
483
|
+
return found;
|
|
484
|
+
}
|
|
485
|
+
else if (entry.isFile && entry.name === fileName && entry.name.endsWith('.md')) {
|
|
486
|
+
return relativePath;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
// 忽略错误
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
// 构建面包屑:基于文档路径
|
|
496
|
+
async function buildBreadcrumbs(docPath) {
|
|
497
|
+
const crumbs = [];
|
|
498
|
+
const normalizedPath = docPath.replace(/\\/g, '/');
|
|
499
|
+
const parts = normalizedPath.split('/').filter(Boolean);
|
|
500
|
+
for (let i = 0; i < parts.length; i++) {
|
|
501
|
+
const isLast = i === parts.length - 1;
|
|
502
|
+
const isLastFile = isLast && parts[i].endsWith('.md');
|
|
503
|
+
const displayName = isLastFile ? parts[i].slice(0, -3) : parts[i];
|
|
504
|
+
if (isLast) {
|
|
505
|
+
crumbs.push({ name: displayName, isLast: true, url: '' });
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
// 中间目录:构建到该目录的路径
|
|
509
|
+
const dirPath = parts.slice(0, i + 1).join('/');
|
|
510
|
+
// 尝试链接到该目录下的 README.md
|
|
511
|
+
let dirUrl = '';
|
|
512
|
+
if (await storageFileExists(joinDocPath(dirPath, 'README.md'))) {
|
|
513
|
+
dirUrl = `/doc?path=${encodeURIComponent(dirPath + '/README.md')}`;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
// 没有 README.md,链接到目录列表页
|
|
517
|
+
dirUrl = `/dir?path=${encodeURIComponent(dirPath)}`;
|
|
518
|
+
}
|
|
519
|
+
crumbs.push({ name: displayName, isLast: false, url: dirUrl });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return crumbs;
|
|
523
|
+
}
|
|
524
|
+
// 获取目录下的文件和子目录列表(仅一层)
|
|
525
|
+
async function getDirListing(dirPath) {
|
|
526
|
+
let entries;
|
|
527
|
+
try {
|
|
528
|
+
entries = await storageReadDir(dirPath);
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
const dirs = [];
|
|
534
|
+
const files = [];
|
|
535
|
+
for (const entry of entries) {
|
|
536
|
+
if (entry.name.startsWith('.'))
|
|
537
|
+
continue;
|
|
538
|
+
const relPath = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
539
|
+
if (entry.isDirectory) {
|
|
540
|
+
// 检查该目录是否在文档树中(非空、非排除)
|
|
541
|
+
const children = await getDocTree(null, relPath);
|
|
542
|
+
if (children.length > 0) {
|
|
543
|
+
dirs.push({ name: entry.name, path: relPath });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else if (entry.isFile && entry.name.endsWith('.md')) {
|
|
547
|
+
files.push({ name: entry.name, path: relPath });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// 排序
|
|
551
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
552
|
+
files.sort((a, b) => {
|
|
553
|
+
const isReadme = (n) => n.toLowerCase() === 'readme.md';
|
|
554
|
+
if (isReadme(a.name) && !isReadme(b.name))
|
|
555
|
+
return -1;
|
|
556
|
+
if (!isReadme(a.name) && isReadme(b.name))
|
|
557
|
+
return 1;
|
|
558
|
+
return a.name.localeCompare(b.name, 'zh-CN');
|
|
559
|
+
});
|
|
560
|
+
return { dirs, files };
|
|
561
|
+
}
|
|
562
|
+
// 目录浏览页面
|
|
563
|
+
app.get('/dir', async (req, res) => {
|
|
564
|
+
try {
|
|
565
|
+
let dirPath = req.query.path || '';
|
|
566
|
+
try {
|
|
567
|
+
dirPath = decodeURIComponent(dirPath);
|
|
568
|
+
}
|
|
569
|
+
catch (e) { }
|
|
570
|
+
dirPath = normalizeDocRelativePath(dirPath);
|
|
571
|
+
if (dirPath === null) {
|
|
572
|
+
return res.status(403).send('<h1>禁止访问</h1>');
|
|
573
|
+
}
|
|
574
|
+
// 先检查是否有 README.md,如果有则重定向到文档页
|
|
575
|
+
if (await storageFileExists(joinDocPath(dirPath, 'README.md'))) {
|
|
576
|
+
return res.redirect(`/doc?path=${encodeURIComponent(dirPath ? dirPath + '/README.md' : 'README.md')}`);
|
|
577
|
+
}
|
|
578
|
+
// 没有 README,显示目录列表
|
|
579
|
+
const listing = await getDirListing(dirPath);
|
|
580
|
+
if (!listing) {
|
|
581
|
+
return await renderDocNotFound(res, dirPath);
|
|
582
|
+
}
|
|
583
|
+
const tree = await getDocTree(DOCS_DIR);
|
|
584
|
+
const treeHtml = renderTree(tree);
|
|
585
|
+
const breadcrumbs = await buildBreadcrumbs(dirPath);
|
|
586
|
+
const dirName = dirPath ? path.basename(dirPath) : '根目录';
|
|
587
|
+
res.render('dir', {
|
|
588
|
+
treeHtml,
|
|
589
|
+
currentDirPath: dirPath,
|
|
590
|
+
dirName,
|
|
591
|
+
listing,
|
|
592
|
+
breadcrumbs,
|
|
593
|
+
assetVersion: ASSET_VERSION,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
res.status(500).send(`<h1>错误</h1><p>${error.message}</p>`);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
// 文档查看页面(必须在通配符路由之前)
|
|
601
|
+
app.get('/doc', async (req, res) => {
|
|
602
|
+
try {
|
|
603
|
+
let docPath = req.query.path;
|
|
604
|
+
if (!docPath) {
|
|
605
|
+
return res.redirect('/');
|
|
606
|
+
}
|
|
607
|
+
// Express 会自动解码 query 参数,但为了安全,我们再次解码确保正确
|
|
608
|
+
try {
|
|
609
|
+
docPath = decodeURIComponent(docPath);
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
// 如果解码失败,使用原始路径
|
|
613
|
+
}
|
|
614
|
+
// 使用智能路径解析
|
|
615
|
+
const resolved = await resolveDocPath(docPath);
|
|
616
|
+
if (!resolved) {
|
|
617
|
+
return await renderDocNotFound(res, docPath);
|
|
618
|
+
}
|
|
619
|
+
// 如果解析后的路径与请求路径不同,重定向到正确路径
|
|
620
|
+
if (resolved.resolvedPath !== docPath) {
|
|
621
|
+
return res.redirect(`/doc?path=${encodeURIComponent(resolved.resolvedPath)}`);
|
|
622
|
+
}
|
|
623
|
+
const content = await storageReadFile(resolved.resolvedPath);
|
|
624
|
+
let htmlContent = md.render(content);
|
|
625
|
+
// 转换文档中的相对路径链接为 /doc?path=... 格式
|
|
626
|
+
// 当前文档所在目录
|
|
627
|
+
const currentDir = path.dirname(docPath);
|
|
628
|
+
htmlContent = rewriteDocAssetUrls(htmlContent, currentDir);
|
|
629
|
+
// 转换文档中的相对路径链接为 /doc?path=... 格式
|
|
630
|
+
// 处理相对路径链接:./xxx.md 或 ../xxx.md 或 xxx.md
|
|
631
|
+
htmlContent = htmlContent.replace(/<a\s+href="([^"]+\.md)"/g, (match, linkPath) => {
|
|
632
|
+
// 如果已经是 /doc?path=... 格式,保持不变
|
|
633
|
+
if (linkPath.startsWith('/doc?')) {
|
|
634
|
+
return match;
|
|
635
|
+
}
|
|
636
|
+
// 先解码,避免双重编码(markdown-it 可能已经编码了)
|
|
637
|
+
let decodedPath = linkPath;
|
|
638
|
+
try {
|
|
639
|
+
decodedPath = decodeURIComponent(linkPath);
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
// 如果解码失败,使用原始路径(可能不是编码的)
|
|
643
|
+
}
|
|
644
|
+
// 如果是绝对路径(以 / 开头但不是 /doc),转换为相对路径
|
|
645
|
+
if (decodedPath.startsWith('/') && !decodedPath.startsWith('/doc')) {
|
|
646
|
+
// 移除开头的 /,作为相对于 docs 目录的路径
|
|
647
|
+
const relativePath = decodedPath.substring(1);
|
|
648
|
+
const docUrl = `/doc?path=${encodeURIComponent(relativePath)}`;
|
|
649
|
+
return `<a href="${docUrl}"`;
|
|
650
|
+
}
|
|
651
|
+
// 如果是相对路径,转换为绝对路径
|
|
652
|
+
let resolvedPath;
|
|
653
|
+
if (decodedPath.startsWith('./')) {
|
|
654
|
+
// 相对于当前目录
|
|
655
|
+
resolvedPath = path.join(currentDir, decodedPath.substring(2));
|
|
656
|
+
}
|
|
657
|
+
else if (decodedPath.startsWith('../')) {
|
|
658
|
+
// 相对于父目录
|
|
659
|
+
resolvedPath = path.join(currentDir, decodedPath);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
// 相对于当前目录(没有 ./ 前缀)
|
|
663
|
+
resolvedPath = path.join(currentDir, decodedPath);
|
|
664
|
+
}
|
|
665
|
+
// 规范化路径(处理 .. 和 .)
|
|
666
|
+
resolvedPath = path.normalize(resolvedPath);
|
|
667
|
+
// 确保路径使用正斜杠(URL 格式)
|
|
668
|
+
const urlPath = resolvedPath.split(path.sep).join('/');
|
|
669
|
+
// 转换为 /doc?path=... 格式(只编码一次)
|
|
670
|
+
const docUrl = `/doc?path=${encodeURIComponent(urlPath)}`;
|
|
671
|
+
return `<a href="${docUrl}"`;
|
|
672
|
+
});
|
|
673
|
+
const tree = await getDocTree(DOCS_DIR);
|
|
674
|
+
const treeHtml = renderTree(tree);
|
|
675
|
+
const fileName = path.basename(docPath);
|
|
676
|
+
const breadcrumbs = await buildBreadcrumbs(docPath);
|
|
677
|
+
res.render('doc', {
|
|
678
|
+
content: htmlContent,
|
|
679
|
+
treeHtml,
|
|
680
|
+
currentPath: docPath,
|
|
681
|
+
fileName,
|
|
682
|
+
breadcrumbs,
|
|
683
|
+
assetVersion: ASSET_VERSION,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
res.status(500).send(`<h1>错误</h1><p>${error.message}</p>`);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
// 处理直接文件路径访问(如 /00-系统总览.md),重定向到 /doc?path=...
|
|
691
|
+
// 注意:此路由必须在 /doc 路由之后,避免拦截 /doc 路由
|
|
692
|
+
app.get('/*.md', async (req, res) => {
|
|
693
|
+
let filePath = req.path.substring(1); // 移除开头的 /
|
|
694
|
+
// 先解码,避免双重编码
|
|
695
|
+
try {
|
|
696
|
+
filePath = decodeURIComponent(filePath);
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
// 如果解码失败,使用原始路径
|
|
700
|
+
}
|
|
701
|
+
// 重定向到 /doc?path=... 格式(只编码一次)
|
|
702
|
+
return res.redirect(`/doc?path=${encodeURIComponent(filePath)}`);
|
|
703
|
+
});
|
|
704
|
+
// WebSocket 连接处理
|
|
705
|
+
io.on('connection', (socket) => {
|
|
706
|
+
console.log('客户端已连接:', socket.id);
|
|
707
|
+
socket.on('disconnect', () => {
|
|
708
|
+
console.log('客户端已断开:', socket.id);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
// 文件监听功能
|
|
712
|
+
if (WATCH_ENABLED && !injectedDocumentStore) {
|
|
713
|
+
console.log('🔍 文件监听模式已启用');
|
|
714
|
+
const watcher = chokidar.watch(DOCS_DIR, {
|
|
715
|
+
ignored: /(^|[\/\\])\../, // 忽略隐藏文件
|
|
716
|
+
persistent: true,
|
|
717
|
+
ignoreInitial: true,
|
|
718
|
+
awaitWriteFinish: {
|
|
719
|
+
stabilityThreshold: 200,
|
|
720
|
+
pollInterval: 100
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
// 统一路径格式(使用正斜杠,兼容跨平台)
|
|
724
|
+
function normalizePath(filePath) {
|
|
725
|
+
const relativePath = path.relative(DOCS_DIR, filePath);
|
|
726
|
+
return relativePath.split(path.sep).join('/');
|
|
727
|
+
}
|
|
728
|
+
watcher.on('change', async (filePath) => {
|
|
729
|
+
// 只监听 .md 文件
|
|
730
|
+
if (!filePath.endsWith('.md')) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const relativePath = normalizePath(filePath);
|
|
734
|
+
console.log(`📝 文件已更新: ${relativePath}`);
|
|
735
|
+
try {
|
|
736
|
+
// 重新读取文件内容并渲染
|
|
737
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
738
|
+
const htmlContent = md.render(content);
|
|
739
|
+
// 通知所有客户端文件已更新
|
|
740
|
+
io.emit('file-changed', {
|
|
741
|
+
path: relativePath,
|
|
742
|
+
content: htmlContent
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
console.error('读取文件失败:', error);
|
|
747
|
+
io.emit('file-error', {
|
|
748
|
+
path: relativePath,
|
|
749
|
+
error: error.message
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
watcher.on('add', (filePath) => {
|
|
754
|
+
if (filePath.endsWith('.md')) {
|
|
755
|
+
const relativePath = normalizePath(filePath);
|
|
756
|
+
console.log(`➕ 新文件已添加: ${relativePath}`);
|
|
757
|
+
io.emit('file-added', { path: relativePath });
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
watcher.on('unlink', (filePath) => {
|
|
761
|
+
if (filePath.endsWith('.md')) {
|
|
762
|
+
const relativePath = normalizePath(filePath);
|
|
763
|
+
console.log(`🗑️ 文件已删除: ${relativePath}`);
|
|
764
|
+
io.emit('file-deleted', { path: relativePath });
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
watcher.on('error', (error) => {
|
|
768
|
+
console.error('文件监听错误:', error);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
console.log('🔒 文件监听模式已关闭');
|
|
773
|
+
}
|
|
774
|
+
const MAX_PORT_ATTEMPTS = 100;
|
|
775
|
+
function startServer(port = PORT_START, attemptsLeft = MAX_PORT_ATTEMPTS) {
|
|
776
|
+
server.once('error', (error) => {
|
|
777
|
+
if (error.code === 'EADDRINUSE' && PORT_FALLBACK_ENABLED && attemptsLeft > 1) {
|
|
778
|
+
console.warn(`⚠️ 端口 ${port} 已被占用,尝试端口 ${port + 1} ...`);
|
|
779
|
+
server.removeAllListeners('error');
|
|
780
|
+
server.removeAllListeners('listening');
|
|
781
|
+
startServer(port + 1, attemptsLeft - 1);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
throw error;
|
|
785
|
+
});
|
|
786
|
+
server.listen(port, HOST, () => {
|
|
787
|
+
const actualPort = server.address().port;
|
|
788
|
+
console.log(`📚 文档服务器已启动`);
|
|
789
|
+
console.log(` 访问地址: http://localhost:${actualPort}`);
|
|
790
|
+
console.log(` 绑定地址: ${HOST}`);
|
|
791
|
+
console.log(` 文档目录: ${DOCS_DIR}`);
|
|
792
|
+
console.log(` ⚡ Watch 模式: ${WATCH_ENABLED ? '已启用(文件修改后自动刷新)' : '已关闭'}`);
|
|
793
|
+
if (OPEN_BROWSER) {
|
|
794
|
+
openBrowser(`http://localhost:${actualPort}`);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
return server;
|
|
798
|
+
}
|
|
799
|
+
function openBrowser(url) {
|
|
800
|
+
const { exec } = require('child_process');
|
|
801
|
+
const command = process.platform === 'darwin'
|
|
802
|
+
? `open "${url}"`
|
|
803
|
+
: process.platform === 'win32'
|
|
804
|
+
? `start "" "${url}"`
|
|
805
|
+
: `xdg-open "${url}"`;
|
|
806
|
+
exec(command, (error) => {
|
|
807
|
+
if (error) {
|
|
808
|
+
console.warn(` ⚠️ 自动打开浏览器失败: ${error.message}`);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
if (process.env.DOCS_AUTOSTART !== 'false') {
|
|
813
|
+
startServer();
|
|
814
|
+
}
|